Add Anchor, Puppet, Tesla kits and perks

Introduce three new kits (Anchor, Puppet, Tesla) and multiple perks, plus related plumbing and behavior changes. Key changes:

- Added kit implementations: AnchorKit (summon iron-golem anchor, knockback resistance/radius effects), PuppetKit (life-drain / fear abilities), TeslaKit (lightning active + passive knockback/fire aura).
- Register new kits and register new perks in SpeedHG startup.
- Added new perks: AdrenalinePerk (post-damage speed proc with cooldown), EnderbluePerk (ender-pearl fall damage handling), Ghost, Pyromaniac, Scavenger (and updated OraclePerk/PerkEventDispatcher usage).
- Extended Perk API (Perk.kt) with onEnderPearlDamage and onPostDamage hooks to support specialized damage handling and post-damage checks.
- PerkManager.isGhost added for checking Ghost perk selection and used to exclude ghost players from targeting/compass logic.
- GameManager.updateCompass now excludes ghost-perk players when computing compass targets.
- Updated PerkEventDispatcher and OraclePerk to integrate new hooks and behaviors.
- Minor language additions in en_US.yml to support new kits/perks.

These changes add new gameplay mechanics and ensure correct event dispatching for pearl/fall/post-damage cases and ghost invisibility to game tracking.
This commit is contained in:
TDSTOS
2026-04-08 02:59:32 +02:00
parent 2be7272f06
commit 1c6b229ad8
15 changed files with 1501 additions and 5 deletions

View File

