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+ * The [start] `message` parameter is a MiniMessage string that may contain the
18+ * following placeholders, which are replaced on every tick:
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+ * Pass `null` for [sound] or [finishSound] to play no sound at that point.
30+ * Set [displayLocation] to [DisplayLocation.NONE] to suppress the message
31+ * entirely.
32+ *
33+ * ### Example usage
34+ * ```kotlin
35+ * CountdownHelper().start(
36+ * plugin = plugin,
37+ * player = player,
38+ * seconds = 10,
39+ * message = "<yellow>Starting in <bold>{seconds}</bold> (<gray>{time}</gray>)",
40+ * displayLocation = DisplayLocation.ACTION_BAR,
41+ * sound = Sound.sound(Key.key("minecraft:ui.button.click"), Sound.Source.MASTER, 1f, 1f),
42+ * finishSound = Sound.sound(Key.key("minecraft:entity.player.levelup"), Sound.Source.MASTER, 1f, 1f),
43+ * onFinish = { p -> p.sendMessage("<green>Go!") }
44+ * )
45+ * ```
46+ */
847class CountdownHelper {
948 private val mm = MiniMessage .miniMessage()
1049
50+ /* *
51+ * Starts a countdown for [player] and ticks every second until [seconds]
52+ * reaches zero.
53+ *
54+ * @param plugin the owning plugin (used to schedule tasks)
55+ * @param player the player to target
56+ * @param seconds total number of seconds to count down from
57+ * @param message MiniMessage string shown each tick; supports
58+ * `{seconds}` and `{time}` placeholders
59+ * @param displayLocation where the message is shown; use
60+ * [DisplayLocation.NONE] to suppress output
61+ * @param sound sound played on every tick, or `null` for silence
62+ * @param finishSound sound played when the countdown ends, or `null`
63+ * for silence
64+ * @param onFinish callback invoked with [player] when the countdown
65+ * reaches zero
66+ */
1167 fun start (
1268 plugin : JavaPlugin ,
1369 player : Player ,
1470 seconds : Int ,
15- message : (remaining: Int ) -> String ,
16- sound : Sound ,
17- finishSound : Sound ,
71+ message : String ,
72+ displayLocation : DisplayLocation ,
73+ sound : Sound ? ,
74+ finishSound : Sound ? ,
1875 onFinish : (Player ) -> Unit
1976 ) {
20- // Countdown Logic
77+ require(seconds > 0 ) { " seconds must be greater than 0" }
78+
79+ var remaining = seconds
80+
81+ val bossBar: BossBar ? = if (displayLocation == DisplayLocation .BOSS_BAR ) {
82+ BossBar .bossBar(
83+ mm.deserialize(applyPlaceholders(message, remaining, seconds)),
84+ 1.0f ,
85+ BossBar .Color .BLUE ,
86+ BossBar .Overlay .PROGRESS
87+ ).also { player.showBossBar(it) }
88+ } else null
89+
90+ var task: org.bukkit.scheduler.BukkitTask ? = null
91+ task = plugin.server.scheduler.runTaskTimer(plugin, Runnable {
92+ if (! player.isOnline) {
93+ bossBar?.let { player.hideBossBar(it) }
94+ task?.cancel()
95+ return @Runnable
96+ }
97+
98+ if (remaining <= 0 ) {
99+ bossBar?.let { player.hideBossBar(it) }
100+ finishSound?.let { player.playSound(it) }
101+ task?.cancel()
102+ onFinish(player)
103+ return @Runnable
104+ }
105+
106+ val formatted = applyPlaceholders(message, remaining, seconds)
107+
108+ when (displayLocation) {
109+ DisplayLocation .NONE -> {}
110+ DisplayLocation .CHAT -> player.sendMessage(mm.deserialize(formatted))
111+ DisplayLocation .TITLE -> player.showTitle(
112+ Title .title(
113+ mm.deserialize(formatted),
114+ mm.deserialize(" " ),
115+ Title .Times .times(
116+ Duration .ZERO ,
117+ Duration .ofMillis(1200 ),
118+ Duration .ZERO
119+ )
120+ )
121+ )
122+ DisplayLocation .BOSS_BAR -> bossBar?.let {
123+ it.name(mm.deserialize(formatted))
124+ it.progress(remaining.toFloat() / seconds.toFloat())
125+ }
126+ DisplayLocation .ACTION_BAR -> player.sendActionBar(mm.deserialize(formatted))
127+ }
128+
129+ sound?.let { player.playSound(it) }
130+ remaining--
131+ }, 0L , 20L )
132+ }
133+
134+ /* *
135+ * Replaces `{seconds}` and `{time}` placeholders in [message].
136+ *
137+ * @param message the raw MiniMessage template
138+ * @param remaining seconds still remaining
139+ * @param total the original total seconds (unused; reserved for future use)
140+ * @return the message with placeholders substituted
141+ */
142+ private fun applyPlaceholders (message : String , remaining : Int , @Suppress(" UNUSED_PARAMETER" ) total : Int ): String {
143+ return message
144+ .replace(" {seconds}" , " ${remaining} s" )
145+ .replace(" {time}" , formatTime(remaining))
146+ }
147+
148+ /* *
149+ * Formats a raw second count into a human-readable string.
150+ *
151+ * Examples:
152+ * - `5` → `5s`
153+ * - `90` → `1m 30s`
154+ * - `3665` → `1h 1m 5s`
155+ *
156+ * @param seconds the number of seconds to format (must be ≥ 0)
157+ * @return formatted time string
158+ */
159+ private fun formatTime (seconds : Int ): String {
160+ val hours = seconds / 3600
161+ val minutes = (seconds % 3600 ) / 60
162+ val secs = seconds % 60
163+
164+ return buildString {
165+ if (hours > 0 ) append(" ${hours} h " )
166+ if (minutes > 0 ) append(" ${minutes} m " )
167+ append(" ${secs} s" )
168+ }.trim()
21169 }
22- }
170+ }
0 commit comments