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'