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_KITS
import club.mcscrims.speedhg.game.modules.LobbyItemManager.Companion.TAG_PERKS import club.mcscrims.speedhg.game.modules.LobbyItemManager.Companion.TAG_PERKS
import club.mcscrims.speedhg.gui.menu.KitSelectorMenu 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.PerkSelectorMenu
import club.mcscrims.speedhg.gui.menu.StatsMenu
import club.mcscrims.speedhg.util.ItemBuilder 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.Material
import org.bukkit.NamespacedKey import org.bukkit.NamespacedKey
import org.bukkit.entity.Player import org.bukkit.entity.Player
@@ -20,14 +24,19 @@ import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.BookMeta
import org.bukkit.inventory.meta.SkullMeta
import org.bukkit.persistence.PersistentDataType import org.bukkit.persistence.PersistentDataType
/** /**
* Manages interactive lobby hotbar items for the LOBBY and STARTING phases. * Manages interactive lobby hotbar items for the LOBBY and STARTING phases.
* *
* ## Items * ## Items
* - Slot 0: Kit selector (CHEST) * - Slot 0: Kit selector (CHEST)
* - Slot 1: Perk selector (ENDER_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 * ## PDC tagging
* Each item is tagged with [KEY_LOBBY_ITEM] → a String value identifying which * Each item is tagged with [KEY_LOBBY_ITEM] → a String value identifying which
@@ -42,6 +51,8 @@ class LobbyItemManager(
private val plugin: SpeedHG private val plugin: SpeedHG
) : Listener { ) : Listener {
private val mm = MiniMessage.miniMessage()
// ── PDC keys ───────────────────────────────────────────────────────────── // ── PDC keys ─────────────────────────────────────────────────────────────
/** Key stored on every lobby item; value identifies the target menu. */ /** Key stored on every lobby item; value identifies the target menu. */
@@ -50,6 +61,9 @@ class LobbyItemManager(
companion object { companion object {
const val TAG_KITS = "kits" const val TAG_KITS = "kits"
const val TAG_PERKS = "perks" const val TAG_PERKS = "perks"
const val TAG_TUTORIAL = "tutorial"
const val TAG_STATS = "stats"
const val TAG_LEADERBOARD = "leaderboard"
} }
// ── Item definitions ────────────────────────────────────────────────────── // ── Item definitions ──────────────────────────────────────────────────────
@@ -82,6 +96,55 @@ class LobbyItemManager(
.build() .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 ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
/** /**
@@ -96,6 +159,9 @@ class LobbyItemManager(
player.inventory.clear() player.inventory.clear()
player.inventory.setItem( 0, buildKitItem( player )) player.inventory.setItem( 0, buildKitItem( player ))
player.inventory.setItem( 1, buildPerkItem( 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 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 ──────────────────────────────────────────────────────── // ── Event handlers ────────────────────────────────────────────────────────
/** /**
@@ -187,8 +288,11 @@ class LobbyItemManager(
when( tag ) when( tag )
{ {
TAG_KITS -> KitSelectorMenu( event.player ).open( event.player ) TAG_KITS -> KitSelectorMenu( event.player ).open( event.player )
TAG_PERKS -> PerkSelectorMenu( 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>' name: '<gold><bold>Kits</bold></gold> <gray>(right-click)</gray>'
perks: perks:
name: '<light_purple><bold>Perks</bold></light_purple> <gray>(right-click)</gray>' 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: ranking:
placement_progress: '<prefix><gray>Placement <aqua><current>/<total></aqua> — Placed <aqua>#<placement></aqua> · <aqua><kills></aqua> Kill(s)</gray>' 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>!' selected: '<green>Equipped: <perk><green>!'
deselected: '<red>Unequipped: <perk><red>!' deselected: '<red>Unequipped: <perk><red>!'
close: '<red>✕ Close</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: perks:
oracle: oracle: