Skip to content

Commit eaadfd4

Browse files
authored
Merge pull request #14 from Trilleo/copilot/complete-countdown-helper-util
Improve CountdownHelper: optional messages, finishMessage, and boss bar color
2 parents a25738d + b1887a1 commit eaadfd4

2 files changed

Lines changed: 219 additions & 5 deletions

File tree

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,216 @@
11
package com.example.exampleplugin.utils
22

3+
import net.kyori.adventure.bossbar.BossBar
34
import net.kyori.adventure.sound.Sound
45
import net.kyori.adventure.text.minimessage.MiniMessage
6+
import net.kyori.adventure.title.Title
57
import org.bukkit.entity.Player
68
import org.bukkit.plugin.java.JavaPlugin
9+
import java.time.Duration
710

11+
/**
12+
* Helper that runs a per-player countdown and displays the progress through
13+
* one of the available [DisplayLocation]s.
14+
*
15+
* ### Message placeholders
16+
*
17+
* Both [start] `message` and `finishMessage` are MiniMessage strings that may
18+
* contain the following placeholders:
19+
*
20+
* | Placeholder | Output example |
21+
* |:-------------|:----------------------------------------|
22+
* | `{seconds}` | `5s` (raw remaining seconds) |
23+
* | `{time}` | `1h 2m 3s` (human-readable time string) |
24+
*
25+
* Either or both placeholders may be omitted from the message string.
26+
*
27+
* ### Optional parameters
28+
*
29+
* * Omit (or pass `null` for) `message` to skip the per-tick display.
30+
* * Omit (or pass `null` for) `finishMessage` to skip the finish display.
31+
* * Pass `null` for `sound` or `finishSound` to play no sound at that point.
32+
* * Set `displayLocation` to [DisplayLocation.NONE] to suppress all output.
33+
*
34+
* ### Boss bar color
35+
*
36+
* When `displayLocation` is [DisplayLocation.BOSS_BAR], pass `bossBarColor`
37+
* to choose the bar color (defaults to [BossBar.Color.BLUE]).
38+
*
39+
* ### Example usage
40+
* ```kotlin
41+
* CountdownHelper().start(
42+
* plugin = plugin,
43+
* player = player,
44+
* seconds = 10,
45+
* displayLocation = DisplayLocation.ACTION_BAR,
46+
* message = "<yellow>Starting in <bold>{seconds}</bold> (<gray>{time}</gray>)",
47+
* finishMessage = "<green>Go!",
48+
* sound = Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.MASTER, 1f, 1f),
49+
* finishSound = Sound.sound(Key.key("minecraft:entity.player.levelup"), Sound.Source.MASTER, 1f, 1f),
50+
* onFinish = { p -> p.sendMessage("<green>Started!") }
51+
* )
52+
* ```
53+
*/
854
class CountdownHelper {
955
private val mm = MiniMessage.miniMessage()
1056

57+
/**
58+
* Starts a countdown for [player] and ticks every second until [seconds]
59+
* reaches zero.
60+
*
61+
* @param plugin the owning plugin (used to schedule tasks)
62+
* @param player the player to target
63+
* @param seconds total number of seconds to count down from
64+
* @param displayLocation where messages are shown; use
65+
* [DisplayLocation.NONE] to suppress all output
66+
* @param message optional MiniMessage string shown on every tick;
67+
* supports `{seconds}` and `{time}` placeholders.
68+
* Pass `null` (or omit) to skip per-tick display
69+
* @param finishMessage optional MiniMessage string shown when the
70+
* countdown ends; supports `{seconds}` and `{time}`
71+
* placeholders. Pass `null` (or omit) to skip the
72+
* finish display
73+
* @param bossBarColor color of the boss bar when [displayLocation] is
74+
* [DisplayLocation.BOSS_BAR]; defaults to
75+
* [BossBar.Color.BLUE]
76+
* @param sound sound played on every tick, or `null` for silence
77+
* @param finishSound sound played when the countdown ends, or `null`
78+
* for silence
79+
* @param onFinish callback invoked with [player] when the countdown
80+
* reaches zero
81+
*/
1182
fun start(
1283
plugin: JavaPlugin,
1384
player: Player,
1485
seconds: Int,
15-
message: (remaining: Int) -> String,
16-
sound: Sound,
17-
finishSound: Sound,
86+
displayLocation: DisplayLocation,
87+
message: String? = null,
88+
finishMessage: String? = null,
89+
bossBarColor: BossBar.Color = BossBar.Color.BLUE,
90+
sound: Sound? = null,
91+
finishSound: Sound? = null,
1892
onFinish: (Player) -> Unit
1993
) {
20-
// Countdown Logic
94+
require(seconds > 0) { "seconds must be greater than 0" }
95+
96+
var remaining = seconds
97+
98+
val initialName = message?.let { applyPlaceholders(it, remaining, seconds) } ?: ""
99+
val bossBar: BossBar? = if (displayLocation == DisplayLocation.BOSS_BAR) {
100+
BossBar.bossBar(
101+
mm.deserialize(initialName),
102+
1.0f,
103+
bossBarColor,
104+
BossBar.Overlay.PROGRESS
105+
).also { player.showBossBar(it) }
106+
} else null
107+
108+
var task: org.bukkit.scheduler.BukkitTask? = null
109+
task = plugin.server.scheduler.runTaskTimer(plugin, Runnable {
110+
if (!player.isOnline) {
111+
bossBar?.let { player.hideBossBar(it) }
112+
task?.cancel()
113+
return@Runnable
114+
}
115+
116+
if (remaining <= 0) {
117+
if (finishMessage != null) {
118+
val formatted = applyPlaceholders(finishMessage, 0, seconds)
119+
showMessage(player, displayLocation, formatted, bossBar, progress = 0.0f)
120+
}
121+
bossBar?.let { player.hideBossBar(it) }
122+
finishSound?.let { player.playSound(it) }
123+
task?.cancel()
124+
onFinish(player)
125+
return@Runnable
126+
}
127+
128+
if (message != null) {
129+
val formatted = applyPlaceholders(message, remaining, seconds)
130+
showMessage(player, displayLocation, formatted, bossBar, remaining.toFloat() / seconds.toFloat())
131+
}
132+
133+
sound?.let { player.playSound(it) }
134+
remaining--
135+
}, 0L, 20L)
136+
}
137+
138+
/**
139+
* Sends [formatted] to [player] at the given [displayLocation].
140+
*
141+
* For [DisplayLocation.BOSS_BAR] the supplied [bossBar] instance is
142+
* updated in-place rather than sending a new message.
143+
*
144+
* @param player the target player
145+
* @param displayLocation where the message is rendered
146+
* @param formatted the already-substituted MiniMessage string
147+
* @param bossBar the active boss bar (only used for
148+
* [DisplayLocation.BOSS_BAR])
149+
* @param progress boss bar fill fraction in the range `[0, 1]`
150+
*/
151+
private fun showMessage(
152+
player: Player,
153+
displayLocation: DisplayLocation,
154+
formatted: String,
155+
bossBar: BossBar?,
156+
progress: Float
157+
) {
158+
when (displayLocation) {
159+
DisplayLocation.NONE -> {}
160+
DisplayLocation.CHAT -> player.sendMessage(mm.deserialize(formatted))
161+
DisplayLocation.TITLE -> player.showTitle(
162+
Title.title(
163+
mm.deserialize(formatted),
164+
mm.deserialize(""),
165+
Title.Times.times(
166+
Duration.ZERO,
167+
Duration.ofMillis(1200),
168+
Duration.ZERO
169+
)
170+
)
171+
)
172+
DisplayLocation.BOSS_BAR -> bossBar?.let {
173+
it.name(mm.deserialize(formatted))
174+
it.progress(progress)
175+
}
176+
DisplayLocation.ACTION_BAR -> player.sendActionBar(mm.deserialize(formatted))
177+
}
178+
}
179+
180+
/**
181+
* Replaces `{seconds}` and `{time}` placeholders in [message].
182+
*
183+
* @param message the raw MiniMessage template
184+
* @param remaining seconds still remaining
185+
* @param total the original total seconds (unused; reserved for future use)
186+
* @return the message with placeholders substituted
187+
*/
188+
private fun applyPlaceholders(message: String, remaining: Int, @Suppress("UNUSED_PARAMETER") total: Int): String {
189+
return message
190+
.replace("{seconds}", "${remaining}s")
191+
.replace("{time}", formatTime(remaining))
192+
}
193+
194+
/**
195+
* Formats a raw second count into a human-readable string.
196+
*
197+
* Examples:
198+
* - `5` → `5s`
199+
* - `90` → `1m 30s`
200+
* - `3665` → `1h 1m 5s`
201+
*
202+
* @param seconds the number of seconds to format (must be >= 0)
203+
* @return formatted time string
204+
*/
205+
private fun formatTime(seconds: Int): String {
206+
val hours = seconds / 3600
207+
val minutes = (seconds % 3600) / 60
208+
val secs = seconds % 60
209+
210+
return buildString {
211+
if (hours > 0) append("${hours}h ")
212+
if (minutes > 0) append("${minutes}m ")
213+
append("${secs}s")
214+
}.trim()
21215
}
22-
}
216+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.example.exampleplugin.utils
2+
3+
/**
4+
* The location where countdown messages are displayed to the player.
5+
*
6+
* | Value | Behaviour |
7+
* |:-------------|:-----------------------------------------------------|
8+
* | [NONE] | No message is displayed |
9+
* | [CHAT] | Message is sent to the player's chat |
10+
* | [TITLE] | Message is shown as a screen title |
11+
* | [BOSS_BAR] | Message is shown in a boss bar that depletes over time |
12+
* | [ACTION_BAR] | Message is shown above the hotbar |
13+
*/
14+
enum class DisplayLocation {
15+
NONE,
16+
CHAT,
17+
TITLE,
18+
BOSS_BAR,
19+
ACTION_BAR
20+
}

0 commit comments

Comments
 (0)