diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index a9a6f6a..279e292 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -10,6 +10,7 @@ import club.mcscrims.speedhg.config.LanguageManager import club.mcscrims.speedhg.database.DatabaseManager import club.mcscrims.speedhg.database.StatsManager import club.mcscrims.speedhg.game.GameManager +import club.mcscrims.speedhg.game.PodiumManager import club.mcscrims.speedhg.game.modules.AntiRunningManager import club.mcscrims.speedhg.gui.listener.MenuListener import club.mcscrims.speedhg.kit.KitManager @@ -69,6 +70,9 @@ class SpeedHG : JavaPlugin() { lateinit var rankingManager: RankingManager private set + lateinit var podiumManager: PodiumManager + private set + override fun onLoad() { instance = this @@ -101,6 +105,7 @@ class SpeedHG : JavaPlugin() { languageManager = LanguageManager( this ) gameManager = GameManager( this ) rankingManager = RankingManager( this ) + podiumManager = PodiumManager( this ) antiRunningManager = AntiRunningManager( this ) scoreboardManager = ScoreboardManager( this ) kitManager = KitManager( this ) @@ -116,6 +121,7 @@ class SpeedHG : JavaPlugin() { override fun onDisable() { + podiumManager.cleanup() if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::databaseManager.isInitialized ) databaseManager.disconnect() kitManager.clearAll() diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index 5eac34a..3be18ca 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -318,6 +318,10 @@ class GameManager( description = "**$winnerName** hat das Spiel gewonnen! GG!", colorHex = 0xFFAA00 // Gold ) + + Bukkit.getScheduler().runTaskLater( plugin, { -> + plugin.podiumManager.launch( winnerUUID ) + }, 60L ) } // --- Helfer Methoden --- diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt new file mode 100644 index 0000000..b718544 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt @@ -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>() + 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() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt b/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt index 78cfd26..d7b2c16 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt @@ -2,70 +2,115 @@ package club.mcscrims.speedhg.ranking import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.Color /** - * Tier-System für SpeedHG. + * Rang-Tier System für SpeedHG. * - * Reihenfolge der Enum-Einträge ist bewusst aufsteigend (niedrig → hoch), - * da [ordinal] für den Rank-Up/Down-Vergleich in [RankingManager] genutzt wird. + * ## Sub-Tier Berechnung + * Jeder Rang (außer [UNRANKED] und [SCRIM_MASTER]) hat drei Unterränge I–III. + * Die Spanne [minScore, nächsterRang.minScore) wird in drei gleichgroße Drittel + * aufgeteilt — unterstes Drittel = III, mittleres = II, oberstes = I. * - * @param displayName Angezeigter Name (z. B. "Gold"). - * @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. ""). - * @param minScore Mindest-scrimScore für diesen Rang (inklusive). UNRANKED - * bekommt [Int.MIN_VALUE] — er wird nie per Score gewählt, - * sondern nur wenn der Spieler in der Placement-Phase ist. + * Beispiel GOLD (1000–1499, Spanne 500): + * Gold III → 1000–1165 (position < 166) + * Gold II → 1166–1332 (position < 332) + * Gold I → 1333–1499 (position ≥ 332) + * + * @param displayName Angezeigter Rang-Name. + * @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. ""). + * @param minScore Untere Score-Grenze (inklusiv). UNRANKED = [Int.MIN_VALUE]. + * @param fireworkColor Feuerwerksfarbe für die Podium-Zeremonie. */ enum class Rank( - val displayName: String, - val colorTag: String, - val minScore: Int + val displayName: String, + val colorTag: String, + val minScore: Int, + val fireworkColor: Color ) { - UNRANKED ("Unranked", "", Int.MIN_VALUE), - BRONZE ("Bronze", "<#CD7F32>", 0 ), - SILVER ("Silver", "", 500 ), - GOLD ("Gold", "", 1000 ), - PLATINUM ("Platinum", "", 1500 ), - DIAMOND ("Diamond", "<#B9F2FF>", 2000 ), - SCRIM_MASTER("Scrim-Master", "", 2500 ); + UNRANKED ("Unranked", "", Int.MIN_VALUE, Color.GRAY ), + BRONZE ("Bronze", "<#CD7F32>", 0, Color.fromRGB(0xCD7F32) ), + SILVER ("Silver", "", 500, Color.fromRGB(0xC0C0C0) ), + GOLD ("Gold", "", 1000, Color.YELLOW ), + PLATINUM ("Platinum", "", 1500, Color.fromRGB(0x00FFFF) ), + DIAMOND ("Diamond", "<#B9F2FF>", 2000, Color.fromRGB(0xB9F2FF) ), + SCRIM_MASTER("Scrim-Master", "", 2500, Color.ORANGE ); - // ── Vorgefertigte Strings (String-Konkatenation einmalig, nicht pro Frame) ── + // ── Vorberechnete Strings & Components (einmal pro Rang, nie pro Frame) ── - /** MiniMessage-String: "Gold". Ideal für Platzhalter in Nachrichten. */ + /** MiniMessage-Tag: "Gold" */ val tag: String = "$colorTag$displayName" - /** Klammer-Prefix für Chat/Scoreboard: "[Gold]". */ + /** Klammer-Prefix: "[Gold]" */ val prefix: String = "$colorTag[$displayName]" /** - * Deserialisiertes Adventure-[Component] des [tag]. - * [lazy] → wird nur beim ersten Zugriff gebaut und dann gecacht. - * Da Enum-Instanzen Singletons sind, passiert das genau einmal pro Rang. + * Deserialisiertes Adventure-Component des [tag]. + * [lazy] → einmalige Deserialisierung, danach gecacht. + * Da Enums Singletons sind, passiert das genau einmal pro Rang. */ val component: Component by lazy { MiniMessage.miniMessage().deserialize(tag) } - /** Deserialisiertes Adventure-[Component] des [prefix]. */ + /** Deserialisiertes Adventure-Component des [prefix]. */ val prefixComponent: Component by lazy { MiniMessage.miniMessage().deserialize(prefix) } + // ── Sub-Tier Logik ──────────────────────────────────────────────────────── + + /** + * Berechnet den Sub-Tier (1 = höchster, 3 = niedrigster) für diesen Rang. + * + * Gibt `null` zurück für [UNRANKED] und [SCRIM_MASTER] — sie haben keine Sub-Tiers. + * + * Algorithmus: + * range = nächsterRang.minScore − this.minScore + * position = (scrimScore − this.minScore).coerceIn(0, range − 1) + * Drittel: position < range/3 → III, < 2*range/3 → II, sonst → I + */ + fun subTier(scrimScore: Int): Int? { + if (this == UNRANKED || this == SCRIM_MASTER) return null + + // Obere Grenze (exklusiv) = minScore des nächsthöheren Rangs + val upperBound = entries + .filter { it != UNRANKED && it.minScore > this.minScore } + .minOfOrNull { it.minScore } + ?: return null // Sicherheitsnetz — sollte für reguläre Ränge nie eintreten + + val range = upperBound - this.minScore + val position = (scrimScore - this.minScore).coerceIn(0, range - 1) + + return when { + position < range / 3 -> 3 // Unteres Drittel → III + position < 2 * range / 3 -> 2 // Mittleres Drittel → II + else -> 1 // Oberes Drittel → I + } + } + + /** Kurzform: "I", "II", "III" oder `null`. */ + fun subTierRoman(scrimScore: Int): String? = when (subTier(scrimScore)) { + 1 -> "I"; 2 -> "II"; 3 -> "III"; else -> null + } + + // ========================================================================= + // Companion + // ========================================================================= + companion object { + private val mm = MiniMessage.miniMessage() /** - * Gibt den **sichtbaren** Rang zurück. + * Sichtbarer Rang eines Spielers inklusive Placement-Phasen-Check. * - * Spieler mit < [RankingManager.PLACEMENT_GAMES] abgeschlossenen Spielen - * bekommen immer [UNRANKED], egal wie hoch ihr interner Score ist. - * - * @param scrimScore Aktueller Scrim-Score. - * @param gamesPlayed Abgeschlossene Spiele (wins + losses zum Zeitpunkt der Abfrage). + * Spieler mit weniger als [RankingManager.PLACEMENT_GAMES] abgeschlossenen + * Spielen erhalten immer [UNRANKED], unabhängig vom internen Score. */ fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank = if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED else fromScore(scrimScore) /** - * Reines Score → Tier Mapping, ignoriert die Placement-Phase. - * Wird intern für Rank-Change-Erkennung (Pre/Post Adjustment) genutzt. - * - * Iteriert 6 Einträge in absteigender Score-Reihenfolge → O(1) für die Praxis. + * Reines Score → Tier Mapping (ignoriert Placement-Phase). + * Wird intern für Rank-Change-Erkennung genutzt. + * Iteriert maximal 6 Einträge → O(1) in der Praxis. */ fun fromScore(scrimScore: Int): Rank = entries @@ -74,5 +119,27 @@ enum class Rank( .sortedByDescending { it.minScore } .firstOrNull { scrimScore >= it.minScore } ?: BRONZE + + /** + * Gibt ein vollständiges [Component] zurück, z. B. "Gold II" oder "Scrim-Master". + * + * ⚠ Für häufige Aufrufe (Scoreboard) empfiehlt es sich, das Ergebnis + * pro Spieler zu cachen und nur bei Score-Änderungen zu invalidieren. + */ + fun getFormattedRankName(scrimScore: Int, gamesPlayed: Int): Component = + mm.deserialize(getFormattedRankTag(scrimScore, gamesPlayed)) + + /** + * Gibt den MiniMessage-String zurück, geeignet als Platzhalter in + * anderen MiniMessage-Templates. + * + * Beispiele: `"Gold II"`, `"Unranked"`. + */ + fun getFormattedRankTag(scrimScore: Int, gamesPlayed: Int): String { + val rank = fromPlayer(scrimScore, gamesPlayed) + val roman = rank.subTierRoman(scrimScore) + return if (roman != null) "${rank.colorTag}${rank.displayName} $roman" + else rank.tag + } } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt index ab89a1a..df207a1 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt @@ -99,6 +99,10 @@ class RankingManager( */ private val eliminationIndex = AtomicInteger(0) + // Erste Einträge = zuerst gestorben (schlechtestes Placement) + // Letzte Einträge = zuletzt gestorben (= 2./3. Platz) + private val eliminationOrder: MutableList = Collections.synchronizedList( mutableListOf() ) + /** Spieleranzahl zu Rundenstart. */ @Volatile private var totalPlayersThisRound = 0 @@ -116,6 +120,7 @@ class RankingManager( ) { roundKills.clear() eliminationIndex.set( 0 ) + eliminationOrder.clear() totalPlayersThisRound = players.size players.forEach { roundKills[ it.uniqueId ] = 0 } plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern, Ranked: $isRankingEnabled.") @@ -157,6 +162,9 @@ class RankingManager( return } + // Eliminationsreihenfolge immer tracken (auch im Unranked-Modus) + if ( !isWinner ) eliminationOrder.add( player.uniqueId ) + // Placement berechnen: 1 = Winner, totalPlayers = Erster Tod (schlecht) val placement = if ( isWinner ) 1 else ( totalPlayersThisRound - eliminationIndex.getAndIncrement() ).coerceAtLeast( 1 ) @@ -184,6 +192,12 @@ class RankingManager( totalPlayersThisRound = 0 } + /** + * Gibt die Eliminationsreihenfolge dieser Runde zurück. + * Index 0 = zuerst gestorben, letzter Index = letzter vor dem Gewinner (= 2. Platz). + */ + fun getEliminationOrder(): List = eliminationOrder.toList() + // ========================================================================= // Rank-Abfrage (für Scoreboard, Chat-Prefix etc.) // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt index 26291ca..0afcea6 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt @@ -2,6 +2,7 @@ package club.mcscrims.speedhg.scoreboard import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.ranking.Rank import club.mcscrims.speedhg.util.trans import fr.mrmicky.fastboard.adventure.FastBoard import net.kyori.adventure.text.Component @@ -66,8 +67,10 @@ class ScoreboardManager( val max = Bukkit.getMaxPlayers().toString() val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) - val rank = plugin.rankingManager.getRank( player ) - val rankComponent = rank.component + val stats = plugin.statsManager.getCachedStats( player.uniqueId ) + val score = stats?.scrimScore ?: 0 + val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 ) + val rankComponent = Rank.getFormattedRankName( score, games ) val lines: List