@@ -23,9 +23,14 @@ 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.AdrenalinePerk
import club.mcscrims.speedhg.perk.impl.BloodlustPerk
import club.mcscrims.speedhg.perk.impl.EnderbluePerk
import club.mcscrims.speedhg.perk.impl.FeatherweightPerk
import club.mcscrims.speedhg.perk.impl.GhostPerk
import club.mcscrims.speedhg.perk.impl.OraclePerk
import club.mcscrims.speedhg.perk.impl.PyromaniacPerk
import club.mcscrims.speedhg.perk.impl.ScavengerPerk
import club.mcscrims.speedhg.perk.impl.VampirePerk
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
import club.mcscrims.speedhg.ranking.RankingManager
@@ -177,13 +182,16 @@ class SpeedHG : JavaPlugin() {
private fun registerKits()
{
kitManager.registerKit( AnchorKit() )
kitManager.registerKit( ArmorerKit() )
kitManager.registerKit( BackupKit() )
kitManager.registerKit( BlackPantherKit() )
kitManager.registerKit( GladiatorKit() )
kitManager.registerKit( GoblinKit() )
kitManager.registerKit( IceMageKit() )
kitManager.registerKit( PuppetKit() )
kitManager.registerKit( RattlesnakeKit() )
kitManager.registerKit( TeslaKit() )
kitManager.registerKit( TheWorldKit() )
kitManager.registerKit( VenomKit() )
kitManager.registerKit( VoodooKit() )
@@ -191,10 +199,15 @@ class SpeedHG : JavaPlugin() {
private fun registerPerks()
{
perkManager.registerPerk( OraclePerk() )
perkManager.registerPerk( VampirePerk() )
perkManager.registerPerk( FeatherweightPerk() )
perkManager.registerPerk( AdrenalinePerk() )
perkManager.registerPerk( BloodlustPerk() )
perkManager.registerPerk( EnderbluePerk() )
perkManager.registerPerk( FeatherweightPerk() )
perkManager.registerPerk( GhostPerk() )
perkManager.registerPerk( OraclePerk() )
perkManager.registerPerk( PyromaniacPerk() )
perkManager.registerPerk( ScavengerPerk() )
perkManager.registerPerk( VampirePerk() )
}
private fun registerCommands()

View File

@@ -356,7 +356,8 @@ class GameManager(
private fun updateCompass()
{
val players = Bukkit.getOnlinePlayers().filter { alivePlayers.contains( it.uniqueId ) }
val players = Bukkit.getOnlinePlayers()
.filter { alivePlayers.contains( it.uniqueId ) && !plugin.perkManager.isGhost( it ) }
for ( p in players )
{

View File

@@ -0,0 +1,317 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.minimessage.MiniMessage
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.NamespacedKey
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.attribute.Attribute
import org.bukkit.entity.IronGolem
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.persistence.PersistentDataType
import org.bukkit.scheduler.BukkitTask
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## AnchorKit
*
* **Passiv (immer aktiv):** 40 % Rückschlag-Reduktion über `GENERIC_KNOCKBACK_RESISTANCE`.
*
* **Active (beide Playstyles):** Beschwört einen Eisengolem als „Anker".
* - Während der Spieler im Radius des Ankers ist: voller NoKnock + Bonus-Schaden.
* - Der Golem kann von Gegnern zerstört werden (20 HP). Bei Tod spielt er den
* Eisengolem-Todesklang und benachrichtigt den Besitzer.
* - Nur ein aktiver Anker gleichzeitig; neuer Anker entfernt den alten.
*
* | Playstyle | Radius | Bonus-Schaden |
* |-------------|--------|----------------------------|
* | AGGRESSIVE | 5 Blöcke | +1,0 HP (0,5 Herzen) auf jedem Treffer |
* | DEFENSIVE | 8 Blöcke | kein Schaden-Bonus, aber +Resistance I |
*
* ### Technische Lösung Golem-Tod-Erkennung ohne eigenen Listener:
* Ein `BukkitTask` prüft alle 10 Ticks (0,5 s), ob `golem.isDead || !golem.isValid`.
* Der Golem wird mit `isSilent = true` gespawnt, sodass wir den Eisengolem-Todesklang
* manuell abspielen können (kein unerwarteter Doppel-Sound).
* Der Golem erhält 20 HP (statt 100 vanilla), damit er in HG-Kämpfen destroybar ist.
*
* ### Rückschlag-Reduktion:
* `onAssign` setzt `GENERIC_KNOCKBACK_RESISTANCE.baseValue = PARTIAL_RESISTANCE`.
* Ein periodischer Task aktualisiert den Wert auf 1.0 (wenn im Radius) oder zurück
* auf PARTIAL_RESISTANCE (wenn außerhalb).
* `onRemove` setzt den Attributwert auf 0,0 zurück.
*/
class AnchorKit : Kit() {
private val plugin get() = SpeedHG.instance
private val mm = MiniMessage.miniMessage()
override val id = "anchor"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("kits.anchor.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("kits.anchor.lore")
override val icon = Material.CHAIN
companion object {
const val PARTIAL_RESISTANCE = 0.4 // 40 % immer aktiv
const val GOLEM_HP = 20.0 // 10 Herzen
const val AGGRESSIVE_RADIUS = 5.0
const val DEFENSIVE_RADIUS = 8.0
const val AGGRESSIVE_BONUS_DMG = 1.0 // +0,5 Herzen
const val MONITOR_INTERVAL_TICKS = 10L // alle 0,5 s prüfen
const val PDC_KEY = "anchor_owner_uuid"
}
private val anchorGolems : MutableMap<UUID, IronGolem> = ConcurrentHashMap()
private val monitorTasks : MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
// ── Gecachte Instanzen ────────────────────────────────────────────────────
private val aggressiveActive = AnchorActive(Playstyle.AGGRESSIVE, AGGRESSIVE_RADIUS)
private val defensiveActive = AnchorActive(Playstyle.DEFENSIVE, DEFENSIVE_RADIUS)
private val aggressivePassive = AnchorPassive(Playstyle.AGGRESSIVE, AGGRESSIVE_RADIUS, bonusDamage = AGGRESSIVE_BONUS_DMG, resistanceBonus = false)
private val defensivePassive = AnchorPassive(Playstyle.DEFENSIVE, DEFENSIVE_RADIUS, bonusDamage = 0.0, resistanceBonus = true)
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
val active = getActiveAbility(playstyle)
val item = ItemBuilder(Material.CHAIN)
.name(active.name)
.lore(listOf(active.description))
.build()
cachedItems[player.uniqueId] = listOf(item)
player.inventory.addItem(item)
}
// ── Lifecycle: Rückschlag-Basis-Resistenz setzen/entfernen ───────────────
override fun onAssign(player: Player, playstyle: Playstyle) {
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)
?.baseValue = PARTIAL_RESISTANCE
}
override fun onRemove(player: Player) {
// Golem entfernen
removeAnchor(player, playDeathSound = false)
// Rückschlag-Resistenz zurücksetzen
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)
?.baseValue = 0.0
cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) }
}
// =========================================================================
// Active Ability Anker-Golem beschwören (beide Playstyles, unterschiedlicher Radius)
// =========================================================================
inner class AnchorActive(
playstyle: Playstyle,
private val radius: Double
) : ActiveAbility(playstyle) {
private val plugin get() = SpeedHG.instance
override val kitId = "anchor"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.items.chain.name")
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.items.chain.description")
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.CHAIN
override fun execute(player: Player): AbilityResult {
// Alten Anker entfernen (kein Todesklang Spieler beschwört neuen)
removeAnchor(player, playDeathSound = false)
val spawnLoc = player.location.clone()
val world = spawnLoc.world ?: return AbilityResult.ConditionNotMet("World is null")
// Eisengolem spawnen
val golem = world.spawn(spawnLoc, IronGolem::class.java) { g ->
g.setAI(false) // keine Bewegung, kein Angriff
g.isSilent = true // Todesklang manuell kontrollieren
g.isInvulnerable = false // muss zerstörbar sein
g.customName(mm.deserialize("<gray>⚓ <white>Anker</white>"))
g.isCustomNameVisible = true
// HP reduzieren (vanilla = 100 HP)
g.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.baseValue = GOLEM_HP
g.health = GOLEM_HP
// PDC: Besitzer-UUID für spätere Identifikation
g.persistentDataContainer.set(
NamespacedKey(plugin, PDC_KEY),
PersistentDataType.STRING,
player.uniqueId.toString()
)
}
anchorGolems[player.uniqueId] = golem
// Monitor-Task: prüft Golem-Zustand + aktualisiert Rückschlag-Resistenz
val task = Bukkit.getScheduler().runTaskTimer(plugin, { ->
val activeGolem = anchorGolems[player.uniqueId]
if (activeGolem == null || activeGolem.isDead || !activeGolem.isValid) {
// Golem wurde von Gegnern zerstört
if (activeGolem?.isDead == true) {
onAnchorDestroyed(player, activeGolem.location)
}
monitorTasks.remove(player.uniqueId)?.cancel()
// Resistenz zurück auf Basis-Wert (Golem ist weg)
if (player.isOnline) {
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)
?.baseValue = PARTIAL_RESISTANCE
}
return@runTaskTimer
}
if (!player.isOnline) {
activeGolem.remove()
anchorGolems.remove(player.uniqueId)
monitorTasks.remove(player.uniqueId)?.cancel()
return@runTaskTimer
}
// Radius-Check: voller NoKnock im Anker-Radius
val inRadius = player.location.distanceSquared(activeGolem.location) <= radius * radius
val targetResistance = if (inRadius) 1.0 else PARTIAL_RESISTANCE
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)?.baseValue = targetResistance
// Visueller Indikator am Golem (Partikelring)
if (inRadius) {
world.spawnParticle(
Particle.CRIT,
activeGolem.location.clone().add(0.0, 2.5, 0.0),
2, 0.1, 0.1, 0.1, 0.0
)
}
}, 0L, MONITOR_INTERVAL_TICKS)
monitorTasks[player.uniqueId] = task
// Feedback
world.playSound(spawnLoc, Sound.ENTITY_IRON_GOLEM_HURT, 1f, 0.5f)
world.spawnParticle(Particle.CLOUD, spawnLoc.clone().add(0.0, 1.0, 0.0), 20, 0.5, 0.3, 0.5, 0.05)
player.sendActionBar(
player.trans("kits.anchor.messages.anchor_placed",
"radius" to radius.toInt().toString())
)
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 0.8f)
player.sendActionBar(player.trans("kits.anchor.messages.ability_charged"))
}
}
// =========================================================================
// Passive Bonus-Schaden und Resistance (Radius-basiert)
// =========================================================================
inner class AnchorPassive(
playstyle: Playstyle,
private val radius: Double,
private val bonusDamage: Double,
private val resistanceBonus: Boolean
) : PassiveAbility(playstyle) {
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.passive.name")
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.passive.description")
override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
val golem = anchorGolems[attacker.uniqueId] ?: return
// Nur wirksam wenn Angreifer im Radius
if (attacker.location.distanceSquared(golem.location) > radius * radius) return
// Bonus-Schaden (Aggressive playstyle)
if (bonusDamage > 0.0) {
event.damage += bonusDamage
attacker.world.spawnParticle(
Particle.CRIT,
victim.location.clone().add(0.0, 1.2, 0.0),
5, 0.2, 0.2, 0.2, 0.0
)
}
}
override fun onHitByEnemy(victim: Player, attacker: Player, event: EntityDamageByEntityEvent) {
if (!resistanceBonus) return
val golem = anchorGolems[victim.uniqueId] ?: return
// Resistance I während im Radius (Defensive playstyle)
if (victim.location.distanceSquared(golem.location) <= radius * radius) {
// Schaden um ~20 % reduzieren (Resistance I Äquivalent)
event.damage *= 0.80
}
}
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Entfernt den aktiven Anker eines Spielers sauber.
* @param playDeathSound Falls `true`, wird der Eisengolem-Todesklang abgespielt.
*/
private fun removeAnchor(player: Player, playDeathSound: Boolean) {
monitorTasks.remove(player.uniqueId)?.cancel()
val golem = anchorGolems.remove(player.uniqueId) ?: return
if (playDeathSound && golem.isValid) {
golem.world.playSound(golem.location, Sound.ENTITY_IRON_GOLEM_DEATH, 1f, 1f)
}
if (golem.isValid) golem.remove()
}
/**
* Wird aufgerufen, wenn der Golem von Gegnern zerstört wurde (HP == 0).
* Der Golem ist zu diesem Zeitpunkt bereits `isDead`, wir spielen den Sound manuell
* (weil der Golem mit `isSilent = true` gespawnt wurde).
*/
private fun onAnchorDestroyed(player: Player, deathLocation: Location) {
anchorGolems.remove(player.uniqueId)
deathLocation.world?.playSound(deathLocation, Sound.ENTITY_IRON_GOLEM_DEATH, 1f, 1f)
deathLocation.world?.spawnParticle(
Particle.EXPLOSION, deathLocation, 3, 0.3, 0.3, 0.3, 0.0
)
if (player.isOnline) {
player.sendActionBar(player.trans("kits.anchor.messages.anchor_destroyed"))
player.playSound(player.location, Sound.ENTITY_IRON_GOLEM_DEATH, 0.8f, 1.3f)
}
}
}

