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.
473 lines
15 KiB
Kotlin
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()
|
|
}
|
|
|
|
} |