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).
This commit is contained in:
TDSTOS
2026-03-26 17:06:40 +01:00
parent b2edcff447
commit 72a58fdd9c
20 changed files with 820 additions and 72 deletions

View File

@@ -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 )
}
}

View File

@@ -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
}
}

View File

@@ -1,6 +1,6 @@
package club.mcscrims.speedhg.database
import java.util.UUID
import java.util.*
/**
* Hält die rohen Spieler-Statistiken im Arbeitsspeicher.

View File

@@ -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.

View File

@@ -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

View File

@@ -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 { "<gray>Suchbegriff eingeben...</gray>" })
.decoration(TextDecoration.ITALIC, false)
)
}
return item
}
private fun buildConfirmItem(): ItemStack {
val item = ItemStack(Material.NAME_TAG)
item.editMeta { meta ->
meta.displayName(
mm.deserialize("<green>✔ Suche bestätigen</green>")
.decoration(TextDecoration.ITALIC, false)
)
val lore = listOf(
Component.empty(),
mm.deserialize("<gray>Klicken um zu suchen</gray>")
.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<java.util.UUID, AnvilSearchMenu>()
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)
}

View File

@@ -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
* ```
* ─────────────────────────
* <Zeile 1 aus Kit.lore>
* <Zeile 2 aus Kit.lore>
*
* 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<Component>()
// 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(
"<gray>Playstyle: <white>► ${currentStyle.displayName}"
).decoration(TextDecoration.ITALIC, false)
lore += Component.empty()
// Interaktions-Hints
lore += mm.deserialize("<yellow>[L-Click]</yellow> <gray>Select Kit</gray>")
.decoration(TextDecoration.ITALIC, false)
lore += mm.deserialize("<gold>[R-Click]</gold> <gray>Change playstyle</gray>")
.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,
"<red>◄ Previous"
)
fun buildNextPageItem(): ItemStack = buildNavItem(
org.bukkit.Material.ARROW,
"<green>Next ►"
)
fun buildSearchItem(currentQuery: String): ItemStack {
val item = ItemStack(org.bukkit.Material.NAME_TAG)
item.editMeta { meta ->
meta.displayName(
mm.deserialize("<yellow>🔍 Search")
.decoration(TextDecoration.ITALIC, false)
)
val lore = mutableListOf<Component>()
lore += separator()
if (currentQuery.isNotEmpty()) {
lore += mm.deserialize("<gray>Current: <white>\"$currentQuery\"")
.decoration(TextDecoration.ITALIC, false)
lore += Component.empty()
}
lore += mm.deserialize("<gray>Click to search</gray>")
.decoration(TextDecoration.ITALIC, false)
lore += separator()
meta.lore(lore)
}
return item
}
fun buildClearSearchItem(): ItemStack = buildNavItem(
org.bukkit.Material.BARRIER,
"<red>✕ Reset search"
)
fun buildCloseItem(): ItemStack = buildNavItem(
org.bukkit.Material.DARK_OAK_DOOR,
"<red>✕ 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)
}

View File

@@ -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)
}
}

View File

@@ -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 044: 45 Kit-Plätze
* - Slots 4553: 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 = "<gradient:red:gold><bold>Kit-Auswahl</bold></gradient>") {
private val plugin = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
// Pagination
private val kitSlots = 45 // Slots 044 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..<size) return
when (slot) {
navSlotPrev -> 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(
"<green>Kit <white>${kit.displayName}</white> gewählt!</green>"
)
)
// 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(
"<gold>Playstyle: <white>► ${next.displayName}</white>"
)
)
}
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<Kit> {
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)
}
}

View File

@@ -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 (16).
* @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
}
}

View File

@@ -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<UUID, Menu>`, 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
}
}

View File

@@ -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
/**

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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
/**

View File

@@ -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

View File

@@ -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 )

View File

@@ -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

View File

@@ -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: '<gradient:dark_gray:gray><bold>Gladiator</bold></gradient>'
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: '<gray>Cage</gray>'
@@ -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'

View File

@@ -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'