Files
GameModes-SpeedHG/src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt
TDSTOS 5be2ae2674 Add podium ceremony and rank enhancements
Introduce a PodiumManager to run an end-of-round podium ceremony and integrate it into the lifecycle. Key changes:

- Add PodiumManager (new): builds podium columns, teleports and freezes top-3 players, spawns TextDisplay entities, posts staggered announcements, and runs a ranked firework show; includes cleanup to remove entities, blocks and cancel tasks.
- SpeedHG: initialize podiumManager and call podiumManager.cleanup() on disable.
- GameManager: trigger plugin.podiumManager.launch(winnerUUID) shortly after the win announcement.
- RankingManager: track elimination order during the round and expose getEliminationOrder() to determine placements.
- Rank: add sub-tier logic (I/II/III), firework color, and helper methods to produce formatted MiniMessage rank tags/components; improve cached component/prefix handling.
- ScoreboardManager: use Rank.getFormattedRankName(...) to show the formatted rank with sub-tier on scoreboards.

Purpose: provide a richer end-of-round experience (visuals, sounds, fireworks) and accurate top-3 determination using elimination order while adding rank sub-tiering and formatted rank output for UI elements.
2026-04-04 00:40:56 +02:00

473 lines
15 KiB
Kotlin

package club.mcscrims.speedhg.game
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.ranking.Rank
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.title.Title
import org.bukkit.Bukkit
import org.bukkit.Color
import org.bukkit.FireworkEffect
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.Sound
import org.bukkit.World
import org.bukkit.entity.Display
import org.bukkit.entity.Entity
import org.bukkit.entity.Firework
import org.bukkit.entity.Player
import org.bukkit.entity.TextDisplay
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.scheduler.BukkitRunnable
import org.bukkit.scheduler.BukkitTask
import java.time.Duration
import java.util.UUID
/**
* Orchestriert die End-of-Round Podium-Zeremonie.
*
* ## Ablauf nach [launch]
* 1. Top-3-Spieler werden aus Überlebensreihenfolge + Kills ermittelt.
* 2. Drei Säulen (Gold/Eisen/Stein) entstehen im Weltzentrum.
* 3. Spieler werden teleportiert, eingefroren und in [GameMode.ADVENTURE] versetzt.
* 4. [TextDisplay]-Entities erscheinen über jedem Spieler (Name + Rang mit Sub-Tier).
* 5. Gestaffelte Ankündigungen: 3. → 2. → 1. Platz, jede mit eigenem Fanfare-Sound.
* 6. Rank-gefärbte Feuerwerke feuern bis Server-Shutdown.
*
* ## Cleanup
* [cleanup] bricht alle Tasks ab, entfernt Entities und stellt Blöcke wieder her.
* Aufruf in [SpeedHG.onDisable] — da der Server nach jeder Runde neu startet,
* ist der Hauptzweck die Entities-Bereinigung. Blockwiederherstellung greift,
* falls die Map ausnahmsweise nicht resettet wird.
*/
class PodiumManager(
private val plugin: SpeedHG
) {
private val mm = MiniMessage.miniMessage()
// ── Cleanup-Tracking ──────────────────────────────────────────────────────
/** Gespeicherte Block-Locations mit Original-Material für Wiederherstellung. */
private val placedBlocks = mutableListOf<Pair<Location, Material>>()
private val spawnedEntities = mutableListOf<Entity>()
private val activeTasks = mutableListOf<BukkitTask>()
// ── Layout-Konstanten ─────────────────────────────────────────────────────
companion object {
/** Horizontaler Abstand (Blöcke) zwischen den Säulen. */
private const val COLUMN_GAP = 4
/** Block-Material je Platzierung. */
private val COLUMN_MATERIAL = mapOf(
1 to Material.GOLD_BLOCK,
2 to Material.IRON_BLOCK,
3 to Material.STONE
)
/** Säulenhöhe in Blöcken — Spieler steht darüber. */
private val COLUMN_HEIGHT = mapOf(1 to 3, 2 to 2, 3 to 1)
/**
* X-Offset vom Zentrum je Platzierung.
* 1. Platz = Mitte, 2. links, 3. rechts → klassisches Podest-Layout.
*/
private val COLUMN_X_OFFSET = mapOf(1 to 0, 2 to -COLUMN_GAP, 3 to COLUMN_GAP)
/** Ticks zwischen gestaffelten Ankündigungen (60 t = 3 s). */
private const val ANNOUNCEMENT_INTERVAL = 60L
/** Feuerwerk-Intervall in Ticks pro Säule (50 t = 2.5 s). */
private const val FIREWORK_INTERVAL = 50
}
// =========================================================================
// Entry Point
// =========================================================================
/**
* Startet die Podium-Zeremonie. Wird typischerweise mit einem kurzen Delay
* aus [GameManager.endGame] aufgerufen, damit der initiale Win-Title zuerst sichtbar ist.
*
* @param winnerUUID UUID des Gewinners (null = kein Gewinner / Draw).
*/
fun launch(
winnerUUID: UUID?
) {
cleanup() // Vorherigen Zustand sicherstellen
val topThree = resolveTopThree( winnerUUID )
if ( topThree.isEmpty() )
{
plugin.logger.info("[PodiumManager] Kein Podium - zu wenige Online-Spieler.")
return
}
val world = Bukkit.getWorld( "world" ) ?: Bukkit.getWorlds().firstOrNull() ?: return
val center = findPodiumCenter( world )
buildColumns( world, center, topThree )
teleportAndFreeze( center, topThree )
spawnTextDisplays( topThree )
staggeredAnnouncements( topThree )
startFireworkShow( topThree )
plugin.logger.info(
"[PodiumManager] Podium gestartet für: " +
topThree.joinToString { "${it.player.name} (${it.placement}.)" }
)
Bukkit.getScheduler().runTaskLater( plugin, { ->
Bukkit.shutdown()
}, 20L * 15 )
}
// =========================================================================
// Top-3 Ermittlung
// =========================================================================
/** Kapselt alle für einen Podest-Platz benötigten Informationen. */
private data class PodiumEntry(
val player: Player,
val placement: Int,
val rank: Rank,
val scrimScore: Int,
val gamesPlayed: Int
)
/**
* Baut die geordnete Top-3-Liste aus den Überlebensdaten des [RankingManager].
*
* Überlebensreihenfolge (Bester zuerst):
* 1. Platz = Gewinner (zuletzt lebendig)
* 2. Platz = zuletzt Eliminierter vor dem Gewinner
* 3. Platz = vorletzter Eliminierter
*/
private fun resolveTopThree(
winnerUUID: UUID?
): List<PodiumEntry>
{
val eliminationOrder = plugin.rankingManager.getEliminationOrder()
// Überlebensreihenfolge: Gewinner vorne, dann Eliminationsreihenfolge umgekehrt
val survivalOrder = buildList {
if ( winnerUUID != null ) add( winnerUUID )
addAll( eliminationOrder.asReversed() )
}
return survivalOrder
.take( 3 )
.mapIndexedNotNull { index, uuid ->
val player = Bukkit.getPlayer( uuid )?.takeIf { it.isOnline }
?: return@mapIndexedNotNull null
val stats = plugin.statsManager.getCachedStats( uuid )
val score = stats?.scrimScore ?: 0
val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 )
PodiumEntry(
player = player,
placement = index + 1,
rank = Rank.fromPlayer( score, games ),
scrimScore = score,
gamesPlayed = games
)
}
}
// =========================================================================
// Podium-Bau
// =========================================================================
/**
* Findet die Podium-Mitte nahe des geografischen Zentrums der Welt (0, 0).
* Verwendet den höchsten Surface-Block, um unter der Erde zu vermeiden.
*/
private fun findPodiumCenter(
world: World
): Location
{
val cy = world.getHighestBlockYAt( 0, 0 ).toDouble()
return Location( world, 0.0, cy, 0.0 )
}
/**
* Platziert Blocksäulen und speichert Original-Material für [cleanup].
*/
private fun buildColumns(
world: World,
center: Location,
entries: List<PodiumEntry>
) {
entries.forEach { entry ->
val xOff = COLUMN_X_OFFSET[ entry.placement ] ?: return@forEach
val height = COLUMN_HEIGHT[ entry.placement ] ?: return@forEach
val mat = COLUMN_MATERIAL[ entry.placement ] ?: return@forEach
repeat( height ) { layer ->
val loc = center.clone().add( xOff.toDouble(), layer.toDouble(), 0.0 )
val block = world.getBlockAt( loc )
placedBlocks += loc.clone() to block.type
block.type = mat
}
}
}
// =========================================================================
// Teleport & Einfrieren
// =========================================================================
/**
* Teleportiert jeden Spieler auf seine Säulenspitze und sperrt die Bewegung.
*
* Freeze-Mechanismus (identisch zu TheWorldKit):
* - Slowness 127 verhindert horizontales Gehen.
* - Repeating-Task zeroed Velocity jedes Tick, verhindert Springen.
*/
private fun teleportAndFreeze(
center: Location,
entries: List<PodiumEntry>
) {
entries.forEach { entry ->
val xOff = COLUMN_X_OFFSET[ entry.placement ] ?: return@forEach
val height = COLUMN_HEIGHT[ entry.placement ] ?: return@forEach
val standLoc = center.clone()
.add( xOff.toDouble(), height.toDouble(), 0.0 )
.apply { yaw = 0f; pitch = 0f }
entry.player.teleport( standLoc )
entry.player.gameMode = GameMode.ADVENTURE
entry.player.addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS, Int.MAX_VALUE, 127, false, false, false
))
val freezeTask = object : BukkitRunnable() {
override fun run()
{
if ( !entry.player.isOnline ) { cancel(); return }
val v = entry.player.velocity
entry.player.velocity = v
.setX( 0.0 )
.setZ( 0.0 )
.let { if ( it.y > 0.0 ) it.setY( 0.0 ) else it }
}
}.runTaskTimer( plugin, 0L, 1L )
activeTasks += freezeTask
}
}
// =========================================================================
// TextDisplay Entities
// =========================================================================
/**
* Spawnt einen [TextDisplay] 2.8 Blöcke über jedem Spieler.
*
* Zeigt:
* Zeile 1: Platzierungs-Emoji + voll formatierter Rang (mit Sub-Tier)
* Zeile 2: Spielername (fett)
*
* [Display.Billboard.CENTER] lässt das Display immer zum Betrachter zeigen.
*/
private fun spawnTextDisplays(
entries: List<PodiumEntry>
) {
entries.forEach { entry ->
val rankTag = Rank.getFormattedRankTag( entry.scrimScore, entry.gamesPlayed )
val placementEmoji = when ( entry.placement ) { 1 -> "🥇"; 2 -> "🥈"; else -> "🥉" }
val text: Component = mm.deserialize(
"$placementEmoji $rankTag\n" +
"<white><bold>${entry.player.name}</bold></white>"
)
val displayLoc = entry.player.location.clone().add( 0.0, 2.8, 0.0 )
val display = displayLoc.world.spawn( displayLoc, TextDisplay::class.java ) { td ->
td.text( text )
td.billboard = Display.Billboard.CENTER
td.isShadowed = true
td.viewRange = 64f
td.backgroundColor = Color.fromARGB( 160, 0, 0, 0 )
}
spawnedEntities += display
}
}
// =========================================================================
// Gestaffelte Ankündigungen
// =========================================================================
/**
* Kündigt Platzierungen von 3. → 2. → 1. an, jeweils [ANNOUNCEMENT_INTERVAL] Ticks versetzt.
*
* | Delay | Platzierung |
* |--------|-------------|
* | 0 t | 3. |
* | 60 t | 2. |
* | 120 t | 1. |
*/
private fun staggeredAnnouncements(
entries: List<PodiumEntry>
) {
entries
.sortedByDescending { it.placement }
.forEachIndexed { index, entry ->
activeTasks += Bukkit.getScheduler().runTaskLater( plugin, { ->
announceEntry( entry )
}, index * ANNOUNCEMENT_INTERVAL )
}
}
private fun announceEntry(
entry: PodiumEntry
) {
val rankTag = Rank.getFormattedRankTag( entry.scrimScore, entry.gamesPlayed )
// Platzierungs-Header je nach Position — steigernde Optik
val header = when (entry.placement) {
1 -> "<gradient:gold:yellow><bold> 🏆 1ST PLACE 🏆 </bold></gradient>"
2 -> "<gray><bold> 🥈 2ND PLACE 🥈 </bold></gray>"
3 -> "<#CD7F32><bold> 🥉 3RD PLACE 🥉 </bold></#CD7F32>"
else -> "#${entry.placement}"
}
val sep = "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</dark_gray>"
Bukkit.getOnlinePlayers().forEach { p ->
p.sendMessage(mm.deserialize(
"$sep\n" +
"$header\n" +
"<white> Player: <bold>${entry.player.name}</bold></white>\n" +
" Rank: $rankTag\n" +
sep
))
}
// Sound & Title abhängig von Platzierung
when (entry.placement) {
1 -> {
// 1. Platz: großer Triumph-Title für alle + persönlicher Levelup-Sound
Bukkit.getOnlinePlayers().forEach { p ->
p.showTitle(
Title.title(
mm.deserialize(
"<gradient:gold:yellow><bold>🏆 CHAMPION 🏆</bold></gradient>"
),
mm.deserialize(
"<white><bold>${entry.player.name}</bold></white> $rankTag"
),
Title.Times.times(
Duration.ofMillis(300),
Duration.ofSeconds(5),
Duration.ofSeconds(1)
)
)
)
p.playSound(p.location, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f)
}
entry.player.playSound(
entry.player.location, Sound.ENTITY_PLAYER_LEVELUP, 1f, 0.8f
)
}
2 -> Bukkit.getOnlinePlayers().forEach { p ->
p.playSound(p.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.9f, 1.4f)
}
3 -> Bukkit.getOnlinePlayers().forEach { p ->
p.playSound(p.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.6f, 1.0f)
}
}
}
// =========================================================================
// Feuerwerk-Show
// =========================================================================
/**
* Feuert rang-gefärbte Feuerwerke über jeder Säule in gestaffeltem Rhythmus.
*
* Jede Säule zündet alle [FIREWORK_INTERVAL] Ticks (2.5 s), mit einem
* Offset von 10 Ticks zwischen den Säulen — kein simultanes Explodieren.
*/
private fun startFireworkShow(
entries: List<PodiumEntry>
) {
val task = object : BukkitRunnable() {
private var tick = 0
override fun run()
{
tick++
entries.forEachIndexed { index, entry ->
val adjustedTick = tick - index * 10
if ( adjustedTick <= 0 || adjustedTick % FIREWORK_INTERVAL != 0 ) return@forEachIndexed
if ( !entry.player.isOnline ) return@forEachIndexed
launchFirework(
location = entry.player.location.clone().add( 0.0, 1.0, 0.0 ),
rank = entry.rank,
placement = entry.placement
)
}
}
}.runTaskTimer( plugin, 40L, 1L )
activeTasks += task
}
private fun launchFirework(
location: Location,
rank: Rank,
placement: Int
) {
val fw = location.world.spawn( location, Firework::class.java )
fw.fireworkMeta = fw.fireworkMeta.apply {
addEffect(
FireworkEffect.builder()
.with(when( placement )
{
1 -> FireworkEffect.Type.STAR
2 -> FireworkEffect.Type.BALL_LARGE
else -> FireworkEffect.Type.BALL
})
.withColor( rank.fireworkColor )
.withFade( Color.WHITE )
.trail( placement == 1 )
.flicker( placement <= 2 )
.build()
)
power = ( 4 - placement ).coerceAtLeast( 1 )
}
}
// =========================================================================
// Cleanup
// =========================================================================
/**
* Bricht alle Tasks ab, entfernt alle Entities und stellt Blöcke wieder her.
*
* Aufruf-Reihenfolge in [SpeedHG.onDisable]:
* ```kotlin
* podiumManager.cleanup()
* statsManager.shutdown()
* databaseManager.disconnect()
* ```
*/
fun cleanup()
{
activeTasks.forEach { it.cancel() }
activeTasks.clear()
spawnedEntities.filter { !it.isDead }.forEach { it.remove() }
spawnedEntities.clear()
// Blöcke wiederherstellen (greift für Maps, die nicht resettet werden)
placedBlocks.forEach { (loc, originalMat) -> loc.block.type = originalMat }
placedBlocks.clear()
}
}