diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt
index f1d05ee..9645a42 100644
--- a/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt
+++ b/src/main/kotlin/club/mcscrims/speedhg/game/modules/LobbyItemManager.kt
@@ -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,14 +24,19 @@ 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
/**
* Manages interactive lobby hotbar items for the LOBBY and STARTING phases.
*
* ## Items
- * - Slot 0: Kit selector (CHEST)
- * - Slot 1: Perk selector (ENDER_CHEST)
+ * - 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( "SpeedHG - How to play" ))
+ meta.author(mm.deserialize( "McScrims Network" ))
+
+ meta.addPages(
+ mm.deserialize(
+ "Welcome!\n\n" +
+ "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!"
+ ),
+ mm.deserialize(
+ "Kits & Perks\n\n" +
+ "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!"
+ )
+ )
+ }
+
+ player.openBook( book )
+ }
+
// ── Event handlers ────────────────────────────────────────────────────────
/**
@@ -187,8 +288,11 @@ class LobbyItemManager(
when( tag )
{
- TAG_KITS -> KitSelectorMenu( event.player ).open( event.player )
- TAG_PERKS -> PerkSelectorMenu( event.player ).open( event.player )
+ 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 )
}
}
diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/LeaderboardMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/LeaderboardMenu.kt
new file mode 100644
index 0000000..7519b7e
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/LeaderboardMenu.kt
@@ -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 0–4)
+ * ```
+ * [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(
+ "", "", "<#CD7F32>",
+ "", "", "",
+ "", "", "", ""
+ )
+
+ /** 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
+ ) {
+ 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 ) { "" }
+ 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${stats.name}" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ meta.lore(listOf(
+ Component.empty(),
+ mm.deserialize( "Rank: $rankTag" )
+ .decoration( TextDecoration.ITALIC, false ),
+ mm.deserialize( "Score: ${score} RR" )
+ .decoration( TextDecoration.ITALIC, false ),
+ mm.deserialize( "Kills: ${stats.kills}" )
+ .decoration( TextDecoration.ITALIC, false ),
+ mm.deserialize( "K/D: ${stats.formattedKD}" )
+ .decoration( TextDecoration.ITALIC, false ),
+ mm.deserialize( "Wins: ${stats.wins}" )
+ .decoration( TextDecoration.ITALIC, false ),
+ mm.deserialize( "Win Rate: ${stats.formattedWinRate}" )
+ .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( "#$placement — Loading…" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ }
+ return item
+ }
+
+ private fun buildEmptySlot(
+ placement: Int
+ ): ItemStack
+ {
+ val item = ItemStack( Material.BARRIER )
+ item.editMeta { meta ->
+ meta.displayName(
+ mm.deserialize( "#$placement — No Data" )
+ .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( "✕ Close" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/StatsMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/StatsMenu.kt
new file mode 100644
index 0000000..fef7417
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/StatsMenu.kt
@@ -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, "Kills", "$kills" ) )
+ inv.setItem( SLOT_DEATHS, buildStatItem( Material.SKELETON_SKULL, "Deaths", "$deaths" ) )
+ inv.setItem( SLOT_WINS, buildStatItem( Material.GOLDEN_APPLE, "Wins", "$wins" ) )
+ inv.setItem( SLOT_LOSSES, buildStatItem( Material.BARRIER, "Losses", "$losses" ) )
+ inv.setItem( SLOT_KD, buildStatItem( Material.BLAZE_ROD, "K/D", "$kd" ) )
+
+ inv.setItem( SLOT_SCORE, buildStatItem( Material.NETHER_STAR, "Scrim Score", "$score RR" ) )
+ inv.setItem( SLOT_RANK, buildRankItem( rank, rankTag ) )
+ inv.setItem( SLOT_WIN_RATE, buildStatItem( Material.EXPERIENCE_BOTTLE, "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( "${player.name}" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ meta.lore(listOf(
+ Component.empty(),
+ mm.deserialize( "Your personal statistics." )
+ .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( "$label" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ meta.lore(listOf(
+ Component.empty(),
+ mm.deserialize( " $value" )
+ .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( "Rank" )
+ .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( "✕ Close" )
+ .decoration( TextDecoration.ITALIC, false )
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml
index b46d801..153ef4c 100644
--- a/src/main/resources/languages/en_US.yml
+++ b/src/main/resources/languages/en_US.yml
@@ -23,6 +23,15 @@ game:
name: 'Kits (right-click)'
perks:
name: 'Perks (right-click)'
+ tutorial:
+ name: 'How to Play'
+ lore: 'Read me to understand the game!'
+ stats:
+ name: 'Profile & Stats'
+ lore: 'View your personal statistics.'
+ leaderboard:
+ name: 'Top 10 Leaderboard'
+ lore: 'Who are the best players?'
ranking:
placement_progress: 'Placement / — Placed # · Kill(s)'
@@ -218,6 +227,10 @@ gui:
selected: 'Equipped: !'
deselected: 'Unequipped: !'
close: '✕ Close'
+ stats_menu:
+ title: 'Profile & Stats'
+ leaderboard_menu:
+ title: 'Top 10 Leaderboard'
perks:
oracle: