Make kit parameters configurable and refactor

Introduce live-configurable parameters and refactor three kits (Anchor, BlackPanther, Blitzcrank).

- AnchorKit: replace hardcoded constants with default values + live config accessors (partial_resistance, golem_hp, radii, bonus dmg, monitor interval). Snapshot config on activation, update doc comments to JSON keys, minor API/formatting cleanup, and make passive/active logic use dynamic radii and damages.
- BlackPantherKit: add defaults and live getters for fist mode, push, pounce and extras (push_knockback_speed/y, fist_mode_damage, projectile_delay_ticks). Snapshot values on activate, update docs to expose JSON keys, and small behavioral formatting fixes.
- BlitzcrankKit: add defaults and live getters for hook, stun and ult settings (range, pull_strength, radii, durations, cooldown, raycast params), update docs and remove unused imports; snapshot config where needed.

Also: several minor code cleanups (whitespace/formatting, removal of unused imports, fully qualified event references) and improved in-code documentation for custom settings. These changes enable per-kit overrides via SPEEDHG_CUSTOM_SETTINGS and make runtime behavior consistent by snapshotting values at ability execution.
This commit is contained in:
TDSTOS
2026-04-12 04:08:08 +02:00
parent c1be2ddabd
commit 411b77cc8d
3 changed files with 661 additions and 371 deletions

View File

