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:
@@ -23,9 +23,14 @@ import club.mcscrims.speedhg.listener.GameStateListener
|
|||||||
import club.mcscrims.speedhg.listener.SoupListener
|
import club.mcscrims.speedhg.listener.SoupListener
|
||||||
import club.mcscrims.speedhg.listener.StatsListener
|
import club.mcscrims.speedhg.listener.StatsListener
|
||||||
import club.mcscrims.speedhg.perk.PerkManager
|
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.BloodlustPerk
|
||||||
|
import club.mcscrims.speedhg.perk.impl.EnderbluePerk
|
||||||
import club.mcscrims.speedhg.perk.impl.FeatherweightPerk
|
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.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.impl.VampirePerk
|
||||||
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
|
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
|
||||||
import club.mcscrims.speedhg.ranking.RankingManager
|
import club.mcscrims.speedhg.ranking.RankingManager
|
||||||
@@ -177,13 +182,16 @@ class SpeedHG : JavaPlugin() {
|
|||||||
|
|
||||||
private fun registerKits()
|
private fun registerKits()
|
||||||
{
|
{
|
||||||
|
kitManager.registerKit( AnchorKit() )
|
||||||
kitManager.registerKit( ArmorerKit() )
|
kitManager.registerKit( ArmorerKit() )
|
||||||
kitManager.registerKit( BackupKit() )
|
kitManager.registerKit( BackupKit() )
|
||||||
kitManager.registerKit( BlackPantherKit() )
|
kitManager.registerKit( BlackPantherKit() )
|
||||||
kitManager.registerKit( GladiatorKit() )
|
kitManager.registerKit( GladiatorKit() )
|
||||||
kitManager.registerKit( GoblinKit() )
|
kitManager.registerKit( GoblinKit() )
|
||||||
kitManager.registerKit( IceMageKit() )
|
kitManager.registerKit( IceMageKit() )
|
||||||
|
kitManager.registerKit( PuppetKit() )
|
||||||
kitManager.registerKit( RattlesnakeKit() )
|
kitManager.registerKit( RattlesnakeKit() )
|
||||||
|
kitManager.registerKit( TeslaKit() )
|
||||||
kitManager.registerKit( TheWorldKit() )
|
kitManager.registerKit( TheWorldKit() )
|
||||||
kitManager.registerKit( VenomKit() )
|
kitManager.registerKit( VenomKit() )
|
||||||
kitManager.registerKit( VoodooKit() )
|
kitManager.registerKit( VoodooKit() )
|
||||||
@@ -191,10 +199,15 @@ class SpeedHG : JavaPlugin() {
|
|||||||
|
|
||||||
private fun registerPerks()
|
private fun registerPerks()
|
||||||
{
|
{
|
||||||
perkManager.registerPerk( OraclePerk() )
|
perkManager.registerPerk( AdrenalinePerk() )
|
||||||
perkManager.registerPerk( VampirePerk() )
|
|
||||||
perkManager.registerPerk( FeatherweightPerk() )
|
|
||||||
perkManager.registerPerk( BloodlustPerk() )
|
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()
|
private fun registerCommands()
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ class GameManager(
|
|||||||
|
|
||||||
private fun updateCompass()
|
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 )
|
for ( p in players )
|
||||||
{
|
{
|
||||||
|
|||||||
317
src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt
Normal file
317
src/main/kotlin/club/mcscrims/speedhg/kit/impl/AnchorKit.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt
Normal file
291
src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
318
src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt
Normal file
318
src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -57,4 +57,29 @@ abstract class Perk {
|
|||||||
*/
|
*/
|
||||||
open fun onEnvironmentalDamage(player: Player, event: EntityDamageEvent) {}
|
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) {}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,37 @@ class PerkManager(
|
|||||||
.forEach { removePerks( it ) }
|
.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 ────────────────────────────────────────────────────────────
|
// ── Persistenz ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private val repository = PlayerPerksRepository( plugin.databaseManager )
|
private val repository = PlayerPerksRepository( plugin.databaseManager )
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/main/kotlin/club/mcscrims/speedhg/perk/impl/GhostPerk.kt
Normal file
48
src/main/kotlin/club/mcscrims/speedhg/perk/impl/GhostPerk.kt
Normal 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.
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ class OraclePerk : Perk() {
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it != player.uniqueId }
|
.filter { it != player.uniqueId }
|
||||||
.mapNotNull { plugin.server.getPlayer(it) }
|
.mapNotNull { plugin.server.getPlayer(it) }
|
||||||
|
.filter { !plugin.perkManager.isGhost(it) }
|
||||||
.minByOrNull { it.location.distanceSquared(player.location) }
|
.minByOrNull { it.location.distanceSquared(player.location) }
|
||||||
|
|
||||||
private fun buildTrackerComponent(player: Player, nearest: Player): Component {
|
private fun buildTrackerComponent(player: Player, nearest: Player): Component {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import org.bukkit.event.Listener
|
|||||||
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
import org.bukkit.event.entity.EntityDamageByEntityEvent
|
||||||
import org.bukkit.event.entity.EntityDamageEvent
|
import org.bukkit.event.entity.EntityDamageEvent
|
||||||
import org.bukkit.event.entity.PlayerDeathEvent
|
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.
|
* Einziger registrierter Listener für alle perk-bezogenen Events.
|
||||||
@@ -71,6 +74,75 @@ class PerkEventDispatcher(
|
|||||||
perkManager.getSelectedPerks(killer).forEach { it.onKillEnemy(killer, event.entity) }
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun isIngame(): Boolean = when (plugin.gameManager.currentState) {
|
private fun isIngame(): Boolean = when (plugin.gameManager.currentState) {
|
||||||
|
|||||||
@@ -175,18 +175,21 @@ perks:
|
|||||||
- '<gray>Gegners (Schleichen / Kompass).</gray>'
|
- '<gray>Gegners (Schleichen / Kompass).</gray>'
|
||||||
- ' '
|
- ' '
|
||||||
- '<yellow>Synergie: <gray>Spielo-Kit zeigt Gamble-Ausgang.'
|
- '<yellow>Synergie: <gray>Spielo-Kit zeigt Gamble-Ausgang.'
|
||||||
|
|
||||||
vampire:
|
vampire:
|
||||||
name: '<gradient:dark_red:red><bold>Vampire</bold></gradient>'
|
name: '<gradient:dark_red:red><bold>Vampire</bold></gradient>'
|
||||||
lore:
|
lore:
|
||||||
- ' '
|
- ' '
|
||||||
- '<gray>10% Chance bei Nahkampftreffer:</gray>'
|
- '<gray>10% Chance bei Nahkampftreffer:</gray>'
|
||||||
- '<red>½ Herz</red> <gray>heilen.</gray>'
|
- '<red>½ Herz</red> <gray>heilen.</gray>'
|
||||||
|
|
||||||
featherweight:
|
featherweight:
|
||||||
name: '<gradient:white:aqua><bold>Featherweight</bold></gradient>'
|
name: '<gradient:white:aqua><bold>Featherweight</bold></gradient>'
|
||||||
lore:
|
lore:
|
||||||
- ' '
|
- ' '
|
||||||
- '<gray>Vollständig immun gegen</gray>'
|
- '<gray>Vollständig immun gegen</gray>'
|
||||||
- '<gray>Fallschaden.</gray>'
|
- '<gray>Fallschaden.</gray>'
|
||||||
|
|
||||||
bloodlust:
|
bloodlust:
|
||||||
name: '<gradient:dark_red:gold><bold>Bloodlust</bold></gradient>'
|
name: '<gradient:dark_red:gold><bold>Bloodlust</bold></gradient>'
|
||||||
lore:
|
lore:
|
||||||
@@ -195,6 +198,46 @@ perks:
|
|||||||
- '<yellow>Speed I</yellow> <gray>+</gray> <green>Regen I</green> <gray>für 5 Sekunden.</gray>'
|
- '<yellow>Speed I</yellow> <gray>+</gray> <green>Regen I</green> <gray>für 5 Sekunden.</gray>'
|
||||||
message: '<red>⚔ Blutrausch! <yellow>Speed I</yellow> + <green>Regen I</green> für 5 Sekunden!</red>'
|
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:
|
kits:
|
||||||
backup:
|
backup:
|
||||||
name: '<gradient:gold:#ff841f><bold>Backup</bold></gradient>'
|
name: '<gradient:gold:#ff841f><bold>Backup</bold></gradient>'
|
||||||
@@ -387,4 +430,63 @@ kits:
|
|||||||
frozen_received: '<red>⏸ You are frozen for 10 seconds!</red>'
|
frozen_received: '<red>⏸ You are frozen for 10 seconds!</red>'
|
||||||
frozen_expired: '<gray>The freeze has worn off.</gray>'
|
frozen_expired: '<gray>The freeze has worn off.</gray>'
|
||||||
freeze_broken: '<gold>Freeze broken — 5 hits reached!</gold>'
|
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!'
|
||||||
Reference in New Issue
Block a user