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.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class CustomGameSettings( data class CustomGameSettings(
@@ -11,12 +15,12 @@ data class CustomGameSettings(
@Serializable @Serializable
data class GameSettings( data class GameSettings(
@SerialName("min_players") val minPlayers: Int = 2, @SerialName("min_players") val minPlayers: Int = 2,
@SerialName("lobby_time") val lobbyTime: Int = 60, @SerialName("lobby_time") val lobbyTime: Int = 60,
@SerialName("invincibility_time") val invincibilityTime: Int = 60, @SerialName("invincibility_time") val invincibilityTime: Int = 60,
@SerialName("border_start") val borderStart: Double = 300.0, @SerialName("border_start") val borderStart: Double = 300.0,
@SerialName("border_end") val borderEnd: Double = 20.0, @SerialName("border_end") val borderEnd: Double = 20.0,
@SerialName("border_shrink_time") val borderShrinkTime: Long = 600L @SerialName("border_shrink_time") val borderShrinkTime: Long = 600L
) )
@Serializable @Serializable
@@ -29,7 +33,6 @@ data class CustomGameSettings(
/** /**
* Kit-spezifische Overrides. * Kit-spezifische Overrides.
* Key = Kit.id (z. B. "gladiator", "venom"). * Key = Kit.id (z. B. "gladiator", "venom").
* Unbekannte Keys werden von kotlinx.serialization ignoriert.
*/ */
val kits: Map<String, KitOverride> = emptyMap() val kits: Map<String, KitOverride> = emptyMap()
@@ -46,7 +49,6 @@ data class CustomGameSettings(
hardcodedDefault: Int hardcodedDefault: Int
): Int ): Int
{ {
// A hardcoded 0 means the kit is explicitly cooldown-based — never override it.
if ( hardcodedDefault == 0 ) return 0 if ( hardcodedDefault == 0 ) return 0
return kits[ kitId ]?.hitsRequired?.takeIf { it >= 0 } return kits[ kitId ]?.hitsRequired?.takeIf { it >= 0 }
@@ -55,57 +57,137 @@ data class CustomGameSettings(
} }
} }
// ----------------------------------------------------------------- // -------------------------------------------------------------------------
// Kit-spezifische Override-Klassen // Kit-spezifische Override-Klassen
// ----------------------------------------------------------------- // -------------------------------------------------------------------------
/** /**
* Gemeinsamer Wrapper für alle Kit-Overrides. * 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 * ## Bekannte Felder
* für jedes Kit eine eigene Klasse notwendig ist. * 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 @Serializable
data class KitOverride( data class KitOverride(
@SerialName("hits_required") val hitsRequired: Int = -1, @SerialName("hits_required") val hitsRequired: Int = -1,
// Goblin // ── Goblin ────────────────────────────────────────────────────────────
@SerialName("steal_duration_seconds") val stealDuration: Int = 60, @SerialName("steal_duration_seconds") val stealDuration: Int = 60,
@SerialName("bunker_radius") val bunkerRadius: Double = 10.0, @SerialName("bunker_radius") val bunkerRadius: Double = 10.0,
// Gladiator // ── Gladiator ─────────────────────────────────────────────────────────
@SerialName("arena_radius") val arenaRadius: Int = 11, @SerialName("arena_radius") val arenaRadius: Int = 11,
@SerialName("arena_height") val arenaHeight: Int = 7, @SerialName("arena_height") val arenaHeight: Int = 7,
@SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180, @SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180,
// Venom // ── Venom ─────────────────────────────────────────────────────────────
@SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L, @SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L,
@SerialName("shield_capacity") val shieldCapacity: Double = 15.0, @SerialName("shield_capacity") val shieldCapacity: Double = 15.0,
// Voodoo // ── Voodoo ────────────────────────────────────────────────────────────
@SerialName("curse_duration_ms") val curseDurationMs: Long = 15_000L, @SerialName("curse_duration_ms") val curseDurationMs: Long = 15_000L,
// BlackPanther // ── BlackPanther ──────────────────────────────────────────────────────
@SerialName("fist_mode_ms") val fistModeDurationMs: Long = 12_000L, @SerialName("fist_mode_ms") val fistModeDurationMs: Long = 12_000L,
@SerialName("push_bonus_damage") val pushBonusDamage: Double = 4.0, @SerialName("push_bonus_damage") val pushBonusDamage: Double = 4.0,
@SerialName("push_radius") val pushRadius: Double = 5.0, @SerialName("push_radius") val pushRadius: Double = 5.0,
@SerialName("pounce_min_fall") val pounceMinFall: Float = 3.0f, @SerialName("pounce_min_fall") val pounceMinFall: Float = 3.0f,
@SerialName("pounce_radius") val pounceRadius: Double = 3.0, @SerialName("pounce_radius") val pounceRadius: Double = 3.0,
@SerialName("pounce_damage") val pounceDamage: Double = 6.0, @SerialName("pounce_damage") val pounceDamage: Double = 6.0,
// Rattlesnake // ── Rattlesnake ───────────────────────────────────────────────────────
@SerialName("pounce_cooldown_ms") val pounceCooldownMs: Long = 20_000L, @SerialName("pounce_cooldown_ms") val pounceCooldownMs: Long = 20_000L,
@SerialName("pounce_max_sneak_ms") val pounceMaxSneakMs: Long = 3_000L, @SerialName("pounce_max_sneak_ms") val pounceMaxSneakMs: Long = 3_000L,
@SerialName("pounce_min_range") val pounceMinRange: Double = 3.0, @SerialName("pounce_min_range") val pounceMinRange: Double = 3.0,
@SerialName("pounce_max_range") val pounceMaxRange: Double = 10.0, @SerialName("pounce_max_range") val pounceMaxRange: Double = 10.0,
@SerialName("pounce_timeout_ticks") val pounceTimeoutTicks: Long = 30L, @SerialName("pounce_timeout_ticks") val pounceTimeoutTicks: Long = 30L,
// ── 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"
}
}
// 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
)
} }

View File

@@ -1,5 +1,7 @@
package club.mcscrims.speedhg.kit 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.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility import club.mcscrims.speedhg.kit.ability.PassiveAbility
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
@@ -21,7 +23,18 @@ import java.util.concurrent.ConcurrentHashMap
* using a `when(playstyle)` expression. * using a `when(playstyle)` expression.
* 5. Register the kit via `plugin.kitManager.registerKit(YourKit())` in [SpeedHG.onEnable]. * 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 { abstract class Kit {
@@ -37,6 +50,30 @@ abstract class Kit {
/** Icon used in the kit selection GUI. */ /** Icon used in the kit selection GUI. */
abstract val icon: Material 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 // Playstyle-specific abilities — implement with a `when` expression
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -44,16 +81,8 @@ abstract class Kit {
/** /**
* Return the [ActiveAbility] for the given [playstyle]. * Return the [ActiveAbility] for the given [playstyle].
* *
* ```kotlin * **Performance note:** Prefer returning a cached singleton over allocating
* override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) { * a new instance on each call.
* 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.
*/ */
abstract fun getActiveAbility( playstyle: Playstyle ): ActiveAbility 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). * 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 ) abstract fun giveItems( player: Player, playstyle: Playstyle )
@@ -77,34 +104,14 @@ abstract class Kit {
// Lifecycle hooks (optional) // 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 ) {} 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 ) {} 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 ) {} 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 ) {} 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 ) {} open fun onItemBreak( player: Player, brokenItem: ItemStack ) {}
} }

