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.database.StatsManager
import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.GameManager
import club.mcscrims.speedhg.game.modules.AntiRunningManager 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.KitManager
import club.mcscrims.speedhg.kit.impl.BackupKit import club.mcscrims.speedhg.kit.impl.*
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.listener.KitEventDispatcher import club.mcscrims.speedhg.kit.listener.KitEventDispatcher
import club.mcscrims.speedhg.listener.ConnectListener import club.mcscrims.speedhg.listener.ConnectListener
import club.mcscrims.speedhg.listener.GameStateListener import club.mcscrims.speedhg.listener.GameStateListener
@@ -118,6 +115,7 @@ class SpeedHG : JavaPlugin() {
pm.registerEvents( SoupListener(), this ) pm.registerEvents( SoupListener(), this )
pm.registerEvents(KitEventDispatcher( this, kitManager ), this ) pm.registerEvents(KitEventDispatcher( this, kitManager ), this )
pm.registerEvents( StatsListener(), 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.SpeedHG
import club.mcscrims.speedhg.game.GameState import club.mcscrims.speedhg.game.GameState
import club.mcscrims.speedhg.gui.menu.KitSelectorMenu
import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.impl.BackupKit import club.mcscrims.speedhg.kit.impl.BackupKit
import club.mcscrims.speedhg.util.sendMsg import club.mcscrims.speedhg.util.sendMsg
@@ -30,9 +31,22 @@ class KitCommand : CommandExecutor, TabCompleter {
return true 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 ) if ( args.isNullOrEmpty() || args.size < 2 )
{ {
player.sendMsg( "commands.kit.usage" ) KitSelectorMenu( player ).open( player )
return true return true
} }
@@ -53,15 +67,7 @@ class KitCommand : CommandExecutor, TabCompleter {
return true return true
} }
val playerKit = plugin.kitManager.getSelectedKit( player ) if ( isBackup && ingame )
val isBackup = playerKit != null && playerKit is BackupKit
if ( !isBackup && isIngame() )
{
player.sendMsg("commands.kit.gameHasStarted")
return true
}
else if ( isBackup && isIngame() )
{ {
if ( kit is BackupKit ) if ( kit is BackupKit )
{ {
@@ -69,6 +75,7 @@ class KitCommand : CommandExecutor, TabCompleter {
return true return true
} }
plugin.kitManager.removeKit( player )
plugin.kitManager.selectKit( player, kit ) plugin.kitManager.selectKit( player, kit )
plugin.kitManager.selectPlaystyle( player, playstyle ) plugin.kitManager.selectPlaystyle( player, playstyle )
plugin.kitManager.applyKit( player ) plugin.kitManager.applyKit( player )
@@ -103,10 +110,4 @@ class KitCommand : CommandExecutor, TabCompleter {
return listOf() 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 package club.mcscrims.speedhg.database
import java.util.UUID import java.util.*
/** /**
* Hält die rohen Spieler-Statistiken im Arbeitsspeicher. * Hält die rohen Spieler-Statistiken im Arbeitsspeicher.

View File

@@ -1,6 +1,6 @@
package club.mcscrims.speedhg.database package club.mcscrims.speedhg.database
import java.util.UUID import java.util.*
/** /**
* Kapselt alle SQL-Operationen für die `player_stats`-Tabelle. * 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 club.mcscrims.speedhg.SpeedHG
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.UUID import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds 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.Material
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import java.util.UUID import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**

View File

@@ -10,7 +10,7 @@ import net.kyori.adventure.text.Component
import org.bukkit.Material import org.bukkit.Material
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import java.util.UUID import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class BackupKit : Kit() { 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.CylinderRegion
import com.sk89q.worldedit.regions.Region import com.sk89q.worldedit.regions.Region
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
import org.bukkit.Bukkit import org.bukkit.*
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.Sound
import org.bukkit.World
import org.bukkit.entity.Player import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import org.bukkit.metadata.FixedMetadataValue import org.bukkit.metadata.FixedMetadataValue
import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType import org.bukkit.potion.PotionEffectType
import org.bukkit.scheduler.BukkitRunnable import org.bukkit.scheduler.BukkitRunnable
import java.util.Random import java.util.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class GladiatorKit : Kit() { class GladiatorKit : Kit() {

View File

@@ -14,7 +14,7 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType import org.bukkit.potion.PotionEffectType
import java.util.UUID import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**

View File

@@ -14,12 +14,7 @@ import org.bukkit.Material
import org.bukkit.NamespacedKey import org.bukkit.NamespacedKey
import org.bukkit.Sound import org.bukkit.Sound
import org.bukkit.block.Block import org.bukkit.block.Block
import org.bukkit.entity.Arrow import org.bukkit.entity.*
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.event.Cancellable import org.bukkit.event.Cancellable
import org.bukkit.event.EventHandler import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority import org.bukkit.event.EventPriority

View File

@@ -23,9 +23,13 @@ class ConnectListener : Listener {
val player = event.player val player = event.player
event.joinMessage( null ) event.joinMessage( null )
if ( plugin.gameManager.currentState == GameState.INGAME || if (( plugin.gameManager.currentState == GameState.INGAME ||
plugin.gameManager.currentState == GameState.INVINCIBILITY ) plugin.gameManager.currentState == GameState.INVINCIBILITY ) &&
!player.hasPermission( "speedhg.bypass" ))
{
player.kick(Component.text("§cThe game has already started!"))
return return
}
Bukkit.getOnlinePlayers().forEach { p -> Bukkit.getOnlinePlayers().forEach { p ->
p.sendMsg( "game.join", "name" to player.name ) 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.FoodLevelChangeEvent
import org.bukkit.event.entity.ItemDespawnEvent import org.bukkit.event.entity.ItemDespawnEvent
import org.bukkit.event.inventory.* import org.bukkit.event.inventory.*
import org.bukkit.event.player.PlayerAttemptPickupItemEvent import org.bukkit.event.player.*
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.inventory.EnchantingInventory import org.bukkit.inventory.EnchantingInventory
import org.bukkit.inventory.ItemStack import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.Damageable import org.bukkit.inventory.meta.Damageable

View File

@@ -86,22 +86,12 @@ kits:
- ' ' - ' '
- 'Select a kit mid-round at any time.' - 'Select a kit mid-round at any time.'
- 'All kits are available to pick.' - 'All kits are available to pick.'
- ' '
- 'PlayStyle: §e%playstyle%'
- ' '
- 'Left-click to select'
- 'Right-click to change playstyle'
gladiator: gladiator:
name: '<gradient:dark_gray:gray><bold>Gladiator</bold></gradient>' name: '<gradient:dark_gray:gray><bold>Gladiator</bold></gradient>'
lore: lore:
- ' ' - ' '
- 'Use your ability to fight enemies' - 'Use your ability to fight enemies'
- 'in a 1v1 above the skies.' - 'in a 1v1 above the skies.'
- ' '
- 'PlayStyle: §e%playstyle%'
- ' '
- 'Left-click to select'
- 'Right-click to change playstyle'
items: items:
ironBars: ironBars:
name: '<gray>Cage</gray>' name: '<gray>Cage</gray>'
@@ -117,11 +107,6 @@ kits:
- ' ' - ' '
- 'DEFENSIVE:' - 'DEFENSIVE:'
- 'Summon a bunker for protection' - 'Summon a bunker for protection'
- ' '
- 'PlayStyle: §e%playstyle%'
- ' '
- 'Left-click to select'
- 'Right-click to change playstyle'
items: items:
steal: steal:
name: '§cSteal Kit' name: '§cSteal Kit'
@@ -142,11 +127,6 @@ kits:
- ' ' - ' '
- 'DEFENSIVE:' - 'DEFENSIVE:'
- 'Summon snowballs and freeze enemies' - 'Summon snowballs and freeze enemies'
- ' '
- 'PlayStyle: §e%playstyle%'
- ' '
- 'Left-click to select'
- 'Right-click to change playstyle'
items: items:
snowball: snowball:
name: '§bFreeze' name: '§bFreeze'
@@ -166,11 +146,6 @@ kits:
- ' ' - ' '
- 'DEFENSIVE:' - 'DEFENSIVE:'
- 'Create a shield for protection' - 'Create a shield for protection'
- ' '
- 'PlayStyle: §e%playstyle%'
- ' '
- 'Left-click to select'
- 'Right-click to change playstyle'
items: items:
wither: wither:
name: '§8Deafening Beam' name: '§8Deafening Beam'

View File

@@ -6,6 +6,11 @@ api-version: '1.21'
depend: depend:
- "WorldEdit" - "WorldEdit"
permissions:
speedhg.bypass:
description: 'Allows joining the server while its running'
default: false
commands: commands:
kit: kit:
description: 'Select kits via command' description: 'Select kits via command'