Add stats & leaderboard menus and lobby items

Add StatsMenu and LeaderboardMenu GUI implementations and wire them into the lobby hotbar. LobbyItemManager now builds a tutorial written-book, a player-head Profile & Stats item, and a leaderboard gold-ingot item, tags them with PDC keys, and opens the appropriate menu or book on interact. Leaderboard loads top-10 asynchronously from the stats manager. Also add corresponding language keys for item names/lores and menu titles.
This commit is contained in:
TDSTOS
2026-04-11 23:29:58 +02:00
parent cdef2315d0
commit f00bd1d8f4
4 changed files with 517 additions and 4 deletions

View File

@@ -5,8 +5,12 @@ import club.mcscrims.speedhg.game.GameState
import club.mcscrims.speedhg.game.modules.LobbyItemManager.Companion.TAG_KITS
import club.mcscrims.speedhg.game.modules.LobbyItemManager.Companion.TAG_PERKS
import club.mcscrims.speedhg.gui.menu.KitSelectorMenu
import club.mcscrims.speedhg.gui.menu.LeaderboardMenu
import club.mcscrims.speedhg.gui.menu.PerkSelectorMenu
import club.mcscrims.speedhg.gui.menu.StatsMenu
import club.mcscrims.speedhg.util.ItemBuilder
import net.kyori.adventure.text.format.TextDecoration
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.entity.Player
@@ -20,6 +24,8 @@ import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BookMeta
import org.bukkit.inventory.meta.SkullMeta
import org.bukkit.persistence.PersistentDataType
/**
@@ -28,6 +34,9 @@ import org.bukkit.persistence.PersistentDataType
* ## Items
* - Slot 0: Kit selector (CHEST)
* - Slot 1: Perk selector (ENDER_CHEST)
* - Slot 4: Tutorial Book (WRITTEN_BOOK)
* - Slot 7: Profile & Stats (PLAYER_HEAD)
* - Slot 8: Leaderboard (GOLD_INGOT)
*
* ## PDC tagging
* Each item is tagged with [KEY_LOBBY_ITEM] → a String value identifying which
@@ -42,6 +51,8 @@ class LobbyItemManager(
private val plugin: SpeedHG
) : Listener {
private val mm = MiniMessage.miniMessage()
// ── PDC keys ─────────────────────────────────────────────────────────────
/** Key stored on every lobby item; value identifies the target menu. */
@@ -50,6 +61,9 @@ class LobbyItemManager(
companion object {
const val TAG_KITS = "kits"
const val TAG_PERKS = "perks"
const val TAG_TUTORIAL = "tutorial"
const val TAG_STATS = "stats"
const val TAG_LEADERBOARD = "leaderboard"
}
// ── Item definitions ──────────────────────────────────────────────────────
@@ -82,6 +96,55 @@ class LobbyItemManager(
.build()
}
fun buildTutorialItem(
player: Player
): ItemStack
{
return ItemBuilder( Material.WRITTEN_BOOK )
.name(plugin.languageManager.getComponent( player, "game.lobby-items.tutorial.name", mapOf() ))
.lore(listOf(plugin.languageManager.getRawMessage( player, "game.lobby-items.tutorial.lore" )))
.pdc( key, PersistentDataType.STRING, TAG_TUTORIAL )
.build()
}
/**
* Builds the Profile & Stats lobby item.
* Uses [SkullMeta] to display the player's own skin, requiring manual
* PDC tagging outside of [ItemBuilder] which does not support skull meta.
*/
fun buildStatsItem(
player: Player
): ItemStack
{
val item = ItemStack( Material.PLAYER_HEAD )
item.editMeta { meta ->
if ( meta is SkullMeta )
meta.owningPlayer = player
meta.displayName(
plugin.languageManager.getComponent( player, "game.lobby-items.stats.name", mapOf() )
)
meta.lore(listOf(
mm.deserialize(plugin.languageManager.getRawMessage( player, "game.lobby-items.stats.lore" ))
.decoration( TextDecoration.ITALIC, false )
))
meta.persistentDataContainer.set( key, PersistentDataType.STRING, TAG_STATS )
}
return item
}
fun buildLeaderboardItem(
player: Player
): ItemStack
{
return ItemBuilder( Material.GOLD_INGOT )
.name(plugin.languageManager.getComponent( player, "game.lobby-items.leaderboard.name", mapOf() ))
.lore(listOf(plugin.languageManager.getRawMessage( player, "game.lobby-items.leaderboard.lore" )))
.pdc( key, PersistentDataType.STRING, TAG_LEADERBOARD )
.build()
}
// ── Public API ────────────────────────────────────────────────────────────
/**
@@ -96,6 +159,9 @@ class LobbyItemManager(
player.inventory.clear()
player.inventory.setItem( 0, buildKitItem( player ))
player.inventory.setItem( 1, buildPerkItem( player ))
player.inventory.setItem( 4, buildTutorialItem( player ))
player.inventory.setItem( 7, buildStatsItem( player ))
player.inventory.setItem( 8, buildLeaderboardItem( player ))
}
/**
@@ -144,6 +210,41 @@ class LobbyItemManager(
else -> false
}
/**
* Opens a written book UI for the player explaining core SpeedHG mechanincs.
* The book is never placed in the player's inventory - it is opened directly.
*/
private fun openTutorialBook(
player: Player
) {
val book = ItemStack( Material.WRITTEN_BOOK )
book.editMeta { meta ->
if ( meta !is BookMeta ) return@editMeta
meta.title(mm.deserialize( "<yellow><bold>SpeedHG - How to play</bold></yellow>" ))
meta.author(mm.deserialize( "<gold>McScrims Network</gold>" ))
meta.addPages(
mm.deserialize(
"<gold><bold>Welcome!</bold></gold>\n\n" +
"<black>SpeedHG is a fast-paced Hunger Games mode.\n\n" +
"Gather resources, pick a Kit, and be the last player standing. " +
"The border shrinks over time — stay alert!</black>"
),
mm.deserialize(
"<gold><bold>Kits & Perks</bold></gold>\n\n" +
"<black>Open the Kit selector (hotbar slot 1) to choose your Kit " +
"and Playstyle.\n\n" +
"Open the Perk selector (slot 2) to pick up to 2 passive Perks. " +
"Combine them wisely!\n\nGood luck!</black>"
)
)
}
player.openBook( book )
}
// ── Event handlers ────────────────────────────────────────────────────────
/**
@@ -189,6 +290,9 @@ class LobbyItemManager(
{
TAG_KITS -> KitSelectorMenu( event.player ).open( event.player )
TAG_PERKS -> PerkSelectorMenu( event.player ).open( event.player )
TAG_TUTORIAL -> openTutorialBook( event.player )
TAG_STATS -> StatsMenu( event.player ).open( event.player )
TAG_LEADERBOARD -> LeaderboardMenu( event.player ).open( event.player )
}
}

View File

@@ -0,0 +1,209 @@
package club.mcscrims.speedhg.gui.menu
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.database.PlayerStats
import club.mcscrims.speedhg.ranking.Rank
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.TextDecoration
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
/**
* Displays the top 10 players by Scrim Score in a 5-row (45-slot) inventory.
*
* ## Layout (rows 04)
* ```
* [F][F][#1][#2][#3][#4][#5][F][F] ← Row 0: places 1-5 (slots 2-6)
* [F][F][#6][#7][#8][#9][#10][F][F] ← Row 1: places 6-10 (slots 11-15)
* [F][F][F][F][F][F][F][F][F] ← Row 2: filler
* [F][F][F][F][F][F][F][F][F] ← Row 3: filler
* [F][F][F][F][F][F][F][F][X] ← Row 4: close at slot 44
* ```
*
* ## Loading strategy
* The inventory opens immediately with animated "Loading…" placeholders,
* then [StatsManager.getLeaderboard] populates the real entries via its
* Main-Thread callback — no blocking, no perceivable delay.
*/
class LeaderboardMenu(
private val player: Player
) : Menu(
rows = 5,
title = player.trans( "gui.leaderboard_menu.title" )
) {
private val plugin get() = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
companion object {
private const val SLOT_CLOSE = 44
/** Slots occupied by the top-10 entries, in placement order. */
private val ENTRY_SLOTS = listOf( 2, 3, 4, 5, 6, 11, 12, 13, 14, 15 )
/** MiniMessage color tags per placement index (0-based). */
private val PLACEMENT_COLORS = listOf(
"<gold>", "<gray>", "<#CD7F32>",
"<white>", "<white>", "<white>",
"<white>", "<white>", "<white>", "<white>"
)
/** Display prefixes per placement index (0-based). */
private val PLACEMENT_LABELS = listOf(
"🥇", "🥈", "🥉",
"4.", "5.", "6.", "7.", "8.", "9.", "10."
)
/** Block material used as background per placement (1-based). */
private fun materialFor(
placement: Int
): Material = when( placement )
{
1 -> Material.GOLD_BLOCK
2 -> Material.IRON_BLOCK
3 -> Material.COPPER_BLOCK
else -> Material.STONE
}
}
// ── Build ─────────────────────────────────────────────────────────────────
override fun build(): Inventory = createInventory( title ).also { buildSkeleton( it ) }
/**
* Fills the inventory with filler + loading placeholders, then immediately
* triggers an async leaderboard fetch whose Main-Thread callback populates
* the real entries into the already-open inventory.
*/
private fun buildSkeleton(
inv: Inventory
) {
inv.clear()
val filler = buildFiller()
repeat( 45 ) { inv.setItem( it, filler ) }
ENTRY_SLOTS.forEachIndexed { index, slot ->
inv.setItem( slot, buildLoadingItem( index + 1 ) )
}
inv.setItem( SLOT_CLOSE, buildCloseItem() )
// Fetch top-10 asynchronously; callback already dispatched to Main Thread
plugin.statsManager.getLeaderboard( limit = 10 ) { entries ->
populateEntries( inv, entries )
}
}
private fun populateEntries(
inv: Inventory,
entries: List<PlayerStats>
) {
ENTRY_SLOTS.forEachIndexed { index, slot ->
val stats = entries.getOrNull( index )
inv.setItem(
slot,
if ( stats != null ) buildEntryItem( index, stats )
else buildEmptySlot( index + 1 )
)
}
}
// ── Click handling ────────────────────────────────────────────────────────
override fun onClick(
event: InventoryClickEvent,
player: Player
) {
val slot = event.rawSlot
if ( slot !in 0 until size ) return
if ( slot == SLOT_CLOSE ) player.closeInventory()
}
// ── Item builders ─────────────────────────────────────────────────────────
private fun buildEntryItem(
index: Int,
stats: PlayerStats
): ItemStack
{
val placement = index + 1
val color = PLACEMENT_COLORS.getOrElse( index ) { "<white>" }
val label = PLACEMENT_LABELS.getOrElse( index ) { "${placement}." }
val score = stats.scrimScore
val games = stats.wins + stats.losses
val rankTag = Rank.getFormattedRankTag( score, games )
val item = ItemStack( materialFor( placement ) )
item.editMeta { meta ->
meta.displayName(
mm.deserialize( "$label $color<bold>${stats.name}</bold><reset>" )
.decoration( TextDecoration.ITALIC, false )
)
meta.lore(listOf(
Component.empty(),
mm.deserialize( "<gray>Rank: </gray>$rankTag" )
.decoration( TextDecoration.ITALIC, false ),
mm.deserialize( "<gray>Score: <white>${score} RR</white></gray>" )
.decoration( TextDecoration.ITALIC, false ),
mm.deserialize( "<gray>Kills: <white>${stats.kills}</white></gray>" )
.decoration( TextDecoration.ITALIC, false ),
mm.deserialize( "<gray>K/D: <white>${stats.formattedKD}</white></gray>" )
.decoration( TextDecoration.ITALIC, false ),
mm.deserialize( "<gray>Wins: <white>${stats.wins}</white></gray>" )
.decoration( TextDecoration.ITALIC, false ),
mm.deserialize( "<gray>Win Rate: <white>${stats.formattedWinRate}</white></gray>" )
.decoration( TextDecoration.ITALIC, false ),
Component.empty()
))
}
return item
}
private fun buildLoadingItem(
placement: Int
): ItemStack
{
val item = ItemStack( Material.GRAY_STAINED_GLASS_PANE )
item.editMeta { meta ->
meta.displayName(
mm.deserialize( "<gray>#$placement — <italic>Loading…</italic></gray>" )
.decoration( TextDecoration.ITALIC, false )
)
}
return item
}
private fun buildEmptySlot(
placement: Int
): ItemStack
{
val item = ItemStack( Material.BARRIER )
item.editMeta { meta ->
meta.displayName(
mm.deserialize( "<gray>#$placement — <red>No Data</red></gray>" )
.decoration( TextDecoration.ITALIC, false )
)
}
return item
}
private fun buildFiller(): ItemStack = ItemStack( Material.BLACK_STAINED_GLASS_PANE ).apply {
editMeta { it.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) ) }
}
private fun buildCloseItem(): ItemStack = ItemStack( Material.DARK_OAK_DOOR ).apply {
editMeta { meta ->
meta.displayName(
mm.deserialize( "<red>✕ Close</red>" )
.decoration( TextDecoration.ITALIC, false )
)
}
}
}

