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.
This commit is contained in:
473
src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt
Normal file
473
src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt
Normal file
@@ -0,0 +1,473 @@
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user