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.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()
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
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.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 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").
|
* Beispiel GOLD (1000–1499, Spanne 500):
|
||||||
* @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. "<gold>").
|
* Gold III → 1000–1165 (position < 166)
|
||||||
* @param minScore Mindest-scrimScore für diesen Rang (inklusive). UNRANKED
|
* Gold II → 1166–1332 (position < 332)
|
||||||
* bekommt [Int.MIN_VALUE] — er wird nie per Score gewählt,
|
* Gold I → 1333–1499 (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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user