View File

@@ -0,0 +1,187 @@
package club.mcscrims.speedhg.gui.menu
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.ranking.Rank
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.TextDecoration
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.Material
import org.bukkit.entity.Player
import org.bukkit.event.inventory.InventoryClickEvent
import org.bukkit.inventory.Inventory
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.SkullMeta
/**
* Displays a player's personal statistics in a 3-row (27-slot) inventory.
*
* ## Layout
* ```
* [F][F][F][F][HEAD][F][F][F][F] ← Row 0: Player head at slot 4
* [K][F][D][F][ W ][F][L][F][KD] ← Row 1: Kills / Deaths / Wins / Losses / K/D
* [F][F][SC][F][RK][F][WR][F][ X] ← Row 2: Score / Rank / Win Rate / Close
* ```
*
* All stats are read from the in-memory [StatsManager] cache — no DB call.
*/
class StatsMenu(
private val player: Player
) : Menu(
rows = 3,
title = player.trans( "gui.stats_menu.title" )
) {
private val plugin get() = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
companion object {
private const val SLOT_HEAD = 4
private const val SLOT_KILLS = 9
private const val SLOT_DEATHS = 11
private const val SLOT_WINS = 13
private const val SLOT_LOSSES = 15
private const val SLOT_KD = 17
private const val SLOT_SCORE = 20
private const val SLOT_RANK = 22
private const val SLOT_WIN_RATE = 24
private const val SLOT_CLOSE = 26
}
// ── Build ─────────────────────────────────────────────────────────────────
override fun build(): Inventory = createInventory( title ).also { populate( it ) }
private fun populate(
inv: Inventory
) {
inv.clear()
val filler = buildFiller()
repeat( 27 ) { inv.setItem( it, filler ) }
// ── Fetch cached stats ────────────────────────────────────────────────
val stats = plugin.statsManager.getCachedStats( player.uniqueId )
val kills = stats?.kills ?: 0
val deaths = stats?.deaths ?: 0
val wins = stats?.wins ?: 0
val losses = stats?.losses ?: 0
val kd = stats?.formattedKD ?: "0.00"
val score = stats?.scrimScore ?: 0
val wr = stats?.formattedWinRate ?: "0.0%"
val games = wins + losses
val rank = Rank.fromPlayer( score, games )
val rankTag = Rank.getFormattedRankTag( score, games )
// ── Populate items ────────────────────────────────────────────────────
inv.setItem( SLOT_HEAD, buildPlayerHead() )
inv.setItem( SLOT_KILLS, buildStatItem( Material.IRON_SWORD, "<green>Kills", "$kills" ) )
inv.setItem( SLOT_DEATHS, buildStatItem( Material.SKELETON_SKULL, "<red>Deaths", "$deaths" ) )
inv.setItem( SLOT_WINS, buildStatItem( Material.GOLDEN_APPLE, "<gold>Wins", "$wins" ) )
inv.setItem( SLOT_LOSSES, buildStatItem( Material.BARRIER, "<red>Losses", "$losses" ) )
inv.setItem( SLOT_KD, buildStatItem( Material.BLAZE_ROD, "<yellow>K/D", "$kd" ) )
inv.setItem( SLOT_SCORE, buildStatItem( Material.NETHER_STAR, "<aqua>Scrim Score", "$score RR" ) )
inv.setItem( SLOT_RANK, buildRankItem( rank, rankTag ) )
inv.setItem( SLOT_WIN_RATE, buildStatItem( Material.EXPERIENCE_BOTTLE, "<green>Win Rate", "$wr" ) )
inv.setItem( SLOT_CLOSE, buildCloseItem() )
}
// ── Click handling ────────────────────────────────────────────────────────
override fun onClick(
event: InventoryClickEvent,
player: Player
) {
val slot = event.rawSlot
if ( slot !in 0 until size ) return
if ( slot == SLOT_CLOSE ) player.closeInventory()
}
// ── Item builders ─────────────────────────────────────────────────────────
private fun buildPlayerHead(): ItemStack
{
val item = ItemStack( Material.PLAYER_HEAD )
item.editMeta { meta ->
if ( meta is SkullMeta )
meta.owningPlayer = player
meta.displayName(
mm.deserialize( "<aqua><bold>${player.name}</bold></aqua>" )
.decoration( TextDecoration.ITALIC, false )
)
meta.lore(listOf(
Component.empty(),
mm.deserialize( "<gray>Your personal statistics.</gray>" )
.decoration( TextDecoration.ITALIC, false ),
Component.empty()
))
}
return item
}
/**
* Builds a generic stat glass-pane item with a [label] and a [value] line in the lore.
*/
private fun buildStatItem(
material: Material,
label: String,
value: String
): ItemStack
{
val item = ItemStack( material )
item.editMeta { meta ->
meta.displayName(
mm.deserialize( "<gray>$label</gray>" )
.decoration( TextDecoration.ITALIC, false )
)
meta.lore(listOf(
Component.empty(),
mm.deserialize( "<white> $value</white>" )
.decoration( TextDecoration.ITALIC, false ),
Component.empty()
))
}
return item
}
private fun buildRankItem(
rank: Rank,
rankTag: String
): ItemStack
{
val item = ItemStack( Material.DIAMOND )
item.editMeta { meta ->
meta.displayName(
mm.deserialize( "<light_purple>Rank</light_purple>" )
.decoration( TextDecoration.ITALIC, false )
)
meta.lore(listOf(
Component.empty(),
mm.deserialize( " $rankTag" )
.decoration( TextDecoration.ITALIC, false ),
Component.empty()
))
}
return item
}
private fun buildFiller(): ItemStack = ItemStack( Material.GRAY_STAINED_GLASS_PANE ).apply {
editMeta { it.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) ) }
}
private fun buildCloseItem(): ItemStack = ItemStack( Material.DARK_OAK_DOOR ).apply {
editMeta { meta ->
meta.displayName(
mm.deserialize( "<red>✕ Close</red>" )
.decoration( TextDecoration.ITALIC, false )
)
}
}
}

View File

@@ -23,6 +23,15 @@ game:
name: '<gold><bold>Kits</bold></gold> <gray>(right-click)</gray>'
perks:
name: '<light_purple><bold>Perks</bold></light_purple> <gray>(right-click)</gray>'
tutorial:
name: '<yellow><bold>How to Play</bold></yellow>'
lore: '<gray>Read me to understand the game!</gray>'
stats:
name: '<aqua><bold>Profile & Stats</bold></aqua>'
lore: '<gray>View your personal statistics.</gray>'
leaderboard:
name: '<gold><bold>Top 10 Leaderboard</bold></gold>'
lore: '<gray>Who are the best players?</gray>'
ranking:
placement_progress: '<prefix><gray>Placement <aqua><current>/<total></aqua> — Placed <aqua>#<placement></aqua> · <aqua><kills></aqua> Kill(s)</gray>'
@@ -218,6 +227,10 @@ gui:
selected: '<green>Equipped: <perk><green>!'
deselected: '<red>Unequipped: <perk><red>!'
close: '<red>✕ Close</red>'
stats_menu:
title: '<aqua><bold>Profile & Stats</bold></aqua>'
leaderboard_menu:
title: '<gold><bold>Top 10 Leaderboard</bold></gold>'
perks:
oracle: