From f00bd1d8f4d798c413b191a423581b5ac11ba1c7 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Sat, 11 Apr 2026 23:29:58 +0200 Subject: [PATCH] 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. --- .../speedhg/game/modules/LobbyItemManager.kt | 112 +++++++++- .../speedhg/gui/menu/LeaderboardMenu.kt | 209 ++++++++++++++++++ .../mcscrims/speedhg/gui/menu/StatsMenu.kt | 187 ++++++++++++++++ src/main/resources/languages/en_US.yml | 13 ++ 4 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/menu/LeaderboardMenu.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/menu/StatsMenu.kt 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: