diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 440d8a1..2d0f361 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -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() diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index de979d9..a838de9 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -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 ) { diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt new file mode 100644 index 0000000..ee067c7 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt @@ -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 + 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 = ConcurrentHashMap() + private val monitorTasks : MutableMap = 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>() + + 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("Anker")) + 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) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt new file mode 100644 index 0000000..e4e13c2 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt @@ -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 + get() = plugin.languageManager.getDefaultRawMessageList("kits.puppet.lore") + override val icon = Material.PHANTOM_MEMBRANE + + // Laufende Drain-Tasks: PlayerUUID → BukkitTask + internal val activeDrainTasks: MutableMap = 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>() + + 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() + .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() + .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() + .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" + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt new file mode 100644 index 0000000..6dc884a --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt @@ -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 + 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>() + + 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() + .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() + + 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() + .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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt index 9093592..5b1fa47 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/Perk.kt @@ -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) {} + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt index 0e17101..e9a5ff4 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/PerkManager.kt @@ -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 ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/AdrenalinePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/AdrenalinePerk.kt new file mode 100644 index 0000000..0b71201 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/AdrenalinePerk.kt @@ -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 + get() = plugin.languageManager.getDefaultRawMessageList("perks.adrenaline.lore") + + override val icon = Material.BLAZE_ROD + + /** UUID → letzter Auslöse-Zeitstempel in Millisekunden. */ + private val lastProc: MutableMap = 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")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/EnderbluePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/EnderbluePerk.kt new file mode 100644 index 0000000..a5657e6 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/EnderbluePerk.kt @@ -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 + 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")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/GhostPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/GhostPerk.kt new file mode 100644 index 0000000..426929a --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/GhostPerk.kt @@ -0,0 +1,48 @@ +package club.mcscrims.speedhg.perk.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.perk.Perk +import net.kyori.adventure.text.Component +import org.bukkit.Material + +/** + * ## 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 + 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. +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt index 7da4cc7..2cb8f8e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/OraclePerk.kt @@ -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 { diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/PyromaniacPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/PyromaniacPerk.kt new file mode 100644 index 0000000..dfa38c5 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/PyromaniacPerk.kt @@ -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 + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScavengerPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScavengerPerk.kt new file mode 100644 index 0000000..c4cbafb --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScavengerPerk.kt @@ -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 + 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")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt index 4e68e1f..4bf7345 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/listener/PerkEventDispatcher.kt @@ -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 = 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) { diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index a06655f..5ab69d9 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -175,18 +175,21 @@ perks: - 'Gegners (Schleichen / Kompass).' - ' ' - 'Synergie: Spielo-Kit zeigt Gamble-Ausgang.' + vampire: name: 'Vampire' lore: - ' ' - '10% Chance bei Nahkampftreffer:' - '½ Herz heilen.' + featherweight: name: 'Featherweight' lore: - ' ' - 'Vollständig immun gegen' - 'Fallschaden.' + bloodlust: name: 'Bloodlust' lore: @@ -195,6 +198,46 @@ perks: - 'Speed I + Regen I für 5 Sekunden.' message: '⚔ Blutrausch! Speed I + Regen I für 5 Sekunden!' + enderblue: + name: 'Enderblood' + lore: + - ' ' + - 'Ender Pearl landings deal' + - 'no fall damage to you.' + message: '⚡ Enderblood absorbed the impact!' + + ghost: + name: 'Ghost' + lore: + - ' ' + - 'You are invisible to' + - 'compass tracking and the' + - 'Oracle perk.' + + pyromaniac: + name: 'Pyromaniac' + lore: + - ' ' + - 'Immune to fire, lava,' + - 'magma blocks and burn ticks.' + + adrenaline: + name: 'Adrenaline' + lore: + - ' ' + - 'Dropping below 3 hearts' + - 'grants Speed II for 5 s.' + - '(30 s cooldown)' + message: '❤ Adrenaline Rush! Speed II for 5 seconds!' + + scavenger: + name: 'Scavenger' + lore: + - ' ' + - 'Every kill drops an extra' + - 'Golden Apple at the corpse.' + message: '🍎 Scavenged a Golden Apple!' + kits: backup: name: 'Backup' @@ -387,4 +430,63 @@ kits: frozen_received: '⏸ You are frozen for 10 seconds!' frozen_expired: 'The freeze has worn off.' freeze_broken: 'Freeze broken — 5 hits reached!' - freeze_hits_left: 'Frozen enemy — hit(s) remaining.' \ No newline at end of file + freeze_hits_left: 'Frozen enemy — hit(s) remaining.' + tesla: + name: 'Tesla' + lore: + - ' ' + - 'AGGRESSIVE: Lightning strikes (5-block radius)' + - 'DEFENSIVE: Knockback + fire aura' + - 'Disabled above Y ≈ 113' + items: + rod: + name: 'Tesla Coil' + description: 'Strike 5 random bolts in a 5-block radius (1.5 ♥ each)' + passive: + name: 'Electromagnetic Field' + description: 'Push back + ignite nearby enemies every 3 s' + messages: + lightning_cast: '⚡ Tesla Coil discharged!' + too_high: 'Too high! Tesla requires ground contact.' + ability_charged: 'Tesla Coil recharged!' + + puppet: + name: 'Puppet' + lore: + - ' ' + - 'AGGRESSIVE: Life drain (max 8 ♥, 2 s)' + - 'DEFENSIVE: Blindness + Slowness III (4 s)' + items: + drain: + name: 'Life Drain' + description: 'Drain life from nearby enemies. Sneak to cancel.' + fear: + name: 'Puppeteer''s Fear' + description: 'Apply Blindness + Slowness to nearby enemies' + messages: + drain_start: 'Draining life...' + draining: '♥ Drained / hearts' + drain_cancelled: 'Drain cancelled.' + no_enemies: 'No enemies nearby!' + feared: 'You are being puppeted!' + fear_cast: 'Fear applied to enemy(s)!' + ability_charged: 'Ability recharged!' + + anchor: + name: 'Anchor' + lore: + - ' ' + - 'Always: 40% knockback resistance' + - 'AGGRESSIVE: 5-block anchor + damage bonus' + - 'DEFENSIVE: 8-block anchor + Resistance I' + items: + chain: + name: '⚓ Deploy Anchor' + description: 'Summon an Iron Golem anchor. Enemies can destroy it!' + passive: + name: 'Anchored' + description: 'NoKnock + bonus within anchor radius' + messages: + anchor_placed: 'Anchor deployed! Radius: blocks.' + anchor_destroyed: '⚓ Your anchor was destroyed!' + ability_charged: 'Anchor ready to deploy!' \ No newline at end of file