View File

@@ -0,0 +1,291 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Bukkit
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.inventory.ItemStack
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.scheduler.BukkitTask
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## PuppetKit (basierend auf Fiddlesticks)
*
* | Playstyle | Fähigkeit |
* |-------------|----------------------------------------------------------------------------------------|
* | AGGRESSIVE | **Life Drain** saugt 4 ♥/s pro Gegner in der Nähe (max. 8 ♥, 2 s). Sneak: Cancel. |
* | DEFENSIVE | **Puppeteer's Fear** Blindness + Slowness III an alle Nahkämpfer für 4 Sekunden. |
*
* ### Cancel-Mechanismus (Aggressive):
* `onToggleSneak` (Hook in [Kit]) wird aufgerufen, wenn der Spieler die Shift-Taste drückt.
* Falls ein Drain-Task aktiv ist, wird er sofort beendet. Das Laden (Charge-State: CHARGING)
* läuft weiter der Spieler bekommt keine Erstattung, da die Fähigkeit bereits angefangen hat.
*
* ### Drain-Timing:
* Der Task feuert alle 20 Ticks (= 1 s) genau zweimal (0s + 1s → insgesamt 2 Sekunden).
* Pro Feuer: `min(8 × numEnemies, 16 totalHealed_hp)` HP wird auf den Caster übertragen.
* Healing: Direkt über `player.health = (player.health + healAmount).coerceAtMost(maxHp)`.
* Drain: Jeder Gegner nimmt `DRAIN_HP_PER_ENEMY_PER_SECOND` Schaden.
*/
class PuppetKit : Kit() {
private val plugin get() = SpeedHG.instance
override val id = "puppet"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("kits.puppet.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("kits.puppet.lore")
override val icon = Material.PHANTOM_MEMBRANE
// Laufende Drain-Tasks: PlayerUUID → BukkitTask
internal val activeDrainTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
companion object {
const val DRAIN_RADIUS = 7.0
const val DRAIN_DURATION_TICKS = 40L // 2 Sekunden
const val DRAIN_TICK_INTERVAL = 20L // pro Sekunde einmal
const val HEAL_PER_ENEMY_PER_S_HP = 8.0 // 4 Herzen = 8 HP
const val MAX_TOTAL_HEAL_HP = 16.0 // 8 Herzen = 16 HP
const val DRAIN_DMG_PER_ENEMY_PER_S = 4.0 // Gegner verlieren 2 Herzen/s
const val FEAR_RADIUS = 7.0
const val FEAR_DURATION_TICKS = 80 // 4 Sekunden
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = NoPassive(Playstyle.AGGRESSIVE)
private val defensivePassive = NoPassive(Playstyle.DEFENSIVE)
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
val (mat, active) = when (playstyle) {
Playstyle.AGGRESSIVE -> Material.PHANTOM_MEMBRANE to aggressiveActive
Playstyle.DEFENSIVE -> Material.BLAZE_ROD to defensiveActive
}
val item = ItemBuilder(mat)
.name(active.name)
.lore(listOf(active.description))
.build()
cachedItems[player.uniqueId] = listOf(item)
player.inventory.addItem(item)
}
override fun onRemove(player: Player) {
// Laufenden Drain abbrechen (z.B. bei Spielende)
activeDrainTasks.remove(player.uniqueId)?.cancel()
cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) }
}
/**
* Sneak → bricht einen laufenden Drain ab.
* Wird von [KitEventDispatcher.onPlayerToggleSneak] aufgerufen.
*/
override fun onToggleSneak(player: Player, isSneaking: Boolean) {
if (!isSneaking) return
val task = activeDrainTasks.remove(player.uniqueId) ?: return
task.cancel()
player.playSound(player.location, Sound.ENTITY_VEX_HURT, 0.6f, 1.8f)
player.sendActionBar(player.trans("kits.puppet.messages.drain_cancelled"))
}
// =========================================================================
// AGGRESSIVE active Life Drain
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private val plugin get() = SpeedHG.instance
override val kitId = "puppet"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.drain.name")
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.drain.description")
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.PHANTOM_MEMBRANE
override fun execute(player: Player): AbilityResult {
// Sicherheit: kein doppelter Drain (kann eigentlich nicht passieren, da
// Charge in CHARGING-State ist, aber defensiv trotzdem prüfen)
if (activeDrainTasks.containsKey(player.uniqueId))
return AbilityResult.ConditionNotMet("Drain already active!")
// Sofort prüfen ob Gegner in der Nähe sind
val initialEnemies = player.world
.getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
if (initialEnemies.isEmpty())
return AbilityResult.ConditionNotMet(
plugin.languageManager.getDefaultRawMessage("kits.puppet.messages.no_enemies")
)
var totalHealedHp = 0.0
var ticksFired = 0
val task = Bukkit.getScheduler().runTaskTimer(plugin, { ->
ticksFired++
// Task selbst beenden wenn: offline, tot, max Heilung erreicht, Zeit abgelaufen
if (!player.isOnline ||
!plugin.gameManager.alivePlayers.contains(player.uniqueId) ||
totalHealedHp >= MAX_TOTAL_HEAL_HP ||
ticksFired * DRAIN_TICK_INTERVAL > DRAIN_DURATION_TICKS) {
activeDrainTasks.remove(player.uniqueId)?.cancel()
return@runTaskTimer
}
val currentEnemies = player.world
.getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
if (currentEnemies.isEmpty()) {
activeDrainTasks.remove(player.uniqueId)?.cancel()
return@runTaskTimer
}
// Heilmenge: 4♥ pro Gegner, gedeckelt auf verbleibendes Maximum
val potentialHeal = HEAL_PER_ENEMY_PER_S_HP * currentEnemies.size
val actualHeal = potentialHeal.coerceAtMost(MAX_TOTAL_HEAL_HP - totalHealedHp)
// Gegner entwässern
currentEnemies.forEach { enemy ->
enemy.damage(DRAIN_DMG_PER_ENEMY_PER_S, player)
// Partikel-Sog: von Gegner zur Puppeteer-Position
enemy.world.spawnParticle(
Particle.CRIMSON_SPORE,
enemy.location.clone().add(0.0, 1.3, 0.0),
8, 0.3, 0.3, 0.3, 0.02
)
}
// Caster heilen
val maxHp = player.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.value ?: 20.0
player.health = (player.health + actualHeal).coerceAtMost(maxHp)
totalHealedHp += actualHeal
// Audio-Visual Feedback
player.world.spawnParticle(
Particle.HEART,
player.location.clone().add(0.0, 2.0, 0.0),
3, 0.4, 0.2, 0.4, 0.0
)
player.playSound(player.location, Sound.ENTITY_GENERIC_DRINK, 0.5f, 0.4f)
player.sendActionBar(
player.trans(
"kits.puppet.messages.draining",
"healed" to "%.1f".format(totalHealedHp / 2.0), // in Herzen
"max" to (MAX_TOTAL_HEAL_HP / 2.0).toInt().toString()
)
)
}, 0L, DRAIN_TICK_INTERVAL)
activeDrainTasks[player.uniqueId] = task
player.playSound(player.location, Sound.ENTITY_VEX_AMBIENT, 1f, 0.4f)
player.sendActionBar(player.trans("kits.puppet.messages.drain_start"))
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.puppet.messages.ability_charged"))
}
}
// =========================================================================
// DEFENSIVE active Puppeteer's Fear (Blindness + Slowness)
// =========================================================================
private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
private val plugin get() = SpeedHG.instance
override val kitId = "puppet"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.fear.name")
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.fear.description")
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.BLAZE_ROD
override fun execute(player: Player): AbilityResult {
val targets = player.world
.getNearbyEntities(player.location, FEAR_RADIUS, FEAR_RADIUS, FEAR_RADIUS)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
if (targets.isEmpty())
return AbilityResult.ConditionNotMet(
plugin.languageManager.getDefaultRawMessage("kits.puppet.messages.no_enemies")
)
targets.forEach { target ->
target.addPotionEffect(
PotionEffect(PotionEffectType.BLINDNESS, FEAR_DURATION_TICKS, 0, false, false, true)
)
target.addPotionEffect(
PotionEffect(PotionEffectType.SLOWNESS, FEAR_DURATION_TICKS, 2, false, false, true)
)
target.sendActionBar(target.trans("kits.puppet.messages.feared"))
target.world.spawnParticle(
Particle.SOUL,
target.location.clone().add(0.0, 1.5, 0.0),
15, 0.4, 0.5, 0.4, 0.03
)
target.playSound(target.location, Sound.ENTITY_PHANTOM_AMBIENT, 0.8f, 0.3f)
}
player.playSound(player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.3f)
player.sendActionBar(
player.trans(
"kits.puppet.messages.fear_cast",
"count" to targets.size.toString()
)
)
return AbilityResult.Success
}
override fun onFullyCharged(player: Player) {
player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
player.sendActionBar(player.trans("kits.puppet.messages.ability_charged"))
}
}
class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {
override val name = "None"
override val description = "None"
}
}