@@ -19,7 +19,6 @@ 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
@@ -29,198 +28,265 @@ import java.util.concurrent.ConcurrentHashMap
/**
* ## AnchorKit
*
* **Passiv (immer aktiv):** 40 % Rückschlag-Reduktion über `GENERIC_KNOCKBACK_RESISTANCE`.
* **Passiv (immer aktiv):** [partialResistance] 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
* - Der Golem kann von Gegnern zerstört werden ([golemHp] 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 |
* | Playstyle | Radius | Bonus-Schaden |
* |-------------|-------------------|---------------------------------------------------|
* | AGGRESSIVE | [aggressiveRadius] Blöcke | +[aggressiveBonusDmg] HP (0,5 Herzen) auf jedem Treffer |
* | DEFENSIVE | [defensiveRadius] 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.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* ### 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.
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------------|--------|---------|---------------------------------------------------|
* | `partial_resistance` | Double | `0.4` | Basis-Rückschlag-Reduktion (40 %) |
* | `golem_hp` | Double | `20.0` | HP des Anker-Golems (10 Herzen) |
* | `aggressive_radius` | Double | `5.0` | Radius im Aggressive-Modus (Blöcke) |
* | `defensive_radius` | Double | `8.0` | Radius im Defensive-Modus (Blöcke) |
* | `aggressive_bonus_dmg` | Double | `1.0` | Bonus-Schaden im Radius (HP) |
* | `monitor_interval_ticks` | Long | `10` | Ticks zwischen Golem-Zustand-Prüfungen |
*/
class AnchorKit : Kit() {
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())
get() = plugin.languageManager.getDefaultComponent( "kits.anchor.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("kits.anchor.lore")
get() = plugin.languageManager.getDefaultRawMessageList( "kits.anchor.lore" )
override val icon = Material.ANVIL
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 DEFAULT_PARTIAL_RESISTANCE = 0.4
const val DEFAULT_GOLEM_HP = 20.0
const val DEFAULT_AGGRESSIVE_RADIUS = 5.0
const val DEFAULT_DEFENSIVE_RADIUS = 8.0
const val DEFAULT_AGGRESSIVE_BONUS_DMG = 1.0
const val DEFAULT_MONITOR_INTERVAL_TICKS = 10L
const val PDC_KEY = "anchor_owner_uuid"
}
private val anchorGolems : MutableMap<UUID, IronGolem> = ConcurrentHashMap()
private val monitorTasks : MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Basis-Rückschlag-Reduktion, die immer aktiv ist (40 % Standard).
* JSON-Schlüssel: `partial_resistance`
*/
private val partialResistance: Double
get() = override().getDouble( "partial_resistance" ) ?: DEFAULT_PARTIAL_RESISTANCE
/**
* HP des Eisengolem-Ankers. Vanilla = 100 HP; niedrigerer Wert macht ihn destroybar.
* JSON-Schlüssel: `golem_hp`
*/
private val golemHp: Double
get() = override().getDouble( "golem_hp" ) ?: DEFAULT_GOLEM_HP
/**
* Radius im Aggressive-Modus in Blöcken.
* JSON-Schlüssel: `aggressive_radius`
*/
private val aggressiveRadius: Double
get() = override().getDouble( "aggressive_radius" ) ?: DEFAULT_AGGRESSIVE_RADIUS
/**
* Radius im Defensive-Modus in Blöcken.
* JSON-Schlüssel: `defensive_radius`
*/
private val defensiveRadius: Double
get() = override().getDouble( "defensive_radius" ) ?: DEFAULT_DEFENSIVE_RADIUS
/**
* Bonus-Schaden pro Treffer im Aggressive-Modus (+0,5 Herzen Standard).
* JSON-Schlüssel: `aggressive_bonus_dmg`
*/
private val aggressiveBonusDmg: Double
get() = override().getDouble( "aggressive_bonus_dmg" ) ?: DEFAULT_AGGRESSIVE_BONUS_DMG
/**
* Ticks zwischen Golem-Zustand-Prüfungen und Resistenz-Updates.
* JSON-Schlüssel: `monitor_interval_ticks`
*/
private val monitorIntervalTicks: Long
get() = override().getLong( "monitor_interval_ticks" ) ?: DEFAULT_MONITOR_INTERVAL_TICKS
// ── Shared kit state ──────────────────────────────────────────────────────
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)
private val aggressiveActive = AnchorActive( Playstyle.AGGRESSIVE )
private val defensiveActive = AnchorActive( Playstyle.DEFENSIVE )
private val aggressivePassive = AnchorPassive( Playstyle.AGGRESSIVE, bonusDamage = true, resistanceBonus = false )
private val defensivePassive = AnchorPassive( Playstyle.DEFENSIVE, bonusDamage = false, resistanceBonus = true )
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getActiveAbility(
playstyle: Playstyle
) = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
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))
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)
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
// ── Lifecycle: Rückschlag-Basis-Resistenz setzen/entfernen ───────────────
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onAssign(player: Player, playstyle: Playstyle) {
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)
?.baseValue = PARTIAL_RESISTANCE
override fun onAssign(
player: Player,
playstyle: Playstyle
) {
val capturedPartialResistance = partialResistance
player.getAttribute( Attribute.GENERIC_KNOCKBACK_RESISTANCE )
?.baseValue = capturedPartialResistance
}
override fun onRemove(player: Player) {
// Golem entfernen
removeAnchor(player, playDeathSound = false)
// Rückschlag-Resistenz zurücksetzen
player.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE)
override fun onRemove(
player: Player
) {
removeAnchor( player, playDeathSound = false )
player.getAttribute( Attribute.GENERIC_KNOCKBACK_RESISTANCE )
?.baseValue = 0.0
cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) }
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
// =========================================================================
// Active Ability Anker-Golem beschwören (beide Playstyles, unterschiedlicher Radius)
// Active Ability Anker-Golem beschwören (beide Playstyles)
// =========================================================================
inner class AnchorActive(
playstyle: Playstyle,
private val radius: Double
) : ActiveAbility(playstyle) {
playstyle: Playstyle
) : 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")
get() = plugin.languageManager.getDefaultRawMessage( "kits.anchor.items.chain.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.items.chain.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.anchor.items.chain.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.ANVIL
override fun execute(player: Player): AbilityResult {
// Alten Anker entfernen (kein Todesklang Spieler beschwört neuen)
removeAnchor(player, playDeathSound = false)
override fun execute(
player: Player
): AbilityResult
{
removeAnchor( player, playDeathSound = false )
val spawnLoc = player.location.clone()
val world = spawnLoc.world ?: return AbilityResult.ConditionNotMet("World is null")
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>"))
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedGolemHp = golemHp
val capturedPartialResistance = partialResistance
val capturedMonitorInterval = monitorIntervalTicks
val capturedRadius = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveRadius
Playstyle.DEFENSIVE -> defensiveRadius
}
val golem = world.spawn( spawnLoc, IronGolem::class.java ) { g ->
g.setAI( false )
g.isSilent = true
g.isInvulnerable = false
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
g.getAttribute( Attribute.GENERIC_MAX_HEALTH )?.baseValue = capturedGolemHp
g.health = capturedGolemHp
// PDC: Besitzer-UUID für spätere Identifikation
g.persistentDataContainer.set(
NamespacedKey(plugin, PDC_KEY),
NamespacedKey( plugin, PDC_KEY ),
PersistentDataType.STRING,
player.uniqueId.toString()
)
}
anchorGolems[player.uniqueId] = golem
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]
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)
if ( activeGolem == null || activeGolem.isDead || !activeGolem.isValid )
{
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
monitorTasks.remove( player.uniqueId )?.cancel()
if ( player.isOnline )
{
player.getAttribute( Attribute.GENERIC_KNOCKBACK_RESISTANCE )
?.baseValue = capturedPartialResistance
}
return@runTaskTimer
}
if (!player.isOnline) {
if ( !player.isOnline )
{
activeGolem.remove()
anchorGolems.remove(player.uniqueId)
monitorTasks.remove(player.uniqueId)?.cancel()
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
val inRadius = player.location.distanceSquared( activeGolem.location ) <= capturedRadius * capturedRadius
val targetResistance = if ( inRadius ) 1.0 else capturedPartialResistance
player.getAttribute( Attribute.GENERIC_KNOCKBACK_RESISTANCE )?.baseValue = targetResistance
// Visueller Indikator am Golem (Partikelring)
if (inRadius) {
if ( inRadius )
{
world.spawnParticle(
Particle.CRIT,
activeGolem.location.clone().add(0.0, 2.5, 0.0),
activeGolem.location.clone().add( 0.0, 2.5, 0.0 ),
2, 0.1, 0.1, 0.1, 0.0
)
}
}, 0L, MONITOR_INTERVAL_TICKS)
}, 0L, capturedMonitorInterval )
monitorTasks[player.uniqueId] = task
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)
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())
player.trans( "kits.anchor.messages.anchor_placed",
"radius" to capturedRadius.toInt().toString() )
)
return AbilityResult.Success
}
@@ -232,42 +298,56 @@ class AnchorKit : Kit() {
inner class AnchorPassive(
playstyle: Playstyle,
private val radius: Double,
private val bonusDamage: Double,
private val bonusDamage: Boolean,
private val resistanceBonus: Boolean
) : PassiveAbility(playstyle) {
) : PassiveAbility( playstyle ) {
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.passive.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.anchor.passive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.anchor.passive.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.anchor.passive.description" )
override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
val golem = anchorGolems[attacker.uniqueId] ?: return
override fun onHitEnemy(
attacker: Player,
victim: Player,
event: org.bukkit.event.entity.EntityDamageByEntityEvent
) {
val golem = anchorGolems[ attacker.uniqueId ] ?: return
// Nur wirksam wenn Angreifer im Radius
if (attacker.location.distanceSquared(golem.location) > radius * radius) return
val capturedRadius = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveRadius
Playstyle.DEFENSIVE -> defensiveRadius
}
// Bonus-Schaden (Aggressive playstyle)
if (bonusDamage > 0.0) {
event.damage += bonusDamage
if ( attacker.location.distanceSquared( golem.location ) > capturedRadius * capturedRadius ) return
if ( bonusDamage )
{
val capturedBonusDmg = aggressiveBonusDmg
event.damage += capturedBonusDmg
attacker.world.spawnParticle(
Particle.CRIT,
victim.location.clone().add(0.0, 1.2, 0.0),
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
override fun onHitByEnemy(
victim: Player,
attacker: Player,
event: org.bukkit.event.entity.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)
val capturedRadius = defensiveRadius
if ( victim.location.distanceSquared( golem.location ) <= capturedRadius * capturedRadius )
{
event.damage *= 0.80
}
}
@@ -277,36 +357,36 @@ class AnchorKit : Kit() {
// 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()
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)
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()
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)
private fun onAnchorDestroyed(
player: Player,
deathLocation: Location
) {
anchorGolems.remove( player.uniqueId )
deathLocation.world?.playSound(deathLocation, Sound.ENTITY_IRON_GOLEM_DEATH, 1f, 1f)
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)
if ( player.isOnline )
{
player.sendActionBar( player.trans( "kits.anchor.messages.anchor_destroyed" ) )
player.playSound( player.location, Sound.ENTITY_IRON_GOLEM_DEATH, 0.8f, 1.3f )
}
}
}

View File

@@ -1,7 +1,6 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
@@ -27,25 +26,27 @@ import java.util.concurrent.ConcurrentHashMap
*
* | Playstyle | Active | Passive |
* |-------------|-------------------------------------------------|-------------------------------------------------|
* | AGGRESSIVE | **Push** knockback all enemies ≤ 5 blocks + | **Vibranium Fists** 6.5 dmg bare-hand for 12 s|
* | AGGRESSIVE | **Push** knockback all enemies ≤ [pushRadius] blocks + | **Vibranium Fists** [fistModeDurationMs]ms bare-hand |
* | | shoot push-projectiles + activate Fist Mode | |
* | DEFENSIVE | (no active item) | **Wakanda Forever!** fall-pounce on enemies |
*
* ### Push (AGGRESSIVE active)
* All enemies within 5 blocks are launched outward. A marked Snowball is fired
* toward each pushed enemy 5 ticks later; on hit it deals **4 bonus damage**
* (handled by [KitEventDispatcher] via [PUSH_PROJECTILE_KEY]).
* CRIT particles spawn at each pushed enemy's position for visual feedback.
* Directly after the push, **Fist Mode** activates for 12 seconds.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* ### Vibranium Fists (AGGRESSIVE passive)
* During Fist Mode, bare-hand attacks (`itemInMainHand == AIR`) override the
* normal damage to **6.5 HP (3.25 hearts)**.
* Typisierte Felder in [CustomGameSettings.KitOverride] werden direkt gelesen.
* Zusätzliche Einstellungen über die `extras`-Map.
*
* ### Wakanda Forever! (DEFENSIVE passive)
* Triggers in [onHitEnemy] when `attacker.fallDistance ≥ 3`. Deals **6 HP**
* to all enemies within 3 blocks of the victim, then creates an explosion
* visual and a small WorldEdit crater at the landing site.
* | Quelle | JSON-Schlüssel | Typ | Default | Beschreibung |
* |------------------|-----------------------------|--------|----------|-------------------------------------------|
* | Typisiertes Feld | `fist_mode_ms` | Long | `12000` | Dauer des Fist-Modus (ms) |
* | Typisiertes Feld | `push_radius` | Double | `5.0` | Radius der Push-Schockwelle (Blöcke) |
* | Typisiertes Feld | `push_bonus_damage` | Double | `4.0` | Bonus-Schaden der Push-Projektile (HP) |
* | Typisiertes Feld | `pounce_min_fall` | Float | `3.0` | Mindest-Fallhöhe für Wakanda-Pounce |
* | Typisiertes Feld | `pounce_radius` | Double | `3.0` | AoE-Radius des Pounce-Aufpralls (Blöcke) |
* | Typisiertes Feld | `pounce_damage` | Double | `6.0` | Schaden des Pounce-Aufpralls (HP) |
* | `extras` | `push_knockback_speed` | Double | `2.0` | Horizontaler Velocity-Multiplikator |
* | `extras` | `push_knockback_y` | Double | `0.45` | Vertikaler Y-Impuls des Pushes |
* | `extras` | `fist_mode_damage` | Double | `6.5` | Schaden der Vibranium-Fists (HP) |
* | `extras` | `projectile_delay_ticks` | Long | `5` | Ticks Verzögerung vor dem Projektil |
*/
class BlackPantherKit : Kit()
{
@@ -54,9 +55,9 @@ class BlackPantherKit : Kit()
override val id = "blackpanther"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("kits.blackpanther.name", mapOf())
get() = plugin.languageManager.getDefaultComponent( "kits.blackpanther.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("kits.blackpanther.lore")
get() = plugin.languageManager.getDefaultRawMessageList( "kits.blackpanther.lore" )
override val icon = Material.BLACK_DYE
/** Players currently in Fist Mode: UUID → expiry timestamp (ms). */
@@ -67,47 +68,129 @@ class BlackPantherKit : Kit()
companion object
{
private fun override() = SpeedHG.instance.customGameManager.settings.kits.kits["blackpanther"]
?: CustomGameSettings.KitOverride()
/** PDC key string shared with [KitEventDispatcher] for push-projectiles. */
const val PUSH_PROJECTILE_KEY = "blackpanther_push_projectile"
private val FIST_MODE_MS = override().fistModeDurationMs // 12 seconds
private val PUSH_RADIUS = override().pushRadius
private val POUNCE_MIN_FALL = override().pounceMinFall
private val POUNCE_RADIUS = override().pounceRadius
private val POUNCE_DAMAGE = override().pounceDamage // 3 hearts = 6 HP
const val DEFAULT_FIST_MODE_DURATION_MS = 12_000L
const val DEFAULT_PUSH_RADIUS = 5.0
const val DEFAULT_PUSH_BONUS_DAMAGE = 4.0
const val DEFAULT_POUNCE_MIN_FALL = 3.0f
const val DEFAULT_POUNCE_RADIUS = 3.0
const val DEFAULT_POUNCE_DAMAGE = 6.0
const val DEFAULT_PUSH_KNOCKBACK_SPEED = 2.0
const val DEFAULT_PUSH_KNOCKBACK_Y = 0.45
const val DEFAULT_FIST_MODE_DAMAGE = 6.5
const val DEFAULT_PROJECTILE_DELAY_TICKS = 5L
}
// ── Cached ability instances ──────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
// ── Live config accessors ─────────────────────────────────────────────────
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle)
/**
* Dauer des Vibranium-Fist-Modus in Millisekunden.
* Quelle: typisiertes Feld `fist_mode_ms`.
*/
private val fistModeDurationMs: Long
get() = override().fistModeDurationMs
/**
* Radius der Push-Schockwelle in Blöcken.
* Quelle: typisiertes Feld `push_radius`.
*/
private val pushRadius: Double
get() = override().pushRadius
/**
* Bonus-Schaden der nachfolgenden Push-Projektile in HP.
* Quelle: typisiertes Feld `push_bonus_damage`.
*/
private val pushBonusDamage: Double
get() = override().pushBonusDamage
/**
* Mindest-Fallhöhe für den Wakanda-Forever-Pounce.
* Quelle: typisiertes Feld `pounce_min_fall`.
*/
private val pounceMinFall: Float
get() = override().pounceMinFall
/**
* AoE-Radius des Wakanda-Pounce-Aufpralls in Blöcken.
* Quelle: typisiertes Feld `pounce_radius`.
*/
private val pounceRadius: Double
get() = override().pounceRadius
/**
* Schaden des Wakanda-Pounce-Aufpralls in HP.
* Quelle: typisiertes Feld `pounce_damage`.
*/
private val pounceDamage: Double
get() = override().pounceDamage
/**
* Horizontaler Velocity-Multiplikator des Pushes.
* Quelle: `extras["push_knockback_speed"]`.
*/
private val pushKnockbackSpeed: Double
get() = override().getDouble( "push_knockback_speed" ) ?: DEFAULT_PUSH_KNOCKBACK_SPEED
/**
* Vertikaler Y-Impuls des Pushes.
* Quelle: `extras["push_knockback_y"]`.
*/
private val pushKnockbackY: Double
get() = override().getDouble( "push_knockback_y" ) ?: DEFAULT_PUSH_KNOCKBACK_Y
/**
* Schaden der Vibranium-Fists pro Treffer in HP (3,25 Herzen Standard).
* Quelle: `extras["fist_mode_damage"]`.
*/
private val fistModeDamage: Double
get() = override().getDouble( "fist_mode_damage" ) ?: DEFAULT_FIST_MODE_DAMAGE
/**
* Ticks Verzögerung vor dem Abschuss des Push-Projektils.
* Quelle: `extras["projectile_delay_ticks"]`.
*/
private val projectileDelayTicks: Long
get() = override().getLong( "projectile_delay_ticks" ) ?: DEFAULT_PROJECTILE_DELAY_TICKS
// ── Gecachte Instanzen ────────────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
override fun getActiveAbility(
playstyle: Playstyle
) = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle)
override fun getPassiveAbility(
playstyle: Playstyle
) = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
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.BLACK_DYE)
.name(aggressiveActive.name)
.lore(listOf(aggressiveActive.description))
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
if ( playstyle != Playstyle.AGGRESSIVE ) return
val item = ItemBuilder( Material.BLACK_DYE )
.name( aggressiveActive.name )
.lore(listOf( aggressiveActive.description ))
.build()
cachedItems[player.uniqueId] = listOf(item)
player.inventory.addItem(item)
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onRemove(
@@ -118,21 +201,24 @@ class BlackPantherKit : Kit()
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
// =========================================================================
// AGGRESSIVE active Push + activate Fist Mode
// =========================================================================
// =========================================================================
// AGGRESSIVE active Push + activate Fist Mode
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId: String get() = "blackpanther"
override val hardcodedHitsRequired: Int get() = 15
override val kitId: String
get() = "blackpanther"
override val hardcodedHitsRequired: Int
get() = 15
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.items.push.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.items.push.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.items.push.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.items.push.description" )
override val triggerMaterial = Material.BLACK_DYE
override fun execute(
@@ -144,148 +230,171 @@ class BlackPantherKit : Kit()
plugin.languageManager.getRawMessage( player, "kits.height_restriction" )
)
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedPushRadius = pushRadius
val capturedKnockbackSpeed = pushKnockbackSpeed
val capturedKnockbackY = pushKnockbackY
val capturedFistModeDurationMs = fistModeDurationMs
val capturedProjectileDelay = projectileDelayTicks
val enemies = player.world
.getNearbyEntities(player.location, PUSH_RADIUS, PUSH_RADIUS, PUSH_RADIUS)
.getNearbyEntities( player.location, capturedPushRadius, capturedPushRadius, capturedPushRadius )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if (enemies.isEmpty())
return AbilityResult.ConditionNotMet("No enemies within ${PUSH_RADIUS.toInt()} blocks!")
if ( enemies.isEmpty() )
return AbilityResult.ConditionNotMet( "No enemies within ${capturedPushRadius.toInt()} blocks!" )
val pushKey = (plugin.kitManager.getSelectedKit(player) as? BlackPantherKit)
?.let { NamespacedKey(plugin, PUSH_PROJECTILE_KEY) }
val pushKey = NamespacedKey( plugin, PUSH_PROJECTILE_KEY )
enemies.forEach { enemy ->
// ── Knockback ──────────────────────────────────────────────────
val knockDir = enemy.location.toVector()
.subtract(player.location.toVector())
.subtract( player.location.toVector() )
.normalize()
.multiply(2.0)
.setY(0.45)
.multiply( capturedKnockbackSpeed )
.setY( capturedKnockbackY )
enemy.velocity = knockDir
enemy.world.spawnParticle(Particle.CRIT,
enemy.location.clone().add(0.0, 1.0, 0.0), 10, 0.3, 0.3, 0.3, 0.0)
enemy.world.spawnParticle(
Particle.CRIT,
enemy.location.clone().add( 0.0, 1.0, 0.0 ),
10, 0.3, 0.3, 0.3, 0.0
)
// ── Trailing push-projectile (deals 4 HP on hit) ──────────────
if (pushKey != null) {
Bukkit.getScheduler().runTaskLater(plugin, { ->
if (!player.isOnline) return@runTaskLater
val snowball = player.world.spawn(
player.eyeLocation, Snowball::class.java
)
snowball.shooter = player
val travelDir = enemy.location.toVector()
.subtract(player.eyeLocation.toVector())
.normalize()
.multiply(1.8)
snowball.velocity = travelDir
snowball.persistentDataContainer.set(pushKey, PersistentDataType.BYTE, 1)
}, 5L)
}
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline ) return@runTaskLater
val snowball = player.world.spawn( player.eyeLocation, Snowball::class.java )
snowball.shooter = player
val travelDir = enemy.location.toVector()
.subtract( player.eyeLocation.toVector() )
.normalize()
.multiply( 1.8 )
snowball.velocity = travelDir
snowball.persistentDataContainer.set( pushKey, PersistentDataType.BYTE, 1 )
}, capturedProjectileDelay )
}
// ── Activate Fist Mode ─────────────────────────────────────────────
fistModeExpiry[player.uniqueId] = System.currentTimeMillis() + FIST_MODE_MS
player.sendActionBar(player.trans("kits.blackpanther.messages.fist_mode_active"))
fistModeExpiry[ player.uniqueId ] = System.currentTimeMillis() + capturedFistModeDurationMs
player.sendActionBar( player.trans( "kits.blackpanther.messages.fist_mode_active" ) )
player.world.playSound(player.location, Sound.ENTITY_RAVAGER_ROAR, 1f, 1.1f)
player.world.playSound(player.location, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 0.8f, 0.7f)
player.world.playSound( player.location, Sound.ENTITY_RAVAGER_ROAR, 1f, 1.1f )
player.world.playSound( player.location, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 0.8f, 0.7f )
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active no active ability
// =========================================================================
// =========================================================================
// DEFENSIVE active no active ability
// =========================================================================
private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
private class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
override val kitId: String = "blackpanther"
override val name = "None"
override val description = "None"
override val hardcodedHitsRequired: Int = 0
override val triggerMaterial = Material.BARRIER
override fun execute(player: Player) = AbilityResult.Success
override fun execute(
player: Player
) = AbilityResult.Success
}
// =========================================================================
// AGGRESSIVE passive Vibranium Fists (6.5 dmg bare-hand during Fist Mode)
// =========================================================================
// =========================================================================
// AGGRESSIVE passive Vibranium Fists (bare-hand during Fist Mode)
// =========================================================================
private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.passive.aggressive.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.passive.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.passive.aggressive.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.passive.aggressive.description" )
override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
val expiry = fistModeExpiry[attacker.uniqueId] ?: return
if (System.currentTimeMillis() > expiry) {
fistModeExpiry.remove(attacker.uniqueId)
override fun onHitEnemy(
attacker: Player,
victim: Player,
event: EntityDamageByEntityEvent
) {
val expiry = fistModeExpiry[ attacker.uniqueId ] ?: return
if ( System.currentTimeMillis() > expiry )
{
fistModeExpiry.remove( attacker.uniqueId )
return
}
if (attacker.inventory.itemInMainHand.type != Material.AIR) return
if ( attacker.inventory.itemInMainHand.type != Material.AIR ) return
event.damage = 6.5 // 3.25 hearts
victim.world.spawnParticle(Particle.CRIT,
victim.location.clone().add(0.0, 1.0, 0.0), 8, 0.3, 0.3, 0.3, 0.0)
attacker.playSound(attacker.location, Sound.ENTITY_PLAYER_ATTACK_CRIT, 1f, 0.9f)
val capturedFistDamage = fistModeDamage
event.damage = capturedFistDamage
victim.world.spawnParticle(
Particle.CRIT,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
8, 0.3, 0.3, 0.3, 0.0
)
attacker.playSound( attacker.location, Sound.ENTITY_PLAYER_ATTACK_CRIT, 1f, 0.9f )
}
}
// =========================================================================
// DEFENSIVE passive Wakanda Forever! (fall-pounce → AOE + crater)
// =========================================================================
// =========================================================================
// DEFENSIVE passive Wakanda Forever! (fall-pounce → AOE + crater)
// =========================================================================
private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.passive.defensive.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blackpanther.passive.defensive.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blackpanther.passive.defensive.description" )
override fun onMove(
player: Player,
event: PlayerMoveEvent
) {
if ( event.to.y >= event.from.y ) return
if ( player.fallDistance < POUNCE_MIN_FALL ) return
val capturedPounceMinFall = pounceMinFall
if ( player.fallDistance < capturedPounceMinFall ) return
val blockBelow = event.to.clone().subtract( 0.0, 0.1, 0.0 ).block
if ( !blockBelow.type.isSolid ) return
val impactLoc = event.to.clone()
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedPounceRadius = pounceRadius
val capturedPounceDamage = pounceDamage
val splashTargets = impactLoc.world
.getNearbyEntities( impactLoc, POUNCE_RADIUS, POUNCE_RADIUS, POUNCE_RADIUS )
.getNearbyEntities( impactLoc, capturedPounceRadius, capturedPounceRadius, capturedPounceRadius )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
splashTargets.forEach { it.damage( POUNCE_DAMAGE, player ) }
splashTargets.forEach { it.damage( capturedPounceDamage, player ) }
impactLoc.world.spawnParticle(Particle.EXPLOSION, impactLoc, 3, 0.5, 0.5, 0.5, 0.0)
impactLoc.world.spawnParticle(Particle.LARGE_SMOKE, impactLoc, 20, 1.0, 0.5, 1.0, 0.05)
impactLoc.world.playSound(impactLoc, Sound.ENTITY_GENERIC_EXPLODE, 1f, 0.7f)
impactLoc.world.playSound(impactLoc, Sound.ENTITY_IRON_GOLEM_HURT, 1f, 0.5f)
impactLoc.world.spawnParticle( Particle.EXPLOSION, impactLoc, 3, 0.5, 0.5, 0.5, 0.0 )
impactLoc.world.spawnParticle( Particle.LARGE_SMOKE, impactLoc, 20, 1.0, 0.5, 1.0, 0.05 )
impactLoc.world.playSound( impactLoc, Sound.ENTITY_GENERIC_EXPLODE, 1f, 0.7f )
impactLoc.world.playSound( impactLoc, Sound.ENTITY_IRON_GOLEM_HURT, 1f, 0.5f )
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
Bukkit.getScheduler().runTaskLater( plugin, Runnable {
WorldEditUtils.createCylinder(
impactLoc.world, impactLoc.clone().subtract(0.0, 1.0, 0.0),
impactLoc.world, impactLoc.clone().subtract( 0.0, 1.0, 0.0 ),
3, true, 2, Material.AIR
)
}, 2L)
}, 2L )
player.sendActionBar(player.trans("kits.blackpanther.messages.wakanda_impact",
mapOf("count" to splashTargets.size.toString())))
player.sendActionBar(
player.trans( "kits.blackpanther.messages.wakanda_impact",
mapOf( "count" to splashTargets.size.toString() ) )
)
// Suppress fall damage for this landing
noFallDamagePlayers.add( player.uniqueId )
player.fallDistance = 0f
}

