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>() private val spawnedEntities = mutableListOf() private val activeTasks = mutableListOf() // ── 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 { 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 ) { 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 ) { 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 ) { 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" + "${entry.player.name}" ) 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 ) { 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 -> " 🏆 1ST PLACE 🏆 " 2 -> " 🥈 2ND PLACE 🥈 " 3 -> "<#CD7F32> 🥉 3RD PLACE 🥉 " else -> "#${entry.placement}" } val sep = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" Bukkit.getOnlinePlayers().forEach { p -> p.sendMessage(mm.deserialize( "$sep\n" + "$header\n" + " Player: ${entry.player.name}\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( "🏆 CHAMPION 🏆" ), mm.deserialize( "${entry.player.name} $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 ) { 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() } }