View File

@@ -0,0 +1,318 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.inventory.ItemStack
import org.bukkit.scheduler.BukkitTask
import org.bukkit.util.Vector
import java.util.Random
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.cos
import kotlin.math.sin
/**
* ## TeslaKit
*
* | Playstyle | Active | Passive |
* |-------------|---------------------------------------------------|------------------------------------------------|
* | AGGRESSIVE | 5 Blitze im 5-Block-Radius (1.5 ♥ pro Treffer) | Rückschlag + Brandschaden-Aura alle 3 s (klein)|
* | DEFENSIVE | | Rückschlag + Brandschaden-Aura alle 3 s (groß) |
*
* **Höhen-Einschränkung**: Beide Mechaniken deaktivieren sich ab Y > [MAX_HEIGHT_Y]
* (~50 Blöcke über Meeresspiegel). Tesla braucht Erdkontakt.
*
* ### Technische Lösung „Visueller Blitz + manueller Schaden":
* `world.strikeLightningEffect()` erzeugt nur Partikel/Sound keinen Block-/Entity-Schaden.
* Direkt danach werden Spieler im 1,5-Block-Radius per `entity.damage()` manuell getroffen.
* Das verhindert ungewollte Nebeneffekte (Feuer, Dorfbewohner-Schaden, eigener Tod durch
* zufälligen Blitzschlag).
*
* ### Passive Aura:
* Ein `BukkitRunnable` (gestartet in `onActivate`, gestoppt in `onDeactivate`) prüft alle
* [AURA_INTERVAL_TICKS] Ticks, ob Gegner in [AURA_RADIUS] Blöcken sind. Falls ja → Velocity-Push
* nach außen + `fireTicks`. Aggressive-Playstyle hat schwächeren Rückschlag, Defensive stärkeren.
*/
class TeslaKit : Kit() {
private val plugin get() = SpeedHG.instance
override val id: String
get() = "tesla"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.tesla.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.tesla.lore" )
override val icon: Material
get() = Material.LIGHTNING_ROD
companion object {
/**
* ~50 Blöcke über Meeresspiegel ( Y ≈ 63 + 50 = 113 )
* Oberhalb dieser Grenze sind beide Fähigkeiten deaktiviert.
*/
const val MAX_HEIGHT_Y = 113.0
// Aggressive Active
const val LIGHTNING_RADIUS = 5.0
const val LIGHTNING_DAMAGE = 3.0
const val LIGHTNING_BOLT_COUNT = 5
const val BOLT_STAGGER_TICKS = 8L
// Passive Aura
const val AURA_RADIUS_AGGRESSIVE = 4.0
const val AURA_RADIUS_DEFENSIVE = 6.0
const val AURA_INTERVAL_TICKS = 60L
const val AURA_FIRE_TICKS = 60
const val KNOCKBACK_AGGRESSIVE = 1.6
const val KNOCKBACK_DEFENSIVE = 2.3
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = NoActive(Playstyle.DEFENSIVE)
private val aggressivePassive = TeslaPassive(
playstyle = Playstyle.AGGRESSIVE,
auraRadius = AURA_RADIUS_AGGRESSIVE,
knockbackStrength = KNOCKBACK_AGGRESSIVE
)
private val defensivePassive = TeslaPassive(
playstyle = Playstyle.DEFENSIVE,
auraRadius = AURA_RADIUS_DEFENSIVE,
knockbackStrength = KNOCKBACK_DEFENSIVE
)
override fun getActiveAbility(
playstyle: Playstyle
) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(
playstyle: Playstyle
) = when (playstyle) {
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
if ( playstyle != Playstyle.AGGRESSIVE )
return
val item = ItemBuilder( Material.LIGHTNING_ROD )
.name( aggressiveActive.name )
.lore(listOf( aggressiveActive.description ))
.build()
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onRemove(
player: Player
) {
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
// =========================================================================
// AGGRESSIVE active gestaffelte Blitze im Nahbereich
// =========================================================================
private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val kitId: String
get() = "tesla"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.description" )
override val triggerMaterial: Material
get() = Material.LIGHTNING_ROD
override val hardcodedHitsRequired: Int
get() = 15
override fun execute(
player: Player
): AbilityResult
{
if ( player.location.y > MAX_HEIGHT_Y )
return AbilityResult.ConditionNotMet(
plugin.languageManager.getDefaultRawMessage( "kits.tesla.messages.too_high" )
)
val world = player.world
repeat( LIGHTNING_BOLT_COUNT ) { index ->
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline )
return@runTaskLater
// Zufällige Position innerhalb des Radius
val angle = rng.nextDouble() * 2.0 * Math.PI
val dist = rng.nextDouble() * LIGHTNING_RADIUS
val strikeLoc = player.location.clone().add(
cos( angle ) * dist,
0.0,
sin( angle ) * dist
)
// Oberfläche bestimmen (Blitze sollen am Boden landen)
strikeLoc.y = world.getHighestBlockYAt( strikeLoc ).toDouble() + 1.0
// Nur visueller Effekt KEIN Block-/Feuer-Schaden
world.strikeLightningEffect( strikeLoc )
// Manueller Schaden an Spielern im Nahbereich des Einschlags
world.getNearbyEntities( strikeLoc, 1.5, 1.5, 1.5 )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { victim ->
victim.damage( LIGHTNING_DAMAGE, player )
victim.world.spawnParticle(
Particle.ELECTRIC_SPARK,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
20, 0.4, 0.5, 0.4, 0.1
)
}
world.spawnParticle(
Particle.ELECTRIC_SPARK, strikeLoc,
12, 0.3, 0.2, 0.3, 0.08
)
}, index * BOLT_STAGGER_TICKS )
}
player.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 1.3f )
player.sendActionBar(player.trans( "kits.tesla.messages.lightning_cast" ))
return AbilityResult.Success
}
override fun onFullyCharged(
player: Player
) {
player.playSound( player.location, Sound.BLOCK_BEACON_ACTIVATE, 0.8f, 1.8f )
player.sendActionBar(player.trans( "kits.tesla.messages.ability_charged" ))
}
}
// =========================================================================
// Passive Aura Rückschlag + Brandschaden im Umkreis (beide Playstyles)
// =========================================================================
class TeslaPassive(
playstyle: Playstyle,
private val auraRadius: Double,
private val knockbackStrength: Double
) : PassiveAbility( playstyle ) {
private val plugin get() = SpeedHG.instance
private val auraTasks = ConcurrentHashMap<UUID, BukkitTask>()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.description" )
override fun onActivate(
player: Player
) {
val task = Bukkit.getScheduler().runTaskTimer( plugin, { ->
// Spieler oder Spielstatus nicht mehr gültig -> Task beenden
if ( !player.isOnline ||
!plugin.gameManager.alivePlayers.contains( player.uniqueId ))
{
auraTasks.remove( player.uniqueId )?.cancel()
return@runTaskTimer
}
// Höhen-Check; kein Effekt über der Grenze
if ( player.location.y > MAX_HEIGHT_Y )
return@runTaskTimer
val nearbyEnemies = player.world
.getNearbyEntities( player.location, auraRadius, auraRadius, auraRadius )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if ( nearbyEnemies.isEmpty() )
return@runTaskTimer
nearbyEnemies.forEach { enemy ->
// Velocity-basierter Rückschlag (radial nach außen)
val pushDir: Vector = enemy.location.toVector()
.subtract( player.location.toVector() )
.normalize()
.multiply( knockbackStrength )
.setY( 0.3 )
enemy.velocity = enemy.velocity.add( pushDir )
enemy.fireTicks = AURA_FIRE_TICKS
enemy.world.spawnParticle(
Particle.ELECTRIC_SPARK,
enemy.location.clone().add( 0.0, 1.0, 0.0 ),
10, 0.3, 0.4, 0.3, 0.06
)
}
// Visuelles Feedback am Tesla-Spieler
player.world.spawnParticle(
Particle.ELECTRIC_SPARK,
player.location.clone().add( 0.0, 1.0, 0.0 ),
6, 0.6, 0.6, 0.6, 0.02
)
player.world.playSound(
player.location,
Sound.ENTITY_LIGHTNING_BOLT_IMPACT,
0.4f, 1.9f
)
}, AURA_INTERVAL_TICKS, AURA_INTERVAL_TICKS )
auraTasks[ player.uniqueId ] = task
}
override fun onDeactivate(
player: Player
) {
auraTasks.remove( player.uniqueId )?.cancel()
}
}
// ── Kein Active für Defensive ─────────────────────────────────────────────
private class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) {
override val kitId = "tesla"
override val name = "None"
override val description = "None"
override val hardcodedHitsRequired = 0
override val triggerMaterial = Material.BARRIER
override fun execute(player: Player) = AbilityResult.Success
}
}

