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

@@ -10,6 +10,7 @@ import club.mcscrims.speedhg.config.LanguageManager
import club.mcscrims.speedhg.database.DatabaseManager import club.mcscrims.speedhg.database.DatabaseManager
import club.mcscrims.speedhg.database.StatsManager import club.mcscrims.speedhg.database.StatsManager
import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.GameManager
import club.mcscrims.speedhg.game.PodiumManager
import club.mcscrims.speedhg.game.modules.AntiRunningManager import club.mcscrims.speedhg.game.modules.AntiRunningManager
import club.mcscrims.speedhg.gui.listener.MenuListener import club.mcscrims.speedhg.gui.listener.MenuListener
import club.mcscrims.speedhg.kit.KitManager import club.mcscrims.speedhg.kit.KitManager
@@ -69,6 +70,9 @@ class SpeedHG : JavaPlugin() {
lateinit var rankingManager: RankingManager lateinit var rankingManager: RankingManager
private set private set
lateinit var podiumManager: PodiumManager
private set
override fun onLoad() override fun onLoad()
{ {
instance = this instance = this
@@ -101,6 +105,7 @@ class SpeedHG : JavaPlugin() {
languageManager = LanguageManager( this ) languageManager = LanguageManager( this )
gameManager = GameManager( this ) gameManager = GameManager( this )
rankingManager = RankingManager( this ) rankingManager = RankingManager( this )
podiumManager = PodiumManager( this )
antiRunningManager = AntiRunningManager( this ) antiRunningManager = AntiRunningManager( this )
scoreboardManager = ScoreboardManager( this ) scoreboardManager = ScoreboardManager( this )
kitManager = KitManager( this ) kitManager = KitManager( this )
@@ -116,6 +121,7 @@ class SpeedHG : JavaPlugin() {
override fun onDisable() override fun onDisable()
{ {
podiumManager.cleanup()
if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown()
if ( ::databaseManager.isInitialized ) databaseManager.disconnect() if ( ::databaseManager.isInitialized ) databaseManager.disconnect()
kitManager.clearAll() kitManager.clearAll()

View File

@@ -318,6 +318,10 @@ class GameManager(
description = "**$winnerName** hat das Spiel gewonnen! GG!", description = "**$winnerName** hat das Spiel gewonnen! GG!",
colorHex = 0xFFAA00 // Gold colorHex = 0xFFAA00 // Gold
) )
Bukkit.getScheduler().runTaskLater( plugin, { ->
plugin.podiumManager.launch( winnerUUID )
}, 60L )
} }
// --- Helfer Methoden --- // --- Helfer Methoden ---

View File

@@ -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<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()
}
}

View File

@@ -2,70 +2,115 @@ package club.mcscrims.speedhg.ranking
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage 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), * ## Sub-Tier Berechnung
* da [ordinal] für den Rank-Up/Down-Vergleich in [RankingManager] genutzt wird. * 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"). * Beispiel GOLD (10001499, Spanne 500):
* @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. "<gold>"). * Gold III → 10001165 (position < 166)
* @param minScore Mindest-scrimScore für diesen Rang (inklusive). UNRANKED * Gold II → 11661332 (position < 332)
* bekommt [Int.MIN_VALUE] — er wird nie per Score gewählt, * Gold I → 13331499 (position ≥ 332)
* sondern nur wenn der Spieler in der Placement-Phase ist. *
* @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( enum class Rank(
val displayName: String, val displayName: String,
val colorTag: String, val colorTag: String,
val minScore: Int val minScore: Int,
val fireworkColor: Color
) { ) {
UNRANKED ("Unranked", "<dark_gray>", Int.MIN_VALUE), UNRANKED ("Unranked", "<dark_gray>", Int.MIN_VALUE, Color.GRAY ),
BRONZE ("Bronze", "<#CD7F32>", 0 ), BRONZE ("Bronze", "<#CD7F32>", 0, Color.fromRGB(0xCD7F32) ),
SILVER ("Silver", "<silver>", 500 ), SILVER ("Silver", "<gray>", 500, Color.fromRGB(0xC0C0C0) ),
GOLD ("Gold", "<gold>", 1000 ), GOLD ("Gold", "<gold>", 1000, Color.YELLOW ),
PLATINUM ("Platinum", "<aqua>", 1500 ), PLATINUM ("Platinum", "<aqua>", 1500, Color.fromRGB(0x00FFFF) ),
DIAMOND ("Diamond", "<#B9F2FF>", 2000 ), DIAMOND ("Diamond", "<#B9F2FF>", 2000, Color.fromRGB(0xB9F2FF) ),
SCRIM_MASTER("Scrim-Master", "<gradient:red:gold>", 2500 ); 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>" 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>" val prefix: String = "$colorTag[$displayName]<reset>"
/** /**
* Deserialisiertes Adventure-[Component] des [tag]. * Deserialisiertes Adventure-Component des [tag].
* [lazy] → wird nur beim ersten Zugriff gebaut und dann gecacht. * [lazy] → einmalige Deserialisierung, danach gecacht.
* Da Enum-Instanzen Singletons sind, passiert das genau einmal pro Rang. * Da Enums Singletons sind, passiert das genau einmal pro Rang.
*/ */
val component: Component by lazy { MiniMessage.miniMessage().deserialize(tag) } 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) } 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 { 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 * Spieler mit weniger als [RankingManager.PLACEMENT_GAMES] abgeschlossenen
* bekommen immer [UNRANKED], egal wie hoch ihr interner Score ist. * Spielen erhalten immer [UNRANKED], unabhängig vom internen Score.
*
* @param scrimScore Aktueller Scrim-Score.
* @param gamesPlayed Abgeschlossene Spiele (wins + losses zum Zeitpunkt der Abfrage).
*/ */
fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank = fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank =
if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED
else fromScore(scrimScore) else fromScore(scrimScore)
/** /**
* Reines Score → Tier Mapping, ignoriert die Placement-Phase. * Reines Score → Tier Mapping (ignoriert Placement-Phase).
* Wird intern für Rank-Change-Erkennung (Pre/Post Adjustment) genutzt. * Wird intern für Rank-Change-Erkennung genutzt.
* * Iteriert maximal 6 Einträge → O(1) in der Praxis.
* Iteriert 6 Einträge in absteigender Score-Reihenfolge → O(1) für die Praxis.
*/ */
fun fromScore(scrimScore: Int): Rank = fun fromScore(scrimScore: Int): Rank =
entries entries
@@ -74,5 +119,27 @@ enum class Rank(
.sortedByDescending { it.minScore } .sortedByDescending { it.minScore }
.firstOrNull { scrimScore >= it.minScore } .firstOrNull { scrimScore >= it.minScore }
?: BRONZE ?: 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
}
} }
} }

