diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 279e292..d49c4e7 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -2,6 +2,7 @@ package club.mcscrims.speedhg import club.mcscrims.speedhg.command.KitCommand import club.mcscrims.speedhg.command.LeaderboardCommand +import club.mcscrims.speedhg.command.PerksCommand import club.mcscrims.speedhg.command.RankingCommand import club.mcscrims.speedhg.command.TimerCommand import club.mcscrims.speedhg.config.CustomGameManager @@ -20,6 +21,12 @@ import club.mcscrims.speedhg.listener.ConnectListener import club.mcscrims.speedhg.listener.GameStateListener import club.mcscrims.speedhg.listener.SoupListener import club.mcscrims.speedhg.listener.StatsListener +import club.mcscrims.speedhg.perk.PerkManager +import club.mcscrims.speedhg.perk.impl.BloodlustPerk +import club.mcscrims.speedhg.perk.impl.FeatherweightPerk +import club.mcscrims.speedhg.perk.impl.OraclePerk +import club.mcscrims.speedhg.perk.impl.VampirePerk +import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager @@ -55,6 +62,9 @@ class SpeedHG : JavaPlugin() { lateinit var kitManager: KitManager private set + lateinit var perkManager: PerkManager + private set + lateinit var databaseManager: DatabaseManager private set @@ -111,7 +121,11 @@ class SpeedHG : JavaPlugin() { kitManager = KitManager( this ) discordWebhookManager = DiscordWebhookManager( this ) + perkManager = PerkManager( this ) + perkManager.initialize() + registerKits() + registerPerks() registerCommands() registerListener() registerRecipes() @@ -122,6 +136,7 @@ class SpeedHG : JavaPlugin() { override fun onDisable() { podiumManager.cleanup() + if ( ::perkManager.isInitialized ) perkManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::databaseManager.isInitialized ) databaseManager.disconnect() kitManager.clearAll() @@ -142,6 +157,14 @@ class SpeedHG : JavaPlugin() { kitManager.registerKit( VoodooKit() ) } + private fun registerPerks() + { + perkManager.registerPerk( OraclePerk() ) + perkManager.registerPerk( VampirePerk() ) + perkManager.registerPerk( FeatherweightPerk() ) + perkManager.registerPerk( BloodlustPerk() ) + } + private fun registerCommands() { val kitCommand = KitCommand() @@ -163,6 +186,7 @@ class SpeedHG : JavaPlugin() { } getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) + getCommand( "perks" )?.setExecutor( PerksCommand() ) } private fun registerListener() @@ -175,6 +199,7 @@ class SpeedHG : JavaPlugin() { pm.registerEvents(KitEventDispatcher( this, kitManager ), this ) pm.registerEvents( StatsListener(), this ) pm.registerEvents( MenuListener(), this ) + pm.registerEvents(PerkEventDispatcher( this, perkManager ), this ) } private fun registerRecipes() diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/PerksCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/PerksCommand.kt new file mode 100644 index 0000000..855c64d --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/command/PerksCommand.kt @@ -0,0 +1,28 @@ +package club.mcscrims.speedhg.command + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.gui.menu.PerkSelectorMenu +import org.bukkit.command.Command +import org.bukkit.command.CommandExecutor +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +class PerksCommand : CommandExecutor { + + private val plugin get() = SpeedHG.instance + + override fun onCommand( + sender: CommandSender, + command: Command, + label: String, + args: Array + ): Boolean { + val player = sender as? Player ?: run { + sender.sendMessage("§cNur Spieler können diesen Befehl nutzen.") + return true + } + + PerkSelectorMenu(player).open(player) + return true + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index 3be18ca..de979d9 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -200,6 +200,7 @@ class GameManager( teleportRandomly( player, world, startBorder / 2 ) plugin.kitManager.applyKit( player ) // verteilt Items + ruft onAssign + passive.onActivate + plugin.perkManager.applyPerks( player ) player.inventory.addItem(ItemStack( Material.COMPASS )) player.sendMsg( "game.started" ) @@ -304,6 +305,7 @@ class GameManager( } plugin.kitManager.clearAll() + plugin.perkManager.removeAllActivePerks() Bukkit.getOnlinePlayers().forEach { p -> p.showTitle(Title.title( diff --git a/src/main/kotlin/club/mcscrims/speedhg/gui/menu/PerkSelectorMenu.kt b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/PerkSelectorMenu.kt new file mode 100644 index 0000000..d276f22 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/gui/menu/PerkSelectorMenu.kt @@ -0,0 +1,244 @@ +package club.mcscrims.speedhg.gui.menu + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.perk.Perk +import club.mcscrims.speedhg.util.trans +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 net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import org.bukkit.Material +import org.bukkit.enchantments.Enchantment +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack + +/** + * Perk-Selector-GUI (3 Reihen × 9 = 27 Slots). + * + * ## Layout + * ``` + * [ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ] Reihe 1: Perks (Slots 0–8) + * [ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ] Reihe 2: Perks (Slots 9–17) + * [ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ] Reihe 3: Info (Slots 18–26) + * ↑ Slot 20 ↑ Slot 24 ↑ Slot 26 + * S1 (Perk) S2 (Perk) X (Schließen) + * ``` + * + * ## Klick-Verhalten + * - **Linksklick auf Perk**: Ausrüsten wenn Slot frei; Abwählen wenn bereits aktiv; Fehler wenn voll + * - **Rechtsklick auf Perk**: Identisch zu Linksklick (Toggle-Logik) + * - **Klick auf Slot-Indikator (S1/S2)**: Deaktiviert den jeweiligen Perk + * - **Klick auf X**: Inventar schließen + */ +class PerkSelectorMenu( + private val player: Player +) : Menu( + rows = 3, + title = player.trans("gui.perk_selector.title") +) { + + private val plugin get() = SpeedHG.instance + private val mm = MiniMessage.miniMessage() + private val lm get() = plugin.languageManager + + private val PERK_SLOTS = 0 until 18 + private val FILLER_ROW = 18 until 27 + private val SLOT_FIRST = 20 // Indikator: Perk-Slot 1 + private val SLOT_SECOND = 24 // Indikator: Perk-Slot 2 + private val SLOT_CLOSE = 26 // Schließen-Button + + // ── Aufbau ──────────────────────────────────────────────────────────────── + + override fun build(): Inventory = createInventory(title).also { populate(it) } + + private fun populate(inv: Inventory) { + inv.clear() + + // Filler-Basis für Reihe 3 + val filler = buildFiller() + FILLER_ROW.forEach { inv.setItem(it, filler) } + + // Perks in Reihen 1 + 2 + plugin.perkManager.getRegisteredPerks().forEachIndexed { index, perk -> + if (index in PERK_SLOTS) inv.setItem(index, buildPerkItem(perk)) + } + + // Ausgerüstete Perks als Slot-Indikatoren + val selected = plugin.perkManager.getSelectedPerks(player) + inv.setItem(SLOT_FIRST, buildSlotIndicator(perk = selected.getOrNull(0), slotNumber = 1)) + inv.setItem(SLOT_SECOND, buildSlotIndicator(perk = selected.getOrNull(1), slotNumber = 2)) + + inv.setItem(SLOT_CLOSE, buildCloseItem()) + } + + // ── Klick-Handling ──────────────────────────────────────────────────────── + + override fun onClick(event: InventoryClickEvent, player: Player) { + val slot = event.rawSlot + if (slot !in 0 until size) return + + when (slot) { + SLOT_CLOSE -> player.closeInventory() + SLOT_FIRST -> deselectIndicatorSlot(index = 0) + SLOT_SECOND -> deselectIndicatorSlot(index = 1) + in PERK_SLOTS -> handlePerkClick(slot) + // Filler-Slots → bereits gecancelt durch MenuListener, nichts zu tun + } + } + + private fun handlePerkClick(slot: Int) { + // Spiel läuft? Änderungen blockieren. + val state = plugin.gameManager.currentState + if (state == GameState.INGAME || state == GameState.INVINCIBILITY) { + player.sendActionBar(mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.game_running"))) + return + } + + val perk = getPerkAtSlot(slot) ?: return + + when { + plugin.perkManager.hasPerk(player, perk.id) -> { + // Bereits ausgerüstet → abwählen + plugin.perkManager.deselectPerk(player, perk) + player.sendActionBar(mm.deserialize( + lm.getRawMessage(player, "gui.perk_selector.deselected"), + Placeholder.component("perk", perk.displayName) + )) + refresh() + } + + plugin.perkManager.isSlotsFull(player) -> { + // Kein freier Slot + player.sendActionBar(mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.slots_full"))) + } + + else -> { + // Freier Slot → ausrüsten + plugin.perkManager.selectPerk(player, perk) + player.sendActionBar(mm.deserialize( + lm.getRawMessage(player, "gui.perk_selector.selected"), + Placeholder.component("perk", perk.displayName) + )) + refresh() + } + } + } + + private fun deselectIndicatorSlot(index: Int) { + val perk = plugin.perkManager.getSelectedPerks(player).getOrNull(index) ?: return + plugin.perkManager.deselectPerk(player, perk) + player.sendActionBar(mm.deserialize( + lm.getRawMessage(player, "gui.perk_selector.deselected"), + Placeholder.component("perk", perk.displayName) + )) + refresh() + } + + fun refresh() = populate(inventory) + + // ── Item-Builder ────────────────────────────────────────────────────────── + + private fun buildPerkItem(perk: Perk): ItemStack { + val isEquipped = plugin.perkManager.hasPerk(player, perk.id) + val item = ItemStack(perk.icon) + + item.editMeta { meta -> + meta.displayName(perk.displayName.decoration(TextDecoration.ITALIC, false)) + + val lore = buildList { + add(separator()) + perk.lore.forEach { rawLine -> + add(mm.deserialize(rawLine).decoration(TextDecoration.ITALIC, false)) + } + add(Component.empty()) + if (isEquipped) { + add(mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.equipped_label")) + .decoration(TextDecoration.ITALIC, false)) + add(mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.click_deselect")) + .decoration(TextDecoration.ITALIC, false)) + } else { + add(mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.click_equip")) + .decoration(TextDecoration.ITALIC, false)) + } + add(separator()) + } + meta.lore(lore) + + if (isEquipped) { + meta.addEnchant(Enchantment.UNBREAKING, 1, true) + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS) + } + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP) + } + + return item + } + + private fun buildSlotIndicator(perk: Perk?, slotNumber: Int): ItemStack { + return if (perk == null) { + ItemStack(Material.GRAY_STAINED_GLASS_PANE).apply { + editMeta { meta -> + meta.displayName( + mm.deserialize( + lm.getRawMessage(player, "gui.perk_selector.slot_empty"), + Placeholder.unparsed("slot", slotNumber.toString()) + ).decoration(TextDecoration.ITALIC, false) + ) + meta.lore(listOf( + Component.empty(), + mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.slot_hint")) + .decoration(TextDecoration.ITALIC, false), + Component.empty() + )) + } + } + } else { + ItemStack(perk.icon).apply { + editMeta { meta -> + // "Slot 1: " + val slotPrefix = mm.deserialize( + lm.getRawMessage(player, "gui.perk_selector.slot_title"), + Placeholder.unparsed("slot", slotNumber.toString()) + ) + meta.displayName( + slotPrefix.append(perk.displayName).decoration(TextDecoration.ITALIC, false) + ) + meta.lore(listOf( + Component.empty(), + mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.click_deselect")) + .decoration(TextDecoration.ITALIC, false), + Component.empty() + )) + meta.addEnchant(Enchantment.UNBREAKING, 1, true) + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP) + } + } + } + } + + private fun buildFiller() = ItemStack(Material.GRAY_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.text(" ").decoration(TextDecoration.ITALIC, false)) } + } + + private fun buildCloseItem() = ItemStack(Material.DARK_OAK_DOOR).apply { + editMeta { meta -> + meta.displayName( + mm.deserialize(lm.getRawMessage(player, "gui.perk_selector.close")) + .decoration(TextDecoration.ITALIC, false) + ) + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ADDITIONAL_TOOLTIP) + } + } + + private fun separator(): Component = + Component.text("────────────────────", NamedTextColor.DARK_GRAY) + .decoration(TextDecoration.ITALIC, false) + + private fun getPerkAtSlot(slot: Int): Perk? = + plugin.perkManager.getRegisteredPerks().getOrNull(slot) +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt b/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt index 465ecbd..eb2abae 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt @@ -39,6 +39,7 @@ class StatsListener : Listener { // Wir sind auf einem Async-Thread — runBlocking ist hier korrekt. runBlocking { plugin.statsManager.loadStats(event.uniqueId, event.name) + plugin.perkManager.loadPerks(event.uniqueId) } } @@ -47,5 +48,6 @@ class StatsListener : Listener { // MONITOR: läuft nach allen anderen Listenern — sicherstellen, // dass alle anderen Plugins ihre finalen Änderungen gemacht haben. plugin.statsManager.saveAndEvict(event.player.uniqueId) + plugin.perkManager.evict(event.player.uniqueId) } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt new file mode 100644 index 0000000..9093592 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt @@ -0,0 +1,60 @@ +package club.mcscrims.speedhg.perk + +import net.kyori.adventure.text.Component +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.entity.EntityDamageEvent + +/** + * Basisklasse für alle passiven Perks in SpeedHG. + * + * Perks sind passive Boni, die Spieler neben ihrem Kit ausrüsten. + * Ein Spieler darf maximal [PerkManager.MAX_PERKS] Perks gleichzeitig aktiv haben. + * + * ## Neuen Perk erstellen + * 1. Diese Klasse erweitern. + * 2. Alle abstrakten Member implementieren. + * 3. Nur die Hooks überschreiben, die tatsächlich gebraucht werden — alle Defaults sind No-Ops. + * 4. Via `plugin.perkManager.registerPerk(DeinPerk())` in [SpeedHG.onEnable] registrieren. + */ +abstract class Perk { + + /** Eindeutiger snake_case Bezeichner. Muss dem Sprachschlüssel-Prefix `perks..*` entsprechen. */ + abstract val id: String + + /** Angezeigter Name im GUI, aus der Sprachdatei geladen. */ + abstract val displayName: Component + + /** Kurze Lore-Zeilen als MiniMessage-Strings aus der Sprachdatei. */ + abstract val lore: List + + /** Icon-Material im Perk-Selector-GUI. */ + abstract val icon: Material + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /** Einmalig aufgerufen wenn das Spiel startet und dieser Perk für [player] aktiv ist. */ + open fun onActivate(player: Player) {} + + /** Aufgerufen wenn die Runde endet oder der Perk entfernt wird. Tasks hier abbrechen. */ + open fun onDeactivate(player: Player) {} + + // ── Combat Hooks (dispatched by PerkEventDispatcher) ────────────────────── + + /** [attacker] (dieser Spieler) hat gerade [victim] per Nahkampf getroffen. */ + open fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {} + + /** Dieser Spieler hat gerade einen Nahkampfhit von [attacker] erhalten. */ + open fun onHitByEnemy(victim: Player, attacker: Player, event: EntityDamageByEntityEvent) {} + + /** Dieser Spieler hat einen anderen Spieler getötet. */ + open fun onKillEnemy(killer: Player, victim: Player) {} + + /** + * Aufgerufen bei Umgebungsschaden (Fall, Feuer, etc.) — KEIN Nahkampf. + * Wird vom Dispatcher mit HIGH-Priority verarbeitet, damit Featherweight canceln kann. + */ + open fun onEnvironmentalDamage(player: Player, event: EntityDamageEvent) {} + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt new file mode 100644 index 0000000..0e17101 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt @@ -0,0 +1,192 @@ +package club.mcscrims.speedhg.perk + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.perk.database.PlayerPerksRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Verwaltet Perk-Registrierung, Spielerauswahl und den Spiel-Lifecycle. + * + * ## Typischer Ablauf + * ``` + * // Startup + * perkManager.registerPerk(OraclePerk()) + * + * // Spieler-Login (AsyncPlayerPreLoginEvent – Async-Thread) + * perkManager.loadPerks(uuid) + * + * // GUI-Klick (Lobby-Phase) + * perkManager.selectPerk(player, perk) + * perkManager.deselectPerk(player, perk) + * + * // Spielstart + * perkManager.applyPerks(player) // ruft onActivate auf + * + * // Spielende + * perkManager.removeAllActivePerks() // ruft onDeactivate für alle Online-Spieler auf + * + * // Spieler-Quit + * perkManager.evict(uuid) + * ``` + */ +class PerkManager( + private val plugin: SpeedHG +) { + + companion object { + const val MAX_PERKS = 2 + } + + // ── Registrierung ───────────────────────────────────────────────────────── + + private val registeredPerks = ConcurrentHashMap() + + /** + * Separate geordnete Liste für konsistentes GUI-Layout. + * Wird nur in onEnable() (single-threaded) befüllt → kein Synchronisierungsbedarf. + */ + private val registrationOrder = mutableListOf() + + fun registerPerk( + perk: Perk + ) { + if (registeredPerks.putIfAbsent( perk.id, perk ) == null ) { + registrationOrder += perk.id + } + plugin.logger.info( "[PerkManager] Registriert: ${perk.id}" ) + } + + /** Gibt alle Perks in stabiler Registrierungsreihenfolge zurück (für GUI-Slots). */ + fun getRegisteredPerks(): List = + registrationOrder.mapNotNull { registeredPerks[ it ] } + + fun getPerk(id: String): Perk? = registeredPerks[ id ] + + // ── Spielerauswahl (Lobby) ──────────────────────────────────────────────── + + /** + * UUID → geordnete Liste der gewählten Perk-IDs (max [MAX_PERKS]). + * Index 0 = ältere Auswahl, Index 1 = neuere Auswahl. + */ + private val selectedPerkIds = ConcurrentHashMap>() + + fun getSelectedPerkIds( uuid: UUID ): List = + selectedPerkIds[ uuid ]?.toList() ?: emptyList() + + fun getSelectedPerks( player: Player ): List = + getSelectedPerkIds( player.uniqueId ).mapNotNull { registeredPerks[ it ] } + + fun hasPerk( player: Player, perkId: String ): Boolean = + selectedPerkIds[ player.uniqueId ]?.contains( perkId ) == true + + fun isSlotsFull( player: Player ): Boolean = + (selectedPerkIds[ player.uniqueId ]?.size ?: 0 ) >= MAX_PERKS + + /** + * Rüstet einen Perk für den Spieler aus. + * @return `true` wenn erfolgreich; `false` wenn bereits ausgerüstet oder Slots voll. + */ + fun selectPerk( + player: Player, + perk: Perk + ): Boolean + { + val list = selectedPerkIds.getOrPut( player.uniqueId ) { mutableListOf() } + if (list.contains( perk.id ) || list.size >= MAX_PERKS ) return false + list += perk.id + saveAsync( player.uniqueId ) + return true + } + + /** + * Entfernt einen Perk aus der Spielerauswahl. + * @return `true` wenn er ausgerüstet war und entfernt wurde. + */ + fun deselectPerk( + player: Player, + perk: Perk + ): Boolean + { + val removed = selectedPerkIds[ player.uniqueId ]?.remove( perk.id ) == true + if ( removed ) saveAsync( player.uniqueId ) + return removed + } + + // ── Spiel-Lifecycle ─────────────────────────────────────────────────────── + + /** Aktiviert alle gewählten Perks für [player]. Nach Teleport in startGame() aufrufen. */ + fun applyPerks( + player: Player + ) { + getSelectedPerks( player ).forEach { it.onActivate( player ) } + } + + /** Deaktiviert alle aktiven Perks für [player]. */ + fun removePerks( + player: Player + ) { + getSelectedPerks( player ).forEach { it.onDeactivate( player ) } + } + + /** Ruft [removePerks] für jeden aktuell online Spieler auf. In endGame() aufrufen. */ + fun removeAllActivePerks() + { + selectedPerkIds.keys + .mapNotNull { plugin.server.getPlayer( it ) } + .forEach { removePerks( it ) } + } + + // ── Persistenz ──────────────────────────────────────────────────────────── + + private val repository = PlayerPerksRepository( plugin.databaseManager ) + private val scope = CoroutineScope( Dispatchers.IO + SupervisorJob() ) + + fun initialize() + { + repository.createTableIfNotExists() + plugin.logger.info( "[PerkManager] Initialisiert." ) + } + + fun shutdown() + { + scope.cancel() + } + + /** + * Lädt die Perks des Spielers aus der DB in den Cache. + * Muss in einem Async-Kontext aufgerufen werden (z.B. AsyncPlayerPreLoginEvent). + */ + suspend fun loadPerks( + uuid: UUID + ): Unit = withContext( Dispatchers.IO ) { + val ids = repository.findByUUID( uuid ) + val valid = ids.filter { registeredPerks.containsKey( it ) }.take( MAX_PERKS ) + selectedPerkIds[ uuid ] = valid.toMutableList() + } + + /** Entfernt den Spieler aus dem In-Memory-Cache. Beim Quit aufrufen. */ + fun evict( + uuid: UUID + ) { + selectedPerkIds.remove( uuid ) + } + + private fun saveAsync( + uuid: UUID + ) { + val ids = selectedPerkIds[ uuid ]?.toList() ?: emptyList() + scope.launch { + runCatching { repository.upsert( uuid, ids ) } + .onFailure { plugin.logger.severe( "[PerkManager] Save fehlgeschlagen: ${it.message}" ) } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/database/PlayerPerksRepository.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/database/PlayerPerksRepository.kt new file mode 100644 index 0000000..bad2531 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/database/PlayerPerksRepository.kt @@ -0,0 +1,72 @@ +package club.mcscrims.speedhg.perk.database + +import club.mcscrims.speedhg.database.DatabaseManager +import java.sql.Types +import java.util.UUID + +/** + * Kapselt alle SQL-Operationen für die `player_perks`-Tabelle. + * + * Das Schema nutzt zwei nullable Spalten (perk1, perk2), da die Slot-Reihenfolge + * semantisch ist: perk1 ist die ältere (zuerst gewählte) Auswahl. + * Entspricht direkt der geordneten Liste im [PerkManager]. + */ +class PlayerPerksRepository(private val db: DatabaseManager) { + + fun createTableIfNotExists() { + db.getConnection().use { conn -> + conn.createStatement().use { stmt -> + stmt.execute( + """ + CREATE TABLE IF NOT EXISTS player_perks ( + uuid VARCHAR(36) NOT NULL, + perk1 VARCHAR(64) DEFAULT NULL, + perk2 VARCHAR(64) DEFAULT NULL, + PRIMARY KEY (uuid) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + """.trimIndent() + ) + } + } + } + + /** + * Gibt die gespeicherten Perk-IDs des Spielers in Auswahlreihenfolge zurück. + * Gibt eine leere Liste zurück wenn keine Daten existieren. + */ + fun findByUUID(uuid: UUID): List { + db.getConnection().use { conn -> + conn.prepareStatement( + "SELECT perk1, perk2 FROM player_perks WHERE uuid = ?" + ).use { stmt -> + stmt.setString(1, uuid.toString()) + stmt.executeQuery().use { rs -> + if (!rs.next()) return emptyList() + return listOfNotNull(rs.getString("perk1"), rs.getString("perk2")) + } + } + } + } + + /** Speichert oder aktualisiert die beiden Perk-Slots des Spielers. */ + fun upsert(uuid: UUID, perkIds: List) { + val perk1 = perkIds.getOrNull(0) + val perk2 = perkIds.getOrNull(1) + + db.getConnection().use { conn -> + conn.prepareStatement( + """ + INSERT INTO player_perks (uuid, perk1, perk2) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE perk1 = VALUES(perk1), perk2 = VALUES(perk2) + """.trimIndent() + ).use { stmt -> + stmt.setString(1, uuid.toString()) + if (perk1 != null) stmt.setString(2, perk1) else stmt.setNull(2, Types.VARCHAR) + if (perk2 != null) stmt.setString(3, perk2) else stmt.setNull(3, Types.VARCHAR) + stmt.executeUpdate() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/BloodlustPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/BloodlustPerk.kt new file mode 100644 index 0000000..5a5f922 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/BloodlustPerk.kt @@ -0,0 +1,42 @@ +package club.mcscrims.speedhg.perk.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.perk.Perk +import club.mcscrims.speedhg.util.trans +import net.kyori.adventure.text.Component +import org.bukkit.Material +import org.bukkit.Sound +import org.bukkit.entity.Player +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType + +/** + * ## Blutrausch + * + * Nach einem Kill erhält der Spieler für **5 Sekunden Speed I + Regeneration I**. + */ +class BloodlustPerk : Perk() { + + private val plugin get() = SpeedHG.instance + + override val id = "bloodlust" + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent("perks.bloodlust.name", mapOf()) + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList("perks.bloodlust.lore") + override val icon = Material.BLAZE_POWDER + + private val DURATION_TICKS = 5 * 20 + + override fun onKillEnemy(killer: Player, victim: Player) { + killer.addPotionEffect( + PotionEffect(PotionEffectType.SPEED, DURATION_TICKS, 0, false, false, true) + ) + killer.addPotionEffect( + PotionEffect(PotionEffectType.REGENERATION, DURATION_TICKS, 0, false, false, true) + ) + + killer.playSound(killer.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.8f) + killer.sendActionBar(killer.trans("perks.bloodlust.message")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/FeatherweightPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/FeatherweightPerk.kt new file mode 100644 index 0000000..a64bff0 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/FeatherweightPerk.kt @@ -0,0 +1,33 @@ +package club.mcscrims.speedhg.perk.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.perk.Perk +import net.kyori.adventure.text.Component +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.entity.EntityDamageEvent + +/** + * ## Federgewicht + * + * Macht den Träger vollständig immun gegen Fallschaden. + * Das Event wird auf HIGH-Priority gecancelt, bevor andere Listener + * den Schaden verarbeiten. + */ +class FeatherweightPerk : Perk() { + + private val plugin get() = SpeedHG.instance + + override val id = "featherweight" + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent("perks.featherweight.name", mapOf()) + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList("perks.featherweight.lore") + override val icon = Material.FEATHER + + override fun onEnvironmentalDamage(player: Player, event: EntityDamageEvent) { + if (event.cause == EntityDamageEvent.DamageCause.FALL) { + event.isCancelled = true + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt new file mode 100644 index 0000000..7da4cc7 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt @@ -0,0 +1,103 @@ +package club.mcscrims.speedhg.perk.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.perk.Perk +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.scheduler.BukkitRunnable +import org.bukkit.scheduler.BukkitTask +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * ## Orakel + * + * Zeigt das Kit und die Entfernung des nächsten lebenden Gegners per Actionbar an, + * sobald der Träger **schleicht** oder **einen Kompass hält**. + * + * **Spielo-Synergie (Platzhalter):** Wenn der Träger das "spielo"-Kit besitzt, + * wird ein Hinweis auf das nächste Gamble-Ergebnis angezeigt. Logik muss beim + * Implementieren des Spielo-Kits befüllt werden. + */ +class OraclePerk : Perk() { + + private val plugin get() = SpeedHG.instance + private val mm = MiniMessage.miniMessage() + + override val id = "oracle" + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent("perks.oracle.name", mapOf()) + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList("perks.oracle.lore") + override val icon = Material.SPYGLASS + + /** Laufende Tracker-Tasks pro Spieler. */ + private val trackerTasks = ConcurrentHashMap() + + override fun onActivate(player: Player) { + val task = object : BukkitRunnable() { + override fun run() { + if (!player.isOnline) { cancel(); return } + + // Nur während aktiver Spielphasen anzeigen + val state = plugin.gameManager.currentState + if (state != GameState.INGAME && state != GameState.INVINCIBILITY) return + + // Träger muss selbst noch leben + if (!plugin.gameManager.alivePlayers.contains(player.uniqueId)) return + + // Nur anzeigen wenn Bedingung erfüllt + val isHoldingCompass = player.inventory.itemInMainHand.type == Material.COMPASS + if (!isHoldingCompass && !player.isSneaking) return + + val nearest = findNearestEnemy(player) ?: return + player.sendActionBar(buildTrackerComponent(player, nearest)) + } + }.runTaskTimer(plugin, 0L, 10L) // Refresh alle 0.5 Sekunden + + trackerTasks[player.uniqueId] = task + } + + override fun onDeactivate(player: Player) { + trackerTasks.remove(player.uniqueId)?.cancel() + } + + // ── Intern ──────────────────────────────────────────────────────────────── + + private fun findNearestEnemy(player: Player): Player? = + plugin.gameManager.alivePlayers + .asSequence() + .filter { it != player.uniqueId } + .mapNotNull { plugin.server.getPlayer(it) } + .minByOrNull { it.location.distanceSquared(player.location) } + + private fun buildTrackerComponent(player: Player, nearest: Player): Component { + val distance = player.location.distance(nearest.location).toInt() + val kitDisplay = plugin.kitManager.getSelectedKit(nearest)?.displayName + ?: mm.deserialize("???") + + val base = mm.deserialize( + " · " + + "Kit: · m", + Placeholder.unparsed("name", nearest.name), + Placeholder.component("kit", kitDisplay), + Placeholder.unparsed("distance", distance.toString()) + ) + + // ── Spielo-Synergie (Platzhalter) ───────────────────────────────── + val playerKit = plugin.kitManager.getSelectedKit(player) + if (playerKit?.id != "spielo") return base + + // TODO: Mit echter Spielo-Logik ersetzen sobald das Kit existiert. + // Temporär: alterniert sekündlich als visueller Platzhalter. + val isGoodGamble = (System.currentTimeMillis() / 3_000L) % 2 == 0L + val hint = if (isGoodGamble) mm.deserialize(" ⬆ Good Gamble") + else mm.deserialize(" ⬇ Bad Gamble") + + return base.append(hint) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/VampirePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/VampirePerk.kt new file mode 100644 index 0000000..6ee17fb --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/VampirePerk.kt @@ -0,0 +1,48 @@ +package club.mcscrims.speedhg.perk.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.perk.Perk +import net.kyori.adventure.text.Component +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.attribute.Attribute +import org.bukkit.entity.Player +import org.bukkit.event.entity.EntityDamageByEntityEvent +import java.util.Random + +/** + * ## Vampir + * + * Jeder Nahkampftreffer gegen einen Gegner hat eine **10%-Chance**, dem Angreifer + * **1 HP (0.5 Herzen)** zu heilen. Kann nicht über das Maximum geheilt werden. + */ +class VampirePerk : Perk() { + + private val plugin get() = SpeedHG.instance + private val rng = Random() + + override val id = "vampire" + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent("perks.vampire.name", mapOf()) + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList("perks.vampire.lore") + override val icon = Material.RED_DYE + + override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) { + if (rng.nextDouble() > 0.10) return // 90% verfehlen + + val maxHp = attacker.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.value ?: 20.0 + if (attacker.health >= maxHp) return // Bereits voll + + attacker.health = (attacker.health + 1.0).coerceAtMost(maxHp) + + // Visuelles + akustisches Feedback + attacker.world.spawnParticle( + Particle.HEART, + attacker.location.clone().add(0.0, 2.2, 0.0), + 3, 0.3, 0.2, 0.3, 0.0 + ) + attacker.playSound(attacker.location, Sound.ENTITY_GENERIC_DRINK, 0.5f, 1.8f) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt new file mode 100644 index 0000000..4e68e1f --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt @@ -0,0 +1,80 @@ +package club.mcscrims.speedhg.perk.listener + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.perk.PerkManager +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.entity.PlayerDeathEvent + +/** + * Einziger registrierter Listener für alle perk-bezogenen Events. + * + * Folgt dem gleichen Single-Dispatcher-Muster wie [KitEventDispatcher]: + * Einmalig registriert, delegiert an die aktiven Perks des jeweiligen Spielers. + * + * ## Prioritätenstrategie + * | Handler | Priorität | ignoreCancelled | Grund | + * |--------------------------|-----------|-----------------|---------------------------------------------| + * | [onMeleeDamage] | MONITOR | true | Liest den finalen Schadenswert nach Modifikation | + * | [onEnvironmentalDamage] | HIGH | false | Featherweight muss canceln bevor Schaden gesetzt wird | + * | [onPlayerKill] | HIGH | true | Vor Drop/Respawn-Verarbeitung | + */ +class PerkEventDispatcher( + private val plugin: SpeedHG, + private val perkManager: PerkManager +) : Listener { + + // ── Nahkampf: onHitEnemy + onHitByEnemy ────────────────────────────────── + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun onMeleeDamage(event: EntityDamageByEntityEvent) { + val attacker = event.damager as? Player ?: return + val victim = event.entity as? Player ?: return + if (!isIngame()) return + // Nur bei lebenden Spielern — kein Hit-Farming an Zuschauern + if (!plugin.gameManager.alivePlayers.contains(victim.uniqueId)) return + + perkManager.getSelectedPerks(attacker).forEach { it.onHitEnemy(attacker, victim, event) } + perkManager.getSelectedPerks(victim).forEach { it.onHitByEnemy(victim, attacker, event) } + } + + // ── Umgebungsschaden: onEnvironmentalDamage ─────────────────────────────── + + /** + * Wir überspringen [EntityDamageByEntityEvent] explizit — Nahkampftreffer + * werden oben in [onMeleeDamage] verarbeitet. Da EntityDamageByEntityEvent + * eine Unterklasse von EntityDamageEvent ist, würde Paper sonst diesen + * Handler für Nahkampfereignisse zusätzlich aufrufen. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = false) + fun onEnvironmentalDamage(event: EntityDamageEvent) { + if (event is EntityDamageByEntityEvent) return // Separater Handler oben + val player = event.entity as? Player ?: return + if (!isIngame()) return + if (!plugin.gameManager.alivePlayers.contains(player.uniqueId)) return + + perkManager.getSelectedPerks(player).forEach { it.onEnvironmentalDamage(player, event) } + } + + // ── Kill: onKillEnemy ───────────────────────────────────────────────────── + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + fun onPlayerKill(event: PlayerDeathEvent) { + if (!isIngame()) return + val killer = event.entity.killer ?: return + + perkManager.getSelectedPerks(killer).forEach { it.onKillEnemy(killer, event.entity) } + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + 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/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index b0b5e02..15d7367 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -138,6 +138,48 @@ gui: input_placeholder: 'Enter search term...' confirm_name: '✔ Confirm Search' confirm_click: 'Click to confirm' + perk_selector: + title: 'Perk-Auswahl' + slot_empty: 'Perk-Slot : Leer' + slot_hint: 'Klicke einen Perk zum Ausrüsten' + slot_title: 'Slot : ' + equipped_label: '✔ Ausgerüstet' + click_equip: 'Klick zum Ausrüsten' + click_deselect: 'Klick zum Abwählen' + slots_full: 'Slots voll! Klicke einen aktiven Perk zum Abwählen.' + game_running: 'Perks können während des Spiels nicht geändert werden!' + selected: 'Ausgerüstet: !' + deselected: 'Abgewählt: !' + close: '✕ Schließen' + +perks: + oracle: + name: 'Oracle' + lore: + - ' ' + - 'Zeigt Kit + Distanz des nächsten' + - 'Gegners (Schleichen / Kompass).' + - ' ' + - 'Synergie: Spielo-Kit zeigt Gamble-Ausgang.' + vampire: + name: 'Vampire' + lore: + - ' ' + - '10% Chance bei Nahkampftreffer:' + - '½ Herz heilen.' + featherweight: + name: 'Featherweight' + lore: + - ' ' + - 'Vollständig immun gegen' + - 'Fallschaden.' + bloodlust: + name: 'Bloodlust' + lore: + - ' ' + - 'Nach einem Kill:' + - 'Speed I + Regen I für 5 Sekunden.' + message: '⚔ Blutrausch! Speed I + Regen I für 5 Sekunden!' kits: backup: diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2b097cf..fa8f480 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -31,4 +31,7 @@ commands: ranking: description: 'Manage the SpeedHG ranking system' usage: '/ranking ' - permission: speedhg.admin.ranking \ No newline at end of file + permission: speedhg.admin.ranking + perks: + description: 'Perk-Auswahl öffnen' + usage: '/perks' \ No newline at end of file