View File

@@ -57,4 +57,29 @@ abstract class Perk {
*/
open fun onEnvironmentalDamage(player: Player, event: EntityDamageEvent) {}
/**
* Aufgerufen wenn dieser Spieler via Enderperle teleportiert wurde und
* direkt danach Fallschaden erhalten würde (Cause: FALL, nach ENDER_PEARL-Teleport).
*
* Der [PerkEventDispatcher] unterscheidet diesen Fall vom normalen Fallschaden
* über ein internes Tracking-Set und ruft diesen Hook **statt** [onEnvironmentalDamage] auf.
*
* → Überschreiben um den Schaden zu canceln ([event.isCancelled = true]).
*/
open fun onEnderPearlDamage(player: Player, event: EntityDamageEvent) {}
/**
* Aufgerufen **nach** vollständiger Schadensberechnung (MONITOR-Priority),
* wenn `event.finalDamage` den endgültigen Abzug nach Rüstung/Modifiern enthält.
* `player.health` ist hier noch der Vor-Schaden-Wert.
*
* Geeignet für Prüfungen der Form: `player.health - event.finalDamage < X`
*
* Gilt für **jeden** Schadenstyp (Nahkampf UND Umgebung).
* Wird nicht aufgerufen wenn das Event bereits gecancelt ist.
*
* → Primär für [AdrenalinePerk].
*/
open fun onPostDamage(player: Player, event: EntityDamageEvent) {}
}

View File

