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:
TDSTOS
2026-04-04 00:40:56 +02:00
parent 2434460c32
commit 5be2ae2674
6 changed files with 604 additions and 37 deletions

View File

@@ -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 IIII.
* 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. "<gold>").
* @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 (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 displayName: String,
val colorTag: String,
val minScore: Int,
val fireworkColor: Color
) {
UNRANKED ("Unranked", "<dark_gray>", Int.MIN_VALUE),
BRONZE ("Bronze", "<#CD7F32>", 0 ),
SILVER ("Silver", "<silver>", 500 ),
GOLD ("Gold", "<gold>", 1000 ),
PLATINUM ("Platinum", "<aqua>", 1500 ),
DIAMOND ("Diamond", "<#B9F2FF>", 2000 ),
SCRIM_MASTER("Scrim-Master", "<gradient:red:gold>", 2500 );
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 );
// ── Vorgefertigte Strings (String-Konkatenation einmalig, nicht pro Frame) ──
// ── Vorberechnete Strings & Components (einmal pro Rang, nie pro Frame) ──
/** MiniMessage-String: "<gold>Gold<reset>". Ideal für Platzhalter in Nachrichten. */
/** MiniMessage-Tag: "<gold>Gold<reset>" */
val tag: String = "$colorTag$displayName<reset>"
/** Klammer-Prefix für Chat/Scoreboard: "<gold>[Gold]<reset>". */
/** Klammer-Prefix: "<gold>[Gold]<reset>" */
val prefix: String = "$colorTag[$displayName]<reset>"
/**
* 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>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
}
}
}