diff --git a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt index e6bef43..fc98cba 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt @@ -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( @@ -11,12 +15,12 @@ data class CustomGameSettings( @Serializable data class GameSettings( - @SerialName("min_players") val minPlayers: Int = 2, - @SerialName("lobby_time") val lobbyTime: Int = 60, - @SerialName("invincibility_time") val invincibilityTime: Int = 60, - @SerialName("border_start") val borderStart: Double = 300.0, - @SerialName("border_end") val borderEnd: Double = 20.0, - @SerialName("border_shrink_time") val borderShrinkTime: Long = 600L + @SerialName("min_players") val minPlayers: Int = 2, + @SerialName("lobby_time") val lobbyTime: Int = 60, + @SerialName("invincibility_time") val invincibilityTime: Int = 60, + @SerialName("border_start") val borderStart: Double = 300.0, + @SerialName("border_end") val borderEnd: Double = 20.0, + @SerialName("border_shrink_time") val borderShrinkTime: Long = 600L ) @Serializable @@ -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 = 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,57 +57,137 @@ 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, + @SerialName("hits_required") val hitsRequired: Int = -1, - // Goblin - @SerialName("steal_duration_seconds") val stealDuration: Int = 60, - @SerialName("bunker_radius") val bunkerRadius: Double = 10.0, + // ── Goblin ──────────────────────────────────────────────────────────── + @SerialName("steal_duration_seconds") val stealDuration: Int = 60, + @SerialName("bunker_radius") val bunkerRadius: Double = 10.0, - // Gladiator - @SerialName("arena_radius") val arenaRadius: Int = 11, - @SerialName("arena_height") val arenaHeight: Int = 7, - @SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180, + // ── Gladiator ───────────────────────────────────────────────────────── + @SerialName("arena_radius") val arenaRadius: Int = 11, + @SerialName("arena_height") val arenaHeight: Int = 7, + @SerialName("wither_after_seconds") val witherAfterSeconds: Int = 180, - // Venom - @SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L, - @SerialName("shield_capacity") val shieldCapacity: Double = 15.0, + // ── Venom ───────────────────────────────────────────────────────────── + @SerialName("shield_duration_ticks") val shieldDurationTicks: Long = 160L, + @SerialName("shield_capacity") val shieldCapacity: Double = 15.0, - // Voodoo - @SerialName("curse_duration_ms") val curseDurationMs: Long = 15_000L, + // ── Voodoo ──────────────────────────────────────────────────────────── + @SerialName("curse_duration_ms") val curseDurationMs: Long = 15_000L, - // 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, - @SerialName("pounce_min_fall") val pounceMinFall: Float = 3.0f, - @SerialName("pounce_radius") val pounceRadius: Double = 3.0, - @SerialName("pounce_damage") val pounceDamage: Double = 6.0, + // ── 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, + @SerialName("pounce_min_fall") val pounceMinFall: Float = 3.0f, + @SerialName("pounce_radius") val pounceRadius: Double = 3.0, + @SerialName("pounce_damage") val pounceDamage: Double = 6.0, - // 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, + // ── 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("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 = 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 - ) } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt index d51a001..33f82fe 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt @@ -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 ) {} } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/NinjaKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/NinjaKit.kt index dbd2505..2d85366 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/NinjaKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/NinjaKit.kt @@ -26,20 +26,49 @@ 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) | - | + * | Playstyle | Aktive Fähigkeit | Passive | + * |-------------|------------------------------------------------------------------|---------| + * | 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,31 +83,87 @@ 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> = ConcurrentHashMap() - private val smokeTasks: MutableMap = ConcurrentHashMap() - private val teleportCooldowns: MutableMap = ConcurrentHashMap() + + private val smokeTasks: MutableMap = ConcurrentHashMap() + private val teleportCooldowns: MutableMap = 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) ─── - 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 defensivePassive = NoPassive( Playstyle.DEFENSIVE ) + private val defensivePassive = NoPassive( Playstyle.DEFENSIVE ) override fun getActiveAbility( playstyle: Playstyle ): ActiveAbility = when( playstyle ) { Playstyle.AGGRESSIVE -> aggressiveActive - Playstyle.DEFENSIVE -> defensiveActive + Playstyle.DEFENSIVE -> defensiveActive } override fun getPassiveAbility( @@ -86,17 +171,18 @@ class NinjaKit : Kit() { ): PassiveAbility = when( playstyle ) { Playstyle.AGGRESSIVE -> aggressivePassive - Playstyle.DEFENSIVE -> defensivePassive + Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() 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( @@ -125,14 +211,14 @@ class NinjaKit : Kit() { isSneaking: Boolean ) { 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 - 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" )) @@ -150,24 +236,26 @@ class NinjaKit : Kit() { } 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 ) teleportCooldowns[ player.uniqueId ] = now } + // ── Teleport implementation ─────────────────────────────────────────────── + private fun performTeleport( player: Player, enemy: Player ) { val enemyDir = enemy.location.direction.normalize() var dest = enemy.location.clone() - .subtract(enemyDir.multiply( 1.8 )) + .subtract( enemyDir.multiply( 1.8 ) ) .add( 0.0, 0.1, 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 player.world.spawnParticle( @@ -185,10 +273,14 @@ class NinjaKit : Kit() { ) 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" )) } + // ========================================================================= + // DEFENSIVE active – Smoke Aura + // ========================================================================= + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { private val plugin get() = SpeedHG.instance @@ -208,63 +300,103 @@ 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() - .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() + .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" - override val name = "None" - override val description = "None" + override val kitId = "ninja" + override val name = "None" + override val description = "None" override val hardcodedHitsRequired = 0 - override val triggerMaterial = Material.BARRIER + override val triggerMaterial = Material.BARRIER override fun execute( player: Player ) = AbilityResult.Success } private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) { - override val name = "None" + override val name = "None" override val description = "None" } diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt index 091232f..ee8e018 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt @@ -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() { @@ -58,23 +96,133 @@ class TheWorldKit : Kit() { override val id = "theworld" override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.theworld.name", mapOf()) + get() = plugin.languageManager.getDefaultComponent( "kits.theworld.name", mapOf() ) override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.theworld.lore") + 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 = 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> = ConcurrentHashMap() @@ -83,63 +231,64 @@ 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 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>() - override fun giveItems(player: Player, playstyle: Playstyle) { - val active = getActiveAbility(playstyle) + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { + val active = getActiveAbility( playstyle ) - val item = ItemBuilder(Material.CLOCK) - .name(active.name) - .lore(listOf(active.description)) + val item = ItemBuilder( Material.CLOCK ) + .name( active.name ) + .lore(listOf( active.description )) .build() - cachedItems[player.uniqueId] = listOf(item) - player.inventory.addItem(item) + cachedItems[ player.uniqueId ] = listOf( item ) + player.inventory.addItem( item ) } - override fun onRemove(player: Player) { - teleportCharges.remove(player.uniqueId) + override fun onRemove( + player: Player + ) { + teleportCharges.remove( player.uniqueId ) - // Cancel tasks + thaw every enemy that was frozen by this player - val toUnfreeze = frozenEnemies.entries - .filter { (_, pair) -> pair.first == player.uniqueId } - .map { (victimUUID, pair) -> victimUUID to pair.second } + // Alle vom verlassenden Spieler verursachten Freezes auftauen + frozenEnemies.entries + .filter { ( _, pair ) -> pair.first == player.uniqueId } + .map { ( victimUUID, pair ) -> victimUUID to pair.second } + .forEach { ( victimUUID, data ) -> + data.task.cancel() + frozenEnemies.remove( victimUUID ) + Bukkit.getPlayer( victimUUID )?.clearFreezeEffects() + } - toUnfreeze.forEach { (victimUUID, data) -> - data.task.cancel() - frozenEnemies.remove(victimUUID) - Bukkit.getPlayer(victimUUID)?.clearFreezeEffects() - } - - cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) } + 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. - * 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 } - val steps = (2 * Math.PI * r * 5).toInt().coerceAtLeast(8) - for (i in 0 until steps) { + 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 ) + { val angle = 2 * Math.PI * i / steps - 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) + 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 ) } r += 1.0 } - }.runTaskTimer(plugin, 0L, 1L) + }.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() - .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()) + .subtract( origin.location.toVector() ) .normalize() - .multiply(2.0) - .setY(0.45) + .multiply( capturedKnockbackSpeed ) + .setY( capturedKnockbackY ) enemy.velocity = dir } - 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.ENTITY_WARDEN_SONIC_BOOM, 1f, 0.8f ) + world.playSound( origin.location, Sound.BLOCK_BEACON_ACTIVATE, 0.6f, 1.5f ) } /** - * 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)) - if (next.block.type.isSolid) return@repeat + 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 - target.y -= 1.0 - target.yaw = player.location.yaw - target.pitch = player.location.pitch + // Auf Füße-Position anpassen + target.y -= 1.0 + target.yaw = player.location.yaw + target.pitch = player.location.pitch - player.teleport(target) - 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.playSound(target, Sound.ENTITY_ENDERMAN_TELEPORT, 0.9f, 1.4f) + player.teleport( target ) + 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.playSound( target, Sound.ENTITY_ENDERMAN_TELEPORT, 0.9f, 1.4f ) } /** - * 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 - frozenEnemies.remove(target.uniqueId)?.second?.task?.cancel() + 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 || - !plugin.gameManager.alivePlayers.contains(target.uniqueId) || - !frozenEnemies.containsKey(target.uniqueId) || - plugin.gameManager.currentState == GameState.ENDING) // ← neu + if ( ticks >= capturedDurationTicks || + !target.isOnline || + !plugin.gameManager.alivePlayers.contains( target.uniqueId ) || + !frozenEnemies.containsKey( target.uniqueId ) || + plugin.gameManager.currentState == GameState.ENDING ) { - doUnfreeze(target) + 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 } + 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) + }.runTaskTimer( plugin, 0L, 1L ) - frozenEnemies[target.uniqueId] = Pair( + 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.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 + ) } - private fun doUnfreeze(target: Player) { - frozenEnemies.remove(target.uniqueId) + private fun doUnfreeze( + target: Player + ) { + frozenEnemies.remove( target.uniqueId ) target.clearFreezeEffects() - if (target.isOnline) - target.sendActionBar(target.trans("kits.theworld.messages.frozen_expired")) + 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() { - removePotionEffect(PotionEffectType.SLOWNESS) - removePotionEffect(PotionEffectType.MINING_FATIGUE) + 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 inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { private val plugin get() = SpeedHG.instance + + /** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */ private val cooldowns: MutableMap = 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 ───────────────────── - val charges = teleportCharges[player.uniqueId] ?: 0 - if (charges > 0) { - blink(player) + override fun execute( + player: Player + ): AbilityResult + { + // ── Vorhandene Blink-Charge verbrauchen ─────────────────────────── + val charges = teleportCharges[ player.uniqueId ] ?: 0 + if ( charges > 0 ) + { + blink( player ) val remaining = charges - 1 - teleportCharges[player.uniqueId] = remaining + teleportCharges[ player.uniqueId ] = remaining - if (remaining > 0) + if ( remaining > 0 ) player.sendActionBar(player.trans( "kits.theworld.messages.teleport_charges", - mapOf("charges" to remaining.toString()))) - else { - teleportCharges.remove(player.uniqueId) - player.sendActionBar(player.trans("kits.theworld.messages.charges_exhausted")) + 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 - return AbilityResult.ConditionNotMet("Cooldown: ${secsLeft}s") + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCooldown = abilityCooldownMs + + if ( now - lastUse < capturedCooldown ) + { + val secsLeft = ( capturedCooldown - ( now - lastUse ) ) / 1000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) } - // ── Shockwave + grant 3 blink charges ──────────────────────────── - doShockwave(player) - teleportCharges[player.uniqueId] = MAX_TELEPORT_CHARGES - cooldowns[player.uniqueId] = now + // ── Schockwelle + Charges vergeben ──────────────────────────────── + val capturedCharges = maxTeleportCharges + + doShockwave( player ) + 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 inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { private val plugin get() = SpeedHG.instance + + /** Cooldowns pro Spieler: UUID → letzter Aktivierungs-Timestamp (ms). */ private val cooldowns: MutableMap = 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 { + override fun execute( + player: Player + ): AbilityResult + { + // ── Cooldown prüfen ─────────────────────────────────────────────── + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCooldown = abilityCooldownMs - // ── Cooldown gate ───────────────────────────────────────────────── - val now = System.currentTimeMillis() - val lastUse = cooldowns[player.uniqueId] ?: 0L - if (now - lastUse < ABILITY_COOLDOWN_MS) { - 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" ) } + // ── Ziele ermitteln ─────────────────────────────────────────────── + val capturedRadius = shockwaveRadius + val targets = player.world .getNearbyEntities( player.location, - SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS, SHOCKWAVE_RADIUS) + capturedRadius, capturedRadius, capturedRadius + ) .filterIsInstance() .filter { it != player && - plugin.gameManager.alivePlayers.contains(it.uniqueId) } + plugin.gameManager.alivePlayers.contains( it.uniqueId ) } - if (targets.isEmpty()) - return AbilityResult.ConditionNotMet("No enemies within range!") + if ( targets.isEmpty() ) + return AbilityResult.ConditionNotMet( "No enemies within range!" ) - doShockwave(player) - targets.forEach { applyFreeze(player, it) } - cooldowns[player.uniqueId] = now + // ── 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) { + private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) { private val plugin get() = SpeedHG.instance 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, victim: Player, event: EntityDamageByEntityEvent ) { - val (frozenBy, data) = frozenEnemies[victim.uniqueId] ?: return - // Only count hits from the player who applied this specific freeze - if (frozenBy != attacker.uniqueId) return + val ( frozenBy, data ) = frozenEnemies[ victim.uniqueId ] ?: return + + // Nur Treffer vom Spieler zählen, der den Freeze ausgelöst hat + if ( frozenBy != attacker.uniqueId ) return data.hitsRemaining-- - if (data.hitsRemaining <= 0) { - doUnfreeze(victim) - attacker.sendActionBar(attacker.trans("kits.theworld.messages.freeze_broken")) - } else { + if ( data.hitsRemaining <= 0 ) + { + doUnfreeze( victim ) + attacker.sendActionBar( attacker.trans( "kits.theworld.messages.freeze_broken" ) ) + } + else + { attacker.sendActionBar(attacker.trans( "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 // ========================================================================= - private class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) { + private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) { override val name = "None" override val description = "None" }