From 72a58fdd9c62de90f98d3fab67c0ff849ca3603a Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Thu, 26 Mar 2026 17:06:40 +0100 Subject: [PATCH] Add Kit GUI (menu/anvil) and integrate MenuListener Introduce a full chest-based kit selection UI and anvil search: add Menu, MenuHolder, KitSelectorMenu, KitItemFactory, AnvilSearchMenu (with AnvilSearchTracker) and a central MenuListener to dispatch menu and anvil interactions. Integrate the GUI into KitCommand (opens selector on no args, enforces mid-game selection rules, remove/replace backup kits when switching) and register MenuListener in the main plugin. Add permission speedhg.bypass and update ConnectListener to kick joiners when a game is running unless they have the bypass permission. Remove static playstyle lore from language file (now rendered dynamically), and apply small import/refactor cleanups (use wildcard imports for kit impls, java.util.* replacements). --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 8 +- .../mcscrims/speedhg/command/KitCommand.kt | 33 +-- .../mcscrims/speedhg/database/PlayerStats.kt | 2 +- .../speedhg/database/PlayerStatsRepository.kt | 2 +- .../mcscrims/speedhg/database/StatsManager.kt | 2 +- .../speedhg/gui/anvil/AnvilSearchMenu.kt | 163 ++++++++++++ .../speedhg/gui/factory/KitItemFactory.kt | 175 +++++++++++++ .../speedhg/gui/listener/MenuListener.kt | 98 +++++++ .../speedhg/gui/menu/KitSelectorMenu.kt | 243 ++++++++++++++++++ .../club/mcscrims/speedhg/gui/menu/Menu.kt | 65 +++++ .../mcscrims/speedhg/gui/menu/MenuHolder.kt | 35 +++ .../kotlin/club/mcscrims/speedhg/kit/Kit.kt | 2 +- .../mcscrims/speedhg/kit/impl/BackupKit.kt | 2 +- .../mcscrims/speedhg/kit/impl/GladiatorKit.kt | 9 +- .../mcscrims/speedhg/kit/impl/TemplateKit.kt | 2 +- .../kit/listener/KitEventDispatcher.kt | 7 +- .../speedhg/listener/ConnectListener.kt | 8 +- .../speedhg/listener/GameStateListener.kt | 6 +- src/main/resources/languages/en_US.yml | 25 -- src/main/resources/plugin.yml | 5 + 20 files changed, 820 insertions(+), 72 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/anvil/AnvilSearchMenu.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/factory/KitItemFactory.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/listener/MenuListener.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/menu/KitSelectorMenu.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/menu/Menu.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/gui/menu/MenuHolder.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 45b2137..1809ef3 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -7,12 +7,9 @@ import club.mcscrims.speedhg.database.DatabaseManager import club.mcscrims.speedhg.database.StatsManager import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.modules.AntiRunningManager +import club.mcscrims.speedhg.gui.listener.MenuListener import club.mcscrims.speedhg.kit.KitManager -import club.mcscrims.speedhg.kit.impl.BackupKit -import club.mcscrims.speedhg.kit.impl.GladiatorKit -import club.mcscrims.speedhg.kit.impl.GoblinKit -import club.mcscrims.speedhg.kit.impl.IceMageKit -import club.mcscrims.speedhg.kit.impl.VenomKit +import club.mcscrims.speedhg.kit.impl.* import club.mcscrims.speedhg.kit.listener.KitEventDispatcher import club.mcscrims.speedhg.listener.ConnectListener import club.mcscrims.speedhg.listener.GameStateListener @@ -118,6 +115,7 @@ class SpeedHG : JavaPlugin() { pm.registerEvents( SoupListener(), this ) pm.registerEvents(KitEventDispatcher( this, kitManager ), this ) pm.registerEvents( StatsListener(), this ) + pm.registerEvents( MenuListener(), this ) } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt index 4d5e275..a7b741c 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt @@ -2,6 +2,7 @@ package club.mcscrims.speedhg.command import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.gui.menu.KitSelectorMenu import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.impl.BackupKit import club.mcscrims.speedhg.util.sendMsg @@ -30,9 +31,22 @@ class KitCommand : CommandExecutor, TabCompleter { return true } + val state = plugin.gameManager.currentState + val selectedKit = plugin.kitManager.getSelectedKit( player ) + val isBackup = selectedKit is BackupKit + + val ingame = state == GameState.INVINCIBILITY || + state == GameState.INGAME + + if ( ingame && !isBackup ) + { + player.sendMsg( "commands.kit.gameHasStarted" ) + return true + } + if ( args.isNullOrEmpty() || args.size < 2 ) { - player.sendMsg( "commands.kit.usage" ) + KitSelectorMenu( player ).open( player ) return true } @@ -53,15 +67,7 @@ class KitCommand : CommandExecutor, TabCompleter { return true } - val playerKit = plugin.kitManager.getSelectedKit( player ) - val isBackup = playerKit != null && playerKit is BackupKit - - if ( !isBackup && isIngame() ) - { - player.sendMsg("commands.kit.gameHasStarted") - return true - } - else if ( isBackup && isIngame() ) + if ( isBackup && ingame ) { if ( kit is BackupKit ) { @@ -69,6 +75,7 @@ class KitCommand : CommandExecutor, TabCompleter { return true } + plugin.kitManager.removeKit( player ) plugin.kitManager.selectKit( player, kit ) plugin.kitManager.selectPlaystyle( player, playstyle ) plugin.kitManager.applyKit( player ) @@ -103,10 +110,4 @@ class KitCommand : CommandExecutor, TabCompleter { return listOf() } - private fun isIngame(): Boolean = when ( plugin.gameManager.currentState ) - { - GameState.INGAME, GameState.INVINCIBILITY -> true - else -> false - } - } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt index ba6af38..19f3dda 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt @@ -1,6 +1,6 @@ package club.mcscrims.speedhg.database -import java.util.UUID +import java.util.* /** * Hält die rohen Spieler-Statistiken im Arbeitsspeicher. diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt index a85e438..87c4a9e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt @@ -1,6 +1,6 @@ package club.mcscrims.speedhg.database -import java.util.UUID +import java.util.* /** * Kapselt alle SQL-Operationen für die `player_stats`-Tabelle. diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt b/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt index fd190bf..620c707 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt @@ -2,7 +2,7 @@ package club.mcscrims.speedhg.database import club.mcscrims.speedhg.SpeedHG import kotlinx.coroutines.* -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration.Companion.milliseconds diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/anvil/AnvilSearchMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/anvil/AnvilSearchMenu.kt new file mode 100644 index 0000000..c71667a --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/anvil/AnvilSearchMenu.kt @@ -0,0 +1,163 @@ +package club.mcscrims.speedhg.gui.anvil + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.gui.menu.KitSelectorMenu +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.AnvilInventory +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.view.AnvilView + +/** + * Suchmenü auf Basis der **Paper Anvil-API** (kein NMS!). + * + * ## Wie Paper's Anvil-API funktioniert + * + * Paper stellt `player.openAnvil(location, force)` bereit. Diese Methode + * gibt eine `InventoryView` zurück. Da wir uns nicht für die physische Position + * interessieren (nur das Inventar-UI), übergeben wir `null` als Ort und + * `true` als Force-Flag (öffnet auch ohne physischen Amboss in der Nähe). + * + * ``` + * Slot 0 (INPUT_LEFT) → Das "Such-Icon" mit dem Placeholder-Text + * Slot 1 (INPUT_RIGHT) → Leer (wird vom Spieler nicht benutzt) + * Slot 2 (OUTPUT) → Das Ergebnis-Item → Klick bestätigt die Suche + * ``` + * + * Der Spieler schreibt in das Umbenennen-Feld des Amboss. Wir lesen den + * eingegebenen Text über `AnvilView.renameText` aus. Paper aktualisiert + * diesen Wert in Echtzeit. Beim Klick auf Slot 2 (Output) schließen wir + * den Amboss und übergeben den Text an [KitSelectorMenu.applySearch]. + * + * **Warum kein `PrepareAnvilEvent` + Fake-Item?** + * Für unsere reine Text-Eingabe brauchen wir kein Live-Feedback im Output-Slot. + * Wir setzen ein statisches Bestätigungs-Item und lesen den Rename-Text beim + * Klick aus. Das ist einfacher und stabiler. + * + * @param player Der suchende Spieler. + * @param returnMenu Das [KitSelectorMenu] zu dem wir nach der Suche zurückspringen. + * @param initialText Vorausgefüllter Text (wird als Rename-Text gesetzt). + */ +@Suppress("DEPRECATION") +class AnvilSearchMenu( + private val player: Player, + private val returnMenu: KitSelectorMenu, + private val initialText: String = "" +) { + private val plugin = SpeedHG.instance + private val mm = MiniMessage.miniMessage() + + /** Öffnet das Anvil-Inventar. */ + fun open(player: Player) { + // Paper API: öffnet einen Amboss ohne physischen Block + val view = player.openAnvil(null, true) ?: return + val anvilInv = view.topInventory as? AnvilInventory ?: return + + // ── Slot 0: Such-Icon (Input) ────────────────────────────────────── + anvilInv.setItem(0, buildInputItem()) + + // ── Slot 2: Bestätigungs-Icon (Output) ──────────────────────────── + anvilInv.setItem(2, buildConfirmItem()) + + // Kosten auf 0 setzen — Spieler soll kein XP zahlen + anvilInv.repairCost = 0 + + // Klick-Listener merken uns über die AnvilTracker-Map + AnvilSearchTracker.register(player, this) + } + + /** + * Verarbeitet einen Klick im Anvil-Inventory. + * Wird vom [MenuListener] über [AnvilSearchTracker] weitergeleitet. + * + * @param event Das Click-Event (bereits gecancelt). + * @param view Die geöffnete [AnvilView] mit dem Eingabetext. + */ + fun onClick(event: InventoryClickEvent, view: AnvilView) { + event.isCancelled = true + + // Nur Klick auf Output-Slot (Slot 2) bestätigt die Suche + if (event.rawSlot != 2) return + + val query = view.renameText ?: "" + + // Amboss schließen und zurück zum Kit-Menü + player.closeInventory() + AnvilSearchTracker.unregister(player) + + // Suchbegriff anwenden → öffnet KitSelectorMenu neu + returnMenu.applySearch(query) + } + + /** Cleanup wenn Spieler Amboss schließt ohne zu bestätigen. */ + fun onClose() { + AnvilSearchTracker.unregister(player) + // Zurück zum Kit-Menü ohne Suchbegriff zu ändern + // Einen Tick warten, damit das aktuelle Close-Event fertig ist + plugin.server.scheduler.runTask(plugin) { -> + returnMenu.open(player) + } + } + + // ------------------------------------------------------------------------- + // Item-Builder + // ------------------------------------------------------------------------- + + private fun buildInputItem(): ItemStack { + val item = ItemStack(Material.NAME_TAG) + item.editMeta { meta -> + meta.displayName( + // Der Name des Input-Items wird als Placeholder-Text im Umbenennungsfeld angezeigt + mm.deserialize(initialText.ifEmpty { "Suchbegriff eingeben..." }) + .decoration(TextDecoration.ITALIC, false) + ) + } + return item + } + + private fun buildConfirmItem(): ItemStack { + val item = ItemStack(Material.NAME_TAG) + item.editMeta { meta -> + meta.displayName( + mm.deserialize("✔ Suche bestätigen") + .decoration(TextDecoration.ITALIC, false) + ) + val lore = listOf( + Component.empty(), + mm.deserialize("Klicken um zu suchen") + .decoration(TextDecoration.ITALIC, false), + Component.empty() + ) + meta.lore(lore) + } + return item + } +} + +/** + * Hält die Zuordnung `Player → AnvilSearchMenu` für alle aktuell offenen + * Anvil-Suchen. Wird benötigt, weil Anvil-Inventories keinen eigenen + * [MenuHolder] haben — sie werden direkt von Bukkit verwaltet. + * + * Die Map wird im [MenuListener] abgefragt. + */ +object AnvilSearchTracker { + + private val openSearches = java.util.concurrent.ConcurrentHashMap() + + fun register(player: Player, menu: AnvilSearchMenu) { + openSearches[player.uniqueId] = menu + } + + fun unregister(player: Player) { + openSearches.remove(player.uniqueId) + } + + fun getMenu(player: Player): AnvilSearchMenu? = openSearches[player.uniqueId] + + fun isSearchOpen(player: Player): Boolean = openSearches.containsKey(player.uniqueId) +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/factory/KitItemFactory.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/factory/KitItemFactory.kt new file mode 100644 index 0000000..954d198 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/factory/KitItemFactory.kt @@ -0,0 +1,175 @@ +package club.mcscrims.speedhg.gui.factory + +import club.mcscrims.speedhg.kit.Kit +import club.mcscrims.speedhg.kit.Playstyle +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.enchantments.Enchantment +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack + +/** + * Erstellt dynamisch die [ItemStack]s für das Kit-Auswahl-Menü. + * + * ## Warum eine eigene Factory? + * + * Die Item-Generierung (Icon, Name, Lore, Enchants) ist von [KitSelectorMenu] + * trennbar und testbar. Außerdem kann sie wiederverwendet werden (z.B. für + * Hotbar-Items oder Chat-Vorschauen). + * + * ## Lore-Aufbau + * ``` + * ───────────────────────── + * + * + * + * Playstyle: ► Aggressive + * + * [L-Klick] Kit auswählen + * [R-Klick] Playstyle wechseln + * ───────────────────────── + * ``` + */ +object KitItemFactory { + + private val mm = MiniMessage.miniMessage() + + /** + * Baut das vollständige Kit-Item inklusive dynamischer Lore. + * + * @param kit Das Kit, das dargestellt werden soll. + * @param currentStyle Der aktuell gewählte [Playstyle] des Spielers. + * @param isSelected Ob dieses Kit gerade das aktiv gewählte ist. + */ + fun buildKitItem( + kit: Kit, + currentStyle: Playstyle, + isSelected: Boolean + ): ItemStack { + val item = ItemStack(kit.icon) + + item.editMeta { meta -> + // ── Name ────────────────────────────────────────────────────────── + meta.displayName( + kit.displayName + .decoration(TextDecoration.ITALIC, false) + ) + + // ── Lore ────────────────────────────────────────────────────────── + val lore = mutableListOf() + + // Trennlinie + lore += separator() + + // Kit-eigene Lore-Zeilen + kit.lore.forEach { line -> + lore += mm.deserialize(line) + .decoration(TextDecoration.ITALIC, false) + } + + lore += Component.empty() + + // Playstyle-Anzeige + lore += mm.deserialize( + "Playstyle: ► ${currentStyle.displayName}" + ).decoration(TextDecoration.ITALIC, false) + + lore += Component.empty() + + // Interaktions-Hints + lore += mm.deserialize("[L-Click] Select Kit") + .decoration(TextDecoration.ITALIC, false) + lore += mm.deserialize("[R-Click] Change playstyle") + .decoration(TextDecoration.ITALIC, false) + + lore += separator() + + meta.lore(lore) + + // ── Ausgewählt-Effekt ────────────────────────────────────────────── + if (isSelected) { + meta.addEnchant(Enchantment.UNBREAKING, 1, true) + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS) + } + + meta.addItemFlags( + ItemFlag.HIDE_ATTRIBUTES, + ItemFlag.HIDE_ADDITIONAL_TOOLTIP + ) + } + + return item + } + + // ── Dekorations-Items ───────────────────────────────────────────────────── + + fun buildFillerItem(): ItemStack { + val item = ItemStack(org.bukkit.Material.GRAY_STAINED_GLASS_PANE) + item.editMeta { meta -> + meta.displayName(Component.text(" ").decoration(TextDecoration.ITALIC, false)) + } + return item + } + + fun buildPrevPageItem(): ItemStack = buildNavItem( + org.bukkit.Material.ARROW, + "◄ Previous" + ) + + fun buildNextPageItem(): ItemStack = buildNavItem( + org.bukkit.Material.ARROW, + "Next ►" + ) + + fun buildSearchItem(currentQuery: String): ItemStack { + val item = ItemStack(org.bukkit.Material.NAME_TAG) + item.editMeta { meta -> + meta.displayName( + mm.deserialize("🔍 Search") + .decoration(TextDecoration.ITALIC, false) + ) + val lore = mutableListOf() + lore += separator() + if (currentQuery.isNotEmpty()) { + lore += mm.deserialize("Current: \"$currentQuery\"") + .decoration(TextDecoration.ITALIC, false) + lore += Component.empty() + } + lore += mm.deserialize("Click to search") + .decoration(TextDecoration.ITALIC, false) + lore += separator() + meta.lore(lore) + } + return item + } + + fun buildClearSearchItem(): ItemStack = buildNavItem( + org.bukkit.Material.BARRIER, + "✕ Reset search" + ) + + fun buildCloseItem(): ItemStack = buildNavItem( + org.bukkit.Material.DARK_OAK_DOOR, + "✕ Close" + ) + + // ── Interne Hilfsmittel ─────────────────────────────────────────────────── + + private fun buildNavItem(material: org.bukkit.Material, title: String): ItemStack { + val item = ItemStack(material) + item.editMeta { meta -> + meta.displayName( + mm.deserialize(title) + .decoration(TextDecoration.ITALIC, false) + ) + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP) + } + return item + } + + private fun separator(): Component = + Component.text("────────────────────", NamedTextColor.DARK_GRAY) + .decoration(TextDecoration.ITALIC, false) +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/listener/MenuListener.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/listener/MenuListener.kt new file mode 100644 index 0000000..35f05c4 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/listener/MenuListener.kt @@ -0,0 +1,98 @@ +package club.mcscrims.speedhg.gui.listener + +import club.mcscrims.speedhg.gui.anvil.AnvilSearchMenu +import club.mcscrims.speedhg.gui.anvil.AnvilSearchTracker +import club.mcscrims.speedhg.gui.menu.MenuHolder +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.view.AnvilView + +/** + * Zentraler, einmalig registrierter Listener für alle Menü-Interaktionen. + * + * ## Dispatch-Strategie + * + * ### Chest-Menüs (KitSelectorMenu und andere Menu-Subklassen) + * ``` + * InventoryClickEvent + * → event.inventory.holder as? MenuHolder (O(1), kein Map-Lookup) + * → holder.menu.onClick(event, player) + * ``` + * + * ### Anvil-Menüs (AnvilSearchMenu) + * Anvil-Inventories haben keinen Custom-Holder (Bukkit setzt intern `CraftAnvil`). + * Deshalb nutzen wir den [AnvilSearchTracker], der `Player → AnvilSearchMenu` + * speichert. Der Lookup ist UUID-basiert und ebenfalls O(1). + * ``` + * InventoryClickEvent (Typ = ANVIL) + * → AnvilSearchTracker.getMenu(player) + * → menu.onClick(event, anvilView) + * ``` + * + * ### Close-Events + * Beim Schließen eines Anvil-Menüs ohne Bestätigung navigiert + * [AnvilSearchMenu.onClose] automatisch zurück zum Kit-Menü. + */ +class MenuListener : Listener { + + // ========================================================================= + // Click-Handling + // ========================================================================= + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = false) + fun onInventoryClick(event: InventoryClickEvent) { + val player = event.whoClicked as? Player ?: return + + // ── Anvil-Suche ──────────────────────────────────────────────────────── + if (event.inventory.type == InventoryType.ANVIL) { + val searchMenu = AnvilSearchTracker.getMenu(player) ?: return + val anvilView = event.view as? AnvilView ?: return + searchMenu.onClick(event, anvilView) + return + } + + // ── Chest-Menü (MenuHolder-Dispatch) ─────────────────────────────────── + val holder = event.inventory.holder as? MenuHolder ?: return + val menu = holder.menu + + // Alle Klicks in Custom-Menüs standardmäßig canceln + event.isCancelled = true + + // Nur Klicks im oberen Inventory (das Menü selbst) verarbeiten, + // nicht Klicks im Spieler-Inventory (unterer Teil) + if (event.clickedInventory != event.inventory) return + + menu.onClick(event, player) + } + + // ========================================================================= + // Close-Handling + // ========================================================================= + + @EventHandler(priority = EventPriority.MONITOR) + fun onInventoryClose(event: InventoryCloseEvent) { + val player = event.player as? Player ?: return + + // ── Anvil geschlossen → zurück zum Kit-Menü ──────────────────────────── + if (event.inventory.type == InventoryType.ANVIL && AnvilSearchTracker.isSearchOpen(player)) { + val searchMenu = AnvilSearchTracker.getMenu(player) ?: return + + // onClose leitet selbst zurück zum Kit-Menü. + // Wir prüfen hier, ob das Close NICHT durch onClick ausgelöst wurde. + // AnvilSearchMenu.onClick ruft unregister() vor player.closeInventory() auf, + // sodass isSearchOpen() bei dem Close-Event danach false zurückgibt. + // Kommt der Close-Event aber zuerst (ESC-Druck), ist der Tracker noch gefüllt. + searchMenu.onClose() + return + } + + // ── Chest-Menü: onClose-Hook aufrufen ───────────────────────────────── + val holder = event.inventory.holder as? MenuHolder ?: return + holder.menu.onClose(event, player) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/KitSelectorMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/KitSelectorMenu.kt new file mode 100644 index 0000000..a998438 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/KitSelectorMenu.kt @@ -0,0 +1,243 @@ +package club.mcscrims.speedhg.gui.menu + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.gui.anvil.AnvilSearchMenu +import club.mcscrims.speedhg.gui.factory.KitItemFactory +import club.mcscrims.speedhg.kit.Kit +import club.mcscrims.speedhg.kit.Playstyle +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.entity.Player +import org.bukkit.event.inventory.ClickType +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory + +/** + * Das Haupt-Kit-Auswahl-Menü mit Pagination und Suchfunktion. + * + * ## Slot-Layout (6 Reihen × 9 Spalten = 54 Slots) + * ``` + * [ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ] ← Kits (Seite 1) + * [ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ] + * [ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ] + * [ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ][ K ] + * [ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ] ← Filler + * [◄] [ F ][ F ][SRCH][F ][ F ][CLR][ F ][►] ← Navigation + * ``` + * - Slots 0–44: 45 Kit-Plätze + * - Slots 45–53: Navigationsleiste + * + * @param player Der Spieler, für den das Menü geöffnet wird. + * @param searchQuery Optionaler Suchbegriff; filtert die angezeigten Kits. + */ +class KitSelectorMenu( + private val player: Player, + private var searchQuery: String = "" +) : Menu(rows = 6, title = "Kit-Auswahl") { + + private val plugin = SpeedHG.instance + private val mm = MiniMessage.miniMessage() + + // Pagination + private val kitSlots = 45 // Slots 0–44 für Kits + private var currentPage = 0 + + // ── Slot-Konstanten ──────────────────────────────────────────────────────── + private val slotPrevPage = 45 + private val slotSearch = 48 + private val slotClearSearch = 50 + private val slotClose = 53 + private val slotNextPage = 53 // wird durch slotClose ersetzt wenn letzte Seite + + // ── Navigation-Slots ────────────────────────────────────────────────────── + private val navSlotPrev = 45 + private val navSlotSearch = 48 + private val navSlotClear = 50 + private val navSlotNext = 53 + + // ------------------------------------------------------------------------- + // Aufbau + // ------------------------------------------------------------------------- + + override fun build(): Inventory { + val inv = createInventory(mm.deserialize(title)) + populate(inv) + return inv + } + + private fun populate(inv: Inventory) { + inv.clear() + + // ── Filler ──────────────────────────────────────────────────────────── + val filler = KitItemFactory.buildFillerItem() + (45 until 54).forEach { inv.setItem(it, filler) } + + // ── Kits ────────────────────────────────────────────────────────────── + val filteredKits = getFilteredKits() + val totalPages = ((filteredKits.size - 1) / kitSlots + 1).coerceAtLeast(1) + + // Seiten-Clamp: wenn Suche Kits reduziert, nicht auf nicht-existenter Seite bleiben + currentPage = currentPage.coerceIn(0, totalPages - 1) + + val pageStart = currentPage * kitSlots + val pageKits = filteredKits.subList( + pageStart.coerceAtMost(filteredKits.size), + (pageStart + kitSlots).coerceAtMost(filteredKits.size) + ) + + pageKits.forEachIndexed { slotIndex, kit -> + val currentStyle = plugin.kitManager.getSelectedPlaystyle(player) + val selectedKit = plugin.kitManager.getSelectedKit(player) + inv.setItem(slotIndex, KitItemFactory.buildKitItem(kit, currentStyle, selectedKit?.id == kit.id)) + } + + // ── Navigation ──────────────────────────────────────────────────────── + if (currentPage > 0) + inv.setItem(navSlotPrev, KitItemFactory.buildPrevPageItem()) + + if (currentPage < totalPages - 1) + inv.setItem(navSlotNext, KitItemFactory.buildNextPageItem()) + + inv.setItem(navSlotSearch, KitItemFactory.buildSearchItem(searchQuery)) + + if (searchQuery.isNotEmpty()) + inv.setItem(navSlotClear, KitItemFactory.buildClearSearchItem()) + } + + // ------------------------------------------------------------------------- + // Klick-Handling + // ------------------------------------------------------------------------- + + override fun onClick(event: InventoryClickEvent, player: Player) { + val slot = event.rawSlot + if (slot !in 0.. handlePrevPage() + navSlotNext -> handleNextPage() + navSlotSearch -> handleSearchClick() + navSlotClear -> handleClearSearch() + in 0 until kitSlots -> handleKitClick(slot, event.click) + } + } + + // ------------------------------------------------------------------------- + // Kit-Interaktionen + // ------------------------------------------------------------------------- + + private fun handleKitClick(slot: Int, clickType: ClickType) { + val kit = getKitAtSlot(slot) ?: return + + when { + // Linksklick → Kit auswählen + clickType.isLeftClick -> selectKit(kit) + // Rechtsklick → Playstyle toggeln (und Menü refreshen) + clickType.isRightClick -> togglePlaystyle(kit) + } + } + + private fun selectKit(kit: Kit) { + plugin.kitManager.selectKit(player, kit) + player.sendActionBar( + MiniMessage.miniMessage().deserialize( + "Kit ${kit.displayName} gewählt!" + ) + ) + // Menü aktualisieren um Auswahl-Glanz zu zeigen + refresh() + } + + private fun togglePlaystyle(kit: Kit) { + val current = plugin.kitManager.getSelectedPlaystyle(player) + val next = when (current) { + Playstyle.AGGRESSIVE -> Playstyle.DEFENSIVE + Playstyle.DEFENSIVE -> Playstyle.AGGRESSIVE + } + plugin.kitManager.selectPlaystyle(player, next) + + // Wenn dieses Kit gerade ausgewählt ist, Playstyle-Änderung übernehmen + if (plugin.kitManager.getSelectedKit(player)?.id == kit.id) { + player.sendActionBar( + MiniMessage.miniMessage().deserialize( + "Playstyle: ► ${next.displayName}" + ) + ) + } + + refresh() + } + + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- + + private fun handlePrevPage() { + if (currentPage > 0) { + currentPage-- + refresh() + } + } + + private fun handleNextPage() { + val totalPages = ((getFilteredKits().size - 1) / kitSlots + 1).coerceAtLeast(1) + if (currentPage < totalPages - 1) { + currentPage++ + refresh() + } + } + + private fun handleSearchClick() { + // Öffne das Anvil-Suchmenü; schließt dieses Menü temporär + AnvilSearchMenu( + player = player, + returnMenu = this, + initialText = searchQuery + ).open(player) + } + + private fun handleClearSearch() { + searchQuery = "" + currentPage = 0 + refresh() + } + + // ------------------------------------------------------------------------- + // Hilfsmittel + // ------------------------------------------------------------------------- + + /** Aktualisiert das geöffnete Inventory in-place ohne es neu zu öffnen. */ + fun refresh() { + populate(inventory) + } + + /** + * Aktualisiert den Suchbegriff und baut das Menü neu auf. + * Wird vom [AnvilSearchMenu] aufgerufen nachdem der Spieler einen Begriff eingegeben hat. + */ + fun applySearch(query: String) { + searchQuery = query.trim() + currentPage = 0 + open(player) // neu öffnen (Anvil hat das vorherige Inventory geschlossen) + } + + private fun getFilteredKits(): List { + val allKits = plugin.kitManager.getRegisteredKits().toList() + if (searchQuery.isEmpty()) return allKits + + val q = searchQuery.lowercase() + return allKits.filter { kit -> + // Suche im Plain-Text des Adventure-Components + kit.id.lowercase().contains(q) || + net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer + .plainText() + .serialize(kit.displayName) + .lowercase() + .contains(q) + } + } + + private fun getKitAtSlot(slot: Int): Kit? { + val filteredKits = getFilteredKits() + val absoluteIndex = currentPage * kitSlots + slot + return filteredKits.getOrNull(absoluteIndex) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/Menu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/Menu.kt new file mode 100644 index 0000000..1504fc7 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/Menu.kt @@ -0,0 +1,65 @@ +package club.mcscrims.speedhg.gui.menu + +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory + +/** + * Abstrakte Basisklasse für alle Chest-basierenden Menüs. + * + * ## Verantwortlichkeiten + * - Erstellt und befüllt das Inventory via [build]. + * - Stellt [onClick] und [onClose] als override-bare Hooks bereit. + * - Öffnet sich selbst via [open]. + * + * ## Designprinzip + * Jede [Menu]-Unterklasse ist zustandsbehaftet (enthält ihren eigenen + * Seiten-Index, Suchbegriff etc.) und ist genau einem Spieler zugeordnet. + * Zwei verschiedene Spieler, die das Kit-Menü öffnen, erhalten je eine + * eigene [KitSelectorMenu]-Instanz. + * + * @param rows Anzahl der Reihen (1–6). + * @param title Inventar-Titel als MiniMessage-String. + */ +abstract class Menu( + protected val rows: Int, + protected val title: String +) { + protected val size: Int = rows * 9 + internal lateinit var inventory: Inventory + private set + + /** Erstellt das Inventory und befüllt es initial. Wird in [open] aufgerufen. */ + protected abstract fun build(): Inventory + + /** + * Verarbeitet einen Klick auf einen Slot. + * Wird vom [MenuListener] aufgerufen. + * Das Event ist zu diesem Zeitpunkt bereits gecancelt. + */ + abstract fun onClick(event: InventoryClickEvent, player: Player) + + /** Optionaler Hook wenn das Inventar geschlossen wird. */ + open fun onClose(event: InventoryCloseEvent, player: Player) {} + + /** Erstellt, befüllt und öffnet das Menü für den Spieler. */ + fun open(player: Player) { + inventory = build() + player.openInventory(inventory) + } + + // ── Hilfsmittel ─────────────────────────────────────────────────────────── + + /** + * Erstellt ein Inventory mit dem korrekten [MenuHolder]. + * Immer statt `Bukkit.createInventory(null, ...)` verwenden. + */ + protected fun createInventory(parsedTitle: net.kyori.adventure.text.Component): Inventory { + val holder = MenuHolder(this) + val inv = Bukkit.createInventory(holder, size, parsedTitle) + holder.bind(inv) + return inv + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/MenuHolder.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/MenuHolder.kt new file mode 100644 index 0000000..9620082 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/MenuHolder.kt @@ -0,0 +1,35 @@ +package club.mcscrims.speedhg.gui.menu + +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.InventoryHolder + +/** + * Verbindet ein Bukkit [Inventory] mit seiner zugehörigen [Menu]-Instanz. + * + * ## Warum kein globales HashMap? + * + * Das naive Muster ist: `openMenus: Map`, das beim Öffnen + * befüllt und beim Schließen geleert wird — mit dem Risiko, bei Crashes + * oder unerwarteten Schließ-Events Einträge zu leaken. + * + * Der sauberere Ansatz: Bukkit erlaubt jedem Inventory einen [InventoryHolder]. + * `MenuHolder` ist dieser Holder. Wann immer der [MenuListener] ein + * `InventoryClickEvent` empfängt, prüft er einfach: + * ```kotlin + * val menu = (event.inventory.holder as? MenuHolder)?.menu ?: return + * ``` + * Kein Map-Lookup, kein UUID-Tracking — der Holder lebt und stirbt + * zusammen mit dem Inventory-Objekt. + */ +class MenuHolder(val menu: Menu) : InventoryHolder { + + /** Wird von Bukkit gesetzt nachdem das Inventory erstellt wurde. */ + private lateinit var inventory: Inventory + + override fun getInventory(): Inventory = inventory + + /** Wird vom Menu direkt nach der Erstellung aufgerufen. */ + internal fun bind(inv: Inventory) { + this.inventory = inv + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt index 49adbab..3605ebc 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt @@ -6,7 +6,7 @@ import net.kyori.adventure.text.Component import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt index e9aee9b..dea4464 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt @@ -10,7 +10,7 @@ import net.kyori.adventure.text.Component import org.bukkit.Material import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap class BackupKit : Kit() { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt index d9b7cfe..6270ea1 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt @@ -15,19 +15,14 @@ import com.sk89q.worldedit.math.Vector2 import com.sk89q.worldedit.regions.CylinderRegion import com.sk89q.worldedit.regions.Region import net.kyori.adventure.text.Component -import org.bukkit.Bukkit -import org.bukkit.Location -import org.bukkit.Material -import org.bukkit.Sound -import org.bukkit.World +import org.bukkit.* import org.bukkit.entity.Player import org.bukkit.inventory.ItemStack import org.bukkit.metadata.FixedMetadataValue import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffectType import org.bukkit.scheduler.BukkitRunnable -import java.util.Random -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap class GladiatorKit : Kit() { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt index 3bc2e9e..387be46 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt @@ -14,7 +14,7 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent import org.bukkit.inventory.ItemStack import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffectType -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt index c278323..973d6e0 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt @@ -14,12 +14,7 @@ import org.bukkit.Material import org.bukkit.NamespacedKey import org.bukkit.Sound import org.bukkit.block.Block -import org.bukkit.entity.Arrow -import org.bukkit.entity.Egg -import org.bukkit.entity.LivingEntity -import org.bukkit.entity.Player -import org.bukkit.entity.Snowball -import org.bukkit.entity.ThrownPotion +import org.bukkit.entity.* import org.bukkit.event.Cancellable import org.bukkit.event.EventHandler import org.bukkit.event.EventPriority diff --git a/src/main/kotlin/club/mcscrims/speedhg/listener/ConnectListener.kt b/src/main/kotlin/club/mcscrims/speedhg/listener/ConnectListener.kt index fd02a5d..82031e7 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/listener/ConnectListener.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/listener/ConnectListener.kt @@ -23,9 +23,13 @@ class ConnectListener : Listener { val player = event.player event.joinMessage( null ) - if ( plugin.gameManager.currentState == GameState.INGAME || - plugin.gameManager.currentState == GameState.INVINCIBILITY ) + if (( plugin.gameManager.currentState == GameState.INGAME || + plugin.gameManager.currentState == GameState.INVINCIBILITY ) && + !player.hasPermission( "speedhg.bypass" )) + { + player.kick(Component.text("§cThe game has already started!")) return + } Bukkit.getOnlinePlayers().forEach { p -> p.sendMsg( "game.join", "name" to player.name ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt b/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt index c1b4109..fde68d6 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt @@ -18,11 +18,7 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent import org.bukkit.event.entity.FoodLevelChangeEvent import org.bukkit.event.entity.ItemDespawnEvent import org.bukkit.event.inventory.* -import org.bukkit.event.player.PlayerAttemptPickupItemEvent -import org.bukkit.event.player.PlayerDropItemEvent -import org.bukkit.event.player.PlayerItemDamageEvent -import org.bukkit.event.player.PlayerJoinEvent -import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.event.player.* import org.bukkit.inventory.EnchantingInventory import org.bukkit.inventory.ItemStack import org.bukkit.inventory.meta.Damageable diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 5ac2395..330e763 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -86,22 +86,12 @@ kits: - ' ' - 'Select a kit mid-round at any time.' - 'All kits are available to pick.' - - ' ' - - 'PlayStyle: §e%playstyle%' - - ' ' - - 'Left-click to select' - - 'Right-click to change playstyle' gladiator: name: 'Gladiator' lore: - ' ' - 'Use your ability to fight enemies' - 'in a 1v1 above the skies.' - - ' ' - - 'PlayStyle: §e%playstyle%' - - ' ' - - 'Left-click to select' - - 'Right-click to change playstyle' items: ironBars: name: 'Cage' @@ -117,11 +107,6 @@ kits: - ' ' - 'DEFENSIVE:' - 'Summon a bunker for protection' - - ' ' - - 'PlayStyle: §e%playstyle%' - - ' ' - - 'Left-click to select' - - 'Right-click to change playstyle' items: steal: name: '§cSteal Kit' @@ -142,11 +127,6 @@ kits: - ' ' - 'DEFENSIVE:' - 'Summon snowballs and freeze enemies' - - ' ' - - 'PlayStyle: §e%playstyle%' - - ' ' - - 'Left-click to select' - - 'Right-click to change playstyle' items: snowball: name: '§bFreeze' @@ -166,11 +146,6 @@ kits: - ' ' - 'DEFENSIVE:' - 'Create a shield for protection' - - ' ' - - 'PlayStyle: §e%playstyle%' - - ' ' - - 'Left-click to select' - - 'Right-click to change playstyle' items: wither: name: '§8Deafening Beam' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b10baaf..b40fd1e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,6 +6,11 @@ api-version: '1.21' depend: - "WorldEdit" +permissions: + speedhg.bypass: + description: 'Allows joining the server while its running' + default: false + commands: kit: description: 'Select kits via command'