View File

@@ -26,20 +26,49 @@ import kotlin.math.sin
/** /**
* ## NinjaKit * ## NinjaKit
* *
* | Playstyle | Aktive Fähigkeit | Passive | * | Playstyle | Aktive Fähigkeit | Passive |
* |-------------|------------------------------------------------------------------|-------------------------------------| * |-------------|------------------------------------------------------------------|---------|
* | AGGRESSIVE | Sneak → teleportiert hinter den letzten Gegner (10-s-Fenster) | - | * | AGGRESSIVE | Sneak → teleportiert hinter den letzten Gegner (Hit-Fenster) | |
* | DEFENSIVE | Smoke-Aura (Blindness I + Slow I) | - | * | 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 * ### Teleport-Mechanismus
* `onToggleSneak` wird vom [KitEventDispatcher] aufgerufen. Er prüft das * [onToggleSneak] wird vom [KitEventDispatcher] aufgerufen. Er prüft das
* [lastHitEnemy]-Fenster (10 s) und berechnet eine Position 1,8 Blöcke * [lastHitEnemy]-Fenster und berechnet eine Position 1,8 Blöcke hinter dem Feind.
* hinter dem Feind (entgegen seiner Blickrichtung).
* *
* ### Smoke-Mechanismus * ### Smoke-Mechanismus
* Ein BukkitTask (10 Ticks) spawnt einen Partikelring mit [SMOKE_RADIUS] Blöcken * Ein BukkitTask spawnt alle [smokRefreshTicks] einen Partikelring. Jeder Feind
* Radius. Jeder Feind im Ring erhält Blindness I + Slowness I (30 Ticks), * im Ring erhält Blindness I + Slowness I, die regelmäßig erneuert werden.
* die alle 0,5 s erneuert werden, solange er im Rauch bleibt.
*/ */
class NinjaKit : Kit() { class NinjaKit : Kit() {
@@ -54,31 +83,87 @@ class NinjaKit : Kit() {
override val icon: Material override val icon: Material
get() = Material.FEATHER 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() internal val lastHitEnemy: MutableMap<UUID, Pair<UUID, Long>> = ConcurrentHashMap()
private val smokeTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
private val teleportCooldowns: MutableMap<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 { companion object {
const val HIT_WINDOW_MS = 10_000L // 10s - Gültigkeit des Teleport-Ziels /**
const val SMOKE_RADIUS = 3.0 // Blöcke * All default values are defined here as named constants so they can be
const val SMOKE_MAX_DURATION = 10_000L // 10s * referenced in documentation and serve as the canonical fallback values
const val TELEPORT_COOLDOWN_MS = 12_000L // 12s zwischen Teleports * 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) ───
private val aggressiveActive = NoActive( Playstyle.AGGRESSIVE ) /**
private val defensiveActive = DefensiveActive() * 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()
private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE ) private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = NoPassive( Playstyle.DEFENSIVE ) private val defensivePassive = NoPassive( Playstyle.DEFENSIVE )
override fun getActiveAbility( override fun getActiveAbility(
playstyle: Playstyle playstyle: Playstyle
): ActiveAbility = when( playstyle ) ): ActiveAbility = when( playstyle )
{ {
Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive Playstyle.DEFENSIVE -> defensiveActive
} }
override fun getPassiveAbility( override fun getPassiveAbility(
@@ -86,17 +171,18 @@ class NinjaKit : Kit() {
): PassiveAbility = when( playstyle ) ): PassiveAbility = when( playstyle )
{ {
Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive Playstyle.DEFENSIVE -> defensivePassive
} }
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>() override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems( override fun giveItems(
player: Player, player: Player,
playstyle: Playstyle playstyle: Playstyle
) { ) {
if ( playstyle != Playstyle.DEFENSIVE ) if ( playstyle != Playstyle.DEFENSIVE ) return
return
val item = ItemBuilder( Material.FEATHER ) val item = ItemBuilder( Material.FEATHER )
.name( defensiveActive.name ) .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( override fun onToggleSneak(
@@ -125,14 +211,14 @@ class NinjaKit : Kit() {
isSneaking: Boolean isSneaking: Boolean
) { ) {
if ( !isSneaking ) return if ( !isSneaking ) return
if (plugin.kitManager.getSelectedPlaystyle( player ) != Playstyle.AGGRESSIVE ) return if ( plugin.kitManager.getSelectedPlaystyle( player ) != Playstyle.AGGRESSIVE ) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastUse = teleportCooldowns[ player.uniqueId ] ?: 0L 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() )) player.sendActionBar(player.trans( "kits.ninja.messages.cooldown", "time" to secLeft.toString() ))
return return
} }
@@ -142,7 +228,7 @@ class NinjaKit : Kit() {
return return
} }
if ( now - hitTime > HIT_WINDOW_MS ) if ( now - hitTime > hitWindowMs )
{ {
lastHitEnemy.remove( player.uniqueId ) lastHitEnemy.remove( player.uniqueId )
player.sendActionBar(player.trans( "kits.ninja.messages.target_expired" )) player.sendActionBar(player.trans( "kits.ninja.messages.target_expired" ))
@@ -150,24 +236,26 @@ class NinjaKit : Kit() {
} }
val enemy = Bukkit.getPlayer( enemyUUID ) ?: return val enemy = Bukkit.getPlayer( enemyUUID ) ?: return
if (!plugin.gameManager.alivePlayers.contains( enemy.uniqueId )) return if ( !plugin.gameManager.alivePlayers.contains( enemy.uniqueId ) ) return
performTeleport( player, enemy ) performTeleport( player, enemy )
teleportCooldowns[ player.uniqueId ] = now teleportCooldowns[ player.uniqueId ] = now
} }
// ── Teleport implementation ───────────────────────────────────────────────
private fun performTeleport( private fun performTeleport(
player: Player, player: Player,
enemy: Player enemy: Player
) { ) {
val enemyDir = enemy.location.direction.normalize() val enemyDir = enemy.location.direction.normalize()
var dest = enemy.location.clone() var dest = enemy.location.clone()
.subtract(enemyDir.multiply( 1.8 )) .subtract( enemyDir.multiply( 1.8 ) )
.add( 0.0, 0.1, 0.0 ) .add( 0.0, 0.1, 0.0 )
if ( !dest.block.type.isAir ) dest = dest.add( 0.0, 1.0, 0.0 ) if ( !dest.block.type.isAir ) dest = dest.add( 0.0, 1.0, 0.0 )
dest.yaw = enemy.location.yaw dest.yaw = enemy.location.yaw
dest.pitch = 0f dest.pitch = 0f
player.world.spawnParticle( player.world.spawnParticle(
@@ -185,10 +273,14 @@ class NinjaKit : Kit() {
) )
player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.7f, 1.8f ) player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.7f, 1.8f )
enemy.playSound( enemy.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.4f, 0.7f ) enemy.playSound( enemy.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.4f, 0.7f )
player.sendActionBar(player.trans( "kits.ninja.messages.teleported" )) player.sendActionBar(player.trans( "kits.ninja.messages.teleported" ))
} }
// =========================================================================
// DEFENSIVE active Smoke Aura
// =========================================================================
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private val plugin get() = SpeedHG.instance private val plugin get() = SpeedHG.instance
@@ -208,63 +300,103 @@ class NinjaKit : Kit() {
player: Player player: Player
): AbilityResult ): AbilityResult
{ {
// Cancel any existing smoke aura before starting a new one
smokeTasks.remove( player.uniqueId )?.cancel() 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, { -> 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() smokeTasks.remove( player.uniqueId )?.cancel()
return@runTaskTimer return@runTaskTimer
} }
val center = player.location spawnSmokeRing( player, capturedRadius )
applyEffectsToEnemies( player, capturedRadius, capturedEffectTicks )
for ( i in 0 until 10 ) }, 0L, capturedRefreshTicks )
{
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 )
smokeTasks[ player.uniqueId ] = task smokeTasks[ player.uniqueId ] = task
// Schedule automatic aura expiry
val capturedDurationTicks = smokeDurationTicks
Bukkit.getScheduler().runTaskLater( plugin, { -> Bukkit.getScheduler().runTaskLater( plugin, { ->
smokeTasks.remove( player.uniqueId )?.cancel() 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 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 ) { private class NoActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) {
override val kitId = "ninja" override val kitId = "ninja"
override val name = "None" override val name = "None"
override val description = "None" override val description = "None"
override val hardcodedHitsRequired = 0 override val hardcodedHitsRequired = 0
override val triggerMaterial = Material.BARRIER override val triggerMaterial = Material.BARRIER
override fun execute( player: Player ) = AbilityResult.Success override fun execute( player: Player ) = AbilityResult.Success
} }
private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) { private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) {
override val name = "None" override val name = "None"
override val description = "None" override val description = "None"
} }

View File

@@ -30,27 +30,65 @@ import kotlin.math.sin
* | Playstyle | Active | Passive | * | Playstyle | Active | Passive |
* |-------------|-------------------------------------------------|------------------------------------------| * |-------------|-------------------------------------------------|------------------------------------------|
* | AGGRESSIVE | Shockwave + 3× Blink in looking direction | | * | 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 * ### AGGRESSIVE active
* First use (off cooldown): radial shockwave → grants 3 blink charges. * Erster Use (off cooldown): radiale Schockwelle → gewährt [maxTeleportCharges] Blink-Charges.
* Each subsequent right-click: teleport up to [TELEPORT_RANGE] blocks in the * Jeder folgende Rechtsklick: teleportiert bis zu [teleportRange] Blöcke in Blickrichtung
* player's looking direction (stops before solid blocks). After all 3 charges * (stoppt vor soliden Blöcken). Nach allen Charges beginnt der [abilityCooldownMs]-Cooldown.
* are spent, the 20 s cooldown begins.
* *
* ### DEFENSIVE active * ### DEFENSIVE active
* Radial shockwave + [applyFreeze] on every nearby alive enemy. Each frozen * Radiale Schockwelle + [applyFreeze] auf jeden nahen lebenden Gegner. Ein 1-Tick-Task
* enemy gets a 1-tick velocity-zeroing task for 10 s. The [DefensivePassive] * setzt die Velocity gefrorener Spieler für [freezeDurationTicks] Ticks auf 0.
* monitors hits from this player on frozen enemies and unfreezes them after * [DefensivePassive] beendet den Freeze nach [maxHitsOnFrozen] Treffern.
* [MAX_HITS_ON_FROZEN] hits or when time expires.
* *
* ### Why hitsRequired = 0? * ### Warum hitsRequired = 0?
* Both active abilities require full control over when [execute] fires. Using * Beide Aktiv-Fähigkeiten steuern intern wann [execute] feuert. Das eingebaute
* the built-in charge system (hitsRequired > 0) would block [execute] after * Charge-System würde nach dem ersten Use blockieren und die Blink/Freeze-Logik
* the first use and prevent the blink/freeze logic from re-running per click. * pro Klick verhindern. Mit `hitsRequired = 0` bleibt der Charge-State dauerhaft
* With hitsRequired = 0 the charge state stays READY permanently and * READY und der interne Cooldown regiert den tatsächlichen Recharge.
* [execute] is called on every right-click — internal cooldown maps govern
* actual recharge.
*/ */
class TheWorldKit : Kit() { class TheWorldKit : Kit() {
@@ -58,23 +96,133 @@ class TheWorldKit : Kit() {
override val id = "theworld" override val id = "theworld"
override val displayName: Component override val displayName: Component
get() = plugin.languageManager.getDefaultComponent("kits.theworld.name", mapOf()) get() = plugin.languageManager.getDefaultComponent( "kits.theworld.name", mapOf() )
override val lore: List<String> override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList("kits.theworld.lore") get() = plugin.languageManager.getDefaultRawMessageList( "kits.theworld.lore" )
override val icon = Material.CLOCK 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. * Cooldown beider Aktiv-Fähigkeiten in Millisekunden.
* Set to [MAX_TELEPORT_CHARGES] on first right-click, decremented per blink. * 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() internal val teleportCharges: MutableMap<UUID, Int> = ConcurrentHashMap()
/** /**
* Active freezes: victimUUID → (attackerUUID, [FrozenData]). * Aktive Freezes: victimUUID → (attackerUUID, [FrozenData]).
* Tracked separately per attacker so [onRemove] only thaws enemies * Getrennt pro Angreifer, damit [onRemove] nur vom verlassenden Spieler verursachte
* frozen by the leaving player. * Freezes auftaut.
*/ */
internal val frozenEnemies: MutableMap<UUID, Pair<UUID, FrozenData>> = ConcurrentHashMap() internal val frozenEnemies: MutableMap<UUID, Pair<UUID, FrozenData>> = ConcurrentHashMap()
@@ -83,63 +231,64 @@ class TheWorldKit : Kit() {
val task: BukkitTask val task: BukkitTask
) )
companion object { // =========================================================================
private fun override() = SpeedHG.instance.customGameManager.settings.kits.kits["theworld"] // Cached ability instances
?: CustomGameSettings.KitOverride() // =========================================================================
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 aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive() private val defensiveActive = DefensiveActive()
private val aggressivePassive = NoPassive(Playstyle.AGGRESSIVE) private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = DefensivePassive() private val defensivePassive = DefensivePassive()
override fun getActiveAbility(playstyle: Playstyle): ActiveAbility = when (playstyle) { override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive Playstyle.DEFENSIVE -> defensiveActive
} }
override fun getPassiveAbility(playstyle: Playstyle): PassiveAbility = when (playstyle) { override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive Playstyle.DEFENSIVE -> defensivePassive
} }
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>() override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) { override fun giveItems(
val active = getActiveAbility(playstyle) player: Player,
playstyle: Playstyle
) {
val active = getActiveAbility( playstyle )
val item = ItemBuilder(Material.CLOCK) val item = ItemBuilder( Material.CLOCK )
.name(active.name) .name( active.name )
.lore(listOf(active.description)) .lore(listOf( active.description ))
.build() .build()
cachedItems[player.uniqueId] = listOf(item) cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem(item) player.inventory.addItem( item )
} }
override fun onRemove(player: Player) { override fun onRemove(
teleportCharges.remove(player.uniqueId) player: Player
) {
teleportCharges.remove( player.uniqueId )
// Cancel tasks + thaw every enemy that was frozen by this player // Alle vom verlassenden Spieler verursachten Freezes auftauen
val toUnfreeze = frozenEnemies.entries frozenEnemies.entries
.filter { (_, pair) -> pair.first == player.uniqueId } .filter { ( _, pair ) -> pair.first == player.uniqueId }
.map { (victimUUID, pair) -> victimUUID to pair.second } .map { ( victimUUID, pair ) -> victimUUID to pair.second }
.forEach { ( victimUUID, data ) ->
data.task.cancel()
frozenEnemies.remove( victimUUID )
Bukkit.getPlayer( victimUUID )?.clearFreezeEffects()
}
toUnfreeze.forEach { (victimUUID, data) -> cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
data.task.cancel()
frozenEnemies.remove(victimUUID)
Bukkit.getPlayer(victimUUID)?.clearFreezeEffects()
}
cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) }
} }
// ========================================================================= // =========================================================================
@@ -147,302 +296,383 @@ 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. * Der Ring-Task wächst um 1 Block/Tick bis [shockwaveRadius] + 1.
* This gives the visual impression of a shockwave spreading outward. * 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 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() { object : BukkitRunnable() {
var r = 0.5 var r = 0.5
override fun run() { override fun run()
if (r > SHOCKWAVE_RADIUS + 1.0) { cancel(); return } {
val steps = (2 * Math.PI * r * 5).toInt().coerceAtLeast(8) if ( r > capturedRadius + 1.0 ) { cancel(); return }
for (i in 0 until steps) { val steps = ( 2 * Math.PI * r * 5 ).toInt().coerceAtLeast( 8 )
for ( i in 0 until steps )
{
val angle = 2 * Math.PI * i / 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(
world.spawnParticle(Particle.SWEEP_ATTACK, loc, 1, 0.0, 0.0, 0.0, 0.0) cos( angle ) * r, 1.0, sin( angle ) * r
world.spawnParticle(Particle.CRIT, loc, 2, 0.1, 0.1, 0.1, 0.0) )
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 )
} }
r += 1.0 r += 1.0
} }
}.runTaskTimer(plugin, 0L, 1L) }.runTaskTimer( plugin, 0L, 1L )
// ── Physics: knock all nearby alive enemies outward ─────────────────── // ── Physik: alle nahen Gegner nach außen schleudern ───────────────────
world.getNearbyEntities(origin.location, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS) world.getNearbyEntities(
origin.location,
capturedRadius, capturedRadius, capturedRadius
)
.filterIsInstance<Player>() .filterIsInstance<Player>()
.filter { it != origin && plugin.gameManager.alivePlayers.contains(it.uniqueId) } .filter { it != origin &&
plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { enemy -> .forEach { enemy ->
val dir = enemy.location.toVector() val dir = enemy.location.toVector()
.subtract(origin.location.toVector()) .subtract( origin.location.toVector() )
.normalize() .normalize()
.multiply(2.0) .multiply( capturedKnockbackSpeed )
.setY(0.45) .setY( capturedKnockbackY )
enemy.velocity = dir enemy.velocity = dir
} }
world.playSound(origin.location, Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.8f) world.playSound( origin.location, Sound.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.8f )
world.playSound(origin.location, Sound.BLOCK_BEACON_ACTIVATE, 0.6f, 1.5f) world.playSound( origin.location, Sound.BLOCK_BEACON_ACTIVATE, 0.6f, 1.5f )
} }
/** /**
* Teleports [player] up to [TELEPORT_RANGE] blocks in their looking direction. * Teleportiert [player] bis zu [teleportRange] Blöcke in Blickrichtung.
* Raycast in 0.4-block increments — stops at the last non-solid block. * 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() val dir = player.location.direction.normalize()
var target = player.eyeLocation.clone() var target = player.eyeLocation.clone()
repeat((TELEPORT_RANGE / 0.4).toInt()) { repeat( ( capturedRange / capturedStepSize ).toInt() ) {
val next = target.clone().add(dir.clone().multiply(0.4)) val next = target.clone().add( dir.clone().multiply( capturedStepSize ) )
if (next.block.type.isSolid) return@repeat if ( next.block.type.isSolid ) return@repeat
target = next target = next
} }
// Adjust to feet position // Auf Füße-Position anpassen
target.y -= 1.0 target.y -= 1.0
target.yaw = player.location.yaw target.yaw = player.location.yaw
target.pitch = player.location.pitch target.pitch = player.location.pitch
player.teleport(target) player.teleport( target )
player.world.spawnParticle(Particle.PORTAL, target, 30, 0.2, 0.5, 0.2, 0.05) player.world.spawnParticle( Particle.PORTAL, target, 30, 0.2, 0.5, 0.2, 0.05 )
player.world.spawnParticle(Particle.REVERSE_PORTAL, target, 12, 0.3, 0.5, 0.3, 0.0) player.world.spawnParticle( Particle.REVERSE_PORTAL, target, 12, 0.3, 0.5, 0.3, 0.0 )
player.playSound(target, Sound.ENTITY_ENDERMAN_TELEPORT, 0.9f, 1.4f) player.playSound( target, Sound.ENTITY_ENDERMAN_TELEPORT, 0.9f, 1.4f )
} }
/** /**
* Immobilises [target], capping hits from [attacker] at [MAX_HITS_ON_FROZEN]. * Immobilisiert [target] und begrenzt Treffer von [attacker] auf [maxHitsOnFrozen].
* A 1-tick repeating task zeros horizontal + upward velocity for 10 seconds. * 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) { private fun applyFreeze(
// Overwrite any existing freeze attacker: Player,
frozenEnemies.remove(target.uniqueId)?.second?.task?.cancel() 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() target.applyFreezeEffects()
val task = object : BukkitRunnable() { val task = object : BukkitRunnable() {
var ticks = 0 var ticks = 0
override fun run() { override fun run()
{
ticks++ ticks++
if (ticks >= FREEZE_DURATION_TICKS || !target.isOnline || if ( ticks >= capturedDurationTicks ||
!plugin.gameManager.alivePlayers.contains(target.uniqueId) || !target.isOnline ||
!frozenEnemies.containsKey(target.uniqueId) || !plugin.gameManager.alivePlayers.contains( target.uniqueId ) ||
plugin.gameManager.currentState == GameState.ENDING) // ← neu !frozenEnemies.containsKey( target.uniqueId ) ||
plugin.gameManager.currentState == GameState.ENDING )
{ {
doUnfreeze(target) doUnfreeze( target )
cancel() cancel()
return return
} }
// Zero horizontal + upward velocity every tick // Horizontale + aufwärts Velocity jedes Tick nullen
val v = target.velocity val v = target.velocity
target.velocity = v.setX(0.0).setZ(0.0) target.velocity = v.setX( 0.0 ).setZ( 0.0 )
.let { if (it.y > 0.0) it.setY(0.0) else it } .let { if ( it.y > 0.0 ) it.setY( 0.0 ) else it }
// Refresh slowness every second so it doesn't expire mid-freeze // Slowness jede Sekunde refreshen damit sie nicht ausläuft
if (ticks % 20 == 0) target.applyFreezeEffects() if ( ticks % capturedRefreshTicks == 0 )
target.applyFreezeEffects()
// Powder-snow visual (cosmetic) // Powder-Snow-Visuals (rein kosmetisch)
if (target.freezeTicks < 140) target.freezeTicks = 140 if ( target.freezeTicks < capturedPowderSnowTicks )
target.freezeTicks = capturedPowderSnowTicks
} }
}.runTaskTimer(plugin, 0L, 1L) }.runTaskTimer( plugin, 0L, 1L )
frozenEnemies[target.uniqueId] = Pair( frozenEnemies[ target.uniqueId ] = Pair(
attacker.uniqueId, 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.sendActionBar( target.trans( "kits.theworld.messages.frozen_received" ) )
target.world.spawnParticle(Particle.SNOWFLAKE, target.world.spawnParticle(
target.location.clone().add(0.0, 1.0, 0.0), 15, 0.3, 0.5, 0.3, 0.05) 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(
frozenEnemies.remove(target.uniqueId) target: Player
) {
frozenEnemies.remove( target.uniqueId )
target.clearFreezeEffects() target.clearFreezeEffects()
if (target.isOnline) if ( target.isOnline )
target.sendActionBar(target.trans("kits.theworld.messages.frozen_expired")) target.sendActionBar( target.trans( "kits.theworld.messages.frozen_expired" ) )
} }
// ── Player extension helpers ────────────────────────────────────────────── // ── Player-Extension-Helpers ──────────────────────────────────────────────
private fun Player.applyFreezeEffects() { private fun Player.applyFreezeEffects()
addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, {
/* duration */ 25, addPotionEffect(PotionEffect(
/* amplifier */ 127, PotionEffectType.SLOWNESS,
/* ambient */ false, 25, 127,
/* particles */ false, false, false, true
/* icon */ true)) ))
addPotionEffect(PotionEffect(PotionEffectType.MINING_FATIGUE, addPotionEffect(PotionEffect(
25, 127, false, false, false)) PotionEffectType.MINING_FATIGUE,
25, 127,
false, false, false
))
} }
private fun Player.clearFreezeEffects() { private fun Player.clearFreezeEffects()
removePotionEffect(PotionEffectType.SLOWNESS) {
removePotionEffect(PotionEffectType.MINING_FATIGUE) removePotionEffect( PotionEffectType.SLOWNESS )
removePotionEffect( PotionEffectType.MINING_FATIGUE )
freezeTicks = 0 freezeTicks = 0
} }
// ========================================================================= // =========================================================================
// AGGRESSIVE active Shockwave → 3× blink // AGGRESSIVE active Schockwelle → 3× Blink
// ========================================================================= // =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) { private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private val plugin get() = SpeedHG.instance private val plugin get() = SpeedHG.instance
/** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */
private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap() private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
override val kitId: String override val kitId: String
get() = "theworld" get() = "theworld"
override val name: String override val name: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.aggressive.name") "kits.theworld.items.clock.aggressive.name"
)
override val description: String override val description: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.aggressive.description") "kits.theworld.items.clock.aggressive.description"
)
override val hardcodedHitsRequired: Int override val hardcodedHitsRequired: Int
get() = 0 get() = 0
override val triggerMaterial = Material.CLOCK override val triggerMaterial = Material.CLOCK
override fun execute(player: Player): AbilityResult { override fun execute(
player: Player
// ── Spend a blink charge if any are available ───────────────────── ): AbilityResult
val charges = teleportCharges[player.uniqueId] ?: 0 {
if (charges > 0) { // ── Vorhandene Blink-Charge verbrauchen ───────────────────────────
blink(player) val charges = teleportCharges[ player.uniqueId ] ?: 0
if ( charges > 0 )
{
blink( player )
val remaining = charges - 1 val remaining = charges - 1
teleportCharges[player.uniqueId] = remaining teleportCharges[ player.uniqueId ] = remaining
if (remaining > 0) if ( remaining > 0 )
player.sendActionBar(player.trans( player.sendActionBar(player.trans(
"kits.theworld.messages.teleport_charges", "kits.theworld.messages.teleport_charges",
mapOf("charges" to remaining.toString()))) mapOf( "charges" to remaining.toString() )
else { ))
teleportCharges.remove(player.uniqueId) else
player.sendActionBar(player.trans("kits.theworld.messages.charges_exhausted")) {
teleportCharges.remove( player.uniqueId )
player.sendActionBar(player.trans( "kits.theworld.messages.charges_exhausted" ))
} }
return AbilityResult.Success return AbilityResult.Success
} }
// ── Cooldown gate ───────────────────────────────────────────────── // ── Cooldown prüfen ───────────────────────────────────────────────
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastUse = cooldowns[player.uniqueId] ?: 0L val lastUse = cooldowns[ player.uniqueId ] ?: 0L
if (now - lastUse < ABILITY_COOLDOWN_MS) { val capturedCooldown = abilityCooldownMs
val secsLeft = (ABILITY_COOLDOWN_MS - (now - lastUse)) / 1000
return AbilityResult.ConditionNotMet("Cooldown: ${secsLeft}s") if ( now - lastUse < capturedCooldown )
{
val secsLeft = ( capturedCooldown - ( now - lastUse ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" )
} }
// ── Shockwave + grant 3 blink charges ──────────────────────────── // ── Schockwelle + Charges vergeben ────────────────────────────────
doShockwave(player) val capturedCharges = maxTeleportCharges
teleportCharges[player.uniqueId] = MAX_TELEPORT_CHARGES
cooldowns[player.uniqueId] = now doShockwave( player )
teleportCharges[ player.uniqueId ] = capturedCharges
cooldowns[ player.uniqueId ] = now
player.sendActionBar(player.trans( player.sendActionBar(player.trans(
"kits.theworld.messages.shockwave_and_blink", "kits.theworld.messages.shockwave_and_blink",
mapOf("charges" to MAX_TELEPORT_CHARGES.toString()))) mapOf( "charges" to capturedCharges.toString() )
))
return AbilityResult.Success 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 inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private val plugin get() = SpeedHG.instance private val plugin get() = SpeedHG.instance
/** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */
private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap() private val cooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
override val kitId: String override val kitId: String
get() = "theworld" get() = "theworld"
override val name: String override val name: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.defensive.name") "kits.theworld.items.clock.defensive.name"
)
override val description: String override val description: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.items.clock.defensive.description") "kits.theworld.items.clock.defensive.description"
)
override val hardcodedHitsRequired: Int override val hardcodedHitsRequired: Int
get() = 0 get() = 0
override val triggerMaterial = Material.CLOCK override val triggerMaterial = Material.CLOCK
override fun execute(player: Player): AbilityResult { override fun execute(
player: Player
): AbilityResult
{
// ── Cooldown prüfen ───────────────────────────────────────────────
val now = System.currentTimeMillis()
val lastUse = cooldowns[ player.uniqueId ] ?: 0L
val capturedCooldown = abilityCooldownMs
// ── Cooldown gate ───────────────────────────────────────────────── if ( now - lastUse < capturedCooldown )
val now = System.currentTimeMillis() {
val lastUse = cooldowns[player.uniqueId] ?: 0L val secsLeft = ( capturedCooldown - ( now - lastUse ) ) / 1000
if (now - lastUse < ABILITY_COOLDOWN_MS) { return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" )
val secsLeft = (ABILITY_COOLDOWN_MS - (now - lastUse)) / 1000
return AbilityResult.ConditionNotMet("Cooldown: ${secsLeft}s")
} }
// ── Ziele ermitteln ───────────────────────────────────────────────
val capturedRadius = shockwaveRadius
val targets = player.world val targets = player.world
.getNearbyEntities( .getNearbyEntities(
player.location, player.location,
SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS) capturedRadius, capturedRadius, capturedRadius
)
.filterIsInstance<Player>() .filterIsInstance<Player>()
.filter { it != player && .filter { it != player &&
plugin.gameManager.alivePlayers.contains(it.uniqueId) } plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if (targets.isEmpty()) if ( targets.isEmpty() )
return AbilityResult.ConditionNotMet("No enemies within range!") return AbilityResult.ConditionNotMet( "No enemies within range!" )
doShockwave(player) // ── Schockwelle + Freeze ──────────────────────────────────────────
targets.forEach { applyFreeze(player, it) } doShockwave( player )
cooldowns[player.uniqueId] = now targets.forEach { applyFreeze( player, it ) }
cooldowns[ player.uniqueId ] = now
player.sendActionBar(player.trans( player.sendActionBar(player.trans(
"kits.theworld.messages.freeze_activated", "kits.theworld.messages.freeze_activated",
mapOf("count" to targets.size.toString()))) mapOf( "count" to targets.size.toString() )
))
return AbilityResult.Success 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) { private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) {
private val plugin get() = SpeedHG.instance private val plugin get() = SpeedHG.instance
override val name: String override val name: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.passive.defensive.name") "kits.theworld.passive.defensive.name"
)
override val description: String override val description: String
get() = plugin.languageManager.getDefaultRawMessage( get() = plugin.languageManager.getDefaultRawMessage(
"kits.theworld.passive.defensive.description") "kits.theworld.passive.defensive.description"
)
/** /**
* Called only when the TheWorld player (attacker) hits someone. * Wird nur aufgerufen wenn der TheWorld-Spieler (Angreifer) jemanden trifft.
* If that someone is frozen and was frozen by this attacker, * Wenn das Opfer vom gleichen Angreifer eingefroren wurde, Treffer-Cap dekrementieren.
* decrement their remaining hit allowance.
*/ */
override fun onHitEnemy( override fun onHitEnemy(
attacker: Player, attacker: Player,
victim: Player, victim: Player,
event: EntityDamageByEntityEvent event: EntityDamageByEntityEvent
) { ) {
val (frozenBy, data) = frozenEnemies[victim.uniqueId] ?: return val ( frozenBy, data ) = frozenEnemies[ victim.uniqueId ] ?: return
// Only count hits from the player who applied this specific freeze
if (frozenBy != attacker.uniqueId) return // Nur Treffer vom Spieler zählen, der den Freeze ausgelöst hat
if ( frozenBy != attacker.uniqueId ) return
data.hitsRemaining-- data.hitsRemaining--
if (data.hitsRemaining <= 0) { if ( data.hitsRemaining <= 0 )
doUnfreeze(victim) {
attacker.sendActionBar(attacker.trans("kits.theworld.messages.freeze_broken")) doUnfreeze( victim )
} else { attacker.sendActionBar( attacker.trans( "kits.theworld.messages.freeze_broken" ) )
}
else
{
attacker.sendActionBar(attacker.trans( attacker.sendActionBar(attacker.trans(
"kits.theworld.messages.freeze_hits_left", "kits.theworld.messages.freeze_hits_left",
mapOf("hits" to data.hitsRemaining.toString()))) mapOf( "hits" to data.hitsRemaining.toString() )
))
} }
} }
} }
@@ -451,7 +681,7 @@ class TheWorldKit : Kit() {
// AGGRESSIVE no-passive // AGGRESSIVE no-passive
// ========================================================================= // =========================================================================
private class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) { private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) {
override val name = "None" override val name = "None"
override val description = "None" override val description = "None"
} }