11package com.example.exampleplugin.utils
22
3+ import net.kyori.adventure.bossbar.BossBar
34import net.kyori.adventure.sound.Sound
45import net.kyori.adventure.text.minimessage.MiniMessage
6+ import net.kyori.adventure.title.Title
57import org.bukkit.entity.Player
68import 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+ */
854class 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+ }
0 commit comments