View File

@@ -99,6 +99,10 @@ class RankingManager(
*/ */
private val eliminationIndex = AtomicInteger(0) private val eliminationIndex = AtomicInteger(0)
// Erste Einträge = zuerst gestorben (schlechtestes Placement)
// Letzte Einträge = zuletzt gestorben (= 2./3. Platz)
private val eliminationOrder: MutableList<UUID> = Collections.synchronizedList( mutableListOf() )
/** Spieleranzahl zu Rundenstart. */ /** Spieleranzahl zu Rundenstart. */
@Volatile @Volatile
private var totalPlayersThisRound = 0 private var totalPlayersThisRound = 0
@@ -116,6 +120,7 @@ class RankingManager(
) { ) {
roundKills.clear() roundKills.clear()
eliminationIndex.set( 0 ) eliminationIndex.set( 0 )
eliminationOrder.clear()
totalPlayersThisRound = players.size totalPlayersThisRound = players.size
players.forEach { roundKills[ it.uniqueId ] = 0 } players.forEach { roundKills[ it.uniqueId ] = 0 }
plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern, Ranked: $isRankingEnabled.") plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern, Ranked: $isRankingEnabled.")
@@ -157,6 +162,9 @@ class RankingManager(
return return
} }
// Eliminationsreihenfolge immer tracken (auch im Unranked-Modus)
if ( !isWinner ) eliminationOrder.add( player.uniqueId )
// Placement berechnen: 1 = Winner, totalPlayers = Erster Tod (schlecht) // Placement berechnen: 1 = Winner, totalPlayers = Erster Tod (schlecht)
val placement = if ( isWinner ) 1 val placement = if ( isWinner ) 1
else ( totalPlayersThisRound - eliminationIndex.getAndIncrement() ).coerceAtLeast( 1 ) else ( totalPlayersThisRound - eliminationIndex.getAndIncrement() ).coerceAtLeast( 1 )
@@ -184,6 +192,12 @@ class RankingManager(
totalPlayersThisRound = 0 totalPlayersThisRound = 0
} }
/**
* Gibt die Eliminationsreihenfolge dieser Runde zurück.
* Index 0 = zuerst gestorben, letzter Index = letzter vor dem Gewinner (= 2. Platz).
*/
fun getEliminationOrder(): List<UUID> = eliminationOrder.toList()
// ========================================================================= // =========================================================================
// Rank-Abfrage (für Scoreboard, Chat-Prefix etc.) // Rank-Abfrage (für Scoreboard, Chat-Prefix etc.)
// ========================================================================= // =========================================================================

View File

@@ -2,6 +2,7 @@ package club.mcscrims.speedhg.scoreboard
import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.game.GameState import club.mcscrims.speedhg.game.GameState
import club.mcscrims.speedhg.ranking.Rank
import club.mcscrims.speedhg.util.trans import club.mcscrims.speedhg.util.trans
import fr.mrmicky.fastboard.adventure.FastBoard import fr.mrmicky.fastboard.adventure.FastBoard
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
@@ -66,8 +67,10 @@ class ScoreboardManager(
val max = Bukkit.getMaxPlayers().toString() val max = Bukkit.getMaxPlayers().toString()
val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" )
val rank = plugin.rankingManager.getRank( player ) val stats = plugin.statsManager.getCachedStats( player.uniqueId )
val rankComponent = rank.component val score = stats?.scrimScore ?: 0
val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 )
val rankComponent = Rank.getFormattedRankName( score, games )
val lines: List<Component> val lines: List<Component>