Add perk system with GUI and integrations

Introduce a complete passive perk system: Perk base class, PerkManager (registration, selection, lifecycle, persistence), and PlayerPerksRepository (DB schema + upsert/find). Add four example perks (Oracle, Vampire, Featherweight, Bloodlust) and a single PerkEventDispatcher to route combat/environment/kill events to active perks. Provide PerkSelectorMenu GUI and /perks command, integrate perk initialization, registration, application and cleanup into SpeedHG and GameManager, and hook load/evict into StatsListener. Also add language entries and register the command in plugin.yml. This change enables players to select up to two passive perks, persists selections, and dispatches relevant events to perk implementations.
This commit is contained in:
TDSTOS
2026-04-04 03:16:43 +02:00
parent 88b0ba8b97
commit 8c2ab684bb
15 changed files with 977 additions and 1 deletions

View File

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

View File

@@ -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<out String>
): Boolean {
val player = sender as? Player ?: run {
sender.sendMessage("§cNur Spieler können diesen Befehl nutzen.")
return true
}
PerkSelectorMenu(player).open(player)
return true
}
}

View File

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

View File

@@ -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 08)
* [ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ][ P ] Reihe 2: Perks (Slots 917)
* [ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ][ F ] Reihe 3: Info (Slots 1826)
* ↑ 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<Component> {
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: <Perk-Name>"
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)
}

View File

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

View File

@@ -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.<id>.*` 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<String>
/** 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) {}
}

View File

@@ -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<String, Perk>()
/**
* Separate geordnete Liste für konsistentes GUI-Layout.
* Wird nur in onEnable() (single-threaded) befüllt → kein Synchronisierungsbedarf.
*/
private val registrationOrder = mutableListOf<String>()
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<Perk> =
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<UUID, MutableList<String>>()
fun getSelectedPerkIds( uuid: UUID ): List<String> =
selectedPerkIds[ uuid ]?.toList() ?: emptyList()
fun getSelectedPerks( player: Player ): List<Perk> =
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}" ) }
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.oracle.lore")
override val icon = Material.SPYGLASS
/** Laufende Tracker-Tasks pro Spieler. */
private val trackerTasks = ConcurrentHashMap<UUID, BukkitTask>()
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("<gray>???")
val base = mm.deserialize(
"<gold>⚲ <yellow><name></yellow> <dark_gray>·</dark_gray> " +
"<white>Kit: </white><kit><dark_gray> · </dark_gray><yellow><distance>m</yellow>",
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(" <green>⬆ Good Gamble")
else mm.deserialize(" <red>⬇ Bad Gamble")
return base.append(hint)
}
}

View File

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

View File

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

View File

@@ -138,6 +138,48 @@ gui:
input_placeholder: '<gray>Enter search term...</gray>'
confirm_name: '<green>✔ Confirm Search</green>'
confirm_click: '<gray>Click to confirm</gray>'
perk_selector:
title: '<gradient:dark_purple:light_purple><bold>Perk-Auswahl</bold></gradient>'
slot_empty: '<gray>Perk-Slot <slot>: <red>Leer</red></gray>'
slot_hint: '<dark_gray>Klicke einen Perk zum Ausrüsten'
slot_title: '<gray>Slot <slot>: </gray>'
equipped_label: '<green>✔ Ausgerüstet</green>'
click_equip: '<gray>Klick zum Ausrüsten</gray>'
click_deselect: '<gray>Klick zum Abwählen</gray>'
slots_full: '<red>Slots voll! Klicke einen aktiven Perk zum Abwählen.'
game_running: '<red>Perks können während des Spiels nicht geändert werden!'
selected: '<green>Ausgerüstet: <perk><green>!'
deselected: '<red>Abgewählt: <perk><red>!'
close: '<red>✕ Schließen</red>'
perks:
oracle:
name: '<gradient:gold:yellow><bold>Oracle</bold></gradient>'
lore:
- ' '
- '<gray>Zeigt Kit + Distanz des nächsten</gray>'
- '<gray>Gegners (Schleichen / Kompass).</gray>'
- ' '
- '<yellow>Synergie: <gray>Spielo-Kit zeigt Gamble-Ausgang.'
vampire:
name: '<gradient:dark_red:red><bold>Vampire</bold></gradient>'
lore:
- ' '
- '<gray>10% Chance bei Nahkampftreffer:</gray>'
- '<red>½ Herz</red> <gray>heilen.</gray>'
featherweight:
name: '<gradient:white:aqua><bold>Featherweight</bold></gradient>'
lore:
- ' '
- '<gray>Vollständig immun gegen</gray>'
- '<gray>Fallschaden.</gray>'
bloodlust:
name: '<gradient:dark_red:gold><bold>Bloodlust</bold></gradient>'
lore:
- ' '
- '<gray>Nach einem Kill:</gray>'
- '<yellow>Speed I</yellow> <gray>+</gray> <green>Regen I</green> <gray>für 5 Sekunden.</gray>'
message: '<red>⚔ Blutrausch! <yellow>Speed I</yellow> + <green>Regen I</green> für 5 Sekunden!</red>'
kits:
backup:

View File

@@ -32,3 +32,6 @@ commands:
description: 'Manage the SpeedHG ranking system'
usage: '/ranking <toggle|status|rank [player]>'
permission: speedhg.admin.ranking
perks:
description: 'Perk-Auswahl öffnen'
usage: '/perks'