Add extensible kit config and use in kits

Introduce a generic `extras` map and typed accessors to CustomGameSettings.KitOverride (getLong/getInt/getDouble/getFloat/getBoolean) and add documentation/examples for runtime JSON overrides. Expose Kit.override() to return the live KitOverride from SpeedHG and document how kits should read config values. Refactor NinjaKit to read all runtime parameters from overrides (teleport cooldown, hit window, smoke radius/duration/refresh/effect), snapshot config at activation, simplify smoke rendering/logic, and clean up cooldown/teleport code. Refactor TheWorldKit to use typed override fields and extras (shockwave, blink and freeze settings), snapshot values for abilities, tidy particle/physics code and improve lifecycle handling. Miscellaneous formatting, comments and small API cleanups across files.
This commit is contained in:
TDSTOS
2026-04-12 03:13:06 +02:00
parent 55a00ee15c
commit c1be2ddabd
4 changed files with 795 additions and 344 deletions

View File

@@ -2,6 +2,10 @@ package club.mcscrims.speedhg.config
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
@Serializable
data class CustomGameSettings(
@@ -29,7 +33,6 @@ data class CustomGameSettings(
/**
* Kit-spezifische Overrides.
* Key = Kit.id (z. B. "gladiator", "venom").
* Unbekannte Keys werden von kotlinx.serialization ignoriert.
*/
val kits: Map<String, KitOverride> = emptyMap()
@@ -46,7 +49,6 @@ data class CustomGameSettings(
hardcodedDefault: Int
): Int
{
// A hardcoded 0 means the kit is explicitly cooldown-based — never override it.
if ( hardcodedDefault == 0 ) return 0
return kits[ kitId ]?.hitsRequired?.takeIf { it >= 0 }
@@ -55,37 +57,64 @@ data class CustomGameSettings(
}
}
// -----------------------------------------------------------------
// -------------------------------------------------------------------------
// Kit-spezifische Override-Klassen
// -----------------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Gemeinsamer Wrapper für alle Kit-Overrides.
* `hitsRequired = -1` bedeutet "nicht gesetzt, nutze Global/Default".
* `extra` nimmt beliebige zusätzliche Kit-Felder auf, ohne dass
* für jedes Kit eine eigene Klasse notwendig ist.
*
* ## Bekannte Felder
* Häufig verwendete Felder sind als typisierte Properties vorhanden, damit
* der JSON direkt deserialisiert werden kann.
*
* ## Generische Felder (`extras`)
* Kits können beliebige zusätzliche Einstellungen über den `extras`-Map
* hinterlegen. Der Schlüssel ist ein freier String (z. B. `"cooldown_ms"`),
* der Wert ist ein [JsonElement].
*
* Zugriff über [getLong], [getInt], [getDouble] — diese geben `null` zurück
* wenn der Schlüssel nicht vorhanden ist, sodass der Aufrufer auf seinen
* eigenen Default zurückfallen kann.
*
* ### Beispiel JSON
* ```json
* {
* "kits": {
* "ninja": {
* "hits_required": 12,
* "extras": {
* "teleport_cooldown_ms": 8000,
* "smoke_radius": 4.0,
* "smoke_max_duration_ms": 12000,
* "hit_window_ms": 15000
* }
* }
* }
* }
* ```
*/
@Serializable
data class KitOverride(
@SerialName("hits_required") val hitsRequired: Int = -1,
// Goblin
// ── Goblin ────────────────────────────────────────────────────────────
@SerialName("steal_duration_seconds") val stealDuration: Int = 60,
@SerialName("bunker_radius") val bunkerRadius: Double = 10.0,
// Gladiator
// ── Gladiator ─────────────────────────────────────────────────────────
@SerialName("arena_radius") val arenaRadius: Int = 11,
@SerialName("arena_height") val arenaHeight: Int = 7,
@SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180,
// Venom
// ── Venom ─────────────────────────────────────────────────────────────
@SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L,
@SerialName("shield_capacity") val shieldCapacity: Double = 15.0,
// Voodoo
// ── Voodoo ────────────────────────────────────────────────────────────
@SerialName("curse_duration_ms") val curseDurationMs: Long = 15_000L,
// BlackPanther
// ── BlackPanther ──────────────────────────────────────────────────────
@SerialName("fist_mode_ms") val fistModeDurationMs: Long = 12_000L,
@SerialName("push_bonus_damage") val pushBonusDamage: Double = 4.0,
@SerialName("push_radius") val pushRadius: Double = 5.0,
@@ -93,19 +122,72 @@ data class CustomGameSettings(
@SerialName("pounce_radius") val pounceRadius: Double = 3.0,
@SerialName("pounce_damage") val pounceDamage: Double = 6.0,
// Rattlesnake
// ── Rattlesnake ───────────────────────────────────────────────────────
@SerialName("pounce_cooldown_ms") val pounceCooldownMs: Long = 20_000L,
@SerialName("pounce_max_sneak_ms") val pounceMaxSneakMs: Long = 3_000L,
@SerialName("pounce_min_range") val pounceMinRange: Double = 3.0,
@SerialName("pounce_max_range") val pounceMaxRange: Double = 10.0,
@SerialName("pounce_timeout_ticks") val pounceTimeoutTicks: Long = 30L,
// TheWorld
@SerialName("tw_ability_cooldown_ms") val abilityCooldownMs: Long = 25_000L,
@SerialName("tw_shockwave_radius") val shockwaveRadius: Double = 6.0,
@SerialName("tw_teleport_range") val teleportRange: Double = 10.0,
@SerialName("tw_max_teleport_charges") val maxTeleportCharges: Int = 3,
@SerialName("tw_freeze_duration_ticks") val freezeDurationTicks: Int = 200,
@SerialName("tw_max_hits_on_frozen") val maxHitsOnFrozen: Int = 5
)
// ── TheWorld ──────────────────────────────────────────────────────────
@SerialName("ability_cooldown_ms") val abilityCooldownMs: Long = 20_000L,
@SerialName("shockwave_radius") val shockwaveRadius: Double = 6.0,
@SerialName("teleport_range") val teleportRange: Double = 10.0,
@SerialName("max_teleport_charges") val maxTeleportCharges: Int = 3,
@SerialName("freeze_duration_ticks") val freezeDurationTicks: Int = 200,
@SerialName("max_hits_on_frozen") val maxHitsOnFrozen: Int = 5,
/**
* Generische Erweiterungs-Map für kit-spezifische Einstellungen, die
* kein eigenes typisiertes Feld haben.
*
* Serialisiert als JSON-Objekt, dessen Werte [JsonElement] sind.
* Zugriff via [getLong], [getInt], [getDouble].
*/
val extras: Map<String, JsonElement> = emptyMap()
) {
// ── Typed accessors for `extras` ──────────────────────────────────────
/**
* Gibt den `extras`-Wert für [key] als [Long] zurück, oder `null`
* wenn der Schlüssel fehlt oder der Wert kein gültiger Ganzzahlwert ist.
*/
fun getLong(
key: String
): Long? = ( extras[ key ] as? JsonPrimitive )?.longOrNull
/**
* Gibt den `extras`-Wert für [key] als [Int] zurück, oder `null`
* wenn der Schlüssel fehlt oder der Wert nicht konvertierbar ist.
*/
fun getInt(
key: String
): Int? = getLong( key )?.toInt()
/**
* Gibt den `extras`-Wert für [key] als [Double] zurück, oder `null`
* wenn der Schlüssel fehlt oder der Wert kein gültiger Dezimalwert ist.
*/
fun getDouble(
key: String
): Double? = ( extras[ key ] as? JsonPrimitive )?.doubleOrNull
/**
* Gibt den `extras`-Wert für [key] als [Float] zurück, oder `null`.
*/
fun getFloat(
key: String
): Float? = getDouble( key )?.toFloat()
/**
* Gibt den `extras`-Wert für [key] als [Boolean] zurück, oder `null`.
*/
fun getBoolean(
key: String
): Boolean? = ( extras[ key ] as? JsonPrimitive )?.let {
it.content.lowercase() == "true"
}
}
}

View File

@@ -1,5 +1,7 @@
package club.mcscrims.speedhg.kit
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import net.kyori.adventure.text.Component
@@ -21,7 +23,18 @@ import java.util.concurrent.ConcurrentHashMap
* using a `when(playstyle)` expression.
* 5. Register the kit via `plugin.kitManager.registerKit(YourKit())` in [SpeedHG.onEnable].
*
* See [TemplateKit] for a fully annotated example.
* ## Configuration
* Each kit's settings are configurable via `SPEEDHG_CUSTOM_SETTINGS` environment variable.
* Use [override] to access the kit's [CustomGameSettings.KitOverride], then call
* [CustomGameSettings.KitOverride.getLong], [CustomGameSettings.KitOverride.getInt], or
* [CustomGameSettings.KitOverride.getDouble] with a fallback:
*
* ```kotlin
* private val cooldownMs: Long
* get() = override().getLong( "cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS
* ```
*
* See [NinjaKit] for a full example.
*/
abstract class Kit {
@@ -37,6 +50,30 @@ abstract class Kit {
/** Icon used in the kit selection GUI. */
abstract val icon: Material
// -------------------------------------------------------------------------
// Configuration helper
// -------------------------------------------------------------------------
/**
* Returns this kit's [CustomGameSettings.KitOverride], or a default instance
* if no override has been configured for this kit.
*
* Intended to be called inside inner ability classes via the outer kit reference:
* ```kotlin
* private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
* private val cooldownMs: Long
* get() = override().getLong( "cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS
* }
* ```
*
* The method is `open` so kits that cache their override as a `lazy` property
* can override this for performance if desired. In most cases the default
* implementation (live lookup) is sufficient — it touches only an in-memory map.
*/
open fun override(): CustomGameSettings.KitOverride =
SpeedHG.instance.customGameManager.settings.kits.kits[ id ]
?: CustomGameSettings.KitOverride()
// -------------------------------------------------------------------------
// Playstyle-specific abilities — implement with a `when` expression
// -------------------------------------------------------------------------
@@ -44,16 +81,8 @@ abstract class Kit {
/**
* Return the [ActiveAbility] for the given [playstyle].
*
* ```kotlin
* override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
* Playstyle.AGGRESSIVE -> AggressiveActive()
* Playstyle.DEFENSIVE -> DefensiveActive()
* }
* ```
*
* **Performance note:** This is called frequently by [KitEventDispatcher].
* Prefer returning a cached singleton (`private val aggressiveActive = AggressiveActive()`)
* over allocating a new instance on each call.
* **Performance note:** Prefer returning a cached singleton over allocating
* a new instance on each call.
*/
abstract fun getActiveAbility( playstyle: Playstyle ): ActiveAbility
@@ -68,8 +97,6 @@ abstract class Kit {
/**
* Give the player their kit-specific items at game start (after teleportation).
* The standard HG items (soup, compass, etc.) are already given by [GameManager].
* Only add kit-exclusive items here.
*/
abstract fun giveItems( player: Player, playstyle: Playstyle )
@@ -77,34 +104,14 @@ abstract class Kit {
// Lifecycle hooks (optional)
// -------------------------------------------------------------------------
/**
* Called once per round when the kit is applied to [player].
* Use for permanent potion effects, scoreboard objectives, repeating tasks, etc.
* The matching [PassiveAbility.onActivate] is called immediately after this.
*/
open fun onAssign( player: Player, playstyle: Playstyle ) {}
/**
* Called when the kit is removed (game over / round reset).
* The matching [PassiveAbility.onDeactivate] is called immediately before this.
*/
open fun onRemove( player: Player ) {}
/**
* Called when the player using this kit scores a kill.
* Dispatched by [KitEventDispatcher] via [PlayerDeathEvent].
*/
open fun onKillEnemy( killer: Player, victim: Player ) {}
/**
* Called when the player toggles sneak. Dispatched by [KitEventDispatcher].
* @param isSneaking true = player just started sneaking.
*/
open fun onToggleSneak( player: Player, isSneaking: Boolean ) {}
/**
* Called when a player's item breaks. Use to replace kit armor automatically.
*/
open fun onItemBreak( player: Player, brokenItem: ItemStack ) {}
}

View File

@@ -27,19 +27,48 @@ import kotlin.math.sin
* ## NinjaKit
*
* | Playstyle | Aktive Fähigkeit | Passive |
* |-------------|------------------------------------------------------------------|-------------------------------------|
* | AGGRESSIVE | Sneak → teleportiert hinter den letzten Gegner (10-s-Fenster) | - |
* | DEFENSIVE | Smoke-Aura (Blindness I + Slow I) | - |
* |-------------|------------------------------------------------------------------|---------|
* | AGGRESSIVE | Sneak → teleportiert hinter den letzten Gegner (Hit-Fenster) | |
* | DEFENSIVE | Smoke-Aura (Blindness I + Slow I) im Umkreis | |
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* Alle Werte können zur Laufzeit per `extras`-Map im JSON überschrieben werden.
* Nicht vorhandene Schlüssel fallen automatisch auf die Defaults im [companion object] zurück.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-------------------------|-------|----------------|--------------------------------------------|
* | `teleport_cooldown_ms` | Long | `12_000` | Cooldown zwischen Teleports (ms) |
* | `hit_window_ms` | Long | `10_000` | Wie lange ein Treffer als Ziel gilt (ms) |
* | `smoke_radius` | Double| `3.0` | Radius der Smoke-Aura (Blöcke) |
* | `smoke_duration_ticks` | Long | `200` | Dauer der Smoke-Aura in Ticks |
* | `smoke_refresh_ticks` | Long | `10` | Ticks zwischen Aura-Aktualisierungen |
* | `smoke_effect_ticks` | Int | `30` | Dauer des Blindness/Slowness-Effekts |
*
* ### Beispiel JSON
* ```json
* {
* "kits": {
* "ninja": {
* "hits_required": 12,
* "extras": {
* "teleport_cooldown_ms": 8000,
* "hit_window_ms": 15000,
* "smoke_radius": 4.5,
* "smoke_duration_ticks": 300
* }
* }
* }
* }
* ```
*
* ### Teleport-Mechanismus
* `onToggleSneak` wird vom [KitEventDispatcher] aufgerufen. Er prüft das
* [lastHitEnemy]-Fenster (10 s) und berechnet eine Position 1,8 Blöcke
* hinter dem Feind (entgegen seiner Blickrichtung).
* [onToggleSneak] wird vom [KitEventDispatcher] aufgerufen. Er prüft das
* [lastHitEnemy]-Fenster und berechnet eine Position 1,8 Blöcke hinter dem Feind.
*
* ### Smoke-Mechanismus
* Ein BukkitTask (10 Ticks) spawnt einen Partikelring mit [SMOKE_RADIUS] Blöcken
* Radius. Jeder Feind im Ring erhält Blindness I + Slowness I (30 Ticks),
* die alle 0,5 s erneuert werden, solange er im Rauch bleibt.
* Ein BukkitTask spawnt alle [smokRefreshTicks] einen Partikelring. Jeder Feind
* im Ring erhält Blindness I + Slowness I, die regelmäßig erneuert werden.
*/
class NinjaKit : Kit() {
@@ -54,19 +83,75 @@ class NinjaKit : Kit() {
override val icon: Material
get() = Material.FEATHER
/** ninjaUUID → (enemyUUID, System.currentTimeMillis() des letzten Treffers) */
// ── Internal state ────────────────────────────────────────────────────────
/** ninjaUUID → (enemyUUID, timestamp-ms of the last qualifying hit) */
internal val lastHitEnemy: MutableMap<UUID, Pair<UUID, Long>> = ConcurrentHashMap()
private val smokeTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
private val teleportCooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
// ── Defaults (used as fallback when no custom settings are present) ───────
companion object {
const val HIT_WINDOW_MS = 10_000L // 10s - Gültigkeit des Teleport-Ziels
const val SMOKE_RADIUS = 3.0 // Blöcke
const val SMOKE_MAX_DURATION = 10_000L // 10s
const val TELEPORT_COOLDOWN_MS = 12_000L // 12s zwischen Teleports
/**
* All default values are defined here as named constants so they can be
* referenced in documentation and serve as the canonical fallback values
* when a game host has not provided a custom override.
*/
const val DEFAULT_TELEPORT_COOLDOWN_MS = 12_000L
const val DEFAULT_HIT_WINDOW_MS = 10_000L
const val DEFAULT_SMOKE_RADIUS = 3.0
const val DEFAULT_SMOKE_DURATION_TICKS = 200L
const val DEFAULT_SMOKE_REFRESH_TICKS = 10L
const val DEFAULT_SMOKE_EFFECT_TICKS = 30
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
// ── Live config accessors (read from override or fall back to defaults) ───
/**
* Milliseconds a player must wait between successive teleports.
* JSON key: `teleport_cooldown_ms`
*/
private val teleportCooldownMs: Long
get() = override().getLong( "teleport_cooldown_ms" ) ?: DEFAULT_TELEPORT_COOLDOWN_MS
/**
* Milliseconds during which the last-hit enemy is considered a valid teleport target.
* JSON key: `hit_window_ms`
*/
private val hitWindowMs: Long
get() = override().getLong( "hit_window_ms" ) ?: DEFAULT_HIT_WINDOW_MS
/**
* Block radius of the defensive smoke aura.
* JSON key: `smoke_radius`
*/
private val smokeRadius: Double
get() = override().getDouble( "smoke_radius" ) ?: DEFAULT_SMOKE_RADIUS
/**
* Total duration (in ticks) of the smoke aura after activation.
* JSON key: `smoke_duration_ticks`
*/
private val smokeDurationTicks: Long
get() = override().getLong( "smoke_duration_ticks" ) ?: DEFAULT_SMOKE_DURATION_TICKS
/**
* How often (in ticks) the smoke aura refreshes particles and reapplies effects.
* JSON key: `smoke_refresh_ticks`
*/
private val smokeRefreshTicks: Long
get() = override().getLong( "smoke_refresh_ticks" ) ?: DEFAULT_SMOKE_REFRESH_TICKS
/**
* Duration (in ticks) of Blindness and Slowness applied to enemies inside the smoke.
* JSON key: `smoke_effect_ticks`
*/
private val smokeEffectTicks: Int
get() = override().getInt( "smoke_effect_ticks" ) ?: DEFAULT_SMOKE_EFFECT_TICKS
// ── Cached ability instances ──────────────────────────────────────────────
private val aggressiveActive = NoActive( Playstyle.AGGRESSIVE )
private val defensiveActive = DefensiveActive()
@@ -89,14 +174,15 @@ class NinjaKit : Kit() {
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
if ( playstyle != Playstyle.DEFENSIVE )
return
if ( playstyle != Playstyle.DEFENSIVE ) return
val item = ItemBuilder( Material.FEATHER )
.name( defensiveActive.name )
@@ -117,7 +203,7 @@ class NinjaKit : Kit() {
}
// =========================================================================
// Sneak → Teleport (nur AGGRESSIVE, via KitEventDispatcher)
// Sneak → Teleport (AGGRESSIVE, dispatched by KitEventDispatcher)
// =========================================================================
override fun onToggleSneak(
@@ -130,9 +216,9 @@ class NinjaKit : Kit() {
val now = System.currentTimeMillis()
val lastUse = teleportCooldowns[ player.uniqueId ] ?: 0L
if ( now - lastUse < TELEPORT_COOLDOWN_MS )
if ( now - lastUse < teleportCooldownMs )
{
val secLeft = ( TELEPORT_COOLDOWN_MS - ( now - lastUse )) / 1000
val secLeft = ( teleportCooldownMs - ( now - lastUse )) / 1000
player.sendActionBar(player.trans( "kits.ninja.messages.cooldown", "time" to secLeft.toString() ))
return
}
@@ -142,7 +228,7 @@ class NinjaKit : Kit() {
return
}
if ( now - hitTime > HIT_WINDOW_MS )
if ( now - hitTime > hitWindowMs )
{
lastHitEnemy.remove( player.uniqueId )
player.sendActionBar(player.trans( "kits.ninja.messages.target_expired" ))
@@ -156,6 +242,8 @@ class NinjaKit : Kit() {
teleportCooldowns[ player.uniqueId ] = now
}
// ── Teleport implementation ───────────────────────────────────────────────
private fun performTeleport(
player: Player,
enemy: Player
@@ -189,6 +277,10 @@ class NinjaKit : Kit() {
player.sendActionBar(player.trans( "kits.ninja.messages.teleported" ))
}
// =========================================================================
// DEFENSIVE active Smoke Aura
// =========================================================================
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private val plugin get() = SpeedHG.instance
@@ -208,51 +300,91 @@ class NinjaKit : Kit() {
player: Player
): AbilityResult
{
// Cancel any existing smoke aura before starting a new one
smokeTasks.remove( player.uniqueId )?.cancel()
// Snapshot the config values at activation time so mid-round changes
// don't alter an already-running aura unexpectedly.
val capturedRefreshTicks = smokeRefreshTicks
val capturedRadius = smokeRadius
val capturedEffectTicks = smokeEffectTicks
val task = Bukkit.getScheduler().runTaskTimer( plugin, { ->
if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ))
if ( !player.isOnline ||
!plugin.gameManager.alivePlayers.contains( player.uniqueId ) )
{
smokeTasks.remove( player.uniqueId )?.cancel()
return@runTaskTimer
}
val center = player.location
spawnSmokeRing( player, capturedRadius )
applyEffectsToEnemies( player, capturedRadius, capturedEffectTicks )
for ( i in 0 until 10 )
{
val angle = i * ( 2.0 * Math.PI / 10.0 )
center.world.spawnParticle(
Particle.CAMPFIRE_COSY_SMOKE,
center.clone().add(cos( angle ) * SMOKE_RADIUS, 0.8, sin( angle ) * SMOKE_RADIUS),
1, 0.05, 0.12, 0.05, 0.004
)
}
center.world
.getNearbyEntities( center, SMOKE_RADIUS, 2.0, SMOKE_RADIUS )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { enemy ->
enemy.addPotionEffect(PotionEffect(
PotionEffectType.BLINDNESS, 30, 0, false, false, true
))
enemy.addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS, 30, 0, false, false, true
))
}
}, 0L, 10L )
}, 0L, capturedRefreshTicks )
smokeTasks[ player.uniqueId ] = task
// Schedule automatic aura expiry
val capturedDurationTicks = smokeDurationTicks
Bukkit.getScheduler().runTaskLater( plugin, { ->
smokeTasks.remove( player.uniqueId )?.cancel()
}, SMOKE_MAX_DURATION * 20L )
}, capturedDurationTicks )
player.playSound( player.location, Sound.ENTITY_ENDERMAN_AMBIENT, 0.7f, 1.8f )
player.sendActionBar(player.trans( "kits.ninja.messages.smoke_activated" ))
return AbilityResult.Success
}
// ── Rendering helpers (private to this inner class) ───────────────────
private fun spawnSmokeRing(
player: Player,
radius: Double
) {
val center = player.location
val steps = 10
for ( i in 0 until steps )
{
val angle = i * ( 2.0 * Math.PI / steps )
center.world.spawnParticle(
Particle.CAMPFIRE_COSY_SMOKE,
center.clone().add(
cos( angle ) * radius,
0.8,
sin( angle ) * radius
),
1, 0.05, 0.12, 0.05, 0.004
)
}
}
private fun applyEffectsToEnemies(
player: Player,
radius: Double,
effectTicks: Int
) {
player.location.world
.getNearbyEntities( player.location, radius, 2.0, radius )
.filterIsInstance<Player>()
.filter { it != player &&
plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { enemy ->
enemy.addPotionEffect(PotionEffect(
PotionEffectType.BLINDNESS, effectTicks, 0,
false, false, true
))
enemy.addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS, effectTicks, 0,
false, false, true
))
}
}
}
// =========================================================================
// Stubs
// =========================================================================
private class NoActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) {
override val kitId = "ninja"

View File

@@ -30,27 +30,65 @@ import kotlin.math.sin
* | Playstyle | Active | Passive |
* |-------------|-------------------------------------------------|------------------------------------------|
* | AGGRESSIVE | Shockwave + 3× Blink in looking direction | |
* | DEFENSIVE | Shockwave + Freeze nearby enemies for 10 s | Hit-cap: frozen enemies survive max 5 hits|
* | DEFENSIVE | Shockwave + Freeze nearby enemies | Hit-cap: frozen enemies survive max 5 hits|
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* Typisierte Felder in [CustomGameSettings.KitOverride] werden direkt gelesen.
* Zusätzliche Einstellungen können über die `extras`-Map konfiguriert werden.
*
* | Quelle | JSON-Schlüssel | Typ | Default | Beschreibung |
* |---------------------|------------------------------|--------|---------|-------------------------------------------|
* | Typisiertes Feld | `tw_ability_cooldown_ms` | Long | `20000` | Cooldown beider Aktiv-Fähigkeiten (ms) |
* | Typisiertes Feld | `tw_shockwave_radius` | Double | `6.0` | Radius der Schockwelle (Blöcke) |
* | Typisiertes Feld | `tw_teleport_range` | Double | `10.0` | Max. Blink-Reichweite (Blöcke) |
* | Typisiertes Feld | `tw_max_teleport_charges` | Int | `3` | Blink-Charges pro Schockwellen-Use |
* | Typisiertes Feld | `tw_freeze_duration_ticks` | Int | `200` | Freeze-Dauer in Ticks (10 s) |
* | Typisiertes Feld | `tw_max_hits_on_frozen` | Int | `5` | Max. Treffer auf gefrorene Gegner |
* | `extras` | `shockwave_knockback_speed` | Double | `2.0` | Velocity-Multiplikator der Schockwelle |
* | `extras` | `shockwave_knockback_y` | Double | `0.45` | Vertikaler Y-Impuls der Schockwelle |
* | `extras` | `blink_step_size` | Double | `0.4` | Raycast-Schrittgröße beim Blink (Blöcke) |
* | `extras` | `freeze_refresh_ticks` | Int | `20` | Ticks zwischen Slowness-Refreshes |
* | `extras` | `freeze_powder_snow_ticks` | Int | `140` | Powder-Snow-Freeze-Ticks (visuell) |
*
* ### Beispiel JSON
* ```json
* {
* "kits": {
* "kits": {
* "theworld": {
* "tw_ability_cooldown_ms": 15000,
* "tw_shockwave_radius": 8.0,
* "tw_max_teleport_charges": 2,
* "tw_freeze_duration_ticks": 160,
* "tw_max_hits_on_frozen": 3,
* "extras": {
* "shockwave_knockback_speed": 2.5,
* "shockwave_knockback_y": 0.5,
* "blink_step_size": 0.3,
* "freeze_refresh_ticks": 15
* }
* }
* }
* }
* }
* ```
*
* ### AGGRESSIVE active
* First use (off cooldown): radial shockwave → grants 3 blink charges.
* Each subsequent right-click: teleport up to [TELEPORT_RANGE] blocks in the
* player's looking direction (stops before solid blocks). After all 3 charges
* are spent, the 20 s cooldown begins.
* Erster Use (off cooldown): radiale Schockwelle → gewährt [maxTeleportCharges] Blink-Charges.
* Jeder folgende Rechtsklick: teleportiert bis zu [teleportRange] Blöcke in Blickrichtung
* (stoppt vor soliden Blöcken). Nach allen Charges beginnt der [abilityCooldownMs]-Cooldown.
*
* ### DEFENSIVE active
* Radial shockwave + [applyFreeze] on every nearby alive enemy. Each frozen
* enemy gets a 1-tick velocity-zeroing task for 10 s. The [DefensivePassive]
* monitors hits from this player on frozen enemies and unfreezes them after
* [MAX_HITS_ON_FROZEN] hits or when time expires.
* Radiale Schockwelle + [applyFreeze] auf jeden nahen lebenden Gegner. Ein 1-Tick-Task
* setzt die Velocity gefrorener Spieler für [freezeDurationTicks] Ticks auf 0.
* [DefensivePassive] beendet den Freeze nach [maxHitsOnFrozen] Treffern.
*
* ### Why hitsRequired = 0?
* Both active abilities require full control over when [execute] fires. Using
* the built-in charge system (hitsRequired > 0) would block [execute] after
* the first use and prevent the blink/freeze logic from re-running per click.
* With hitsRequired = 0 the charge state stays READY permanently and
* [execute] is called on every right-click — internal cooldown maps govern
* actual recharge.
* ### Warum hitsRequired = 0?
* Beide Aktiv-Fähigkeiten steuern intern wann [execute] feuert. Das eingebaute
* Charge-System würde nach dem ersten Use blockieren und die Blink/Freeze-Logik
* pro Klick verhindern. Mit `hitsRequired = 0` bleibt der Charge-State dauerhaft
* READY und der interne Cooldown regiert den tatsächlichen Recharge.
*/
class TheWorldKit : Kit() {
@@ -63,18 +101,128 @@ class TheWorldKit : Kit() {
get() = plugin.languageManager.getDefaultRawMessageList( "kits.theworld.lore" )
override val icon = Material.CLOCK
// ── Shared kit state ──────────────────────────────────────────────────────
// =========================================================================
// Defaults
// =========================================================================
companion object {
const val DEFAULT_ABILITY_COOLDOWN_MS = 20_000L
const val DEFAULT_SHOCKWAVE_RADIUS = 6.0
const val DEFAULT_TELEPORT_RANGE = 10.0
const val DEFAULT_MAX_TELEPORT_CHARGES = 3
const val DEFAULT_FREEZE_DURATION_TICKS = 200
const val DEFAULT_MAX_HITS_ON_FROZEN = 5
const val DEFAULT_SHOCKWAVE_KNOCKBACK_SPEED = 2.0
const val DEFAULT_SHOCKWAVE_KNOCKBACK_Y = 0.45
const val DEFAULT_BLINK_STEP_SIZE = 0.4
const val DEFAULT_FREEZE_REFRESH_TICKS = 20
const val DEFAULT_FREEZE_POWDER_SNOW_TICKS = 140
}
// =========================================================================
// Live config accessors
//
// Typisierte KitOverride-Felder werden direkt von override() gelesen.
// extras-Werte fallen auf die Companion-Defaults zurück.
// =========================================================================
/**
* Aggressive blink charges: playerUUID → remaining uses.
* Set to [MAX_TELEPORT_CHARGES] on first right-click, decremented per blink.
* Cooldown beider Aktiv-Fähigkeiten in Millisekunden.
* Quelle: typisiertes Feld `tw_ability_cooldown_ms`.
*/
private val abilityCooldownMs: Long
get() = override().abilityCooldownMs
/**
* Radius der Schockwelle in Blöcken.
* Quelle: typisiertes Feld `tw_shockwave_radius`.
*/
private val shockwaveRadius: Double
get() = override().shockwaveRadius
/**
* Maximale Blink-Reichweite in Blöcken.
* Quelle: typisiertes Feld `tw_teleport_range`.
*/
private val teleportRange: Double
get() = override().teleportRange
/**
* Anzahl der Blink-Charges nach einer Schockwelle.
* Quelle: typisiertes Feld `tw_max_teleport_charges`.
*/
private val maxTeleportCharges: Int
get() = override().maxTeleportCharges
/**
* Dauer des Freezes in Ticks.
* Quelle: typisiertes Feld `tw_freeze_duration_ticks`.
*/
private val freezeDurationTicks: Int
get() = override().freezeDurationTicks
/**
* Maximale Anzahl an Treffern gegen einen gefrorenen Gegner.
* Quelle: typisiertes Feld `tw_max_hits_on_frozen`.
*/
private val maxHitsOnFrozen: Int
get() = override().maxHitsOnFrozen
/**
* Horizontaler Velocity-Multiplikator der Schockwelle.
* Quelle: `extras["shockwave_knockback_speed"]`.
*/
private val shockwaveKnockbackSpeed: Double
get() = override().getDouble( "shockwave_knockback_speed" )
?: DEFAULT_SHOCKWAVE_KNOCKBACK_SPEED
/**
* Vertikaler Y-Impuls der Schockwelle.
* Quelle: `extras["shockwave_knockback_y"]`.
*/
private val shockwaveKnockbackY: Double
get() = override().getDouble( "shockwave_knockback_y" )
?: DEFAULT_SHOCKWAVE_KNOCKBACK_Y
/**
* Raycast-Schrittgröße in Blöcken beim Blink.
* Kleinere Werte = präzisere Kollisionserkennung, mehr CPU-Last.
* Quelle: `extras["blink_step_size"]`.
*/
private val blinkStepSize: Double
get() = override().getDouble( "blink_step_size" )
?: DEFAULT_BLINK_STEP_SIZE
/**
* Ticks zwischen Slowness-Refreshes während des Freezes.
* Quelle: `extras["freeze_refresh_ticks"]`.
*/
private val freezeRefreshTicks: Int
get() = override().getInt( "freeze_refresh_ticks" )
?: DEFAULT_FREEZE_REFRESH_TICKS
/**
* Powder-Snow-Freeze-Ticks (rein visuell, kein Gameplay-Effekt).
* Quelle: `extras["freeze_powder_snow_ticks"]`.
*/
private val freezePowderSnowTicks: Int
get() = override().getInt( "freeze_powder_snow_ticks" )
?: DEFAULT_FREEZE_POWDER_SNOW_TICKS
// =========================================================================
// Shared kit state
// =========================================================================
/**
* Aggressive Blink-Charges: playerUUID → verbleibende Uses.
* Wird auf [maxTeleportCharges] gesetzt beim ersten Rechtsklick, dann dekrementiert.
*/
internal val teleportCharges: MutableMap<UUID, Int> = ConcurrentHashMap()
/**
* Active freezes: victimUUID → (attackerUUID, [FrozenData]).
* Tracked separately per attacker so [onRemove] only thaws enemies
* frozen by the leaving player.
* Aktive Freezes: victimUUID → (attackerUUID, [FrozenData]).
* Getrennt pro Angreifer, damit [onRemove] nur vom verlassenden Spieler verursachte
* Freezes auftaut.
*/
internal val frozenEnemies: MutableMap<UUID, Pair<UUID, FrozenData>> = ConcurrentHashMap()
@@ -83,37 +231,37 @@ class TheWorldKit : Kit() {
val task: BukkitTask
)
companion object {
private fun override() = SpeedHG.instance.customGameManager.settings.kits.kits["theworld"]
?: CustomGameSettings.KitOverride()
// =========================================================================
// Cached ability instances
// =========================================================================
private val ABILITY_COOLDOWN_MS = override().abilityCooldownMs
private val SHOCKWAVE_RADIUS = override().shockwaveRadius
private val TELEPORT_RANGE = override().teleportRange
private val MAX_TELEPORT_CHARGES = override().maxTeleportCharges
private val FREEZE_DURATION_TICKS = override().freezeDurationTicks
private val MAX_HITS_ON_FROZEN = override().maxHitsOnFrozen
}
// ── Cached ability instances ──────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = DefensivePassive()
override fun getActiveAbility(playstyle: Playstyle): ActiveAbility = when (playstyle) {
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle): PassiveAbility = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
val active = getActiveAbility( playstyle )
val item = ItemBuilder( Material.CLOCK )
@@ -125,15 +273,16 @@ class TheWorldKit : Kit() {
player.inventory.addItem( item )
}
override fun onRemove(player: Player) {
override fun onRemove(
player: Player
) {
teleportCharges.remove( player.uniqueId )
// Cancel tasks + thaw every enemy that was frozen by this player
val toUnfreeze = frozenEnemies.entries
// Alle vom verlassenden Spieler verursachten Freezes auftauen
frozenEnemies.entries
.filter { ( _, pair ) -> pair.first == player.uniqueId }
.map { ( victimUUID, pair ) -> victimUUID to pair.second }
toUnfreeze.forEach { (victimUUID, data) ->
.forEach { ( victimUUID, data ) ->
data.task.cancel()
frozenEnemies.remove( victimUUID )
Bukkit.getPlayer( victimUUID )?.clearFreezeEffects()
@@ -147,23 +296,37 @@ class TheWorldKit : Kit() {
// =========================================================================
/**
* Expanding ring of particles + radial knockback.
* Expandierender Partikelring + radialer Rückschlag.
*
* The ring BukkitRunnable adds one ring per tick, radius grows by 1 block/tick.
* This gives the visual impression of a shockwave spreading outward.
* Der Ring-Task wächst um 1 Block/Tick bis [shockwaveRadius] + 1.
* Knockback-Stärke und Y-Impuls werden aus der Live-Config gelesen,
* aber zum Aktivierungszeitpunkt als lokale Variablen gesnapshot,
* damit eine Mitte-Runde-Konfigurationsänderung die laufende Animation
* nicht inkonsistent macht.
*/
private fun doShockwave(origin: Player) {
private fun doShockwave(
origin: Player
) {
val world = origin.world
// ── Visual: expanding particle ring ───────────────────────────────────
// Werte zum Aktivierungszeitpunkt snapshotten
val capturedRadius = shockwaveRadius
val capturedKnockbackSpeed = shockwaveKnockbackSpeed
val capturedKnockbackY = shockwaveKnockbackY
// ── Visueller Partikelring ────────────────────────────────────────────
object : BukkitRunnable() {
var r = 0.5
override fun run() {
if (r > SHOCKWAVE_RADIUS + 1.0) { cancel(); return }
override fun run()
{
if ( r > capturedRadius + 1.0 ) { cancel(); return }
val steps = ( 2 * Math.PI * r * 5 ).toInt().coerceAtLeast( 8 )
for (i in 0 until steps) {
for ( i in 0 until steps )
{
val angle = 2 * Math.PI * i / steps
val loc = origin.location.clone().add(cos(angle) * r, 1.0, sin(angle) * r)
val loc = origin.location.clone().add(
cos( angle ) * r, 1.0, sin( angle ) * r
)
world.spawnParticle( Particle.SWEEP_ATTACK, loc, 1, 0.0, 0.0, 0.0, 0.0 )
world.spawnParticle( Particle.CRIT, loc, 2, 0.1, 0.1, 0.1, 0.0 )
}
@@ -171,16 +334,20 @@ class TheWorldKit : Kit() {
}
}.runTaskTimer( plugin, 0L, 1L )
// ── Physics: knock all nearby alive enemies outward ───────────────────
world.getNearbyEntities(origin.location, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS)
// ── Physik: alle nahen Gegner nach außen schleudern ───────────────────
world.getNearbyEntities(
origin.location,
capturedRadius, capturedRadius, capturedRadius
)
.filterIsInstance<Player>()
.filter { it != origin && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
.filter { it != origin &&
plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { enemy ->
val dir = enemy.location.toVector()
.subtract( origin.location.toVector() )
.normalize()
.multiply(2.0)
.setY(0.45)
.multiply( capturedKnockbackSpeed )
.setY( capturedKnockbackY )
enemy.velocity = dir
}
@@ -189,20 +356,25 @@ class TheWorldKit : Kit() {
}
/**
* Teleports [player] up to [TELEPORT_RANGE] blocks in their looking direction.
* Raycast in 0.4-block increments — stops at the last non-solid block.
* Teleportiert [player] bis zu [teleportRange] Blöcke in Blickrichtung.
* Raycast in [blinkStepSize]-Schritten — stoppt am letzten nicht-soliden Block.
*/
private fun blink(player: Player) {
private fun blink(
player: Player
) {
val capturedRange = teleportRange
val capturedStepSize = blinkStepSize
val dir = player.location.direction.normalize()
var target = player.eyeLocation.clone()
repeat((TELEPORT_RANGE / 0.4).toInt()) {
val next = target.clone().add(dir.clone().multiply(0.4))
repeat( ( capturedRange / capturedStepSize ).toInt() ) {
val next = target.clone().add( dir.clone().multiply( capturedStepSize ) )
if ( next.block.type.isSolid ) return@repeat
target = next
}
// Adjust to feet position
// Auf Füße-Position anpassen
target.y -= 1.0
target.yaw = player.location.yaw
target.pitch = player.location.pitch
@@ -214,106 +386,136 @@ class TheWorldKit : Kit() {
}
/**
* Immobilises [target], capping hits from [attacker] at [MAX_HITS_ON_FROZEN].
* A 1-tick repeating task zeros horizontal + upward velocity for 10 seconds.
* Immobilisiert [target] und begrenzt Treffer von [attacker] auf [maxHitsOnFrozen].
* Ein 1-Tick-Task setzt horizontale + aufwärts gerichtete Velocity auf 0.
*
* Konfigurationswerte werden zum Aktivierungszeitpunkt gesnapshot, damit
* eine Konfigurationsänderung den laufenden Freeze nicht inkonsistent macht.
*/
private fun applyFreeze(attacker: Player, target: Player) {
// Overwrite any existing freeze
private fun applyFreeze(
attacker: Player,
target: Player
) {
// Vorhandenen Freeze überschreiben
frozenEnemies.remove( target.uniqueId )?.second?.task?.cancel()
val capturedDurationTicks = freezeDurationTicks
val capturedRefreshTicks = freezeRefreshTicks
val capturedPowderSnowTicks = freezePowderSnowTicks
val capturedMaxHits = maxHitsOnFrozen
target.applyFreezeEffects()
val task = object : BukkitRunnable() {
var ticks = 0
override fun run() {
override fun run()
{
ticks++
if (ticks >= FREEZE_DURATION_TICKS || !target.isOnline ||
if ( ticks >= capturedDurationTicks ||
!target.isOnline ||
!plugin.gameManager.alivePlayers.contains( target.uniqueId ) ||
!frozenEnemies.containsKey( target.uniqueId ) ||
plugin.gameManager.currentState == GameState.ENDING) // ← neu
plugin.gameManager.currentState == GameState.ENDING )
{
doUnfreeze( target )
cancel()
return
}
// Zero horizontal + upward velocity every tick
// Horizontale + aufwärts Velocity jedes Tick nullen
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 }
// Refresh slowness every second so it doesn't expire mid-freeze
if (ticks % 20 == 0) target.applyFreezeEffects()
// Slowness jede Sekunde refreshen damit sie nicht ausläuft
if ( ticks % capturedRefreshTicks == 0 )
target.applyFreezeEffects()
// Powder-snow visual (cosmetic)
if (target.freezeTicks < 140) target.freezeTicks = 140
// Powder-Snow-Visuals (rein kosmetisch)
if ( target.freezeTicks < capturedPowderSnowTicks )
target.freezeTicks = capturedPowderSnowTicks
}
}.runTaskTimer( plugin, 0L, 1L )
frozenEnemies[ target.uniqueId ] = Pair(
attacker.uniqueId,
FrozenData(hitsRemaining = MAX_HITS_ON_FROZEN, task = task)
FrozenData( hitsRemaining = capturedMaxHits, task = task )
)
target.sendActionBar( target.trans( "kits.theworld.messages.frozen_received" ) )
target.world.spawnParticle(Particle.SNOWFLAKE,
target.location.clone().add(0.0, 1.0, 0.0), 15, 0.3, 0.5, 0.3, 0.05)
target.world.spawnParticle(
Particle.SNOWFLAKE,
target.location.clone().add( 0.0, 1.0, 0.0 ),
15, 0.3, 0.5, 0.3, 0.05
)
}
private fun doUnfreeze(target: Player) {
private fun doUnfreeze(
target: Player
) {
frozenEnemies.remove( target.uniqueId )
target.clearFreezeEffects()
if ( target.isOnline )
target.sendActionBar( target.trans( "kits.theworld.messages.frozen_expired" ) )
}
// ── Player extension helpers ──────────────────────────────────────────────
// ── Player-Extension-Helpers ──────────────────────────────────────────────
private fun Player.applyFreezeEffects() {
addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS,
/* duration */ 25,
/* amplifier */ 127,
/* ambient */ false,
/* particles */ false,
/* icon */ true))
addPotionEffect(PotionEffect(PotionEffectType.MINING_FATIGUE,
25, 127, false, false, false))
private fun Player.applyFreezeEffects()
{
addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS,
25, 127,
false, false, true
))
addPotionEffect(PotionEffect(
PotionEffectType.MINING_FATIGUE,
25, 127,
false, false, false
))
}
private fun Player.clearFreezeEffects() {
private fun Player.clearFreezeEffects()
{
removePotionEffect( PotionEffectType.SLOWNESS )
removePotionEffect( PotionEffectType.MINING_FATIGUE )
freezeTicks = 0
}
// =========================================================================
// AGGRESSIVE active Shockwave → 3× blink
// AGGRESSIVE active Schockwelle → 3× Blink
// =========================================================================
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private val plugin get() = SpeedHG.instance
/** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */
private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
override val kitId: String
get() = "theworld"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.aggressive.name")
"kits.theworld.items.clock.aggressive.name"
)
override val description: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.aggressive.description")
"kits.theworld.items.clock.aggressive.description"
)
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial = Material.CLOCK
override fun execute(player: Player): AbilityResult {
// ── Spend a blink charge if any are available ─────────────────────
override fun execute(
player: Player
): AbilityResult
{
// ── Vorhandene Blink-Charge verbrauchen ───────────────────────────
val charges = teleportCharges[ player.uniqueId ] ?: 0
if (charges > 0) {
if ( charges > 0 )
{
blink( player )
val remaining = charges - 1
teleportCharges[ player.uniqueId ] = remaining
@@ -321,71 +523,91 @@ class TheWorldKit : Kit() {
if ( remaining > 0 )
player.sendActionBar(player.trans(
"kits.theworld.messages.teleport_charges",
mapOf("charges" to remaining.toString())))
else {
mapOf( "charges" to remaining.toString() )
))
else
{
teleportCharges.remove( player.uniqueId )
player.sendActionBar(player.trans( "kits.theworld.messages.charges_exhausted" ))
}
return AbilityResult.Success
}
// ── Cooldown gate ─────────────────────────────────────────────────
// ── Cooldown prüfen ───────────────────────────────────────────────
val now = System.currentTimeMillis()
val lastUse = cooldowns[ player.uniqueId ] ?: 0L
if (now - lastUse < ABILITY_COOLDOWN_MS) {
val secsLeft = (ABILITY_COOLDOWN_MS - (now - lastUse)) / 1000
val capturedCooldown = abilityCooldownMs
if ( now - lastUse < capturedCooldown )
{
val secsLeft = ( capturedCooldown - ( now - lastUse ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" )
}
// ── Shockwave + grant 3 blink charges ────────────────────────────
// ── Schockwelle + Charges vergeben ────────────────────────────────
val capturedCharges = maxTeleportCharges
doShockwave( player )
teleportCharges[player.uniqueId] = MAX_TELEPORT_CHARGES
teleportCharges[ player.uniqueId ] = capturedCharges
cooldowns[ player.uniqueId ] = now
player.sendActionBar(player.trans(
"kits.theworld.messages.shockwave_and_blink",
mapOf("charges" to MAX_TELEPORT_CHARGES.toString())))
mapOf( "charges" to capturedCharges.toString() )
))
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active Shockwave + freeze + 5-hit cap
// DEFENSIVE active Schockwelle + Freeze + 5-Treffer-Cap
// =========================================================================
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private val plugin get() = SpeedHG.instance
/** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */
private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
override val kitId: String
get() = "theworld"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.defensive.name")
"kits.theworld.items.clock.defensive.name"
)
override val description: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.defensive.description")
"kits.theworld.items.clock.defensive.description"
)
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial = Material.CLOCK
override fun execute(player: Player): AbilityResult {
// ── Cooldown gate ─────────────────────────────────────────────────
override fun execute(
player: Player
): AbilityResult
{
// ── Cooldown prüfen ───────────────────────────────────────────────
val now = System.currentTimeMillis()
val lastUse = cooldowns[ player.uniqueId ] ?: 0L
if (now - lastUse < ABILITY_COOLDOWN_MS) {
val secsLeft = (ABILITY_COOLDOWN_MS - (now - lastUse)) / 1000
val capturedCooldown = abilityCooldownMs
if ( now - lastUse < capturedCooldown )
{
val secsLeft = ( capturedCooldown - ( now - lastUse ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" )
}
// ── Ziele ermitteln ───────────────────────────────────────────────
val capturedRadius = shockwaveRadius
val targets = player.world
.getNearbyEntities(
player.location,
SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS)
capturedRadius, capturedRadius, capturedRadius
)
.filterIsInstance<Player>()
.filter { it != player &&
plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
@@ -393,20 +615,22 @@ class TheWorldKit : Kit() {
if ( targets.isEmpty() )
return AbilityResult.ConditionNotMet( "No enemies within range!" )
// ── Schockwelle + Freeze ──────────────────────────────────────────
doShockwave( player )
targets.forEach { applyFreeze( player, it ) }
cooldowns[ player.uniqueId ] = now
player.sendActionBar(player.trans(
"kits.theworld.messages.freeze_activated",
mapOf("count" to targets.size.toString())))
mapOf( "count" to targets.size.toString() )
))
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE passive 5-hit cap on frozen enemies
// DEFENSIVE passive 5-Treffer-Cap auf gefrorene Gegner
// =========================================================================
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) {
@@ -415,15 +639,16 @@ class TheWorldKit : Kit() {
override val name: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.passive.defensive.name")
"kits.theworld.passive.defensive.name"
)
override val description: String
get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.passive.defensive.description")
"kits.theworld.passive.defensive.description"
)
/**
* Called only when the TheWorld player (attacker) hits someone.
* If that someone is frozen and was frozen by this attacker,
* decrement their remaining hit allowance.
* Wird nur aufgerufen wenn der TheWorld-Spieler (Angreifer) jemanden trifft.
* Wenn das Opfer vom gleichen Angreifer eingefroren wurde, Treffer-Cap dekrementieren.
*/
override fun onHitEnemy(
attacker: Player,
@@ -431,18 +656,23 @@ class TheWorldKit : Kit() {
event: EntityDamageByEntityEvent
) {
val ( frozenBy, data ) = frozenEnemies[ victim.uniqueId ] ?: return
// Only count hits from the player who applied this specific freeze
// Nur Treffer vom Spieler zählen, der den Freeze ausgelöst hat
if ( frozenBy != attacker.uniqueId ) return
data.hitsRemaining--
if (data.hitsRemaining <= 0) {
if ( data.hitsRemaining <= 0 )
{
doUnfreeze( victim )
attacker.sendActionBar( attacker.trans( "kits.theworld.messages.freeze_broken" ) )
} else {
}
else
{
attacker.sendActionBar(attacker.trans(
"kits.theworld.messages.freeze_hits_left",
mapOf("hits" to data.hitsRemaining.toString())))
mapOf( "hits" to data.hitsRemaining.toString() )
))
}
}
}