Files
GameModes-SpeedHG/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.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

145 lines
5.7 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package club.mcscrims.speedhg.ranking
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.Color
/**
* Rang-Tier System für SpeedHG.
*
* ## Sub-Tier Berechnung
* Jeder Rang (außer [UNRANKED] und [SCRIM_MASTER]) hat drei Unterränge IIII.
* Die Spanne [minScore, nächsterRang.minScore) wird in drei gleichgroße Drittel
* aufgeteilt — unterstes Drittel = III, mittleres = II, oberstes = I.
*
* Beispiel GOLD (10001499, Spanne 500):
* Gold III → 10001165 (position < 166)
* Gold II → 11661332 (position < 332)
* Gold I → 13331499 (position ≥ 332)
*
* @param displayName Angezeigter Rang-Name.
* @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. "<gold>").
* @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 fireworkColor: Color
) {
UNRANKED ("Unranked", "<dark_gray>", Int.MIN_VALUE, Color.GRAY ),
BRONZE ("Bronze", "<#CD7F32>", 0, Color.fromRGB(0xCD7F32) ),
SILVER ("Silver", "<gray>", 500, Color.fromRGB(0xC0C0C0) ),
GOLD ("Gold", "<gold>", 1000, Color.YELLOW ),
PLATINUM ("Platinum", "<aqua>", 1500, Color.fromRGB(0x00FFFF) ),
DIAMOND ("Diamond", "<#B9F2FF>", 2000, Color.fromRGB(0xB9F2FF) ),
SCRIM_MASTER("Scrim-Master", "<gradient:red:gold>", 2500, Color.ORANGE );
// ── Vorberechnete Strings & Components (einmal pro Rang, nie pro Frame) ──
/** MiniMessage-Tag: "<gold>Gold<reset>" */
val tag: String = "$colorTag$displayName<reset>"
/** Klammer-Prefix: "<gold>[Gold]<reset>" */
val prefix: String = "$colorTag[$displayName]<reset>"
/**
* 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]. */
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()
/**
* Sichtbarer Rang eines Spielers inklusive Placement-Phasen-Check.
*
* 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 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
.asSequence()
.filter { it != UNRANKED }
.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>Gold II<reset>"`, `"<dark_gray>Unranked<reset>"`.
*/
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<reset>"
else rank.tag
}
}
}