@@ -144,6 +144,37 @@ class PerkManager(
.forEach { removePerks( it ) }
}
/**
* Gibt `true` zurück, wenn [player] das Geist-Perk ausgerüstet hat.
*
* Wird an folgenden Stellen aufgerufen, um den Spieler aus Trackings zu entfernen:
*
* **1. GameManager.updateCompass** — beim Iterieren über potenzielle Kompass-Ziele:
* ```kotlin
* for (target in players) {
* if (p == target) continue
* if (plugin.perkManager.isGhost(target)) continue // ← NEU
* val dist = p.location.distanceSquared(target.location)
* ...
* }
* ```
*
* **2. OraclePerk.findNearestEnemy** — beim Filtern der alivePlayers-Sequenz:
* ```kotlin
* plugin.gameManager.alivePlayers
* .asSequence()
* .filter { it != player.uniqueId }
* .mapNotNull { plugin.server.getPlayer(it) }
* .filter { !plugin.perkManager.isGhost(it) } // ← NEU
* .minByOrNull { it.location.distanceSquared(player.location) }
* ```
*
* @param player Der zu prüfende Spieler.
* @return `true` wenn [GhostPerk] in der aktiven Perk-Auswahl des Spielers ist.
*/
fun isGhost(player: Player): Boolean =
getSelectedPerkIds(player.uniqueId).contains("ghost")
// ── Persistenz ────────────────────────────────────────────────────────────
private val repository = PlayerPerksRepository( plugin.databaseManager )

View File

@@ -0,0 +1,99 @@
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.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## Adrenalin (Adrenaline)
*
* Fällt die eigene HP durch einen Treffer unter 3 Herzen (6.0 HP),
* wird für 5 Sekunden [PotionEffectType.SPEED] Level II gewährt.
*
* ### Cooldown
* 30 Sekunden pro Spieler. Damit das Perk bei Dauerfeuer mit wenig HP nicht
* dauerhaft aktiv ist, wird der Auslöse-Zeitpunkt in [lastProc] gespeichert.
*
* ### Technische Umsetzung — warum ein neuer Hook?
* Das Perk muss die HP **nach** Abzug des Schadens prüfen. Der bestehende
* [onHitByEnemy]-Hook läuft auf MONITOR-Priority (d.h. Event ist nicht cancelled)
* und liest `event.finalDamage`, aber `player.health` ist dort noch der
* Wert **vor** dem Schaden, weil der Bukkit-Damage-Stack die Health erst
* nach allen MONITOR-Listenern tatsächlich abzieht.
*
* Daher benötigt das Perk den neuen [onPostDamage]-Hook, der vom Dispatcher
* über einen separaten `@EventHandler(priority = MONITOR)` bedient wird,
* der explizit `player.health - event.finalDamage` ausliest.
*
* Alternativ könnte [Bukkit.getScheduler().runTask] (nächsten Tick) genutzt
* werden, aber die finalDamage-Prüfung ist präziser und sauberer.
*/
class AdrenalinePerk : Perk() {
private val plugin get() = SpeedHG.instance
override val id = "adrenaline"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("perks.adrenaline.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.adrenaline.lore")
override val icon = Material.BLAZE_ROD
/** UUID → letzter Auslöse-Zeitstempel in Millisekunden. */
private val lastProc: MutableMap<UUID, Long> = ConcurrentHashMap()
companion object {
private const val HP_THRESHOLD = 6.0 // 3 Herzen
private const val COOLDOWN_MS = 30_000L // 30 Sekunden
private const val DURATION_TICKS = 5 * 20 // 5 Sekunden
}
override fun onDeactivate(player: Player) {
lastProc.remove(player.uniqueId)
}
/**
* Wird vom [PerkEventDispatcher] mit MONITOR-Priority aufgerufen,
* **nachdem** alle anderen Modifier den Schaden festgelegt haben.
*
* `event.finalDamage` ist der tatsächlich abgezogene Wert nach Rüstung etc.
* `player.health` ist hier noch der **Vor-Schaden**-Wert — daher die Subtraktion.
*/
override fun onPostDamage(player: Player, event: EntityDamageEvent) {
// Bereits gecancelt → kein Schaden → kein Adrenalin-Check
if (event.isCancelled) return
val healthAfter = player.health - event.finalDamage
if (healthAfter >= HP_THRESHOLD) return
val now = System.currentTimeMillis()
if (now - (lastProc[player.uniqueId] ?: 0L) < COOLDOWN_MS) return
lastProc[player.uniqueId] = now
player.addPotionEffect(
PotionEffect(PotionEffectType.SPEED, DURATION_TICKS, 1, false, false, true)
)
player.world.spawnParticle(
Particle.CRIT,
player.location.clone().add(0.0, 1.0, 0.0),
15, 0.3, 0.5, 0.3, 0.1
)
player.playSound(player.location, Sound.ENTITY_PLAYER_ATTACK_STRONG, 0.8f, 1.6f)
player.sendActionBar(player.trans("perks.adrenaline.message"))
}
}

View File

@@ -0,0 +1,60 @@
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.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageEvent
/**
* ## Enderblut (Enderblood)
*
* Der Spieler erleidet keinen Schaden durch das Landen von Enderperlen.
*
* ### Technische Umsetzung
* Ender-Perlen-Schaden wird von Minecraft als [EntityDamageEvent] mit Cause
* [EntityDamageEvent.DamageCause.FALL] direkt nach dem Teleport geliefert.
* Das Unterscheidungsmerkmal zum normalen Fallschaden: Der [PerkEventDispatcher]
* trackt via [PlayerTeleportEvent] (Cause: ENDER_PEARL), wer gerade
* teleportiert wurde, und ruft dann den spezialisierten Hook [onEnderPearlDamage]
* auf anstelle des normalen [onEnvironmentalDamage].
*
* Dies hält den Hook vollständig von normalem Fallschaden getrennt —
* [FeatherweightPerk] und [EnderbluePerk] können beide gleichzeitig ausgerüstet
* sein, ohne sich gegenseitig zu stören.
*/
class EnderbluePerk : Perk() {
private val plugin get() = SpeedHG.instance
override val id = "enderblue"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("perks.enderblue.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.enderblue.lore")
override val icon = Material.ENDER_PEARL
/**
* Aufgerufen vom Dispatcher wenn der Spieler via Enderperle teleportiert wurde
* und direkt danach Fallschaden erleiden würde.
* Cancelt das Event und gibt dem Spieler visuelles Feedback.
*/
override fun onEnderPearlDamage(player: Player, event: EntityDamageEvent) {
event.isCancelled = true
player.world.spawnParticle(
Particle.PORTAL,
player.location.clone().add(0.0, 1.0, 0.0),
20, 0.4, 0.5, 0.4, 0.08
)
player.playSound(player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.6f, 1.4f)
player.sendActionBar(player.trans("perks.enderblue.message"))
}
}

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
/**
* ## Geist (Ghost)
*
* Der Spieler ist für das Kompass-Tracking in [GameManager.updateCompass] und
* für das Orakel-Perk ([OraclePerk.findNearestEnemy]) unsichtbar.
*
* ### Technische Umsetzung
* Dieses Perk hat **keine eigenen Event-Hooks** — es ist rein passiv-prüfend.
* Stattdessen stellt der [PerkManager] die Hilfsmethode [PerkManager.isGhost]
* bereit. Diese wird an zwei Stellen aufgerufen:
*
* 1. **[GameManager.updateCompass]**: Beim Iterieren über `players` den
* jeweiligen `target` per `if (plugin.perkManager.isGhost(target)) continue`
* überspringen.
*
* 2. **[OraclePerk.findNearestEnemy]**: Beim Filtern der alivePlayers-Sequenz
* per `.filter { !plugin.perkManager.isGhost(Bukkit.getPlayer(it)!!) }`.
*
* ### Wichtig
* Der Geist-Spieler ist weiterhin für andere Spieler **sichtbar** (kein
* Invisibility-Potion). Er taucht nur nicht als Kompass-Ziel oder Orakel-Ziel auf.
* Für echte Unsichtbarkeit wäre ein Invisibility-Potion-Effekt in [onActivate]
* nötig — das ist hier bewusst nicht implementiert, um Fairness zu wahren.
*/
class GhostPerk : Perk() {
private val plugin get() = SpeedHG.instance
override val id = "ghost"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("perks.ghost.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.ghost.lore")
override val icon = Material.GLASS
// Keine Lifecycle- oder Event-Hooks nötig — die Logik liegt
// in PerkManager.isGhost() und den aufrufenden Stellen.
}