View File

@@ -6,7 +6,6 @@ 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.kit.listener.KitEventDispatcher
import club.mcscrims.speedhg.kit.listener.KitEventDispatcher.Companion.MAX_KNOCKBACK_HEIGHT_Y
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.trans
@@ -35,26 +34,28 @@ import kotlin.math.sin
* | Playstyle | Aktive Fähigkeit |
* |-------------|---------------------------------------------------------------|
* | AGGRESSIVE | **Hook** zieht ersten Feind in der Schusslinie heran |
* | DEFENSIVE | **Stun** friert alle nahen Feinde für 3 s ein |
* | Beide | **Ult** expandierende Schockwelle + AoE-Schaden |
* | DEFENSIVE | **Stun** friert alle nahen Feinde für [stunDurationTicks] Ticks ein |
* | Beide | **Ult** expandierende Schockwelle + AoE-Schaden |
*
* ### Hook synchroner Raycast
* 0,4-Block-Schritte von `eyeLocation` entlang `eyeLocation.direction`.
* Erster Feind getroffen → Velocity-Pull Richtung Caster. Alle Partikel werden
* synchron im selben Tick gezeichnet.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* ### Stun Freeze-Mechanismus
* Slowness 127 + Mining Fatigue 127 für [STUN_DURATION_TICKS] Ticks.
* Zusätzlich setzt ein BukkitTask die Velocity aller gestunnten Spieler auf 0.
* Alle Werte werden über die `extras`-Map konfiguriert und fallen auf die
* Defaults im [companion object] zurück, wenn kein Wert gesetzt ist.
*
* ### Ult passive onInteract als Auslöser
* Das Ult-Item (BLAZE_POWDER) besitzt einen PDC-Tag ([ultItemKey]).
* `KitEventDispatcher.onInteract` ruft **zuerst** `passive.onInteract` auf,
* dann erst den triggerMaterial-Check. [UltPassive.onInteract] fängt das
* BLAZE_POWDER-Rechtsklick-Event ab und cancelt es, bevor der Dispatcher
* etwas unternimmt → kein Dispatcher-Umbau notwendig.
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-------------------------|--------|-------------|----------------------------------------------|
* | `hook_range` | Double | `10.0` | Maximale Reichweite des Hooks (Blöcke) |
* | `hook_pull_strength` | Double | `2.7` | Velocity-Multiplikator beim Pull |
* | `stun_radius` | Double | `5.0` | AoE-Radius des Stuns (Blöcke) |
* | `stun_duration_ticks` | Long | `60` | Dauer des Stuns in Ticks (3 Sekunden) |
* | `ult_radius` | Double | `6.0` | Radius der Ult-Schockwelle (Blöcke) |
* | `ult_damage` | Double | `5.0` | Schaden der Ult pro Treffer (HP) |
* | `ult_cooldown_ms` | Long | `30000` | Cooldown der Ult zwischen Uses (ms) |
* | `hook_step_size` | Double | `0.4` | Raycast-Schrittgröße für den Hook (Blöcke) |
* | `hook_hit_radius` | Double | `0.6` | Kollisionsradius des Hooks (Blöcke) |
*/
class BlitzcrankKit : Kit() {
class BlitzcrankKit : Kit()
{
private val plugin get() = SpeedHG.instance
@@ -76,15 +77,84 @@ class BlitzcrankKit : Kit() {
private val ultCooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
companion object {
const val HOOK_RANGE = 10.0 // Blöcke
const val HOOK_PULL_STRENGTH = 2.7 // Velocity-Multiplikator
const val STUN_RADIUS = 5.0 // Blöcke
const val STUN_DURATION_TICKS = 60 // 3 Sekunden
const val ULT_RADIUS = 6.0 // Blöcke
const val ULT_DAMAGE = 5.0 // 2,5 Herzen
const val ULT_COOLDOWN_MS = 30_000L
const val DEFAULT_HOOK_RANGE = 10.0
const val DEFAULT_HOOK_PULL_STRENGTH = 2.7
const val DEFAULT_STUN_RADIUS = 5.0
const val DEFAULT_STUN_DURATION_TICKS = 60L
const val DEFAULT_ULT_RADIUS = 6.0
const val DEFAULT_ULT_DAMAGE = 5.0
const val DEFAULT_ULT_COOLDOWN_MS = 30_000L
const val DEFAULT_HOOK_STEP_SIZE = 0.4
const val DEFAULT_HOOK_HIT_RADIUS = 0.6
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Maximale Reichweite des Hooks in Blöcken.
* JSON-Schlüssel: `hook_range`
*/
private val hookRange: Double
get() = override().getDouble( "hook_range" ) ?: DEFAULT_HOOK_RANGE
/**
* Velocity-Multiplikator des Hook-Pulls.
* JSON-Schlüssel: `hook_pull_strength`
*/
private val hookPullStrength: Double
get() = override().getDouble( "hook_pull_strength" ) ?: DEFAULT_HOOK_PULL_STRENGTH
/**
* AoE-Radius des Stuns in Blöcken.
* JSON-Schlüssel: `stun_radius`
*/
private val stunRadius: Double
get() = override().getDouble( "stun_radius" ) ?: DEFAULT_STUN_RADIUS
/**
* Dauer des Stuns in Ticks.
* JSON-Schlüssel: `stun_duration_ticks`
*/
private val stunDurationTicks: Long
get() = override().getLong( "stun_duration_ticks" ) ?: DEFAULT_STUN_DURATION_TICKS
/**
* Radius der Ult-Schockwelle in Blöcken.
* JSON-Schlüssel: `ult_radius`
*/
private val ultRadius: Double
get() = override().getDouble( "ult_radius" ) ?: DEFAULT_ULT_RADIUS
/**
* Schaden der Ult pro getroffenen Spieler in HP.
* JSON-Schlüssel: `ult_damage`
*/
private val ultDamage: Double
get() = override().getDouble( "ult_damage" ) ?: DEFAULT_ULT_DAMAGE
/**
* Cooldown der Ult in Millisekunden.
* JSON-Schlüssel: `ult_cooldown_ms`
*/
private val ultCooldownMs: Long
get() = override().getLong( "ult_cooldown_ms" ) ?: DEFAULT_ULT_COOLDOWN_MS
/**
* Raycast-Schrittgröße des Hooks in Blöcken.
* JSON-Schlüssel: `hook_step_size`
*/
private val hookStepSize: Double
get() = override().getDouble( "hook_step_size" ) ?: DEFAULT_HOOK_STEP_SIZE
/**
* Kollisionsradius des Hooks in Blöcken.
* JSON-Schlüssel: `hook_hit_radius`
*/
private val hookHitRadius: Double
get() = override().getDouble( "hook_hit_radius" ) ?: DEFAULT_HOOK_HIT_RADIUS
// ── Gecachte Instanzen ────────────────────────────────────────────────────
private val aggressiveActive = HookActive()
private val defensiveActive = StunActive()
private val aggressivePassive = UltPassive( Playstyle.AGGRESSIVE )
@@ -112,7 +182,8 @@ class BlitzcrankKit : Kit() {
player: Player,
playstyle: Playstyle
) {
val mainItem = when (playstyle) {
val mainItem = when( playstyle )
{
Playstyle.AGGRESSIVE -> ItemBuilder( Material.FISHING_ROD )
.name( aggressiveActive.name )
.lore(listOf( aggressiveActive.description ))
@@ -150,28 +221,33 @@ class BlitzcrankKit : Kit() {
) {
if ( caster.location.y > MAX_KNOCKBACK_HEIGHT_Y )
{
caster.sendActionBar(caster.trans( "kits.height_restriction" ))
caster.sendActionBar( caster.trans( "kits.height_restriction" ) )
return
}
val now = System.currentTimeMillis()
val now = System.currentTimeMillis()
val lastUlt = ultCooldowns[ caster.uniqueId ] ?: 0L
if ( now - lastUlt < ULT_COOLDOWN_MS )
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedUltCooldownMs = ultCooldownMs
val capturedUltRadius = ultRadius
val capturedUltDamage = ultDamage
if ( now - lastUlt < capturedUltCooldownMs )
{
val secLeft = ( ULT_COOLDOWN_MS - ( now - lastUlt )) / 1000
caster.sendActionBar(caster.trans( "kits.blitzcrank.messages.ult_cooldown", "time" to secLeft.toString() ))
val secLeft = ( capturedUltCooldownMs - ( now - lastUlt ) ) / 1000
caster.sendActionBar( caster.trans( "kits.blitzcrank.messages.ult_cooldown", "time" to secLeft.toString() ) )
return
}
val targets = caster.world
.getNearbyEntities( caster.location, ULT_RADIUS, ULT_RADIUS, ULT_RADIUS )
.getNearbyEntities( caster.location, capturedUltRadius, capturedUltRadius, capturedUltRadius )
.filterIsInstance<Player>()
.filter { it != caster && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if ( targets.isEmpty() )
{
caster.sendActionBar(caster.trans( "kits.blitzcrank.messages.ult_no_targets" ))
caster.sendActionBar( caster.trans( "kits.blitzcrank.messages.ult_no_targets" ) )
return
}
@@ -180,13 +256,13 @@ class BlitzcrankKit : Kit() {
override fun run()
{
if ( r > ULT_RADIUS + 1.0 ) { cancel(); return }
if ( r > capturedUltRadius + 1.0 ) { cancel(); return }
val steps = ( 2 * Math.PI * r * 5 ).toInt().coerceAtLeast( 8 )
repeat( steps ) { i ->
val angle = 2.0 * Math.PI * i / steps
caster.world.spawnParticle(
Particle.ELECTRIC_SPARK,
caster.location.clone().add(cos( angle ) * r, 1.0, sin( angle ) * r ),
caster.location.clone().add( cos( angle ) * r, 1.0, sin( angle ) * r ),
1, 0.0, 0.0, 0.0, 0.0
)
}
@@ -195,7 +271,7 @@ class BlitzcrankKit : Kit() {
}.runTaskTimer( plugin, 0L, 1L )
targets.forEach { target ->
target.damage( ULT_DAMAGE, caster )
target.damage( capturedUltDamage, caster )
target.velocity = target.location.toVector()
.subtract( caster.location.toVector() )
.normalize()
@@ -203,9 +279,9 @@ class BlitzcrankKit : Kit() {
.setY( 0.5 )
}
caster.world.playSound( caster.location, Sound.ENTITY_GENERIC_EXPLODE, 1f, 1.5f )
caster.world.playSound( caster.location, Sound.ENTITY_GENERIC_EXPLODE, 1f, 1.5f )
caster.world.playSound( caster.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.8f, 1.8f )
caster.sendActionBar(caster.trans( "kits.blitzcrank.messages.ult_fired", "count" to targets.size.toString() ))
caster.sendActionBar( caster.trans( "kits.blitzcrank.messages.ult_fired", "count" to targets.size.toString() ) )
ultCooldowns[ caster.uniqueId ] = now
}
@@ -214,19 +290,22 @@ class BlitzcrankKit : Kit() {
// AGGRESSIVE active Hook (synchroner Raycast)
// =========================================================================
private inner class HookActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class HookActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "blitzcrank"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.items.hook.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.items.hook.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.items.hook.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.items.hook.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.FISHING_ROD
override fun execute(player: Player): AbilityResult
override fun execute(
player: Player
): AbilityResult
{
if ( player.location.y > MAX_KNOCKBACK_HEIGHT_Y )
return AbilityResult.ConditionNotMet(
@@ -236,51 +315,57 @@ class BlitzcrankKit : Kit() {
val eyeLoc = player.eyeLocation
val dir = eyeLoc.direction.normalize()
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedHookRange = hookRange
val capturedHookStepSize = hookStepSize
val capturedHookHitRadius = hookHitRadius
val capturedPullStrength = hookPullStrength
var hookTarget: Player? = null
var dist = 0.4
var dist = capturedHookStepSize
// Synchroner Scan: trivial schnell (max ~25 Iterationen)
while (dist <= HOOK_RANGE && hookTarget == null) {
val point = eyeLoc.clone().add(dir.clone().multiply(dist))
while ( dist <= capturedHookRange && hookTarget == null )
{
val point = eyeLoc.clone().add( dir.clone().multiply( dist ) )
// Block im Weg → Hook stoppt hier
if (point.block.type.isSolid) break
if ( point.block.type.isSolid ) break
// Partikel-Trail entlang des Strahls
player.world.spawnParticle(Particle.ELECTRIC_SPARK, point, 1, 0.0, 0.0, 0.0, 0.0)
player.world.spawnParticle( Particle.ELECTRIC_SPARK, point, 1, 0.0, 0.0, 0.0, 0.0 )
hookTarget = point.world
?.getNearbyEntities(point, 0.6, 0.6, 0.6)
?.getNearbyEntities( point, capturedHookHitRadius, capturedHookHitRadius, capturedHookHitRadius )
?.filterIsInstance<Player>()
?.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
?.minByOrNull { it.location.distanceSquared(point) }
?.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
?.minByOrNull { it.location.distanceSquared( point ) }
dist += 0.4
dist += capturedHookStepSize
}
if (hookTarget == null) {
// Kein Treffer Funken am Strahlende
val endPt = eyeLoc.clone().add(dir.multiply(dist.coerceAtMost(HOOK_RANGE)))
player.world.spawnParticle(Particle.ELECTRIC_SPARK, endPt, 10, 0.3, 0.3, 0.3, 0.06)
return AbilityResult.ConditionNotMet("Kein Ziel in Reichweite!")
if ( hookTarget == null )
{
val endPt = eyeLoc.clone().add( dir.multiply( dist.coerceAtMost( capturedHookRange ) ) )
player.world.spawnParticle( Particle.ELECTRIC_SPARK, endPt, 10, 0.3, 0.3, 0.3, 0.06 )
return AbilityResult.ConditionNotMet( "Kein Ziel in Reichweite!" )
}
val target = hookTarget
// Pull: Velocity in Richtung Caster
target.velocity = player.location.toVector()
.subtract(target.location.toVector())
.subtract( target.location.toVector() )
.normalize()
.multiply(HOOK_PULL_STRENGTH)
.setY(0.65)
.multiply( capturedPullStrength )
.setY( 0.65 )
target.world.spawnParticle(Particle.ELECTRIC_SPARK,
target.location.clone().add(0.0, 1.0, 0.0), 22, 0.4, 0.4, 0.4, 0.14)
target.world.playSound(target.location, Sound.ENTITY_IRON_GOLEM_HURT, 0.9f, 1.6f)
target.sendActionBar(target.trans("kits.blitzcrank.messages.hooked"))
target.world.spawnParticle(
Particle.ELECTRIC_SPARK,
target.location.clone().add( 0.0, 1.0, 0.0 ),
22, 0.4, 0.4, 0.4, 0.14
)
target.world.playSound( target.location, Sound.ENTITY_IRON_GOLEM_HURT, 0.9f, 1.6f )
target.sendActionBar( target.trans( "kits.blitzcrank.messages.hooked" ) )
player.playSound(player.location, Sound.ENTITY_FISHING_BOBBER_RETRIEVE, 1f, 0.4f)
player.sendActionBar(player.trans("kits.blitzcrank.messages.hook_hit"))
player.playSound( player.location, Sound.ENTITY_FISHING_BOBBER_RETRIEVE, 1f, 0.4f )
player.sendActionBar( player.trans( "kits.blitzcrank.messages.hook_hit" ) )
return AbilityResult.Success
}
@@ -290,15 +375,16 @@ class BlitzcrankKit : Kit() {
// DEFENSIVE active Stun (AoE-Freeze)
// =========================================================================
private inner class StunActive : ActiveAbility(Playstyle.DEFENSIVE) {
private inner class StunActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "blitzcrank"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.items.stun.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.items.stun.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.items.stun.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.items.stun.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.PISTON
@@ -311,48 +397,60 @@ class BlitzcrankKit : Kit() {
plugin.languageManager.getRawMessage( player, "kits.height_restriction" )
)
val targets = player.world
.getNearbyEntities(player.location, STUN_RADIUS, STUN_RADIUS, STUN_RADIUS)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedStunRadius = stunRadius
val capturedStunDurationTicks = stunDurationTicks
if (targets.isEmpty())
return AbilityResult.ConditionNotMet("Keine Feinde in ${STUN_RADIUS.toInt()} Blöcken!")
val targets = player.world
.getNearbyEntities( player.location, capturedStunRadius, capturedStunRadius, capturedStunRadius )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if ( targets.isEmpty() )
return AbilityResult.ConditionNotMet( "Keine Feinde in ${capturedStunRadius.toInt()} Blöcken!" )
targets.forEach { target ->
// Potion-Effekte für maximales Einfrieren (Amplifier 127 = sofortiger Stopp)
target.addPotionEffect(
PotionEffect(PotionEffectType.SLOWNESS, STUN_DURATION_TICKS, 127, false, false, true)
PotionEffect( PotionEffectType.SLOWNESS, capturedStunDurationTicks.toInt(), 127, false, false, true )
)
target.addPotionEffect(
PotionEffect(PotionEffectType.MINING_FATIGUE, STUN_DURATION_TICKS, 127, false, false, false)
PotionEffect( PotionEffectType.MINING_FATIGUE, capturedStunDurationTicks.toInt(), 127, false, false, false )
)
// Velocity-Reset-Task: verhindert Springen und Rutschen
var stunTick = 0
val task = Bukkit.getScheduler().runTaskTimer(plugin, { ->
val task = Bukkit.getScheduler().runTaskTimer( plugin, { ->
stunTick++
if (stunTick >= STUN_DURATION_TICKS || !target.isOnline ||
!plugin.gameManager.alivePlayers.contains(target.uniqueId)) {
stunTasks.remove(target.uniqueId)?.cancel()
if ( stunTick >= capturedStunDurationTicks ||
!target.isOnline ||
!plugin.gameManager.alivePlayers.contains( target.uniqueId ) )
{
stunTasks.remove( target.uniqueId )?.cancel()
return@runTaskTimer
}
val v = target.velocity
target.velocity = v.setX(0.0).setZ(0.0).let { if (it.y > 0.0) it.setY(0.0) else it }
}, 0L, 1L)
target.velocity = v.setX( 0.0 ).setZ( 0.0 ).let { if ( it.y > 0.0 ) it.setY( 0.0 ) else it }
}, 0L, 1L )
stunTasks[target.uniqueId] = task
stunTasks[ target.uniqueId ] = task
target.world.spawnParticle(Particle.ELECTRIC_SPARK,
target.location.clone().add(0.0, 1.5, 0.0), 25, 0.3, 0.5, 0.3, 0.14)
target.sendActionBar(target.trans("kits.blitzcrank.messages.stunned"))
target.world.spawnParticle(
Particle.ELECTRIC_SPARK,
target.location.clone().add( 0.0, 1.5, 0.0 ),
25, 0.3, 0.5, 0.3, 0.14
)
target.sendActionBar( target.trans( "kits.blitzcrank.messages.stunned" ) )
}
player.world.playSound(player.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 1f, 0.7f)
player.world.spawnParticle(Particle.ELECTRIC_SPARK,
player.location.clone().add(0.0, 1.0, 0.0), 35, 2.0, 0.5, 2.0, 0.14)
player.sendActionBar(player.trans("kits.blitzcrank.messages.stun_cast",
"count" to targets.size.toString()))
player.world.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 1f, 0.7f )
player.world.spawnParticle(
Particle.ELECTRIC_SPARK,
player.location.clone().add( 0.0, 1.0, 0.0 ),
35, 2.0, 0.5, 2.0, 0.14
)
player.sendActionBar(
player.trans( "kits.blitzcrank.messages.stun_cast",
"count" to targets.size.toString() )
)
return AbilityResult.Success
}
@@ -362,28 +460,31 @@ class BlitzcrankKit : Kit() {
// Shared Ult-Passive fängt BLAZE_POWDER-Rechtsklick via onInteract ab
// =========================================================================
inner class UltPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {
inner class UltPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.passive.name")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.passive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage("kits.blitzcrank.passive.description")
get() = plugin.languageManager.getDefaultRawMessage( "kits.blitzcrank.passive.description" )
/**
* Wird vom KitEventDispatcher **vor** dem triggerMaterial-Check aufgerufen.
* Prüft PDC-Tag → falls Ult-Item: Event canceln + Ult feuern.
*/
override fun onInteract(player: Player, event: PlayerInteractEvent) {
if (!event.action.isRightClick) return
override fun onInteract(
player: Player,
event: PlayerInteractEvent
) {
if ( !event.action.isRightClick ) return
val pdc = player.inventory.itemInMainHand.itemMeta
?.persistentDataContainer ?: return
if (!pdc.has(ultItemKey, PersistentDataType.BYTE)) return
if ( !pdc.has( ultItemKey, PersistentDataType.BYTE ) ) return
event.isCancelled = true // Vanilla-Interaktion (Feuer-Charge) unterbinden
fireUlt(player)
event.isCancelled = true
fireUlt( player )
}
}
}