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:
@@ -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()
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
473
src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt
Normal file
473
src/main/kotlin/club/mcscrims/speedhg/game/PodiumManager.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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. "<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 (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. "<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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> = 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<UUID> = eliminationOrder.toList()
|
||||
|
||||
// =========================================================================
|
||||
// Rank-Abfrage (für Scoreboard, Chat-Prefix etc.)
|
||||
// =========================================================================
|
||||
|
||||
@@ -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<Component>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user