View File

@@ -73,6 +73,7 @@ class OraclePerk : Perk() {
.asSequence()
.filter { it != player.uniqueId }
.mapNotNull { plugin.server.getPlayer(it) }
.filter { !plugin.perkManager.isGhost(it) }
.minByOrNull { it.location.distanceSquared(player.location) }
private fun buildTrackerComponent(player: Player, nearest: Player): Component {

View File

@@ -0,0 +1,66 @@
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
/**
* ## Feuerläufer (Pyromaniac)
*
* Vollständige Immunität gegen alle feuer- und lavabedingten Schadensquellen.
*
* ### Abgedeckte Damage Causes
* | Cause | Beschreibung |
* |--------------|-------------------------------------------|
* | FIRE | Direktes Berühren von Feuerblöcken |
* | FIRE_TICK | Brennen (nachdem Feuer/Lava angesteckt) |
* | LAVA | Direktes Berühren von Lava |
* | HOT_FLOOR | Laufen über Magmablöcke |
*
* ### Warum HIGH-Priority?
* Das Event wird auf HIGH gecancelt, **bevor** es auf MONITOR gelesen wird.
* So sehen Adrenalin's MONITOR-Handler und der Standard-Schaden-Stack
* immer den korrekten (gecancelten) Zustand.
*
* Da [onEnvironmentalDamage] schon HIGH hat (siehe [PerkEventDispatcher]),
* reicht der Aufruf über den bestehenden Hook vollständig aus —
* kein neuer Dispatcher-Handler nötig.
*/
class PyromaniacPerk : Perk() {
private val plugin get() = SpeedHG.instance
override val id = "pyromaniac"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("perks.pyromaniac.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.pyromaniac.lore")
override val icon = Material.FIRE_CHARGE
private val FIRE_CAUSES = setOf(
EntityDamageEvent.DamageCause.FIRE,
EntityDamageEvent.DamageCause.FIRE_TICK,
EntityDamageEvent.DamageCause.LAVA,
EntityDamageEvent.DamageCause.HOT_FLOOR,
)
/**
* Cancelt alle feuer- und lavabedingten Schadensevents.
* Der Spieler muss außerdem nicht brennen — [Player.fireTicks] wird
* auf 0 gesetzt damit auch bestehende Brandeffekte sofort gelöscht werden.
*/
override fun onEnvironmentalDamage(player: Player, event: EntityDamageEvent) {
if (event.cause !in FIRE_CAUSES) return
event.isCancelled = true
// Bestehende Brand-Ticks löschen (z.B. wenn der Spieler bereits brennt)
if (player.fireTicks > 0) player.fireTicks = 0
}
}

View File

@@ -0,0 +1,52 @@
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.inventory.ItemStack
/**
* ## Plünderer (Scavenger)
*
* Tötet der Träger einen Gegner, wird **zusätzlich** zum normalen Drop-Loot
* ein [Material.GOLDEN_APPLE] an der Leichen-Position gedroppt.
*
* ### Warum [onKillEnemy] statt [PlayerDeathEvent]?
* Der [PerkEventDispatcher] dispatcht [onKillEnemy] schon auf HIGH-Priority
* nach dem Kill, bevor Item-Drops verarbeitet werden. Das Drop fällt so
* sauber in den gleichen Tick wie der übrige Loot und ist sofort aufhebbar.
*
* ### Kein doppeltes Drop durch [GameManager.onPlayerEliminated]
* [GameManager.onPlayerEliminated] droxt die Inventar-Inhalte des Opfers
* **separat** via `player.world.dropItemNaturally`. Unser Goldapfel-Drop
* kommt aus dem Killer-Perk und ist unabhängig davon — kein Konflikt.
*/
class ScavengerPerk : Perk() {
private val plugin get() = SpeedHG.instance
override val id = "scavenger"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("perks.scavenger.name", mapOf())
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("perks.scavenger.lore")
override val icon = Material.GOLDEN_APPLE
override fun onKillEnemy(killer: Player, victim: Player) {
// Goldapfel am Sterbeort des Opfers droppen
victim.world.dropItemNaturally(
victim.location,
ItemStack(Material.GOLDEN_APPLE)
)
killer.playSound(killer.location, Sound.ENTITY_ITEM_PICKUP, 0.9f, 1.5f)
killer.sendActionBar(killer.trans("perks.scavenger.message"))
}
}

View File

@@ -10,6 +10,9 @@ import org.bukkit.event.Listener
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.event.entity.EntityDamageEvent
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.player.PlayerTeleportEvent
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Einziger registrierter Listener für alle perk-bezogenen Events.
@@ -71,6 +74,75 @@ class PerkEventDispatcher(
perkManager.getSelectedPerks(killer).forEach { it.onKillEnemy(killer, event.entity) }
}
// ── Enderperle: Tracking + Hook-Dispatch ──────────────────────────────────
/**
* UUID-Set von Spielern, die gerade via Enderperle teleportiert wurden
* und deren nächsten FALL-Schaden wir als Ender-Pearl-Schaden identifizieren.
* Wird 10 Ticks nach dem Teleport automatisch geleert.
*/
private val recentEnderPearlUsers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
/**
* Registriert den Spieler als "gerade via Enderperle teleportiert".
* Das Flag bleibt für 10 Ticks (0.5 s) gesetzt — genug Zeit für den
* darauf folgenden FALL-Schadens-Event.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onEnderPearlTeleport(event: PlayerTeleportEvent) {
if (event.cause != PlayerTeleportEvent.TeleportCause.ENDER_PEARL) return
val player = event.player
if (!isIngame()) return
if (!plugin.gameManager.alivePlayers.contains(player.uniqueId)) return
recentEnderPearlUsers += player.uniqueId
// Safety-Cleanup: Flag nach 10 Ticks entfernen, falls der Damage-Event
// nicht auftritt (z.B. durch Featherweight bereits gecancelt).
plugin.server.scheduler.runTaskLater(plugin, { ->
recentEnderPearlUsers -= player.uniqueId
}, 10L)
}
/**
* Feuert nach vollständiger Schadensauflösung.
* Verteilt [Perk.onPostDamage] an alle aktiven Perks des Spielers UND
* leitet Ender-Perlen-Schaden an [Perk.onEnderPearlDamage] weiter,
* anstatt ihn als normalen Umgebungsschaden zu behandeln.
*
* Hinweis: ignoreCancelled = false, weil [AdrenalinePerk.onPostDamage]
* selbst prüft ob das Event gecancelt ist, und Enderblut den Cancel
* erst hier vornimmt.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
fun onAnyDamageMonitor(event: EntityDamageEvent) {
val player = event.entity as? Player ?: return
if (!isIngame()) return
if (!plugin.gameManager.alivePlayers.contains(player.uniqueId)) return
val perks = perkManager.getSelectedPerks(player)
// ── Enderperlen-Schaden (FALL nach Ender-Pearl-Teleport) ─────────────
if (event.cause == EntityDamageEvent.DamageCause.FALL &&
recentEnderPearlUsers.remove(player.uniqueId)
) {
// Spezialisierten Hook aufrufen — NICHT onPostDamage, da das Perk
// das Event hier erst canceln kann (vor der Health-Verarbeitung).
perks.forEach { it.onEnderPearlDamage(player, event) }
// Wenn gecancelt, brauchen wir kein Adrenalin-Check
if (event.isCancelled) return
}
// ── Adrenalin & co: Post-Damage-Hook ─────────────────────────────────
// Nur aufrufen wenn das Event nicht gecancelt ist —
// AdrenalinePerk prüft intern nochmals, aber Early-Return hier ist effizienter.
if (!event.isCancelled) {
perks.forEach { it.onPostDamage(player, event) }
}
}
// ── Helper ────────────────────────────────────────────────────────────────
private fun isIngame(): Boolean = when (plugin.gameManager.currentState) {

View File

@@ -175,18 +175,21 @@ perks:
- '<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:
@@ -195,6 +198,46 @@ perks:
- '<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>'
enderblue:
name: '<gradient:dark_purple:aqua><bold>Enderblood</bold></gradient>'
lore:
- ' '
- '<gray>Ender Pearl landings deal</gray>'
- '<gray>no fall damage to you.</gray>'
message: '<aqua>⚡ Enderblood absorbed the impact!</aqua>'
ghost:
name: '<gradient:white:gray><bold>Ghost</bold></gradient>'
lore:
- ' '
- '<gray>You are invisible to</gray>'
- '<gray>compass tracking and the</gray>'
- '<gray>Oracle perk.</gray>'
pyromaniac:
name: '<gradient:red:gold><bold>Pyromaniac</bold></gradient>'
lore:
- ' '
- '<gray>Immune to fire, lava,</gray>'
- '<gray>magma blocks and burn ticks.</gray>'
adrenaline:
name: '<gradient:red:yellow><bold>Adrenaline</bold></gradient>'
lore:
- ' '
- '<gray>Dropping below 3 hearts</gray>'
- '<gray>grants <yellow>Speed II</yellow> for 5 s.</gray>'
- '<dark_gray>(30 s cooldown)</dark_gray>'
message: '<red>❤ Adrenaline Rush! <yellow>Speed II</yellow> for 5 seconds!</red>'
scavenger:
name: '<gradient:gold:yellow><bold>Scavenger</bold></gradient>'
lore:
- ' '
- '<gray>Every kill drops an extra</gray>'
- '<gold>Golden Apple</gold> <gray>at the corpse.</gray>'
message: '<gold>🍎 Scavenged a Golden Apple!</gold>'
kits:
backup:
name: '<gradient:gold:#ff841f><bold>Backup</bold></gradient>'
@@ -387,4 +430,63 @@ kits:
frozen_received: '<red>⏸ You are frozen for 10 seconds!</red>'
frozen_expired: '<gray>The freeze has worn off.</gray>'
freeze_broken: '<gold>Freeze broken — 5 hits reached!</gold>'
freeze_hits_left: '<aqua>Frozen enemy — <hits> hit(s) remaining.</aqua>'
freeze_hits_left: '<aqua>Frozen enemy — <hits> hit(s) remaining.</aqua>'
tesla:
name: '<gradient:yellow:aqua><bold>Tesla</bold></gradient>'
lore:
- ' '
- 'AGGRESSIVE: Lightning strikes (5-block radius)'
- 'DEFENSIVE: Knockback + fire aura'
- '<dark_gray>Disabled above Y ≈ 113'
items:
rod:
name: '<yellow>Tesla Coil'
description: 'Strike 5 random bolts in a 5-block radius (1.5 ♥ each)'
passive:
name: '<yellow>Electromagnetic Field'
description: 'Push back + ignite nearby enemies every 3 s'
messages:
lightning_cast: '<yellow>⚡ Tesla Coil discharged!'
too_high: '<red>Too high! Tesla requires ground contact.'
ability_charged: '<yellow>Tesla Coil recharged!'
puppet:
name: '<gradient:dark_purple:light_purple><bold>Puppet</bold></gradient>'
lore:
- ' '
- 'AGGRESSIVE: Life drain (max 8 ♥, 2 s)'
- 'DEFENSIVE: Blindness + Slowness III (4 s)'
items:
drain:
name: '<dark_purple>Life Drain'
description: 'Drain life from nearby enemies. Sneak to cancel.'
fear:
name: '<dark_purple>Puppeteer''s Fear'
description: 'Apply Blindness + Slowness to nearby enemies'
messages:
drain_start: '<dark_purple>Draining life...'
draining: '<dark_purple>♥ Drained <healed>/<max> hearts'
drain_cancelled: '<gray>Drain cancelled.'
no_enemies: '<red>No enemies nearby!'
feared: '<dark_purple>You are being puppeted!'
fear_cast: '<dark_purple>Fear applied to <count> enemy(s)!'
ability_charged: '<yellow>Ability recharged!'
anchor:
name: '<gradient:gray:white><bold>Anchor</bold></gradient>'
lore:
- ' '
- 'Always: 40% knockback resistance'
- 'AGGRESSIVE: 5-block anchor + damage bonus'
- 'DEFENSIVE: 8-block anchor + Resistance I'
items:
chain:
name: '<gray>⚓ Deploy Anchor'
description: 'Summon an Iron Golem anchor. Enemies can destroy it!'
passive:
name: '<gray>Anchored'
description: 'NoKnock + bonus within anchor radius'
messages:
anchor_placed: '<gray>Anchor deployed! Radius: <radius> blocks.'
anchor_destroyed: '<red>⚓ Your anchor was destroyed!'
ability_charged: '<gray>Anchor ready to deploy!'