From 5a828a39936b2602b6167aa613280cb608c14bd6 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Sun, 12 Apr 2026 05:06:43 +0200 Subject: [PATCH] Add live config overrides and refactor kits Introduce runtime-configurable settings and defaults across multiple kits and clean up implementations. Kits (Armorer, Backup, Gladiator, Goblin, IceMage, Puppet, and others) now expose companion-object default constants and read values via override() (SPEEDHG_CUSTOM_SETTINGS) with typed accessors. Activation and tick-time behaviour snapshot these values so mid-round config changes don't produce inconsistent effects. Also includes general refactors: clearer getters, small API/formatting cleanup, improved no-op ability placeholders, and minor behavior protections (e.g. disallow stealing Backup kit). --- .../mcscrims/speedhg/kit/impl/ArmorerKit.kt | 252 +++++-- .../mcscrims/speedhg/kit/impl/BackupKit.kt | 34 +- .../mcscrims/speedhg/kit/impl/GladiatorKit.kt | 150 +++-- .../mcscrims/speedhg/kit/impl/GoblinKit.kt | 85 ++- .../mcscrims/speedhg/kit/impl/IceMageKit.kt | 94 ++- .../mcscrims/speedhg/kit/impl/PuppetKit.kt | 398 +++++++---- .../speedhg/kit/impl/RattlesnakeKit.kt | 369 +++++++---- .../mcscrims/speedhg/kit/impl/SpieloKit.kt | 616 +++++++++++------- .../mcscrims/speedhg/kit/impl/TeslaKit.kt | 229 ++++--- .../mcscrims/speedhg/kit/impl/TridentKit.kt | 272 +++++--- .../mcscrims/speedhg/kit/impl/VenomKit.kt | 129 +++- .../mcscrims/speedhg/kit/impl/VoodooKit.kt | 356 ++++++---- .../club/mcscrims/speedhg/util/ItemBuilder.kt | 2 +- 13 files changed, 2016 insertions(+), 970 deletions(-) diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/ArmorerKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/ArmorerKit.kt index fb0aff0..8d7ff15 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/ArmorerKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/ArmorerKit.kt @@ -19,9 +19,9 @@ import java.util.* import java.util.concurrent.ConcurrentHashMap /** - * ## Armorer + * ## ArmorerKit * - * Upgrades the player's **Chestplate + Boots** every 2 kills. + * Upgrades the player's **Chestplate + Boots** every [killsPerTier] kills. * * | Tier | Kills | Material | * |------|-------|-----------| @@ -32,22 +32,31 @@ import java.util.concurrent.ConcurrentHashMap * When a piece breaks ([onItemBreak]), it is immediately replaced with the * current tier so the player is never left without armor. * - * - **AGGRESSIVE passive**: +Strength I (5 s) for every kill. - * - **DEFENSIVE passive**: +Protection I enchant on iron/diamond pieces. + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-----------------------|-----|---------|-----------------------------------------------------------| + * | `kills_per_tier` | Int | `2` | Number of kills required to advance one armor tier | + * | `strength_ticks` | Int | `100` | Duration in ticks of Strength I granted on kill (aggressive) | */ -class ArmorerKit : Kit() { +class ArmorerKit : Kit() +{ private val plugin get() = SpeedHG.instance - override val id = "armorer" - override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.armorer.name", mapOf()) - override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.armorer.lore") - override val icon = Material.IRON_CHESTPLATE + override val id: String + get() = "armorer" - // ── Kill tracking ───────────────────────────────────────────────────────── - internal val killCounts: MutableMap = ConcurrentHashMap() + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.armorer.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.armorer.lore" ) + + override val icon: Material + get() = Material.IRON_CHESTPLATE companion object { private val CHESTPLATE_TIERS = listOf( @@ -65,87 +74,156 @@ class ArmorerKit : Kit() { Sound.ITEM_ARMOR_EQUIP_IRON, Sound.ITEM_ARMOR_EQUIP_DIAMOND ) + + const val DEFAULT_KILLS_PER_TIER = 2 + const val DEFAULT_STRENGTH_TICKS = 100 } + // ── Live config accessors ───────────────────────────────────────────────── + + /** + * Number of kills required to advance one armor tier. + * JSON key: `kills_per_tier` + */ + private val killsPerTier: Int + get() = override().getInt( "kills_per_tier" ) ?: DEFAULT_KILLS_PER_TIER + + /** + * Duration in ticks of Strength I applied to the killer on each kill (aggressive). + * JSON key: `strength_ticks` + */ + private val strengthTicks: Int + get() = override().getInt( "strength_ticks" ) ?: DEFAULT_STRENGTH_TICKS + + // ── Kill tracking ───────────────────────────────────────────────────────── + internal val killCounts: MutableMap = ConcurrentHashMap() + // ── Cached ability instances ────────────────────────────────────────────── - private val aggressiveActive = NoActive(Playstyle.AGGRESSIVE) - private val defensiveActive = NoActive(Playstyle.DEFENSIVE) + private val aggressiveActive = NoActive( Playstyle.AGGRESSIVE ) + private val defensiveActive = NoActive( Playstyle.DEFENSIVE ) private val aggressivePassive = AggressivePassive() private val defensivePassive = DefensivePassive() - override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) { + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } - override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) { + + override fun getPassiveAbility( + playstyle: Playstyle + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() - override fun giveItems(player: Player, playstyle: Playstyle) { /* armor is given in onAssign */ } + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { /* armor is given in onAssign */ } - override fun onAssign(player: Player, playstyle: Playstyle) { - killCounts[player.uniqueId] = 0 - setArmorTier(player, tier = 0, playstyle) + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onAssign( + player: Player, + playstyle: Playstyle + ) { + killCounts[ player.uniqueId ] = 0 + setArmorTier( player, tier = 0, playstyle ) } - override fun onRemove(player: Player) { - killCounts.remove(player.uniqueId) + override fun onRemove( + player: Player + ) { + killCounts.remove( player.uniqueId ) } // ── Kit-level kill hook (upgrades armor) ────────────────────────────────── - override fun onKillEnemy(killer: Player, victim: Player) { - val newKills = killCounts.compute(killer.uniqueId) { _, v -> (v ?: 0) + 1 } ?: return - val tier = (newKills / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex) - val prevTier = ((newKills - 1) / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex) + override fun onKillEnemy( + killer: Player, + victim: Player + ) { + val newKills = killCounts.compute( killer.uniqueId ) { _, v -> ( v ?: 0 ) + 1 } ?: return - if (tier > prevTier) { - val playstyle = plugin.kitManager.getSelectedPlaystyle(killer) - setArmorTier(killer, tier, playstyle) - killer.playSound(killer.location, EQUIP_SOUNDS[tier], 1f, 1f) - killer.sendActionBar(killer.trans("kits.armorer.messages.armor_upgraded", - mapOf("tier" to (tier + 1).toString()))) + // Snapshot the tier threshold at kill time + val capturedKillsPerTier = killsPerTier + + val tier = ( newKills / capturedKillsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex ) + val prevTier = ( ( newKills - 1 ) / capturedKillsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex ) + + if ( tier > prevTier ) + { + val playstyle = plugin.kitManager.getSelectedPlaystyle( killer ) + setArmorTier( killer, tier, playstyle ) + killer.playSound( killer.location, EQUIP_SOUNDS[ tier ], 1f, 1f ) + killer.sendActionBar( + killer.trans( + "kits.armorer.messages.armor_upgraded", + mapOf( "tier" to ( tier + 1 ).toString() ) + ) + ) } } // ── Auto-replace on armor break ─────────────────────────────────────────── - override fun onItemBreak(player: Player, brokenItem: ItemStack) { - val kills = killCounts[player.uniqueId] ?: return - val tier = (kills / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex) - val playstyle = plugin.kitManager.getSelectedPlaystyle(player) + override fun onItemBreak( + player: Player, + brokenItem: ItemStack + ) { + val kills = killCounts[ player.uniqueId ] ?: return + val tier = ( kills / killsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex ) + val playstyle = plugin.kitManager.getSelectedPlaystyle( player ) - when { - brokenItem.type.name.endsWith("_CHESTPLATE") -> { - player.inventory.chestplate = buildPiece(CHESTPLATE_TIERS[tier], tier, playstyle) - player.sendActionBar(player.trans("kits.armorer.messages.armor_replaced")) + when + { + brokenItem.type.name.endsWith( "_CHESTPLATE" ) -> + { + player.inventory.chestplate = buildPiece( CHESTPLATE_TIERS[ tier ], tier, playstyle ) + player.sendActionBar( player.trans( "kits.armorer.messages.armor_replaced" ) ) } - brokenItem.type.name.endsWith("_BOOTS") -> { - player.inventory.boots = buildPiece(BOOT_TIERS[tier], tier, playstyle) - player.sendActionBar(player.trans("kits.armorer.messages.armor_replaced")) + brokenItem.type.name.endsWith( "_BOOTS" ) -> + { + player.inventory.boots = buildPiece( BOOT_TIERS[ tier ], tier, playstyle ) + player.sendActionBar( player.trans( "kits.armorer.messages.armor_replaced" ) ) } } } // ── Helpers ─────────────────────────────────────────────────────────────── - private fun setArmorTier(player: Player, tier: Int, playstyle: Playstyle) { - player.inventory.chestplate = buildPiece(CHESTPLATE_TIERS[tier], tier, playstyle) - player.inventory.boots = buildPiece(BOOT_TIERS[tier], tier, playstyle) + private fun setArmorTier( + player: Player, + tier: Int, + playstyle: Playstyle + ) { + player.inventory.chestplate = buildPiece( CHESTPLATE_TIERS[ tier ], tier, playstyle ) + player.inventory.boots = buildPiece( BOOT_TIERS[ tier ], tier, playstyle ) } /** - * Builds an armor ItemStack, adding Protection I for DEFENSIVE builds + * Builds an armor [ItemStack], adding Protection I for DEFENSIVE builds * starting at iron tier (tier ≥ 1). */ - private fun buildPiece(material: Material, tier: Int, playstyle: Playstyle): ItemStack { - val item = ItemStack(material) - if (playstyle == Playstyle.DEFENSIVE && tier >= 1) { - item.editMeta { it.addEnchant(Enchantment.PROTECTION, 1, true) } - } + private fun buildPiece( + material: Material, + tier: Int, + playstyle: Playstyle + ): ItemStack + { + val item = ItemStack( material ) + if ( playstyle == Playstyle.DEFENSIVE && tier >= 1 ) + item.editMeta { it.addEnchant( Enchantment.PROTECTION, 1, true ) } return item } @@ -153,45 +231,75 @@ class ArmorerKit : Kit() { // AGGRESSIVE passive – Strength I on every kill // ========================================================================= - private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.armorer.passive.aggressive.name") - override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.armorer.passive.aggressive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.aggressive.name" ) - override fun onKillEnemy(killer: Player, victim: Player) { - killer.addPotionEffect(PotionEffect(PotionEffectType.STRENGTH, 5 * 20, 0)) - killer.playSound(killer.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.4f) + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.aggressive.description" ) + + override fun onKillEnemy( + killer: Player, + victim: Player + ) { + // Snapshot at kill time so a mid-round config change is consistent + val capturedStrengthTicks = strengthTicks + + killer.addPotionEffect( PotionEffect( PotionEffectType.STRENGTH, capturedStrengthTicks, 0 ) ) + killer.playSound( killer.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.4f ) } + } // ========================================================================= - // DEFENSIVE passive – Protection enchant is sufficient; no extra hook needed + // DEFENSIVE passive – Protection enchant is applied at tier; no extra hook needed // ========================================================================= - 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.armorer.passive.defensive.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.defensive.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.armorer.passive.defensive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.defensive.description" ) + } // ========================================================================= - // Shared no-ability active + // Shared no-active placeholder // ========================================================================= - inner class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) { - override val kitId: String = "armorer" - override val name = "None" - override val description = "None" - override val triggerMaterial = Material.BARRIER - override val hardcodedHitsRequired: Int = 0 - override fun execute(player: Player) = AbilityResult.Success + inner class NoActive( + playstyle: Playstyle + ) : ActiveAbility( playstyle ) + { + + override val kitId: String + get() = "armorer" + + override val name: String + get() = "None" + + override val description: String + get() = "None" + + override val triggerMaterial: Material + get() = Material.BARRIER + + override val hardcodedHitsRequired: Int + get() = 0 + + override fun execute( + player: Player + ): AbilityResult = AbilityResult.Success + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt index 8322060..4f511c1 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/BackupKit.kt @@ -13,7 +13,20 @@ import org.bukkit.inventory.ItemStack import java.util.* import java.util.concurrent.ConcurrentHashMap -class BackupKit : Kit() { +/** + * ## BackupKit + * + * A stub kit that currently has no active or passive abilities. + * All ability slots return no-op implementations. + * + * This kit intentionally exposes no configurable constants — there is nothing + * to override via `SPEEDHG_CUSTOM_SETTINGS` at this time. When abilities are + * added in the future, their constants should be placed in the `companion object` + * and wired through `override().getInt( ... ) ?: DEFAULT_*` following the + * standard pattern. + */ +class BackupKit : Kit() +{ private val plugin get() = SpeedHG.instance @@ -57,9 +70,15 @@ class BackupKit : Kit() { override val cachedItems = ConcurrentHashMap>() - override fun giveItems( player: Player, playstyle: Playstyle ) {} + override fun giveItems( + player: Player, + playstyle: Playstyle + ) {} - private class NoActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) { + private class NoActive( + playstyle: Playstyle + ) : ActiveAbility( playstyle ) + { override val kitId: String get() = "backup" @@ -76,11 +95,16 @@ class BackupKit : Kit() { override val triggerMaterial: Material get() = Material.BARRIER - override fun execute( player: Player ): AbilityResult { return AbilityResult.Success } + override fun execute( + player: Player + ): AbilityResult = AbilityResult.Success } - private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) { + private class NoPassive( + playstyle: Playstyle + ) : PassiveAbility( playstyle ) + { override val name: String get() = "None" diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt index d0a0780..e4b82ab 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GladiatorKit.kt @@ -25,7 +25,24 @@ import org.bukkit.scheduler.BukkitRunnable import java.util.* import java.util.concurrent.ConcurrentHashMap -class GladiatorKit : Kit() { +/** + * ## GladiatorKit + * + * Both playstyles share the same active ability — challenging a nearby enemy to a + * 1v1 duel inside a glass cylinder spawned high above the world. + * + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the typed [CustomGameSettings.KitOverride] fields. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |------------------------|-----|---------|---------------------------------------------------| + * | `arena_radius` | Int | `11` | Radius of the glass arena cylinder (blocks) | + * | `arena_height` | Int | `7` | Height of the glass arena cylinder (blocks) | + * | `wither_after_seconds` | Int | `180` | Seconds before Wither IV is applied to both players| + */ +class GladiatorKit : Kit() +{ private val plugin get() = SpeedHG.instance @@ -41,11 +58,35 @@ class GladiatorKit : Kit() { override val icon: Material get() = Material.IRON_BARS - private val kitOverride: CustomGameSettings.KitOverride by lazy { - plugin.customGameManager.settings.kits.kits["gladiator"] - ?: CustomGameSettings.KitOverride() + companion object { + const val DEFAULT_ARENA_RADIUS = 11 + const val DEFAULT_ARENA_HEIGHT = 7 + const val DEFAULT_WITHER_AFTER_SECONDS = 180 } + // ── Live config accessors ───────────────────────────────────────────────── + + /** + * Radius of the gladiator arena cylinder in blocks. + * Source: typed field `arena_radius`. + */ + private val arenaRadius: Int + get() = override().arenaRadius + + /** + * Height of the gladiator arena cylinder in blocks. + * Source: typed field `arena_height`. + */ + private val arenaHeight: Int + get() = override().arenaHeight + + /** + * Seconds into the fight before Wither IV is applied to force a resolution. + * Source: typed field `wither_after_seconds`. + */ + private val witherAfterSeconds: Int + get() = override().witherAfterSeconds + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AllActive( Playstyle.AGGRESSIVE ) private val defensiveActive = AllActive( Playstyle.DEFENSIVE ) @@ -96,7 +137,10 @@ class GladiatorKit : Kit() { items.forEach { player.inventory.remove( it ) } } - private inner class AllActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) { + private inner class AllActive( + playstyle: Playstyle + ) : ActiveAbility( playstyle ) + { private val plugin get() = SpeedHG.instance @@ -110,7 +154,7 @@ class GladiatorKit : Kit() { get() = plugin.languageManager.getDefaultRawMessage( "kits.gladiator.items.ironBars.description" ) override val hardcodedHitsRequired: Int - get() = 15 + get() = DEFAULT_WITHER_AFTER_SECONDS override val triggerMaterial: Material get() = Material.IRON_BARS @@ -122,40 +166,54 @@ class GladiatorKit : Kit() { val lineOfSight = player.getTargetEntity( 3 ) as? Player ?: return AbilityResult.ConditionNotMet( "No player in line of sight" ) - if (player.hasMetadata( KitMetaData.IN_GLADIATOR.getKey() ) || - lineOfSight.hasMetadata( KitMetaData.IN_GLADIATOR.getKey() )) + if ( player.hasMetadata( KitMetaData.IN_GLADIATOR.getKey() ) || + lineOfSight.hasMetadata( KitMetaData.IN_GLADIATOR.getKey() ) ) return AbilityResult.ConditionNotMet( "Already in gladiator fight" ) - val radius = kitOverride.arenaRadius - val height = kitOverride.arenaHeight + // Snapshot config values at activation time + val capturedRadius = arenaRadius + val capturedHeight = arenaHeight - player.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true )) - lineOfSight.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true )) + player.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true ) ) + lineOfSight.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true ) ) - val gladiatorRegion = getGladiatorLocation(player.location.clone().add( 0.0, 64.0, 0.0 ), radius, height + 2 ) + val gladiatorRegion = getGladiatorLocation( + player.location.clone().add( 0.0, 64.0, 0.0 ), + capturedRadius, + capturedHeight + 2 + ) val center = BukkitAdapter.adapt( player.world, gladiatorRegion.center ) - WorldEditUtils.createCylinder( player.world, center, radius - 1, true, 1, Material.WHITE_STAINED_GLASS ) - WorldEditUtils.createCylinder( player.world, center, radius - 1, false, height, Material.WHITE_STAINED_GLASS ) - WorldEditUtils.createCylinder( player.world, center.clone().add( 0.0, height - 1.0, 0.0 ), radius -1, true, 1, Material.WHITE_STAINED_GLASS ) + WorldEditUtils.createCylinder( player.world, center, capturedRadius - 1, true, 1, Material.WHITE_STAINED_GLASS ) + WorldEditUtils.createCylinder( player.world, center, capturedRadius - 1, false, capturedHeight, Material.WHITE_STAINED_GLASS ) + WorldEditUtils.createCylinder( player.world, center.clone().add( 0.0, capturedHeight - 1.0, 0.0 ), capturedRadius - 1, true, 1, Material.WHITE_STAINED_GLASS ) Bukkit.getScheduler().runTaskLater( plugin, { -> for ( vector3 in gladiatorRegion ) { - val block = player.world.getBlockAt(BukkitAdapter.adapt( player.world, vector3 )) + val block = player.world.getBlockAt( BukkitAdapter.adapt( player.world, vector3 ) ) if ( block.type.isAir ) continue - block.setMetadata( KitMetaData.GLADIATOR_BLOCK.getKey(), FixedMetadataValue( plugin, true )) + block.setMetadata( KitMetaData.GLADIATOR_BLOCK.getKey(), FixedMetadataValue( plugin, true ) ) } }, 5L ) - val gladiatorFight = GladiatorFight( gladiatorRegion, player, lineOfSight, radius, height ) + val gladiatorFight = GladiatorFight( + gladiatorRegion, + player, + lineOfSight, + capturedRadius, + capturedHeight + ) gladiatorFight.runTaskTimer( plugin, 0, 20 ) return AbilityResult.Success } } - private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) { + private class NoPassive( + playstyle: Playstyle + ) : PassiveAbility( playstyle ) + { override val name: String get() = "None" @@ -182,8 +240,16 @@ class GladiatorKit : Kit() { location.blockY, location.blockY + height ) - return if (!hasEnoughSpace( region )) - getGladiatorLocation(location.add(if ( random.nextBoolean() ) -10.0 else 10.0, 5.0, if ( random.nextBoolean() ) -10.0 else 10.0 ), radius, height ) + return if ( !hasEnoughSpace( region ) ) + getGladiatorLocation( + location.add( + if ( random.nextBoolean() ) -10.0 else 10.0, + 5.0, + if ( random.nextBoolean() ) -10.0 else 10.0 + ), + radius, + height + ) else region } @@ -201,10 +267,10 @@ class GladiatorKit : Kit() { { val adapt = BukkitAdapter.adapt( world, vector3 ) - if (!world.worldBorder.isInside( adapt )) + if ( !world.worldBorder.isInside( adapt ) ) return false - if (!world.getBlockAt( adapt ).type.isAir) + if ( !world.getBlockAt( adapt ).type.isAir ) return false } return true @@ -216,7 +282,8 @@ class GladiatorKit : Kit() { val enemy: Player, val radius: Int, val height: Int - ) : BukkitRunnable() { + ) : BukkitRunnable() + { private val plugin get() = SpeedHG.instance @@ -230,10 +297,10 @@ class GladiatorKit : Kit() { fun init() { - gladiator.addPotionEffect(PotionEffect( PotionEffectType.RESISTANCE, 40, 20 )) - enemy.addPotionEffect(PotionEffect( PotionEffectType.RESISTANCE, 40, 20 )) - gladiator.teleport(Location( world, center.x + radius / 2, center.y + 1, center.z, 90f, 0f )) - enemy.teleport(Location( world, center.x - radius / 2, center.y + 1, center.z, -90f, 0f )) + gladiator.addPotionEffect( PotionEffect( PotionEffectType.RESISTANCE, 40, 20 ) ) + enemy.addPotionEffect( PotionEffect( PotionEffectType.RESISTANCE, 40, 20 ) ) + gladiator.teleport( Location( world, center.x + radius / 2, center.y + 1, center.z, 90f, 0f ) ) + enemy.teleport( Location( world, center.x - radius / 2, center.y + 1, center.z, -90f, 0f ) ) } private var ended = false @@ -252,15 +319,18 @@ class GladiatorKit : Kit() { return } - if (region.contains(BukkitAdapter.asBlockVector( gladiator.location )) && - region.contains(BukkitAdapter.asBlockVector( enemy.location ))) + if ( region.contains( BukkitAdapter.asBlockVector( gladiator.location ) ) && + region.contains( BukkitAdapter.asBlockVector( enemy.location ) ) ) { timer++ - if ( timer > kitOverride.witherAfterSeconds ) + // Snapshot at tick time so a mid-fight config change is consistent + val capturedWitherAfterSeconds = witherAfterSeconds + + if ( timer > capturedWitherAfterSeconds ) { - gladiator.addPotionEffect(PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 )) - enemy.addPotionEffect(PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 )) + gladiator.addPotionEffect( PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 ) ) + enemy.addPotionEffect( PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 ) ) } } } @@ -273,25 +343,25 @@ class GladiatorKit : Kit() { gladiator.apply { removeMetadata( KitMetaData.IN_GLADIATOR.getKey(), plugin ) removePotionEffect( PotionEffectType.WITHER ) - addPotionEffect(PotionEffect( PotionEffectType.RESISTANCE, 40, 20 )) + addPotionEffect( PotionEffect( PotionEffectType.RESISTANCE, 40, 20 ) ) - if ( isOnline && plugin.gameManager.alivePlayers.contains( uniqueId )) + if ( isOnline && plugin.gameManager.alivePlayers.contains( uniqueId ) ) teleport( oldLocationGladiator ) } enemy.apply { removeMetadata( KitMetaData.IN_GLADIATOR.getKey(), plugin ) removePotionEffect( PotionEffectType.WITHER ) - addPotionEffect(PotionEffect( PotionEffectType.RESISTANCE, 40, 20 )) + addPotionEffect( PotionEffect( PotionEffectType.RESISTANCE, 40, 20 ) ) - if ( isOnline && plugin.gameManager.alivePlayers.contains( uniqueId )) + if ( isOnline && plugin.gameManager.alivePlayers.contains( uniqueId ) ) teleport( oldLocationEnemy ) } for ( vector3 in region ) { - val block = world.getBlockAt(BukkitAdapter.adapt( world, vector3 )) - if (!block.hasMetadata( KitMetaData.GLADIATOR_BLOCK.getKey() )) continue + val block = world.getBlockAt( BukkitAdapter.adapt( world, vector3 ) ) + if ( !block.hasMetadata( KitMetaData.GLADIATOR_BLOCK.getKey() ) ) continue block.removeMetadata( KitMetaData.GLADIATOR_BLOCK.getKey(), plugin ) } diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt index 1941e35..3ab42e6 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt @@ -21,7 +21,25 @@ import org.bukkit.scheduler.BukkitTask import java.util.* import java.util.concurrent.ConcurrentHashMap -class GoblinKit : Kit() { +/** + * ## GoblinKit + * + * | Playstyle | Active | Passive | + * |-------------|------------------------------------------------------------------------|---------| + * | AGGRESSIVE | **Steal** – copies the target's kit for [stealDuration] seconds | – | + * | DEFENSIVE | **Bunker** – spawns a hollow [bunkerRadius]-block cobblestone sphere | – | + * + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the typed [CustomGameSettings.KitOverride] fields. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-------------------------|--------|---------|-------------------------------------------------------| + * | `steal_duration_seconds`| Int | `60` | Seconds the stolen kit is active before reverting | + * | `bunker_radius` | Double | `10.0` | Radius of the hollow cobblestone bunker sphere | + */ +class GoblinKit : Kit() +{ private val plugin get() = SpeedHG.instance @@ -37,11 +55,27 @@ class GoblinKit : Kit() { override val icon: Material get() = Material.MOSSY_COBBLESTONE - private val kitOverride: CustomGameSettings.KitOverride by lazy { - plugin.customGameManager.settings.kits.kits["goblin"] - ?: CustomGameSettings.KitOverride() + companion object { + const val DEFAULT_STEAL_DURATION_SECONDS = 60 + const val DEFAULT_BUNKER_RADIUS = 10.0 } + // ── Live config accessors ───────────────────────────────────────────────── + + /** + * Seconds the stolen kit remains active before reverting to the player's own kit. + * Source: typed field `steal_duration_seconds`. + */ + private val stealDuration: Int + get() = override().stealDuration + + /** + * Radius of the hollow cobblestone bunker sphere in blocks. + * Source: typed field `bunker_radius`. + */ + private val bunkerRadius: Double + get() = override().bunkerRadius + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() @@ -110,7 +144,8 @@ class GoblinKit : Kit() { items.forEach { player.inventory.remove( it ) } } - private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance @@ -138,10 +173,15 @@ class GoblinKit : Kit() { val lineOfSight = player.getTargetEntity( 3 ) as? Player ?: return AbilityResult.ConditionNotMet( "No player in line of sight" ) - val targetKit = plugin.kitManager.getSelectedKit( lineOfSight ) ?: return AbilityResult.ConditionNotMet( "Target has no kit" ) + val targetKit = plugin.kitManager.getSelectedKit( lineOfSight ) + ?: return AbilityResult.ConditionNotMet( "Target has no kit" ) val targetPlaystyle = plugin.kitManager.getSelectedPlaystyle( lineOfSight ) - val currentKit = plugin.kitManager.getSelectedKit( player ) ?: return AbilityResult.ConditionNotMet( "Error while copying kit" ) + if ( targetKit is BackupKit ) + return AbilityResult.ConditionNotMet( "Backup kit cannot be stolen" ) + + val currentKit = plugin.kitManager.getSelectedKit( player ) + ?: return AbilityResult.ConditionNotMet( "Error while copying kit" ) val currentPlaystyle = plugin.kitManager.getSelectedPlaystyle( player ) activeStealTasks.remove( player.uniqueId ) @@ -151,10 +191,13 @@ class GoblinKit : Kit() { plugin.kitManager.selectPlaystyle( player, targetPlaystyle ) plugin.kitManager.applyKit( player ) + // Snapshot the duration at activation time + val capturedStealDuration = stealDuration + val task = Bukkit.getScheduler().runTaskLater( plugin, { -> activeStealTasks.remove( player.uniqueId ) - // Nur wiederherstellen, wenn Spieler noch alive und Spiel läuft - if (plugin.gameManager.alivePlayers.contains( player.uniqueId ) && + // Only restore if player is still alive and game is running + if ( plugin.gameManager.alivePlayers.contains( player.uniqueId ) && plugin.gameManager.currentState == GameState.INGAME ) { plugin.kitManager.removeKit( player ) @@ -162,12 +205,14 @@ class GoblinKit : Kit() { plugin.kitManager.selectPlaystyle( player, currentPlaystyle ) plugin.kitManager.applyKit( player ) } - }, 20L * kitOverride.stealDuration) + }, 20L * capturedStealDuration ) activeStealTasks[ player.uniqueId ] = task player.playSound( player.location, Sound.ENTITY_EVOKER_CAST_SPELL, 1f, 1.5f ) - player.sendActionBar(player.trans( "kits.goblin.messages.stole_kit", mapOf(), "kit" to targetKit.displayName )) + player.sendActionBar( + player.trans( "kits.goblin.messages.stole_kit", mapOf(), "kit" to targetKit.displayName ) + ) return AbilityResult.Success } @@ -180,7 +225,8 @@ class GoblinKit : Kit() { } - private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance @@ -206,10 +252,13 @@ class GoblinKit : Kit() { val world = player.world val location = player.location + // Snapshot the radius at activation time + val capturedBunkerRadius = bunkerRadius + WorldEditUtils.createSphere( world, location, - kitOverride.bunkerRadius, + capturedBunkerRadius, false, Material.MOSSY_COBBLESTONE ) @@ -218,21 +267,22 @@ class GoblinKit : Kit() { WorldEditUtils.createSphere( world, location, - kitOverride.bunkerRadius, + capturedBunkerRadius, false, Material.AIR ) }, 20L * 15 ) player.playSound( player.location, Sound.BLOCK_PISTON_EXTEND, 1f, 0.8f ) - player.sendActionBar(player.trans( "kits.goblin.messages.spawn_bunker" )) + player.sendActionBar( player.trans( "kits.goblin.messages.spawn_bunker" ) ) return AbilityResult.Success } } - private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) { + private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { override val name: String get() = "None" @@ -242,7 +292,8 @@ class GoblinKit : Kit() { } - private class DefensiveNoPassive : PassiveAbility( Playstyle.DEFENSIVE ) { + private class DefensiveNoPassive : PassiveAbility( Playstyle.DEFENSIVE ) + { override val name: String get() = "None" diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt index 99448bb..579dd89 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/IceMageKit.kt @@ -20,7 +20,28 @@ import org.bukkit.potion.PotionEffectType import java.util.* import java.util.concurrent.ConcurrentHashMap -class IceMageKit : Kit() { +/** + * ## IceMageKit + * + * | Playstyle | Active | Passive | + * |-------------|-------------------------------------------------------------------|-----------------------------------------------------| + * | AGGRESSIVE | – | Speed I in ice biomes; [slowChance] Slowness on hit | + * | DEFENSIVE | **Snowball** – throws a 360° ring of frozen snowballs | – | + * + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |---------------------|--------|---------|-----------------------------------------------------------------| + * | `slow_chance_denom` | Int | `3` | `1-in-N` chance to apply Slowness on melee hit (aggressive) | + * | `snowball_count` | Int | `16` | Number of snowballs fired in the 360° ring (defensive) | + * | `snowball_speed` | Double | `1.5` | Launch speed of each snowball | + * | `freeze_ticks` | Int | `60` | Freeze ticks applied to enemies hit by a snowball | + * | `slow_ticks` | Int | `40` | Slowness II ticks applied to enemies hit by a snowball | + */ +class IceMageKit : Kit() +{ private val plugin get() = SpeedHG.instance @@ -36,6 +57,52 @@ class IceMageKit : Kit() { override val icon: Material get() = Material.SNOWBALL + companion object { + const val DEFAULT_SLOW_CHANCE_DENOM = 3 + const val DEFAULT_SNOWBALL_COUNT = 16 + const val DEFAULT_SNOWBALL_SPEED = 1.5 + const val DEFAULT_FREEZE_TICKS = 60 + const val DEFAULT_SLOW_TICKS = 40 + } + + // ── Live config accessors ───────────────────────────────────────────────── + + /** + * Denominator of the `1-in-N` Slowness-on-hit chance for the aggressive passive. + * A value of `3` means roughly a 33 % chance. + * JSON key: `slow_chance_denom` + */ + private val slowChanceDenom: Int + get() = override().getInt( "slow_chance_denom" ) ?: DEFAULT_SLOW_CHANCE_DENOM + + /** + * Number of snowballs launched in the 360° ring by the defensive active. + * JSON key: `snowball_count` + */ + private val snowballCount: Int + get() = override().getInt( "snowball_count" ) ?: DEFAULT_SNOWBALL_COUNT + + /** + * Launch speed of each snowball in the ring. + * JSON key: `snowball_speed` + */ + private val snowballSpeed: Double + get() = override().getDouble( "snowball_speed" ) ?: DEFAULT_SNOWBALL_SPEED + + /** + * Freeze ticks applied to enemies hit by an IceMage snowball. + * JSON key: `freeze_ticks` + */ + private val freezeTicks: Int + get() = override().getInt( "freeze_ticks" ) ?: DEFAULT_FREEZE_TICKS + + /** + * Slowness II ticks applied to enemies hit by an IceMage snowball. + * JSON key: `slow_ticks` + */ + private val slowTicks: Int + get() = override().getInt( "slow_ticks" ) ?: DEFAULT_SLOW_TICKS + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() @@ -89,7 +156,8 @@ class IceMageKit : Kit() { items.forEach { player.inventory.remove( it ) } } - private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { + private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { override val kitId: String get() = "icemage" @@ -115,7 +183,8 @@ class IceMageKit : Kit() { } - private class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance @@ -139,13 +208,14 @@ class IceMageKit : Kit() { ): AbilityResult { player.playSound( player.location, Sound.ENTITY_PLAYER_HURT_FREEZE, 1f, 1.5f ) - player.sendActionBar(player.trans( "kits.icemage.messages.shoot_snowballs" )) + player.sendActionBar( player.trans( "kits.icemage.messages.shoot_snowballs" ) ) return AbilityResult.Success } } - private class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) { + private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance private val random = Random() @@ -172,8 +242,8 @@ class IceMageKit : Kit() { event: PlayerMoveEvent ) { val biome = player.world.getBiome( player.location ) - if (!biomeList.contains( biome.name.lowercase() )) return - player.addPotionEffect(PotionEffect( PotionEffectType.SPEED, 20, 0 )) + if ( !biomeList.contains( biome.name.lowercase() ) ) return + player.addPotionEffect( PotionEffect( PotionEffectType.SPEED, 20, 0 ) ) } override fun onHitEnemy( @@ -181,13 +251,17 @@ class IceMageKit : Kit() { victim: Player, event: EntityDamageByEntityEvent ) { - if (random.nextInt( 3 ) < 1 ) - victim.addPotionEffect(PotionEffect( PotionEffectType.SLOWNESS, 60, 0 )) + // Snapshot at hit time for consistency + val capturedDenom = slowChanceDenom + + if ( random.nextInt( capturedDenom ) < 1 ) + victim.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, slowTicks, 0 ) ) } } - private class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) { + private class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) + { override val name: String get() = "None" diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt index 04452f8..7586eb8 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/PuppetKit.kt @@ -19,7 +19,7 @@ import org.bukkit.inventory.ItemStack import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffectType import org.bukkit.scheduler.BukkitTask -import java.util.UUID +import java.util.* import java.util.concurrent.ConcurrentHashMap /** @@ -27,243 +27,353 @@ import java.util.concurrent.ConcurrentHashMap * * | Playstyle | Fähigkeit | * |-------------|----------------------------------------------------------------------------------------| - * | AGGRESSIVE | **Life Drain** – saugt 4 ♥/s pro Gegner in der Nähe (max. 8 ♥, 2 s). Sneak: Cancel. | - * | DEFENSIVE | **Puppeteer's Fear** – Blindness + Slowness III an alle Nahkämpfer für 4 Sekunden. | + * | AGGRESSIVE | **Life Drain** – saugt [healPerEnemyPerSHp] HP/s pro Gegner (max. [maxTotalHealHp], [drainDurationTicks] ticks). Sneak: Cancel. | + * | DEFENSIVE | **Puppeteer's Fear** – Blindness + Slowness III an alle Nahkämpfer in [fearRadius] Blöcken für [fearDurationTicks] Ticks. | * - * ### Cancel-Mechanismus (Aggressive): - * `onToggleSneak` (Hook in [Kit]) wird aufgerufen, wenn der Spieler die Shift-Taste drückt. - * Falls ein Drain-Task aktiv ist, wird er sofort beendet. Das Laden (Charge-State: CHARGING) - * läuft weiter – der Spieler bekommt keine Erstattung, da die Fähigkeit bereits angefangen hat. + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) * - * ### Drain-Timing: - * Der Task feuert alle 20 Ticks (= 1 s) genau zweimal (0s + 1s → insgesamt 2 Sekunden). - * Pro Feuer: `min(8 × numEnemies, 16 − totalHealed_hp)` HP wird auf den Caster übertragen. - * Healing: Direkt über `player.health = (player.health + healAmount).coerceAtMost(maxHp)`. - * Drain: Jeder Gegner nimmt `DRAIN_HP_PER_ENEMY_PER_SECOND` Schaden. + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-----------------------------|--------|---------|------------------------------------------------------------| + * | `drain_radius` | Double | `7.0` | Radius in blocks in which enemies are drained | + * | `drain_duration_ticks` | Long | `40` | Total drain duration in ticks (2 seconds) | + * | `drain_tick_interval` | Long | `20` | Ticks between each drain pulse | + * | `heal_per_enemy_per_s_hp` | Double | `8.0` | HP healed per nearby enemy per drain pulse | + * | `max_total_heal_hp` | Double | `16.0` | Maximum total HP the caster can heal per activation | + * | `drain_dmg_per_enemy_per_s` | Double | `4.0` | Damage dealt to each enemy per drain pulse | + * | `fear_radius` | Double | `7.0` | Radius in blocks for the Fear ability | + * | `fear_duration_ticks` | Int | `80` | Duration in ticks of Blindness and Slowness from Fear | */ -class PuppetKit : Kit() { +class PuppetKit : Kit() +{ private val plugin get() = SpeedHG.instance - override val id = "puppet" - override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.puppet.name", mapOf()) - override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.puppet.lore") - override val icon = Material.PHANTOM_MEMBRANE + override val id: String + get() = "puppet" - // Laufende Drain-Tasks: PlayerUUID → BukkitTask - internal val activeDrainTasks: MutableMap = ConcurrentHashMap() + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.puppet.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.puppet.lore" ) + + override val icon: Material + get() = Material.PHANTOM_MEMBRANE companion object { - const val DRAIN_RADIUS = 7.0 - const val DRAIN_DURATION_TICKS = 40L // 2 Sekunden - const val DRAIN_TICK_INTERVAL = 20L // pro Sekunde einmal - const val HEAL_PER_ENEMY_PER_S_HP = 8.0 // 4 Herzen = 8 HP - const val MAX_TOTAL_HEAL_HP = 16.0 // 8 Herzen = 16 HP - const val DRAIN_DMG_PER_ENEMY_PER_S = 4.0 // Gegner verlieren 2 Herzen/s - const val FEAR_RADIUS = 7.0 - const val FEAR_DURATION_TICKS = 80 // 4 Sekunden + const val DEFAULT_DRAIN_RADIUS = 7.0 + const val DEFAULT_DRAIN_DURATION_TICKS = 40L + const val DEFAULT_DRAIN_TICK_INTERVAL = 20L + const val DEFAULT_HEAL_PER_ENEMY_PER_S_HP = 8.0 + const val DEFAULT_MAX_TOTAL_HEAL_HP = 16.0 + const val DEFAULT_DRAIN_DMG_PER_ENEMY_PER_S = 4.0 + const val DEFAULT_FEAR_RADIUS = 7.0 + const val DEFAULT_FEAR_DURATION_TICKS = 80 } - // ── Gecachte Instanzen ──────────────────────────────────────────────────── + // ── Live config accessors ───────────────────────────────────────────────── + /** + * Radius in blocks in which nearby enemies are included in the drain. + * JSON key: `drain_radius` + */ + private val drainRadius: Double + get() = override().getDouble( "drain_radius" ) ?: DEFAULT_DRAIN_RADIUS + + /** + * Total duration of the drain in ticks. + * JSON key: `drain_duration_ticks` + */ + private val drainDurationTicks: Long + get() = override().getLong( "drain_duration_ticks" ) ?: DEFAULT_DRAIN_DURATION_TICKS + + /** + * Ticks between each drain pulse (heal + damage tick). + * JSON key: `drain_tick_interval` + */ + private val drainTickInterval: Long + get() = override().getLong( "drain_tick_interval" ) ?: DEFAULT_DRAIN_TICK_INTERVAL + + /** + * HP healed per nearby enemy per drain pulse. + * JSON key: `heal_per_enemy_per_s_hp` + */ + private val healPerEnemyPerSHp: Double + get() = override().getDouble( "heal_per_enemy_per_s_hp" ) ?: DEFAULT_HEAL_PER_ENEMY_PER_S_HP + + /** + * Maximum total HP the caster can heal across the full drain duration. + * JSON key: `max_total_heal_hp` + */ + private val maxTotalHealHp: Double + get() = override().getDouble( "max_total_heal_hp" ) ?: DEFAULT_MAX_TOTAL_HEAL_HP + + /** + * Damage dealt to each drained enemy per pulse. + * JSON key: `drain_dmg_per_enemy_per_s` + */ + private val drainDmgPerEnemyPerS: Double + get() = override().getDouble( "drain_dmg_per_enemy_per_s" ) ?: DEFAULT_DRAIN_DMG_PER_ENEMY_PER_S + + /** + * Radius in blocks for the Puppeteer's Fear ability. + * JSON key: `fear_radius` + */ + private val fearRadius: Double + get() = override().getDouble( "fear_radius" ) ?: DEFAULT_FEAR_RADIUS + + /** + * Duration in ticks of Blindness and Slowness applied by Fear. + * JSON key: `fear_duration_ticks` + */ + private val fearDurationTicks: Int + get() = override().getInt( "fear_duration_ticks" ) ?: DEFAULT_FEAR_DURATION_TICKS + + // ── Running drain tasks: PlayerUUID → BukkitTask ────────────────────────── + internal val activeDrainTasks: MutableMap = ConcurrentHashMap() + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() - private val aggressivePassive = NoPassive(Playstyle.AGGRESSIVE) - private val defensivePassive = NoPassive(Playstyle.DEFENSIVE) + private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE ) + private val defensivePassive = NoPassive( Playstyle.DEFENSIVE ) - override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) { + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } - override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) { + + override fun getPassiveAbility( + playstyle: Playstyle + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() - override fun giveItems(player: Player, playstyle: Playstyle) { - val (mat, active) = when (playstyle) { + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { + val ( mat, active ) = when( playstyle ) + { Playstyle.AGGRESSIVE -> Material.PHANTOM_MEMBRANE to aggressiveActive Playstyle.DEFENSIVE -> Material.BLAZE_ROD to defensiveActive } - val item = ItemBuilder(mat) - .name(active.name) - .lore(listOf(active.description)) + + val item = ItemBuilder( mat ) + .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) { - // Laufenden Drain abbrechen (z.B. bei Spielende) - activeDrainTasks.remove(player.uniqueId)?.cancel() - cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) } + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onRemove( + player: Player + ) { + activeDrainTasks.remove( player.uniqueId )?.cancel() + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } /** - * Sneak → bricht einen laufenden Drain ab. - * Wird von [KitEventDispatcher.onPlayerToggleSneak] aufgerufen. + * Sneak → cancels a running drain. + * Dispatched by [KitEventDispatcher.onPlayerToggleSneak]. */ - override fun onToggleSneak(player: Player, isSneaking: Boolean) { - if (!isSneaking) return - val task = activeDrainTasks.remove(player.uniqueId) ?: return + override fun onToggleSneak( + player: Player, + isSneaking: Boolean + ) { + if ( !isSneaking ) return + val task = activeDrainTasks.remove( player.uniqueId ) ?: return task.cancel() - player.playSound(player.location, Sound.ENTITY_VEX_HURT, 0.6f, 1.8f) - player.sendActionBar(player.trans("kits.puppet.messages.drain_cancelled")) + player.playSound( player.location, Sound.ENTITY_VEX_HURT, 0.6f, 1.8f ) + player.sendActionBar( player.trans( "kits.puppet.messages.drain_cancelled" ) ) } // ========================================================================= // AGGRESSIVE active – Life Drain // ========================================================================= - private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance - override val kitId = "puppet" + override val kitId: String + get() = "puppet" + override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.drain.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.drain.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.drain.description") - override val hardcodedHitsRequired = 15 - override val triggerMaterial = Material.PHANTOM_MEMBRANE + get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.drain.description" ) - override fun execute(player: Player): AbilityResult { - // Sicherheit: kein doppelter Drain (kann eigentlich nicht passieren, da - // Charge in CHARGING-State ist, aber defensiv trotzdem prüfen) - if (activeDrainTasks.containsKey(player.uniqueId)) - return AbilityResult.ConditionNotMet("Drain already active!") + override val hardcodedHitsRequired: Int + get() = 15 - // Sofort prüfen ob Gegner in der Nähe sind - val initialEnemies = player.world - .getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS) - .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + override val triggerMaterial: Material + get() = Material.PHANTOM_MEMBRANE - if (initialEnemies.isEmpty()) - return AbilityResult.ConditionNotMet( - plugin.languageManager.getDefaultRawMessage("kits.puppet.messages.no_enemies") - ) + override fun execute( + player: Player + ): AbilityResult + { + // Defensive guard: a double-drain cannot happen in practice because the + // charge enters CHARGING state, but we protect against it explicitly. + if ( activeDrainTasks.containsKey( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Drain already active!" ) - var totalHealedHp = 0.0 - var ticksFired = 0 + // Snapshot all config values at activation time so a mid-round change + // cannot alter an already-running drain unexpectedly. + val capturedDrainRadius = drainRadius + val capturedDrainDurationTicks = drainDurationTicks + val capturedDrainTickInterval = drainTickInterval + val capturedHealPerEnemy = healPerEnemyPerSHp + val capturedMaxTotalHeal = maxTotalHealHp + val capturedDrainDmg = drainDmgPerEnemyPerS - val task = Bukkit.getScheduler().runTaskTimer(plugin, { -> + var totalHealedHp = 0.0 + var ticksElapsed = 0L - ticksFired++ - - // Task selbst beenden wenn: offline, tot, max Heilung erreicht, Zeit abgelaufen - if (!player.isOnline || - !plugin.gameManager.alivePlayers.contains(player.uniqueId) || - totalHealedHp >= MAX_TOTAL_HEAL_HP || - ticksFired * DRAIN_TICK_INTERVAL > DRAIN_DURATION_TICKS) { - - activeDrainTasks.remove(player.uniqueId)?.cancel() + val task = Bukkit.getScheduler().runTaskTimer( plugin, { -> + if ( ticksElapsed >= capturedDrainDurationTicks || + totalHealedHp >= capturedMaxTotalHeal ) + { + activeDrainTasks.remove( player.uniqueId )?.cancel() return@runTaskTimer } - val currentEnemies = player.world - .getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS) - .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + ticksElapsed += capturedDrainTickInterval - if (currentEnemies.isEmpty()) { - activeDrainTasks.remove(player.uniqueId)?.cancel() - return@runTaskTimer - } - - // Heilmenge: 4♥ pro Gegner, gedeckelt auf verbleibendes Maximum - val potentialHeal = HEAL_PER_ENEMY_PER_S_HP * currentEnemies.size - val actualHeal = potentialHeal.coerceAtMost(MAX_TOTAL_HEAL_HP - totalHealedHp) - - // Gegner entwässern - currentEnemies.forEach { enemy -> - enemy.damage(DRAIN_DMG_PER_ENEMY_PER_S, player) - // Partikel-Sog: von Gegner zur Puppeteer-Position - enemy.world.spawnParticle( - Particle.CRIMSON_SPORE, - enemy.location.clone().add(0.0, 1.3, 0.0), - 8, 0.3, 0.3, 0.3, 0.02 + val enemies = player.world + .getNearbyEntities( + player.location, + capturedDrainRadius, + capturedDrainRadius, + capturedDrainRadius ) + .filterIsInstance() + .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } + + if ( enemies.isEmpty() ) return@runTaskTimer + + enemies.forEach { enemy -> + enemy.damage( capturedDrainDmg, player ) } - // Caster heilen - val maxHp = player.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.value ?: 20.0 - player.health = (player.health + actualHeal).coerceAtMost(maxHp) + val remainingHealBudget = capturedMaxTotalHeal - totalHealedHp + val rawHeal = capturedHealPerEnemy * enemies.size + val actualHeal = rawHeal.coerceAtMost( remainingHealBudget ) + + val maxHp = player.getAttribute( Attribute.GENERIC_MAX_HEALTH )?.value ?: 20.0 + player.health = ( player.health + actualHeal ).coerceAtMost( maxHp ) totalHealedHp += actualHeal - // Audio-Visual Feedback + // Audio-visual feedback player.world.spawnParticle( Particle.HEART, - player.location.clone().add(0.0, 2.0, 0.0), + player.location.clone().add( 0.0, 2.0, 0.0 ), 3, 0.4, 0.2, 0.4, 0.0 ) - player.playSound(player.location, Sound.ENTITY_GENERIC_DRINK, 0.5f, 0.4f) + player.playSound( player.location, Sound.ENTITY_GENERIC_DRINK, 0.5f, 0.4f ) player.sendActionBar( player.trans( "kits.puppet.messages.draining", - "healed" to "%.1f".format(totalHealedHp / 2.0), // in Herzen - "max" to (MAX_TOTAL_HEAL_HP / 2.0).toInt().toString() + "healed" to "%.1f".format( totalHealedHp / 2.0 ), + "max" to ( capturedMaxTotalHeal / 2.0 ).toInt().toString() ) ) - }, 0L, DRAIN_TICK_INTERVAL) + }, 0L, capturedDrainTickInterval ) - activeDrainTasks[player.uniqueId] = task + activeDrainTasks[ player.uniqueId ] = task - player.playSound(player.location, Sound.ENTITY_VEX_AMBIENT, 1f, 0.4f) - player.sendActionBar(player.trans("kits.puppet.messages.drain_start")) + player.playSound( player.location, Sound.ENTITY_VEX_AMBIENT, 1f, 0.4f ) + player.sendActionBar( player.trans( "kits.puppet.messages.drain_start" ) ) return AbilityResult.Success } + } // ========================================================================= // DEFENSIVE active – Puppeteer's Fear (Blindness + Slowness) // ========================================================================= - private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) { + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance - override val kitId = "puppet" + override val kitId: String + get() = "puppet" + override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.fear.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.fear.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.puppet.items.fear.description") - override val hardcodedHitsRequired = 15 - override val triggerMaterial = Material.BLAZE_ROD + get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.fear.description" ) + + override val hardcodedHitsRequired: Int + get() = 15 + + override val triggerMaterial: Material + get() = Material.BLAZE_ROD + + override fun execute( + player: Player + ): AbilityResult + { + // Snapshot config values at activation time + val capturedFearRadius = fearRadius + val capturedFearDurationTicks = fearDurationTicks - override fun execute(player: Player): AbilityResult { val targets = player.world - .getNearbyEntities(player.location, FEAR_RADIUS, FEAR_RADIUS, FEAR_RADIUS) + .getNearbyEntities( + player.location, + capturedFearRadius, + capturedFearRadius, + capturedFearRadius + ) .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } - if (targets.isEmpty()) + if ( targets.isEmpty() ) return AbilityResult.ConditionNotMet( - plugin.languageManager.getDefaultRawMessage("kits.puppet.messages.no_enemies") + plugin.languageManager.getDefaultRawMessage( "kits.puppet.messages.no_enemies" ) ) targets.forEach { target -> target.addPotionEffect( - PotionEffect(PotionEffectType.BLINDNESS, FEAR_DURATION_TICKS, 0, false, false, true) + PotionEffect( PotionEffectType.BLINDNESS, capturedFearDurationTicks, 0, false, false, true ) ) target.addPotionEffect( - PotionEffect(PotionEffectType.SLOWNESS, FEAR_DURATION_TICKS, 2, false, false, true) + PotionEffect( PotionEffectType.SLOWNESS, capturedFearDurationTicks, 2, false, false, true ) ) - target.sendActionBar(target.trans("kits.puppet.messages.feared")) + target.sendActionBar( target.trans( "kits.puppet.messages.feared" ) ) target.world.spawnParticle( Particle.SOUL, - target.location.clone().add(0.0, 1.5, 0.0), + target.location.clone().add( 0.0, 1.5, 0.0 ), 15, 0.4, 0.5, 0.4, 0.03 ) - target.playSound(target.location, Sound.ENTITY_PHANTOM_AMBIENT, 0.8f, 0.3f) + target.playSound( target.location, Sound.ENTITY_PHANTOM_AMBIENT, 0.8f, 0.3f ) } - player.playSound(player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.3f) + player.playSound( player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.3f ) player.sendActionBar( player.trans( "kits.puppet.messages.fear_cast", @@ -272,10 +382,24 @@ class PuppetKit : Kit() { ) return AbilityResult.Success } + } - class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) { - override val name = "None" - override val description = "None" + // ========================================================================= + // Shared no-passive placeholder + // ========================================================================= + + class NoPassive( + playstyle: Playstyle + ) : PassiveAbility( playstyle ) + { + + override val name: String + get() = "None" + + override val description: String + get() = "None" + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt index cd6d560..d1105ac 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/RattlesnakeKit.kt @@ -1,7 +1,6 @@ package club.mcscrims.speedhg.kit.impl import club.mcscrims.speedhg.SpeedHG -import club.mcscrims.speedhg.config.CustomGameSettings import club.mcscrims.speedhg.kit.Kit import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.ability.AbilityResult @@ -24,100 +23,174 @@ import java.util.* import java.util.concurrent.ConcurrentHashMap /** - * ## Rattlesnake + * ## RattlesnakeKit * - * | Playstyle | Active | Passive | - * |-------------|------------------------------------------------|--------------------------------------| - * | AGGRESSIVE | Sneak-charged pounce (3–10 blocks) | Poison II on pounce-hit | - * | DEFENSIVE | – | 25 % counter-venom on being hit | + * | Playstyle | Active | Passive | + * |-------------|-------------------------------------------------|-----------------------------------| + * | AGGRESSIVE | Sneak-charged pounce ([pounceMinRange]–[pounceMaxRange] blocks) | Poison II on pounce-hit | + * | DEFENSIVE | – | 25 % counter-venom on being hit | * - * Both playstyles receive **Speed II** at game start. + * Both playstyles receive **Speed II** permanently at game start. * - * ### Pounce mechanics - * 1. Hold sneak (up to 3 s) → charge builds linearly from 3 to 10 blocks. - * 2. Right-click the SLIME_BALL to launch. A 1.5 s timeout task is scheduled. - * 3. If [onHitEnemy] fires while the player is marked as *pouncing*: apply Poison II - * to 1 target (before Feast) or 3 targets (after Feast) and clear the flag. - * 4. If the timeout fires first (miss): apply Nausea + Slowness to nearby enemies. + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * The five pounce-related values are stored as **typed fields** in + * [CustomGameSettings.KitOverride] and are therefore accessed directly as + * properties rather than through `extras`. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-----------------------|--------|------------|-------------------------------------------------------| + * | `pounce_cooldown_ms` | Long | `20_000` | Cooldown between pounce uses in milliseconds | + * | `pounce_max_sneak_ms` | Long | `3_000` | Maximum sneak duration that contributes to range | + * | `pounce_min_range` | Double | `3.0` | Minimum pounce range (blocks) at zero sneak charge | + * | `pounce_max_range` | Double | `10.0` | Maximum pounce range (blocks) at full sneak charge | + * | `pounce_timeout_ticks`| Long | `30` | Ticks before a pounce-in-flight times out (miss) | */ -class RattlesnakeKit : Kit() { +class RattlesnakeKit : Kit() +{ private val plugin get() = SpeedHG.instance - override val id = "rattlesnake" - override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.rattlesnake.name", mapOf()) - override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.rattlesnake.lore") - override val icon = Material.SLIME_BALL + override val id: String + get() = "rattlesnake" - // ── Shared state (accessed by inner ability classes via outer-class reference) ─ - internal val sneakStartTimes: MutableMap = ConcurrentHashMap() - internal val pouncingPlayers: MutableSet = ConcurrentHashMap.newKeySet() - internal val lastPounceUse: MutableMap = ConcurrentHashMap() + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.rattlesnake.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.rattlesnake.lore" ) + + override val icon: Material + get() = Material.SLIME_BALL companion object { - private fun override() = SpeedHG.instance.customGameManager.settings.kits.kits["rattlesnake"] - ?: CustomGameSettings.KitOverride() - - private val POUNCE_COOLDOWN_MS = override().pounceCooldownMs - private val MAX_SNEAK_MS = override().pounceMaxSneakMs - private val MIN_RANGE = override().pounceMinRange - private val MAX_RANGE = override().pounceMaxRange - private val POUNCE_TIMEOUT_TICKS = override().pounceTimeoutTicks + const val DEFAULT_POUNCE_COOLDOWN_MS = 20_000L + const val DEFAULT_MAX_SNEAK_MS = 3_000L + const val DEFAULT_POUNCE_MIN_RANGE = 3.0 + const val DEFAULT_POUNCE_MAX_RANGE = 10.0 + const val DEFAULT_POUNCE_TIMEOUT_TICKS = 30L } - // ── Cached ability instances ────────────────────────────────────────────── + // ── Live config accessors (typed KitOverride fields) ────────────────────── + + /** + * Cooldown between pounce uses in milliseconds. + * Source: typed field `pounce_cooldown_ms`. + */ + private val pounceCooldownMs: Long + get() = override().pounceCooldownMs + + /** + * Maximum sneak duration (ms) whose charge contributes to pounce range. + * Source: typed field `pounce_max_sneak_ms`. + */ + private val maxSneakMs: Long + get() = override().pounceMaxSneakMs + + /** + * Minimum pounce range in blocks at zero sneak charge. + * Source: typed field `pounce_min_range`. + */ + private val pounceMinRange: Double + get() = override().pounceMinRange + + /** + * Maximum pounce range in blocks at full sneak charge. + * Source: typed field `pounce_max_range`. + */ + private val pounceMaxRange: Double + get() = override().pounceMaxRange + + /** + * Ticks after launch before a pounce that hasn't connected is treated as a miss. + * Source: typed field `pounce_timeout_ticks`. + */ + private val pounceTimeoutTicks: Long + get() = override().pounceTimeoutTicks + + // ── Shared mutable state (accessed by inner ability classes) ────────────── + internal val sneakStartTimes: MutableMap = ConcurrentHashMap() + internal val pouncingPlayers: MutableSet = ConcurrentHashMap.newKeySet() + internal val lastPounceUse: MutableMap = ConcurrentHashMap() + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() private val aggressivePassive = AggressivePassive() private val defensivePassive = DefensivePassive() - override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) { + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } - override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) { + + override fun getPassiveAbility( + playstyle: Playstyle + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() - override fun giveItems(player: Player, playstyle: Playstyle) { - if (playstyle != Playstyle.AGGRESSIVE) return - val item = ItemBuilder(Material.SLIME_BALL) - .name(aggressiveActive.name) - .lore(listOf(aggressiveActive.description)) + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { + if ( playstyle != Playstyle.AGGRESSIVE ) return + + val item = ItemBuilder( Material.SLIME_BALL ) + .name( aggressiveActive.name ) + .lore(listOf( aggressiveActive.description )) .build() - cachedItems[player.uniqueId] = listOf(item) - player.inventory.addItem(item) + + cachedItems[ player.uniqueId ] = listOf( item ) + player.inventory.addItem( item ) } - override fun onAssign(player: Player, playstyle: Playstyle) { + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onAssign( + player: Player, + playstyle: Playstyle + ) { player.addPotionEffect( - PotionEffect(PotionEffectType.SPEED, Int.MAX_VALUE, 1, false, false, true) + PotionEffect( PotionEffectType.SPEED, Int.MAX_VALUE, 1, false, false, true ) ) } - override fun onRemove(player: Player) { - player.removePotionEffect(PotionEffectType.SPEED) - sneakStartTimes.remove(player.uniqueId) - pouncingPlayers.remove(player.uniqueId) - lastPounceUse.remove(player.uniqueId) - cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) } + override fun onRemove( + player: Player + ) { + player.removePotionEffect( PotionEffectType.SPEED ) + sneakStartTimes.remove( player.uniqueId ) + pouncingPlayers.remove( player.uniqueId ) + lastPounceUse.remove( player.uniqueId ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } - override fun onToggleSneak(player: Player, isSneaking: Boolean) { - if (plugin.kitManager.getSelectedPlaystyle(player) != Playstyle.AGGRESSIVE) return - if (isSneaking) sneakStartTimes[player.uniqueId] = System.currentTimeMillis() + override fun onToggleSneak( + player: Player, + isSneaking: Boolean + ) { + if ( plugin.kitManager.getSelectedPlaystyle( player ) != Playstyle.AGGRESSIVE ) return + if ( isSneaking ) sneakStartTimes[ player.uniqueId ] = System.currentTimeMillis() } // ========================================================================= // AGGRESSIVE active – sneak-charged pounce // ========================================================================= - private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance @@ -125,151 +198,205 @@ class RattlesnakeKit : Kit() { get() = "rattlesnake" override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.items.pounce.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.items.pounce.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.items.pounce.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.items.pounce.description" ) + override val hardcodedHitsRequired: Int get() = 0 - override val triggerMaterial = Material.SLIME_BALL - override fun execute(player: Player): AbilityResult { - if (!player.isSneaking) - return AbilityResult.ConditionNotMet("Sneak while activating the pounce!") + override val triggerMaterial: Material + get() = Material.SLIME_BALL + + override fun execute( + player: Player + ): AbilityResult + { + if ( !player.isSneaking ) + return AbilityResult.ConditionNotMet( "Sneak while activating the pounce!" ) val now = System.currentTimeMillis() - if (now - (lastPounceUse[player.uniqueId] ?: 0L) < POUNCE_COOLDOWN_MS) { - val remaining = ((POUNCE_COOLDOWN_MS - (now - (lastPounceUse[player.uniqueId] ?: 0L))) / 1000) - return AbilityResult.ConditionNotMet("Cooldown: ${remaining}s remaining") + + // Snapshot all config values at activation time + val capturedPounceCooldownMs = pounceCooldownMs + val capturedMaxSneakMs = maxSneakMs + val capturedPounceMinRange = pounceMinRange + val capturedPounceMaxRange = pounceMaxRange + val capturedPounceTimeoutTicks = pounceTimeoutTicks + + if ( now - ( lastPounceUse[ player.uniqueId ] ?: 0L ) < capturedPounceCooldownMs ) + { + val remaining = ( capturedPounceCooldownMs - ( now - ( lastPounceUse[ player.uniqueId ] ?: 0L ) ) ) / 1000 + return AbilityResult.ConditionNotMet( "Cooldown: ${remaining}s remaining" ) } - // Sneak duration → range (3 – 10 blocks) - val sneakDuration = (now - (sneakStartTimes[player.uniqueId] ?: now)) - .coerceIn(0L, MAX_SNEAK_MS) - val range = MIN_RANGE + (sneakDuration.toDouble() / MAX_SNEAK_MS) * (MAX_RANGE - MIN_RANGE) + // Sneak duration → range (min–max blocks) + val sneakDuration = ( now - ( sneakStartTimes[ player.uniqueId ] ?: now ) ) + .coerceIn( 0L, capturedMaxSneakMs ) + val range = capturedPounceMinRange + + ( sneakDuration.toDouble() / capturedMaxSneakMs ) * ( capturedPounceMaxRange - capturedPounceMinRange ) val target = player.world - .getNearbyEntities(player.location, range, range, range) + .getNearbyEntities( player.location, range, range, range ) .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } - .minByOrNull { it.location.distanceSquared(player.location) } - ?: return AbilityResult.ConditionNotMet("No enemies within ${range.toInt()} blocks!") + .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } + .minByOrNull { it.location.distanceSquared( player.location ) } + ?: return AbilityResult.ConditionNotMet( "No enemies within ${range.toInt()} blocks!" ) - // ── Launch ─────────────────────────────────────────────────────── + // ── Launch ──────────────────────────────────────────────────────── val launchVec: Vector = target.location.toVector() - .subtract(player.location.toVector()) + .subtract( player.location.toVector() ) .normalize() - .multiply(1.9) - .setY(0.55) + .multiply( 1.9 ) + .setY( 0.55 ) player.velocity = launchVec - player.playSound(player.location, Sound.ENTITY_SLIME_JUMP, 1f, 1.7f) + player.playSound( player.location, Sound.ENTITY_SLIME_JUMP, 1f, 1.7f ) - pouncingPlayers.add(player.uniqueId) - lastPounceUse[player.uniqueId] = now + pouncingPlayers.add( player.uniqueId ) + lastPounceUse[ player.uniqueId ] = now // ── Miss timeout ────────────────────────────────────────────────── - Bukkit.getScheduler().runTaskLater(plugin, { -> - if (!pouncingPlayers.remove(player.uniqueId)) return@runTaskLater // already hit + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !pouncingPlayers.remove( player.uniqueId ) ) return@runTaskLater // already hit if ( !player.isOnline ) return@runTaskLater - player.world.getNearbyEntities(player.location, 5.0, 5.0, 5.0) + player.world.getNearbyEntities( player.location, 5.0, 5.0, 5.0 ) .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } .forEach { enemy -> - enemy.addPotionEffect(PotionEffect(PotionEffectType.NAUSEA, 3 * 20, 0)) - enemy.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, 3 * 20, 0)) + enemy.addPotionEffect( PotionEffect( PotionEffectType.NAUSEA, 3 * 20, 0 ) ) + enemy.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 3 * 20, 0 ) ) } - player.sendActionBar(player.trans("kits.rattlesnake.messages.pounce_miss")) - }, POUNCE_TIMEOUT_TICKS) + player.sendActionBar( player.trans( "kits.rattlesnake.messages.pounce_miss" ) ) + }, capturedPounceTimeoutTicks ) return AbilityResult.Success } + } // ========================================================================= // AGGRESSIVE passive – pounce-hit processing // ========================================================================= - private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.passive.aggressive.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.aggressive.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.passive.aggressive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.aggressive.description" ) /** - * Called AFTER the normal damage has been applied. + * Called AFTER normal damage has been applied. * If the attacker is currently pouncing, consume the flag and apply Poison II * to 1 target (before Feast) or up to 3 targets (after Feast). */ - override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) { - if (!pouncingPlayers.remove(attacker.uniqueId)) return // not a pounce-hit + override fun onHitEnemy( + attacker: Player, + victim: Player, + event: EntityDamageByEntityEvent + ) { + if ( !pouncingPlayers.remove( attacker.uniqueId ) ) return // not a pounce-hit - val maxTargets = if (plugin.gameManager.feastManager.hasSpawned) 3 else 1 + val maxTargets = if ( plugin.gameManager.feastManager.hasSpawned ) 3 else 1 val targets = buildList { - add(victim) - if (maxTargets > 1) { + add( victim ) + if ( maxTargets > 1 ) + { victim.world - .getNearbyEntities(victim.location, 4.0, 4.0, 4.0) + .getNearbyEntities( victim.location, 4.0, 4.0, 4.0 ) .filterIsInstance() .filter { it != victim && it != attacker && - plugin.gameManager.alivePlayers.contains(it.uniqueId) } - .take(maxTargets - 1) - .forEach { add(it) } + plugin.gameManager.alivePlayers.contains( it.uniqueId ) } + .take( maxTargets - 1 ) + .forEach { add( it ) } } } targets.forEach { t -> - t.addPotionEffect(PotionEffect(PotionEffectType.POISON, 8 * 20, 1)) // Poison II - t.world.spawnParticle(Particle.ITEM_SLIME, t.location.clone().add(0.0, 1.0, 0.0), - 12, 0.4, 0.4, 0.4, 0.0) + t.addPotionEffect( PotionEffect( PotionEffectType.POISON, 8 * 20, 1 ) ) + t.world.spawnParticle( + Particle.ITEM_SLIME, + t.location.clone().add( 0.0, 1.0, 0.0 ), + 12, 0.4, 0.4, 0.4, 0.0 + ) } - attacker.playSound(attacker.location, Sound.ENTITY_SLIME_ATTACK, 1f, 0.7f) + attacker.playSound( attacker.location, Sound.ENTITY_SLIME_ATTACK, 1f, 0.7f ) attacker.sendActionBar( - attacker.trans("kits.rattlesnake.messages.pounce_hit", - mapOf("count" to targets.size.toString())) + attacker.trans( + "kits.rattlesnake.messages.pounce_hit", + mapOf( "count" to targets.size.toString() ) + ) ) } + } // ========================================================================= // DEFENSIVE active – no active ability // ========================================================================= - private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) { - override val kitId: String = "rattlesnake" - override val name = "None" - override val description = "None" - override val hardcodedHitsRequired: Int = 0 - override val triggerMaterial = Material.BARRIER - override fun execute(player: Player) = AbilityResult.Success + private class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { + + override val kitId: String + get() = "rattlesnake" + + override val name: String + get() = "None" + + override val description: String + get() = "None" + + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.BARRIER + + override fun execute( + player: Player + ): AbilityResult = AbilityResult.Success + } // ========================================================================= // DEFENSIVE passive – counter-venom (25 % proc on being hit) // ========================================================================= - private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) { + private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance private val rng = Random() override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.passive.defensive.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.defensive.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.rattlesnake.passive.defensive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.defensive.description" ) - override fun onHitByEnemy(victim: Player, attacker: Player, event: EntityDamageByEntityEvent) { - if (rng.nextDouble() >= 0.25) return + override fun onHitByEnemy( + victim: Player, + attacker: Player, + event: EntityDamageByEntityEvent + ) { + if ( rng.nextDouble() >= 0.25 ) return - attacker.addPotionEffect(PotionEffect(PotionEffectType.POISON, 3 * 20, 0)) // Poison I, 3 s - victim.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 3 * 20, 1)) // Speed II, 3 s - victim.playSound(victim.location, Sound.ENTITY_SLIME_HURT, 0.8f, 1.8f) - victim.sendActionBar(victim.trans("kits.rattlesnake.messages.venom_proc")) + attacker.addPotionEffect( PotionEffect( PotionEffectType.POISON, 3 * 20, 0 ) ) // Poison I, 3 s + victim.addPotionEffect( PotionEffect( PotionEffectType.SPEED, 3 * 20, 1 ) ) // Speed II, 3 s + victim.playSound( victim.location, Sound.ENTITY_SLIME_HURT, 0.8f, 1.8f ) + victim.sendActionBar( victim.trans( "kits.rattlesnake.messages.venom_proc" ) ) } + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SpieloKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SpieloKit.kt index 03828f7..ec7a527 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SpieloKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SpieloKit.kt @@ -38,8 +38,7 @@ import java.util.concurrent.ConcurrentHashMap * | Playstyle | Beschreibung | * |-------------|---------------------------------------------------------------------------------| * | AGGRESSIVE | Gambeln per Knopfdruck – Items, Events oder **Instant Death** möglich | - * | DEFENSIVE | Öffnet eine Slot-Maschinen-GUI (nur wenn kein Feind in der Nähe) – sicherer: | - * | | keine Dia-Armor, kein Instant-Death-Outcome | + * | DEFENSIVE | Öffnet eine Slot-Maschinen-GUI (nur wenn kein Feind in [safeRadius] Blöcken) | * * ### Aggressive – Outcome-Wahrscheinlichkeiten * | 5 % | Instant Death | @@ -48,162 +47,233 @@ import java.util.concurrent.ConcurrentHashMap * | 20 % | Neutrale Items | * | 50 % | Positive Items (inkl. möglicher Dia-Armor) | * - * ### Defensive – Slot-Maschinen-GUI - * Öffnet sich nur wenn kein Feind in [SAFE_RADIUS] Blöcken ist. - * Gleiche Outcome-Tabelle, ABER ohne Instant-Death und ohne Dia-Armor. - * Die GUI animiert drei Walzen nacheinander, bevor das Ergebnis feststeht. + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) * - * ### Integration - * Die [SlotMachineGui] nutzt einen eigenen [InventoryHolder]. Der Click-Dispatch - * läuft über den zentralen [MenuListener] – dafür muss in [MenuListener.onInventoryClick] - * ein zusätzlicher Branch ergänzt werden: - * ```kotlin - * val spieloHolder = event.inventory.holder as? SpieloKit.SlotMachineGui ?: ... - * spieloHolder.onClick(event) - * ``` + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |----------------------|--------|------------|----------------------------------------------------------| + * | `active_cooldown_ms` | Long | `12_000` | Cooldown between aggressive gamble uses in milliseconds | + * | `safe_radius` | Double | `12.0` | Radius (blocks) checked for enemies before opening GUI | */ -class SpieloKit : Kit() { +class SpieloKit : Kit() +{ private val plugin get() = SpeedHG.instance private val rng = Random() - private val mm = MiniMessage.miniMessage() + private val mm = MiniMessage.miniMessage() + + override val id: String + get() = "spielo" - override val id = "spielo" override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.spielo.name", mapOf()) + get() = plugin.languageManager.getDefaultComponent( "kits.spielo.name", mapOf() ) + override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.spielo.lore") - override val icon = Material.GOLD_NUGGET + get() = plugin.languageManager.getDefaultRawMessageList( "kits.spielo.lore" ) - // Blockiert Doppel-Trigger während eine Animation läuft - internal val gamblingPlayers: MutableSet = ConcurrentHashMap.newKeySet() - - // Cooldowns für den Aggressive-Automaten - private val activeCooldowns: MutableMap = ConcurrentHashMap() + override val icon: Material + get() = Material.GOLD_NUGGET companion object { - const val ACTIVE_COOLDOWN_MS = 12_000L // 12 s zwischen Aggressive-Uses - const val SAFE_RADIUS = 12.0 // Feind-Radius für Defensive-GUI-Sperrung + const val DEFAULT_ACTIVE_COOLDOWN_MS = 12_000L + const val DEFAULT_SAFE_RADIUS = 12.0 } - // ── Gecachte Instanzen ──────────────────────────────────────────────────── + // ── Live config accessors ───────────────────────────────────────────────── + /** + * Cooldown between aggressive gamble uses in milliseconds. + * JSON key: `active_cooldown_ms` + */ + private val activeCooldownMs: Long + get() = override().getLong( "active_cooldown_ms" ) ?: DEFAULT_ACTIVE_COOLDOWN_MS + + /** + * Radius in blocks checked for enemies before the defensive slot-machine GUI opens. + * If an enemy is within this radius the quick animation fires instead. + * JSON key: `safe_radius` + */ + private val safeRadius: Double + get() = override().getDouble( "safe_radius" ) ?: DEFAULT_SAFE_RADIUS + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** Blocks double-trigger while an animation is running. */ + internal val gamblingPlayers: MutableSet = ConcurrentHashMap.newKeySet() + + /** Cooldown timestamps for the aggressive instant-gamble. */ + private val activeCooldowns: MutableMap = ConcurrentHashMap() + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() - private val aggressivePassive = NoPassive(Playstyle.AGGRESSIVE) - private val defensivePassive = NoPassive(Playstyle.DEFENSIVE) + private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE ) + private val defensivePassive = NoPassive( Playstyle.DEFENSIVE ) - override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) { + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } - override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) { + + override fun getPassiveAbility( + playstyle: Playstyle + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() - override fun giveItems(player: Player, playstyle: Playstyle) { - val (mat, active) = when (playstyle) { + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { + val ( mat, active ) = when( playstyle ) + { Playstyle.AGGRESSIVE -> Material.GOLD_NUGGET to aggressiveActive Playstyle.DEFENSIVE -> Material.GOLD_BLOCK to defensiveActive } - val item = ItemBuilder(mat) - .name(active.name) - .lore(listOf(active.description)) + + val item = ItemBuilder( mat ) + .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) { - gamblingPlayers.remove(player.uniqueId) - activeCooldowns.remove(player.uniqueId) - cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) } + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onRemove( + player: Player + ) { + gamblingPlayers.remove( player.uniqueId ) + activeCooldowns.remove( player.uniqueId ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } // ========================================================================= // AGGRESSIVE active – Sofort-Gamble (Instant-Death möglich) // ========================================================================= - private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance - override val kitId = "spielo" + override val kitId: String + get() = "spielo" + override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.spielo.items.automat.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.automat.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.spielo.items.automat.description") - override val hardcodedHitsRequired = 12 - override val triggerMaterial = Material.GOLD_NUGGET + get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.automat.description" ) - override fun execute(player: Player): AbilityResult { - if (gamblingPlayers.contains(player.uniqueId)) - return AbilityResult.ConditionNotMet("Slotmachine is already running!") + override val hardcodedHitsRequired: Int + get() = 12 - val now = System.currentTimeMillis() - val lastUse = activeCooldowns[player.uniqueId] ?: 0L - if (now - lastUse < ACTIVE_COOLDOWN_MS) { - val secLeft = (ACTIVE_COOLDOWN_MS - (now - lastUse)) / 1000 - return AbilityResult.ConditionNotMet("Cooldown: ${secLeft}s") + override val triggerMaterial: Material + get() = Material.GOLD_NUGGET + + override fun execute( + player: Player + ): AbilityResult + { + if ( gamblingPlayers.contains( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Slotmachine is already running!" ) + + val now = System.currentTimeMillis() + val lastUse = activeCooldowns[ player.uniqueId ] ?: 0L + + // Snapshot the cooldown at activation time + val capturedCooldownMs = activeCooldownMs + + if ( now - lastUse < capturedCooldownMs ) + { + val secLeft = ( capturedCooldownMs - ( now - lastUse ) ) / 1000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secLeft}s" ) } - activeCooldowns[player.uniqueId] = now - gamblingPlayers.add(player.uniqueId) + activeCooldowns[ player.uniqueId ] = now + gamblingPlayers.add( player.uniqueId ) - // Kurze Sound-Animation (0,8 s) → dann Ergebnis - playQuickAnimation(player) { - gamblingPlayers.remove(player.uniqueId) - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@playQuickAnimation - resolveOutcome(player, allowInstantDeath = true, allowDiamondArmor = true) + // Short sound animation (≈ 0.9 s) → then resolve + playQuickAnimation( player ) { + gamblingPlayers.remove( player.uniqueId ) + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@playQuickAnimation + resolveOutcome( player, allowInstantDeath = true, allowDiamondArmor = true ) } return AbilityResult.Success } + } // ========================================================================= // DEFENSIVE active – Slot-Maschinen-GUI öffnen // ========================================================================= - private inner class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) { + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance - override val kitId = "spielo" - override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.spielo.items.slotautomat.name") - override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.spielo.items.slotautomat.description") - override val hardcodedHitsRequired = 15 - override val triggerMaterial = Material.GOLD_BLOCK + override val kitId: String + get() = "spielo" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.slotautomat.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.slotautomat.description" ) + + override val hardcodedHitsRequired: Int + get() = 15 + + override val triggerMaterial: Material + get() = Material.GOLD_BLOCK + + override fun execute( + player: Player + ): AbilityResult + { + if ( gamblingPlayers.contains( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Slotmachine is already running!" ) + + // Snapshot the radius at activation time + val capturedSafeRadius = safeRadius - override fun execute(player: Player): AbilityResult { - // Prüfen ob ein Feind zu nah ist val enemyNearby = plugin.gameManager.alivePlayers .asSequence() .filter { it != player.uniqueId } - .mapNotNull { Bukkit.getPlayer(it) } - .any { it.location.distanceSquared(player.location) <= SAFE_RADIUS * SAFE_RADIUS } + .mapNotNull { Bukkit.getPlayer( it ) } + .any { it.location.distanceSquared( player.location ) <= capturedSafeRadius * capturedSafeRadius } - if (gamblingPlayers.contains(player.uniqueId)) - return AbilityResult.ConditionNotMet("Slotmachine is already running!") - - if (enemyNearby) + if ( enemyNearby ) { - playQuickAnimation(player) { - gamblingPlayers.remove(player.uniqueId) - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@playQuickAnimation - resolveOutcome(player, allowInstantDeath = false, allowDiamondArmor = false) + playQuickAnimation( player ) { + gamblingPlayers.remove( player.uniqueId ) + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@playQuickAnimation + resolveOutcome( player, allowInstantDeath = false, allowDiamondArmor = false ) } return AbilityResult.Success } - SlotMachineGui(player).open() + SlotMachineGui( player ).open() return AbilityResult.Success } + } // ========================================================================= @@ -225,108 +295,114 @@ class SpieloKit : Kit() { * 2. Spieler klickt Slot 22 ("Drehen") → Animation startet, Button wird gelb. * 3. Walzen stoppen gestaffelt (Walze 1 → 2 → 3). * 4. Nach dem letzten Stop: Outcome auflösen, GUI schließen. - * - * Der Click-Dispatch muss im [MenuListener] ergänzt werden: - * ```kotlin - * (event.inventory.holder as? SpieloKit.SlotMachineGui)?.onClick(event) - * ``` */ - inner class SlotMachineGui(private val player: Player) : InventoryHolder { + inner class SlotMachineGui( + private val player: Player + ) : InventoryHolder + { private val inv: Inventory = Bukkit.createInventory( this, 27, - mm.deserialize("🎰 Slot-Machine") + mm.deserialize( "🎰 Slot-Machine" ) ) - private val reelSlots = intArrayOf(11, 13, 15) - private val spinButton = 22 + private val reelSlots = intArrayOf( 11, 13, 15 ) + private val spinButton = 22 - // Symbole die auf den Walzen erscheinen (nur visuell – kein Einfluss auf Outcome) private val reelSymbols = listOf( Material.GOLD_NUGGET, Material.EMERALD, Material.IRON_INGOT, Material.GOLDEN_APPLE, Material.MUSHROOM_STEW, Material.EXPERIENCE_BOTTLE, Material.TNT, Material.BARRIER, Material.NETHER_STAR, Material.LAPIS_LAZULI ) - private var isSpinning = false + private var isSpinning = false private var lastAnimTask: BukkitTask? = null override fun getInventory(): Inventory = inv - fun open() { + fun open() + { drawLayout() - player.openInventory(inv) + player.openInventory( inv ) } - private fun drawLayout() { + private fun drawLayout() + { val filler = buildFiller() - repeat(27) { inv.setItem(it, filler) } - reelSlots.forEach { inv.setItem(it, buildReelItem(reelSymbols.random())) } - inv.setItem(spinButton, buildSpinButton(spinning = false)) + repeat( 27 ) { inv.setItem( it, filler ) } + reelSlots.forEach { inv.setItem( it, buildReelItem( reelSymbols.random() ) ) } + inv.setItem( spinButton, buildSpinButton( spinning = false ) ) } - // ── Event-Hooks (aufgerufen von MenuListener) ───────────────────────── + // ── Event hooks (dispatched from MenuListener) ──────────────────────── - fun onClick(event: InventoryClickEvent) { + fun onClick( + event: InventoryClickEvent + ) { event.isCancelled = true - if (isSpinning) return - if (event.rawSlot != spinButton) return + if ( isSpinning ) return + if ( event.rawSlot != spinButton ) return isSpinning = true - gamblingPlayers.add(player.uniqueId) - inv.setItem(spinButton, buildSpinButton(spinning = true)) + gamblingPlayers.add( player.uniqueId ) + inv.setItem( spinButton, buildSpinButton( spinning = true ) ) startSpinAnimation() } - /** Aufgerufen wenn Inventar geschlossen wird (z.B. ESC). */ - fun onClose() { + /** Called when the inventory is closed (e.g. ESC). */ + fun onClose() + { lastAnimTask?.cancel() - // Charge nur zurückgeben wenn noch nicht gedreht wurde - if (!isSpinning) { - gamblingPlayers.remove(player.uniqueId) - } - // Wenn isSpinning == true läuft die Animation noch – Cleanup in onAllReelsStopped + // Only refund the lock if the player never actually spun + if ( !isSpinning ) gamblingPlayers.remove( player.uniqueId ) + // If isSpinning == true the animation is still running — cleanup in onAllReelsStopped } // ── Animation ───────────────────────────────────────────────────────── /** - * Startet die gestaffelte Walzen-Animation. - * Walze 1 stoppt nach 8 Frames, Walze 2 nach 12, Walze 3 nach 16. - * Jeder Frame dauert 2 Ticks (0,1 s). Starts sind versetzt (+5 Ticks pro Walze). + * Starts the staggered reel animation. + * Reel 1 stops after 8 frames, reel 2 after 12, reel 3 after 16. + * Each frame takes 2 ticks (0.1 s). Starts are staggered (+5 ticks per reel). */ - private fun startSpinAnimation() { - val framesPerReel = intArrayOf(8, 12, 16) - val startDelays = longArrayOf(0L, 5L, 10L) - val ticksPerFrame = 2L - var stoppedReels = 0 + private fun startSpinAnimation() + { + val framesPerReel = intArrayOf( 8, 12, 16 ) + val startDelays = longArrayOf( 0L, 5L, 10L ) + val ticksPerFrame = 2L + var stoppedReels = 0 - for (reelIdx in 0..2) { - val slot = reelSlots[reelIdx] - val maxFrames = framesPerReel[reelIdx] - var frame = 0 + for ( reelIdx in 0..2 ) + { + val slot = reelSlots[ reelIdx ] + val maxFrames = framesPerReel[ reelIdx ] + var frame = 0 - val task = object : BukkitRunnable() { + val task = object : BukkitRunnable() + { override fun run() { - if (!player.isOnline) { - this.cancel(); return - } + if ( !player.isOnline ) { this.cancel(); return } frame++ - if (frame <= maxFrames) { - // Zufälliges Walzen-Symbol während Rotation - inv.setItem(slot, buildReelItem(reelSymbols.random())) - val pitch = (0.6f + frame * 0.07f).coerceAtMost(2.0f) - player.playSound(player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.4f, pitch) - } else { - // Einrasten – finales Symbol (zufällig, rein visuell) - inv.setItem(slot, buildReelItem(reelSymbols.random())) - player.playSound(player.location, Sound.BLOCK_NOTE_BLOCK_CHIME, 0.9f, 1.1f + reelIdx * 0.2f) + if ( frame <= maxFrames ) + { + inv.setItem( slot, buildReelItem( reelSymbols.random() ) ) + val pitch = ( 0.6f + frame * 0.07f ).coerceAtMost( 2.0f ) + player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.4f, pitch ) + } + else + { + inv.setItem( slot, buildReelItem( reelSymbols.random() ) ) + player.playSound( + player.location, + Sound.BLOCK_NOTE_BLOCK_CHIME, + 0.9f, 1.1f + reelIdx * 0.2f + ) stoppedReels++ - if (stoppedReels == 3) onAllReelsStopped() + if ( stoppedReels == 3 ) onAllReelsStopped() this.cancel() } @@ -337,42 +413,47 @@ class SpieloKit : Kit() { } } - private fun onAllReelsStopped() { - player.playSound(player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.7f, 1.5f) + private fun onAllReelsStopped() + { + player.playSound( player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.7f, 1.5f ) - // Kurze Pause, dann Outcome auslösen und GUI schließen - Bukkit.getScheduler().runTaskLater(plugin, { -> - gamblingPlayers.remove(player.uniqueId) - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@runTaskLater + Bukkit.getScheduler().runTaskLater( plugin, { -> + gamblingPlayers.remove( player.uniqueId ) + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@runTaskLater player.closeInventory() - // Defensive: kein Instant-Death, keine Dia-Armor - resolveOutcome(player, allowInstantDeath = false, allowDiamondArmor = false) - }, 20L) + resolveOutcome( player, allowInstantDeath = false, allowDiamondArmor = false ) + }, 20L ) } - // ── Item-Builder ────────────────────────────────────────────────────── + // ── Item builders ───────────────────────────────────────────────────── - private fun buildReelItem(material: Material) = ItemStack(material).also { item -> + private fun buildReelItem( + material: Material + ) = ItemStack( material ).also { item -> item.editMeta { meta -> - meta.displayName(Component.text(" ").decoration(TextDecoration.ITALIC, false)) + meta.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) ) } } - private fun buildSpinButton(spinning: Boolean): ItemStack { - val mat = if (spinning) Material.YELLOW_CONCRETE else Material.LIME_CONCRETE - val name = if (spinning) - mm.deserialize("⟳ Spins...") + private fun buildSpinButton( + spinning: Boolean + ): ItemStack + { + val mat = if ( spinning ) Material.YELLOW_CONCRETE else Material.LIME_CONCRETE + val name = if ( spinning ) + mm.deserialize( "⟳ Spins..." ) else - mm.deserialize("▶ Spin!") + mm.deserialize( "▶ Spin!" ) - return ItemStack(mat).also { item -> + return ItemStack( mat ).also { item -> item.editMeta { meta -> - meta.displayName(name.decoration(TextDecoration.ITALIC, false)) - if (!spinning) { + meta.displayName( name.decoration( TextDecoration.ITALIC, false ) ) + if ( !spinning ) + { meta.lore(listOf( Component.empty(), - mm.deserialize("Click to spin the reels.") - .decoration(TextDecoration.ITALIC, false), + mm.deserialize( "Click to spin the reels." ) + .decoration( TextDecoration.ITALIC, false ), Component.empty() )) } @@ -380,165 +461,196 @@ class SpieloKit : Kit() { } } - private fun buildFiller() = ItemStack(Material.BLACK_STAINED_GLASS_PANE).also { item -> + private fun buildFiller() = ItemStack( Material.BLACK_STAINED_GLASS_PANE ).also { item -> item.editMeta { meta -> - meta.displayName(Component.text(" ").decoration(TextDecoration.ITALIC, false)) + meta.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) ) } } + } // ========================================================================= - // Outcome-Auflösung – gemeinsam für Aggressive und Defensive + // Outcome resolution – shared by both playstyles // ========================================================================= /** - * Löst das Gamble-Ergebnis auf. - * @param allowInstantDeath true = Aggressive (5 % Instant Death möglich) - * @param allowDiamondArmor true = Aggressive (Dia-Armor in Loot möglich) + * Resolves the gamble result. + * @param allowInstantDeath `true` = aggressive (5 % instant death possible) + * @param allowDiamondArmor `true` = aggressive (diamond armor in loot pool) */ - fun resolveOutcome(player: Player, allowInstantDeath: Boolean, allowDiamondArmor: Boolean) { + fun resolveOutcome( + player: Player, + allowInstantDeath: Boolean, + allowDiamondArmor: Boolean + ) { val roll = rng.nextDouble() - when { - allowInstantDeath && roll < 0.05 -> triggerInstantDeath(player) - allowInstantDeath && roll < 0.20 -> triggerRandomDisaster(player) - roll < (if (allowInstantDeath) 0.30 else 0.10) -> applyNegativeEffect(player) - roll < (if (allowInstantDeath) 0.50 else 0.30) -> giveNeutralItems(player) - else -> givePositiveItems(player, allowDiamondArmor) + when + { + allowInstantDeath && roll < 0.05 -> triggerInstantDeath( player ) + allowInstantDeath && roll < 0.20 -> triggerRandomDisaster( player ) + roll < ( if ( allowInstantDeath ) 0.30 else 0.10 ) -> applyNegativeEffect( player ) + roll < ( if ( allowInstantDeath ) 0.50 else 0.30 ) -> giveNeutralItems( player ) + else -> givePositiveItems( player, allowDiamondArmor ) } } - // ── Einzelne Outcome-Typen ──────────────────────────────────────────────── + // ── Individual outcome types ────────────────────────────────────────────── - private fun triggerInstantDeath(player: Player) { - player.world.spawnParticle(Particle.EXPLOSION, player.location, 5, 0.5, 0.5, 0.5, 0.0) - player.world.playSound(player.location, Sound.ENTITY_WITHER_SPAWN, 1f, 1.5f) - player.sendActionBar(player.trans("kits.spielo.messages.instant_death")) + private fun triggerInstantDeath( + player: Player + ) { + player.world.spawnParticle( Particle.EXPLOSION, player.location, 5, 0.5, 0.5, 0.5, 0.0 ) + player.world.playSound( player.location, Sound.ENTITY_WITHER_SPAWN, 1f, 1.5f ) + player.sendActionBar( player.trans( "kits.spielo.messages.instant_death" ) ) - // Einen Tick später töten damit das ActionBar-Paket noch ankommt - Bukkit.getScheduler().runTaskLater(plugin, { -> - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@runTaskLater + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@runTaskLater player.health = 0.0 - }, 3L) + }, 3L ) } - private fun triggerRandomDisaster(player: Player) { + private fun triggerRandomDisaster( + player: Player + ) { val disaster = listOf( MeteorDisaster(), TornadoDisaster(), EarthquakeDisaster(), ThunderDisaster() ).random() - disaster.warn(player) - Bukkit.getScheduler().runTaskLater(plugin, { -> - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@runTaskLater - disaster.trigger(plugin, player) - }, disaster.warningDelayTicks) + disaster.warn( player ) + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@runTaskLater + disaster.trigger( plugin, player ) + }, disaster.warningDelayTicks ) - player.world.playSound(player.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.8f, 0.6f) - player.sendActionBar(player.trans("kits.spielo.messages.gamble_event")) + player.world.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.8f, 0.6f ) + player.sendActionBar( player.trans( "kits.spielo.messages.gamble_event" ) ) } - private fun applyNegativeEffect(player: Player) { + private fun applyNegativeEffect( + player: Player + ) { val outcomes: List<() -> Unit> = listOf( - { player.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, 6 * 20, 1)) }, - { player.addPotionEffect(PotionEffect(PotionEffectType.MINING_FATIGUE, 6 * 20, 1)) }, - { player.addPotionEffect(PotionEffect(PotionEffectType.NAUSEA, 5 * 20, 0)) }, - { player.addPotionEffect(PotionEffect(PotionEffectType.WEAKNESS, 8 * 20, 0)) }, + { player.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 6 * 20, 1 ) ) }, + { player.addPotionEffect( PotionEffect( PotionEffectType.MINING_FATIGUE, 6 * 20, 1 ) ) }, + { player.addPotionEffect( PotionEffect( PotionEffectType.NAUSEA, 5 * 20, 0 ) ) }, + { player.addPotionEffect( PotionEffect( PotionEffectType.WEAKNESS, 8 * 20, 0 ) ) }, { player.fireTicks = 4 * 20 } ) outcomes.random().invoke() - player.playSound(player.location, Sound.ENTITY_VILLAGER_NO, 1f, 0.8f) + player.playSound( player.location, Sound.ENTITY_VILLAGER_NO, 1f, 0.8f ) player.world.spawnParticle( Particle.ANGRY_VILLAGER, - player.location.clone().add(0.0, 2.0, 0.0), + player.location.clone().add( 0.0, 2.0, 0.0 ), 8, 0.4, 0.3, 0.4, 0.0 ) - player.sendActionBar(player.trans("kits.spielo.messages.gamble_bad")) + player.sendActionBar( player.trans( "kits.spielo.messages.gamble_bad" ) ) } - private fun giveNeutralItems(player: Player) { + private fun giveNeutralItems( + player: Player + ) { val items = listOf( - ItemStack(Material.ARROW, rng.nextInt(5) + 3), - ItemStack(Material.BREAD, rng.nextInt(4) + 2), - ItemStack(Material.IRON_INGOT, rng.nextInt(3) + 1), - ItemStack(Material.COBBLESTONE, rng.nextInt(8) + 4), + ItemStack( Material.ARROW, rng.nextInt( 5 ) + 3 ), + ItemStack( Material.BREAD, rng.nextInt( 4 ) + 2 ), + ItemStack( Material.IRON_INGOT, rng.nextInt( 3 ) + 1 ), + ItemStack( Material.COBBLESTONE, rng.nextInt( 8 ) + 4 ) ) - player.inventory.addItem(items.random()) + player.inventory.addItem( items.random() ) - player.playSound(player.location, Sound.ENTITY_ITEM_PICKUP, 0.8f, 1.0f) - player.sendActionBar(player.trans("kits.spielo.messages.gamble_neutral")) + player.playSound( player.location, Sound.ENTITY_ITEM_PICKUP, 0.8f, 1.0f ) + player.sendActionBar( player.trans( "kits.spielo.messages.gamble_neutral" ) ) } - private fun givePositiveItems(player: Player, allowDiamondArmor: Boolean) { - data class LootEntry(val item: ItemStack, val weight: Int) + private fun givePositiveItems( + player: Player, + allowDiamondArmor: Boolean + ) { + data class LootEntry( val item: ItemStack, val weight: Int ) val pool = buildList { - add(LootEntry(ItemStack(Material.MUSHROOM_STEW, 3), 30)) - add(LootEntry(ItemStack(Material.MUSHROOM_STEW, 5), 15)) - add(LootEntry(ItemStack(Material.GOLDEN_APPLE), 20)) - add(LootEntry(ItemStack(Material.ENCHANTED_GOLDEN_APPLE), 3)) - add(LootEntry(ItemStack(Material.EXPERIENCE_BOTTLE, 5), 12)) - add(LootEntry(buildSplashPotion(PotionEffectType.STRENGTH, 200, 0), 8)) - add(LootEntry(buildSplashPotion(PotionEffectType.SPEED, 400, 0), 8)) - add(LootEntry(buildSplashPotion(PotionEffectType.REGENERATION, 160, 1), 8)) - // Eisen-Rüstung: immer möglich - add(LootEntry(ItemStack(Material.IRON_CHESTPLATE), 4)) - add(LootEntry(ItemStack(Material.IRON_HELMET), 4)) - // Dia-Rüstung: nur Aggressive - if (allowDiamondArmor) { - add(LootEntry(ItemStack(Material.DIAMOND_CHESTPLATE), 2)) - add(LootEntry(ItemStack(Material.DIAMOND_HELMET), 2)) + add( LootEntry( ItemStack( Material.MUSHROOM_STEW, 3 ), 30 ) ) + add( LootEntry( ItemStack( Material.MUSHROOM_STEW, 5 ), 15 ) ) + add( LootEntry( ItemStack( Material.GOLDEN_APPLE ), 20 ) ) + add( LootEntry( ItemStack( Material.ENCHANTED_GOLDEN_APPLE ), 3 ) ) + add( LootEntry( ItemStack( Material.EXPERIENCE_BOTTLE, 5 ), 12 ) ) + add( LootEntry( buildSplashPotion( PotionEffectType.STRENGTH, 200, 0 ), 8 ) ) + add( LootEntry( buildSplashPotion( PotionEffectType.SPEED, 400, 0 ), 8 ) ) + add( LootEntry( buildSplashPotion( PotionEffectType.REGENERATION, 160, 1 ), 8 ) ) + add( LootEntry( ItemStack( Material.IRON_CHESTPLATE ), 4 ) ) + add( LootEntry( ItemStack( Material.IRON_HELMET ), 4 ) ) + if ( allowDiamondArmor ) + { + add( LootEntry( ItemStack( Material.DIAMOND_CHESTPLATE ), 2 ) ) + add( LootEntry( ItemStack( Material.DIAMOND_HELMET ), 2 ) ) } } val totalWeight = pool.sumOf { it.weight } - var roll = rng.nextInt(totalWeight) - val chosen = pool.first { entry -> roll -= entry.weight; roll < 0 } - player.inventory.addItem(chosen.item.clone()) + var roll = rng.nextInt( totalWeight ) + val chosen = pool.first { entry -> roll -= entry.weight; roll < 0 } + player.inventory.addItem( chosen.item.clone() ) - player.playSound(player.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1.4f) + player.playSound( player.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1.4f ) player.world.spawnParticle( Particle.HAPPY_VILLAGER, - player.location.clone().add(0.0, 1.5, 0.0), + player.location.clone().add( 0.0, 1.5, 0.0 ), 12, 0.4, 0.4, 0.4, 0.0 ) - player.sendActionBar(player.trans("kits.spielo.messages.gamble_good")) + player.sendActionBar( player.trans( "kits.spielo.messages.gamble_good" ) ) } // ========================================================================= - // Stubs + // Shared no-passive placeholder // ========================================================================= - class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) { - override val name = "None" - override val description = "None" + class NoPassive( + playstyle: Playstyle + ) : PassiveAbility( playstyle ) + { + + override val name: String + get() = "None" + + override val description: String + get() = "None" + } // ========================================================================= - // Hilfsmethoden + // Shared helpers // ========================================================================= - /** Klicker-Sounds mit steigendem Pitch, danach Callback. */ - private fun playQuickAnimation(player: Player, onFinish: () -> Unit) { - for (i in 0..5) { - Bukkit.getScheduler().runTaskLater(plugin, { -> - if (!player.isOnline) return@runTaskLater - player.playSound(player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.9f, 0.5f + i * 0.25f) + /** Click-sounds with rising pitch, then fires [onFinish] callback. */ + private fun playQuickAnimation( + player: Player, + onFinish: () -> Unit + ) { + for ( i in 0..5 ) + { + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !player.isOnline ) return@runTaskLater + player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.9f, 0.5f + i * 0.25f ) player.world.spawnParticle( Particle.NOTE, - player.location.clone().add(0.0, 2.3, 0.0), + player.location.clone().add( 0.0, 2.3, 0.0 ), 1, 0.2, 0.1, 0.2, 0.0 ) - }, i * 3L) + }, i * 3L ) } - Bukkit.getScheduler().runTaskLater(plugin, Runnable(onFinish), 18L) + Bukkit.getScheduler().runTaskLater( plugin, Runnable( onFinish ), 18L ) } - private fun buildSplashPotion(type: PotionEffectType, duration: Int, amplifier: Int) = - ItemStack(Material.SPLASH_POTION).also { potion -> - potion.editMeta { meta -> - if (meta is org.bukkit.inventory.meta.PotionMeta) - meta.addCustomEffect(PotionEffect(type, duration, amplifier), true) - } + private fun buildSplashPotion( + type: PotionEffectType, + duration: Int, + amplifier: Int + ) = ItemStack( Material.SPLASH_POTION ).also { potion -> + potion.editMeta { meta -> + if ( meta is org.bukkit.inventory.meta.PotionMeta ) + meta.addCustomEffect( PotionEffect( type, duration, amplifier ), true ) } + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt index 859252f..71a585d 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TeslaKit.kt @@ -16,8 +16,6 @@ import org.bukkit.Sound import org.bukkit.entity.Player import org.bukkit.event.entity.EntityDamageByEntityEvent import org.bukkit.inventory.ItemStack -import org.bukkit.scheduler.BukkitTask -import org.bukkit.util.Vector import java.util.Random import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -27,88 +25,138 @@ import kotlin.math.sin /** * ## TeslaKit * - * | Playstyle | Active | Passive | - * |-------------|---------------------------------------------------|------------------------------------------------| - * | AGGRESSIVE | 5 Blitze im 5-Block-Radius (1.5 ♥ pro Treffer) | Rückschlag + Brandschaden-Aura alle 3 s (klein)| - * | DEFENSIVE | – | Rückschlag + Brandschaden-Aura alle 3 s (groß) | + * | Playstyle | Active | Passive | + * |-------------|---------------------------------------------------|--------------------------------------------------| + * | AGGRESSIVE | [lightningBoltCount] bolts in [lightningRadius] blocks ([lightningDamage] HP each) | [auraChance] % counter-shock on hit | + * | DEFENSIVE | – | [auraChance] % counter-shock on hit | * - * **Höhen-Einschränkung**: Beide Mechaniken deaktivieren sich ab Y > [MAX_HEIGHT_Y] - * (~50 Blöcke über Meeresspiegel). Tesla braucht Erdkontakt. + * **Height restriction**: Both mechanics deactivate above Y > [MAX_HEIGHT_Y]. + * Tesla needs ground contact. * - * ### Technische Lösung – „Visueller Blitz + manueller Schaden": - * `world.strikeLightningEffect()` erzeugt nur Partikel/Sound – keinen Block-/Entity-Schaden. - * Direkt danach werden Spieler im 1,5-Block-Radius per `entity.damage()` manuell getroffen. - * Das verhindert ungewollte Nebeneffekte (Feuer, Dorfbewohner-Schaden, eigener Tod durch - * zufälligen Blitzschlag). + * ### Technical note – "Visual lightning + manual damage": + * `world.strikeLightningEffect()` produces only particles/sound — no block or entity damage. + * Players within 1.5 blocks of the strike are then damaged manually via `entity.damage()`. + * This prevents unwanted side-effects (fire, villager conversion, self-death from random bolts). * - * ### Passive Aura: - * Ein `BukkitRunnable` (gestartet in `onActivate`, gestoppt in `onDeactivate`) prüft alle - * [AURA_INTERVAL_TICKS] Ticks, ob Gegner in [AURA_RADIUS] Blöcken sind. Falls ja → Velocity-Push - * nach außen + `fireTicks`. Aggressive-Playstyle hat schwächeren Rückschlag, Defensive stärkeren. + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-----------------------|--------|---------|--------------------------------------------------------| + * | `lightning_radius` | Double | `5.0` | Radius (blocks) in which bolts are scattered | + * | `lightning_damage` | Double | `3.0` | HP damage per player hit by a bolt | + * | `lightning_bolt_count`| Int | `5` | Number of bolts fired per activation | + * | `bolt_stagger_ticks` | Long | `8` | Ticks between each successive bolt | + * | `aura_chance` | Double | `0.05` | Probability (0–1) of the counter-shock passive firing | + * | `aura_fire_ticks` | Int | `60` | Fire ticks applied to the attacker by the counter-shock| */ -class TeslaKit : Kit() { +class TeslaKit : Kit() +{ private val plugin get() = SpeedHG.instance override val id: String get() = "tesla" + override val displayName: Component get() = plugin.languageManager.getDefaultComponent( "kits.tesla.name", mapOf() ) + override val lore: List get() = plugin.languageManager.getDefaultRawMessageList( "kits.tesla.lore" ) + override val icon: Material get() = Material.LIGHTNING_ROD companion object { - /** - * ~50 Blöcke über Meeresspiegel ( Y ≈ 63 + 50 = 113 ) - * Oberhalb dieser Grenze sind beide Fähigkeiten deaktiviert. - */ + /** ~50 blocks above sea level (Y ≈ 63 + 50 = 113). Above this both abilities deactivate. */ const val MAX_HEIGHT_Y = 113.0 - // Aggressive Active - const val LIGHTNING_RADIUS = 5.0 - const val LIGHTNING_DAMAGE = 3.0 - const val LIGHTNING_BOLT_COUNT = 5 - const val BOLT_STAGGER_TICKS = 8L - - // Passive Aura - const val AURA_CHANCE = 0.05 - const val AURA_FIRE_TICKS = 60 + const val DEFAULT_LIGHTNING_RADIUS = 5.0 + const val DEFAULT_LIGHTNING_DAMAGE = 3.0 + const val DEFAULT_LIGHTNING_BOLT_COUNT = 5 + const val DEFAULT_BOLT_STAGGER_TICKS = 8L + const val DEFAULT_AURA_CHANCE = 0.05 + const val DEFAULT_AURA_FIRE_TICKS = 60 } - // ── Gecachte Instanzen ──────────────────────────────────────────────────── + // ── Live config accessors ───────────────────────────────────────────────── + /** + * Radius in blocks within which lightning bolts are randomly scattered. + * JSON key: `lightning_radius` + */ + private val lightningRadius: Double + get() = override().getDouble( "lightning_radius" ) ?: DEFAULT_LIGHTNING_RADIUS + + /** + * HP damage dealt to each player struck by a bolt (within 1.5 blocks of impact). + * JSON key: `lightning_damage` + */ + private val lightningDamage: Double + get() = override().getDouble( "lightning_damage" ) ?: DEFAULT_LIGHTNING_DAMAGE + + /** + * Total number of bolts fired per activation. + * JSON key: `lightning_bolt_count` + */ + private val lightningBoltCount: Int + get() = override().getInt( "lightning_bolt_count" ) ?: DEFAULT_LIGHTNING_BOLT_COUNT + + /** + * Ticks between each successive bolt in the staggered sequence. + * JSON key: `bolt_stagger_ticks` + */ + private val boltStaggerTicks: Long + get() = override().getLong( "bolt_stagger_ticks" ) ?: DEFAULT_BOLT_STAGGER_TICKS + + /** + * Probability (0.0–1.0) that the counter-shock passive fires on being hit. + * JSON key: `aura_chance` + */ + private val auraChance: Double + get() = override().getDouble( "aura_chance" ) ?: DEFAULT_AURA_CHANCE + + /** + * Fire ticks applied to the attacker when the counter-shock passive procs. + * JSON key: `aura_fire_ticks` + */ + private val auraFireTicks: Int + get() = override().getInt( "aura_fire_ticks" ) ?: DEFAULT_AURA_FIRE_TICKS + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() - private val defensiveActive = NoActive(Playstyle.DEFENSIVE) - private val aggressivePassive = TeslaPassive( - playstyle = Playstyle.AGGRESSIVE - ) - private val defensivePassive = TeslaPassive( - playstyle = Playstyle.DEFENSIVE - ) + private val defensiveActive = NoActive( Playstyle.DEFENSIVE ) + private val aggressivePassive = TeslaPassive( Playstyle.AGGRESSIVE ) + private val defensivePassive = TeslaPassive( Playstyle.DEFENSIVE ) + + // ── Playstyle routing ───────────────────────────────────────────────────── override fun getActiveAbility( playstyle: Playstyle - ) = when (playstyle) { + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } + override fun getPassiveAbility( playstyle: Playstyle - ) = when (playstyle) { + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() override fun giveItems( player: Player, playstyle: Playstyle ) { - if ( playstyle != Playstyle.AGGRESSIVE ) - return + if ( playstyle != Playstyle.AGGRESSIVE ) return val item = ItemBuilder( Material.LIGHTNING_ROD ) .name( aggressiveActive.name ) @@ -119,6 +167,8 @@ class TeslaKit : Kit() { player.inventory.addItem( item ) } + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + override fun onRemove( player: Player ) { @@ -126,22 +176,27 @@ class TeslaKit : Kit() { } // ========================================================================= - // AGGRESSIVE active – gestaffelte Blitze im Nahbereich + // AGGRESSIVE active – staggered lightning bolts in melee range // ========================================================================= - private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance private val rng = Random() override val kitId: String get() = "tesla" + override val name: String get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.name" ) + override val description: String get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.description" ) + override val triggerMaterial: Material get() = Material.LIGHTNING_ROD + override val hardcodedHitsRequired: Int get() = 15 @@ -156,32 +211,37 @@ class TeslaKit : Kit() { val world = player.world - repeat( LIGHTNING_BOLT_COUNT ) { index -> - Bukkit.getScheduler().runTaskLater( plugin, { -> - if ( !player.isOnline ) - return@runTaskLater + // Snapshot all config values at activation time so a mid-round + // config change cannot alter a bolt sequence already in flight. + val capturedRadius = lightningRadius + val capturedDamage = lightningDamage + val capturedBoltCount = lightningBoltCount + val capturedStaggerTicks = boltStaggerTicks - // Zufällige Position innerhalb des Radius - val angle = rng.nextDouble() * 2.0 * Math.PI - val dist = rng.nextDouble() * LIGHTNING_RADIUS + repeat( capturedBoltCount ) { index -> + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !player.isOnline ) return@runTaskLater + + val angle = rng.nextDouble() * 2.0 * Math.PI + val dist = rng.nextDouble() * capturedRadius val strikeLoc = player.location.clone().add( cos( angle ) * dist, 0.0, sin( angle ) * dist ) - // Oberfläche bestimmen (Blitze sollen am Boden landen) + // Land the bolt on the surface strikeLoc.y = world.getHighestBlockYAt( strikeLoc ).toDouble() + 1.0 - // Nur visueller Effekt – KEIN Block-/Feuer-Schaden + // Visual only — no block/fire damage world.strikeLightningEffect( strikeLoc ) - // Manueller Schaden an Spielern im Nahbereich des Einschlags + // Manual damage to nearby players world.getNearbyEntities( strikeLoc, 1.5, 1.5, 1.5 ) .filterIsInstance() .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } .forEach { victim -> - victim.damage( LIGHTNING_DAMAGE, player ) + victim.damage( capturedDamage, player ) victim.world.spawnParticle( Particle.ELECTRIC_SPARK, victim.location.clone().add( 0.0, 1.0, 0.0 ), @@ -194,29 +254,31 @@ class TeslaKit : Kit() { 12, 0.3, 0.2, 0.3, 0.08 ) - }, index * BOLT_STAGGER_TICKS ) + }, index * capturedStaggerTicks ) } player.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 1.3f ) - player.sendActionBar(player.trans( "kits.tesla.messages.lightning_cast" )) + player.sendActionBar( player.trans( "kits.tesla.messages.lightning_cast" ) ) return AbilityResult.Success } } // ========================================================================= - // Passive Aura – Rückschlag + Brandschaden im Umkreis (beide Playstyles) + // Passive – counter-shock aura (both playstyles) // ========================================================================= - class TeslaPassive( + inner class TeslaPassive( playstyle: Playstyle - ) : PassiveAbility( playstyle ) { + ) : PassiveAbility( playstyle ) + { private val plugin get() = SpeedHG.instance private val rng = Random() override val name: String get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.name" ) + override val description: String get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.description" ) @@ -225,23 +287,23 @@ class TeslaKit : Kit() { attacker: Player, event: EntityDamageByEntityEvent ) { - if ( rng.nextDouble() > AURA_CHANCE ) - return + // Snapshot at hit time so a config change mid-round is consistent + if ( rng.nextDouble() > auraChance ) return - attacker.fireTicks = AURA_FIRE_TICKS + val capturedFireTicks = auraFireTicks + + attacker.fireTicks = capturedFireTicks attacker.world.spawnParticle( Particle.ELECTRIC_SPARK, attacker.location.clone().add( 0.0, 1.0, 0.0 ), 10, 0.3, 0.4, 0.3, 0.06 ) - victim.world.spawnParticle( Particle.ELECTRIC_SPARK, victim.location.clone().add( 0.0, 1.0, 0.0 ), 6, 0.6, 0.6, 0.6, 0.02 ) - victim.world.playSound( victim.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, @@ -251,15 +313,34 @@ class TeslaKit : Kit() { } - // ── Kein Active für Defensive ───────────────────────────────────────────── + // ========================================================================= + // Defensive no-active placeholder + // ========================================================================= + + private class NoActive( + playstyle: Playstyle + ) : ActiveAbility( playstyle ) + { + + override val kitId: String + get() = "tesla" + + override val name: String + get() = "None" + + override val description: String + get() = "None" + + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.BARRIER + + override fun execute( + player: Player + ): AbilityResult = AbilityResult.Success - private class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) { - override val kitId = "tesla" - override val name = "None" - override val description = "None" - override val hardcodedHitsRequired = 0 - override val triggerMaterial = Material.BARRIER - override fun execute(player: Player) = AbilityResult.Success } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TridentKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TridentKit.kt index 7db0b97..abe037b 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TridentKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TridentKit.kt @@ -27,63 +27,124 @@ import java.util.concurrent.ConcurrentHashMap /** * ## TridentKit * - * | Playstyle | Fähigkeit | - * |-------------|----------------------------------------------------------------------------| - * | AGGRESSIVE | **Dive**: 3 Charges – Hochsprung, bei Landung schlägt Blitz ein | - * | DEFENSIVE | **Parry**: 20 % Chance – Angreifer abprallen + Slowness I (2 s) | + * | Playstyle | Active | Passive | + * |-------------|-----------------------------------------------------------------------------|------------------------------------------------| + * | AGGRESSIVE | **Dive** – [maxDiveCharges] charges; launches up, lightning strikes on land | – | + * | DEFENSIVE | – | **Parry** – [parryChance] % bounce + Slowness | * - * ### Dive-Mechanismus - * `hitsRequired = 0` → Fähigkeit ist immer READY; interne [diveCharges] verwalten - * die 3 Sprünge einer Sequenz. Coodown [SEQUENCE_COOLDOWN_MS] gilt nur zwischen - * vollständigen Sequenzen (wenn alle Charges verbraucht wurden). + * ### Dive mechanic + * `hitsRequired = 0` → ability is always READY; internal [diveCharges] manage + * the per-sequence uses. [sequenceCooldownMs] applies only between full sequences + * (when all charges are exhausted). * - * Jeder Charge-Verbrauch startet einen 1-Tick-Monitor: - * 1. Warte auf Velocity-Wechsel (aufwärts → abwärts) - * 2. Sobald Block unterhalb solid → [triggerLightningStrike] + * Each charge consumption starts a 1-tick landing monitor: + * 1. Wait for velocity to flip (ascending → descending). + * 2. Once the block below is solid → [triggerLightningStrike]. * - * ### Parry-Mechanismus - * [onHitByEnemy] mit 20 % Chance + Dreizack-Check (Haupt- oder Offhand). + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * All values read from the `extras` map with companion-object defaults as fallback. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-------------------------|--------|------------|------------------------------------------------------| + * | `max_dive_charges` | Int | `3` | Number of dive charges per sequence | + * | `sequence_cooldown_ms` | Long | `25_000` | Cooldown between full sequences in milliseconds | + * | `lightning_radius` | Double | `3.5` | Radius (blocks) of the landing lightning strike | + * | `lightning_damage` | Double | `4.0` | HP damage dealt to enemies hit by the lightning | + * | `parry_chance` | Double | `0.20` | Probability (0–1) of the parry passive firing | + * | `parry_slowness_ticks` | Int | `40` | Slowness I ticks applied to the attacker on parry | */ -class TridentKit : Kit() { +class TridentKit : Kit() +{ private val plugin get() = SpeedHG.instance override val id: String get() = "trident" + override val displayName: Component get() = plugin.languageManager.getDefaultComponent( "kits.trident.name", mapOf() ) + override val lore: List get() = plugin.languageManager.getDefaultRawMessageList( "kits.trident.lore" ) + override val icon: Material get() = Material.TRIDENT - /** Verbleibende Dive-Charges: 0 = neue Sequenz erforderlich. */ - internal val diveCharges: MutableMap = ConcurrentHashMap() - private val diveMonitors: MutableMap = ConcurrentHashMap() - private val lastSequenceTime: MutableMap = ConcurrentHashMap() - - /** Players who have recently launched a dive and should not receive fall damage. */ - internal val noFallDamagePlayers: MutableSet = ConcurrentHashMap.newKeySet() - companion object { - const val MAX_DIVE_CHARGES = 3 - const val SEQUENCE_COOLDOWN_MS = 25_000L // Cooldown zwischen vollst. Sequenzen - const val LIGHTNING_RADIUS = 3.5 // Blöcke um den Einschlagpunkt - const val LIGHTNING_DAMAGE = 4.0 // 2 Herzen - const val PARRY_CHANCE = 0.20 // 20 % - const val PARRY_SLOWNESS_TICKS = 40 // 2 Sekunden + const val DEFAULT_MAX_DIVE_CHARGES = 3 + const val DEFAULT_SEQUENCE_COOLDOWN_MS = 25_000L + const val DEFAULT_LIGHTNING_RADIUS = 3.5 + const val DEFAULT_LIGHTNING_DAMAGE = 4.0 + const val DEFAULT_PARRY_CHANCE = 0.20 + const val DEFAULT_PARRY_SLOWNESS_TICKS = 40 } - // ── Gecachte Instanzen ──────────────────────────────────────────────────── + // ── Live config accessors ───────────────────────────────────────────────── + /** + * Number of dive charges granted at the start of each sequence. + * JSON key: `max_dive_charges` + */ + private val maxDiveCharges: Int + get() = override().getInt( "max_dive_charges" ) ?: DEFAULT_MAX_DIVE_CHARGES + + /** + * Cooldown in milliseconds between full dive sequences. + * JSON key: `sequence_cooldown_ms` + */ + private val sequenceCooldownMs: Long + get() = override().getLong( "sequence_cooldown_ms" ) ?: DEFAULT_SEQUENCE_COOLDOWN_MS + + /** + * Radius in blocks of the lightning strike triggered on landing. + * JSON key: `lightning_radius` + */ + private val lightningRadius: Double + get() = override().getDouble( "lightning_radius" ) ?: DEFAULT_LIGHTNING_RADIUS + + /** + * HP damage dealt to each player hit by the landing lightning strike. + * JSON key: `lightning_damage` + */ + private val lightningDamage: Double + get() = override().getDouble( "lightning_damage" ) ?: DEFAULT_LIGHTNING_DAMAGE + + /** + * Probability (0.0–1.0) of the defensive parry passive firing on being hit. + * JSON key: `parry_chance` + */ + private val parryChance: Double + get() = override().getDouble( "parry_chance" ) ?: DEFAULT_PARRY_CHANCE + + /** + * Duration in ticks of Slowness I applied to the attacker when parried. + * JSON key: `parry_slowness_ticks` + */ + private val parrySlownessTicks: Int + get() = override().getInt( "parry_slowness_ticks" ) ?: DEFAULT_PARRY_SLOWNESS_TICKS + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** Remaining dive charges per player: 0 = new sequence required. */ + internal val diveCharges: MutableMap = ConcurrentHashMap() + private val diveMonitors: MutableMap = ConcurrentHashMap() + private val lastSequenceTime: MutableMap = ConcurrentHashMap() + + /** Players who have recently launched a dive and must not receive fall damage. */ + internal val noFallDamagePlayers: MutableSet = ConcurrentHashMap.newKeySet() + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = DiveActive() private val defensiveActive = NoActive( Playstyle.DEFENSIVE ) private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE ) private val defensivePassive = ParryPassive() + // ── Playstyle routing ───────────────────────────────────────────────────── + override fun getActiveAbility( playstyle: Playstyle - ) = when( playstyle ) + ): ActiveAbility = when( playstyle ) { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive @@ -91,12 +152,14 @@ class TridentKit : Kit() { override fun getPassiveAbility( playstyle: Playstyle - ) = when( playstyle ) + ): PassiveAbility = when( playstyle ) { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() override fun giveItems( @@ -109,7 +172,7 @@ class TridentKit : Kit() { "kits.trident.item.trident.defensive.name" val trident = ItemBuilder( Material.TRIDENT ) - .name(plugin.languageManager.getDefaultRawMessage( nameKey )) + .name( plugin.languageManager.getDefaultRawMessage( nameKey ) ) .lore(listOf( plugin.languageManager.getDefaultRawMessage( if ( playstyle == Playstyle.AGGRESSIVE ) @@ -126,6 +189,8 @@ class TridentKit : Kit() { player.inventory.addItem( trident ) } + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + override fun onRemove( player: Player ) { @@ -137,7 +202,7 @@ class TridentKit : Kit() { } // ========================================================================= - // Dive: Landungs-Monitor + // Dive: landing monitor // ========================================================================= private fun startDiveMonitor( @@ -146,14 +211,14 @@ class TridentKit : Kit() { diveMonitors.remove( player.uniqueId )?.cancel() var wasAscending = true - var elapsed = 0 + var elapsed = 0 val task = Bukkit.getScheduler().runTaskTimer( plugin, { -> elapsed++ - // Safety-Timeout: 10 Sekunden + // Safety timeout: 10 seconds if ( elapsed > 200 || !player.isOnline || - !plugin.gameManager.alivePlayers.contains( player.uniqueId )) + !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) { diveMonitors.remove( player.uniqueId )?.cancel() return@runTaskTimer @@ -180,7 +245,7 @@ class TridentKit : Kit() { diveMonitors.remove( player.uniqueId )?.cancel() } } - }, 4L, 1L ) // 4 Ticks Anlauf (verhindert Sofort-Trigger auf dem Boden) + }, 4L, 1L ) // 4-tick head-start prevents instant ground trigger diveMonitors[ player.uniqueId ] = task } @@ -188,46 +253,55 @@ class TridentKit : Kit() { private fun triggerLightningStrike( player: Player ) { - val loc = player.location + val loc = player.location val world = loc.world ?: return world.strikeLightningEffect( loc ) world.playSound( loc, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 0.75f ) world.spawnParticle( Particle.ELECTRIC_SPARK, loc, 45, 1.2, 0.5, 1.2, 0.2 ) - world.spawnParticle( Particle.EXPLOSION, loc, 3, 0.4, 0.2, 0.4, 0.0 ) + world.spawnParticle( Particle.EXPLOSION, loc, 3, 0.4, 0.2, 0.4, 0.0 ) - world.getNearbyEntities( loc, LIGHTNING_RADIUS, LIGHTNING_RADIUS, LIGHTNING_RADIUS ) + // Snapshot at landing time so a mid-sequence config change is consistent + val capturedRadius = lightningRadius + val capturedDamage = lightningDamage + + world.getNearbyEntities( loc, capturedRadius, capturedRadius, capturedRadius ) .filterIsInstance() .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } .forEach { enemy -> - enemy.damage( LIGHTNING_DAMAGE, player ) - enemy.addPotionEffect(PotionEffect( - PotionEffectType.SLOWNESS, 40, 0, false, false, true - )) + enemy.damage( capturedDamage, player ) + enemy.addPotionEffect( + PotionEffect( PotionEffectType.SLOWNESS, 40, 0, false, false, true ) + ) } val remaining = diveCharges.getOrDefault( player.uniqueId, 0 ) - val msgKey = if ( remaining > 0 ) "kits.trident.messages.charges_left" - else "kits.trident.messages.sequence_done" - player.sendActionBar(player.trans( msgKey, "charges" to remaining.toString() )) + val msgKey = if ( remaining > 0 ) "kits.trident.messages.charges_left" + else "kits.trident.messages.sequence_done" + player.sendActionBar( player.trans( msgKey, "charges" to remaining.toString() ) ) } // ========================================================================= - // AGGRESSIVE active – Dive-Charges + // AGGRESSIVE active – Dive charges // ========================================================================= - private inner class DiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { + private inner class DiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance override val kitId: String get() = "trident" + override val name: String get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.items.trident.aggressive.name" ) + override val description: String get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.items.trident.aggressive.description" ) + override val triggerMaterial: Material get() = Material.TRIDENT + override val hardcodedHitsRequired: Int get() = 0 @@ -235,19 +309,23 @@ class TridentKit : Kit() { player: Player ): AbilityResult { - val now = System.currentTimeMillis() + val now = System.currentTimeMillis() val charges = diveCharges.getOrDefault( player.uniqueId, 0 ) + // Snapshot config values at activation time + val capturedMaxCharges = maxDiveCharges + val capturedCooldownMs = sequenceCooldownMs + if ( charges <= 0 ) { val lastSeq = lastSequenceTime[ player.uniqueId ] ?: 0L - if ( now - lastSeq < SEQUENCE_COOLDOWN_MS ) + if ( now - lastSeq < capturedCooldownMs ) { - val secLeft = ( SEQUENCE_COOLDOWN_MS - ( now - lastSeq )) / 1000 - return AbilityResult.ConditionNotMet("Cooldown: ${secLeft}s") + val secLeft = ( capturedCooldownMs - ( now - lastSeq ) ) / 1000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secLeft}s" ) } lastSequenceTime[ player.uniqueId ] = now - diveCharges[ player.uniqueId ] = MAX_DIVE_CHARGES - 1 + diveCharges[ player.uniqueId ] = capturedMaxCharges - 1 } else diveCharges[ player.uniqueId ] = charges - 1 @@ -255,19 +333,16 @@ class TridentKit : Kit() { noFallDamagePlayers.add( player.uniqueId ) val remaining = diveCharges.getOrDefault( player.uniqueId, 0 ) - player.sendActionBar(player.trans( "kits.trident.messages.dive_launched", "charges" to remaining.toString() )) + player.sendActionBar( + player.trans( "kits.trident.messages.dive_launched", "charges" to remaining.toString() ) + ) player.world.spawnParticle( Particle.ELECTRIC_SPARK, player.location.clone().add( 0.0, 0.5, 0.0 ), 15, 0.3, 0.2, 0.3, 0.12 ) - - player.playSound( - player.location, - Sound.ENTITY_LIGHTNING_BOLT_IMPACT, - 0.7f, 1.6f - ) + player.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 0.7f, 1.6f ) startDiveMonitor( player ) return AbilityResult.Success @@ -276,69 +351,98 @@ class TridentKit : Kit() { } // ========================================================================= - // DEFENSIVE passive – Parry (20 %) + // DEFENSIVE passive – Parry // ========================================================================= - private inner class ParryPassive : PassiveAbility( Playstyle.DEFENSIVE ) { + private inner class ParryPassive : PassiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance private val rng = Random() override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.trident.passive.defensive.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.passive.defensive.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.trident.passive.defensive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.passive.defensive.description" ) override fun onHitByEnemy( victim: Player, attacker: Player, event: EntityDamageByEntityEvent ) { - if ( rng.nextDouble() >= PARRY_CHANCE ) return + // Snapshot at hit time + if ( rng.nextDouble() >= parryChance ) return val mainType = victim.inventory.itemInMainHand.type - val offType = victim.inventory.itemInOffHand.type + val offType = victim.inventory.itemInOffHand.type if ( mainType != Material.TRIDENT && offType != Material.TRIDENT ) return + val capturedSlownessTicks = parrySlownessTicks + attacker.velocity = attacker.location.toVector() .subtract( victim.location.toVector() ) .normalize() .multiply( 1.7 ) .setY( 0.45 ) - attacker.addPotionEffect(PotionEffect( - PotionEffectType.SLOWNESS, PARRY_SLOWNESS_TICKS, 0, false, false, true - )) + attacker.addPotionEffect( + PotionEffect( PotionEffectType.SLOWNESS, capturedSlownessTicks, 0, false, false, true ) + ) victim.world.spawnParticle( Particle.SWEEP_ATTACK, victim.location.clone().add( 0.0, 1.0, 0.0 ), 6, 0.3, 0.3, 0.3, 0.0 ) - victim.world.playSound( victim.location, Sound.ITEM_SHIELD_BLOCK, 1f, 0.65f ) victim.world.playSound( victim.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 0.3f, 1.9f ) - victim.sendActionBar(victim.trans( "kits.trident.messages.parry_success" )) - attacker.sendActionBar(attacker.trans( "kits.trident.messages.parried_by_victim" )) + victim.sendActionBar( victim.trans( "kits.trident.messages.parry_success" ) ) + attacker.sendActionBar( attacker.trans( "kits.trident.messages.parried_by_victim" ) ) } } - // ─── Stubs ──────────────────────────────────────────────────────────────── + // ── Stubs ───────────────────────────────────────────────────────────────── + + private class NoActive( + playstyle: Playstyle + ) : ActiveAbility( playstyle ) + { + + override val kitId: String + get() = "trident" + + override val name: String + get() = "None" + + override val description: String + get() = "None" + + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.BARRIER + + override fun execute( + player: Player + ): AbilityResult = AbilityResult.Success - private class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) { - override val kitId = "trident" - override val name = "None" - override val description = "None" - override val hardcodedHitsRequired = 0 - 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 description = "None" + private class NoPassive( + playstyle: Playstyle + ) : PassiveAbility( playstyle ) + { + + override val name: String + get() = "None" + + override val description: String + get() = "None" + } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt index 8e0b862..f83c70f 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VenomKit.kt @@ -1,7 +1,6 @@ package club.mcscrims.speedhg.kit.impl import club.mcscrims.speedhg.SpeedHG -import club.mcscrims.speedhg.config.CustomGameSettings import club.mcscrims.speedhg.kit.Kit import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.ability.AbilityResult @@ -26,10 +25,29 @@ import java.util.concurrent.ConcurrentHashMap import kotlin.math.cos import kotlin.math.sin -class VenomKit : Kit() { +/** + * ## VenomKit + * + * | Playstyle | Active | Passive | + * |-------------|--------------------------------------------------------------|-------------------------------------------------| + * | AGGRESSIVE | **Deafening Beam** – dragon-breath raycast, Blindness + Wither | – | + * | DEFENSIVE | **Shield of Darkness** – absorbs [shieldCapacity] damage for [shieldDurationTicks] ticks | Absorb hits while shield is active | + * + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * The two shield parameters are **typed fields** in [CustomGameSettings.KitOverride] + * and are therefore accessed directly rather than through `extras`. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |-------------------------|--------|---------|-------------------------------------------------------| + * | `shield_duration_ticks` | Long | `160` | Ticks the shield stays active before expiring | + * | `shield_capacity` | Double | `15.0` | Total damage the shield can absorb before breaking | + */ +class VenomKit : Kit() +{ data class ActiveShield( - var remainingCapacity: Double = 15.0, + var remainingCapacity: Double, val expireTask: BukkitTask, val particleTask: BukkitTask ) @@ -49,11 +67,27 @@ class VenomKit : Kit() { override val icon: Material get() = Material.SPIDER_EYE - private val kitOverride: CustomGameSettings.KitOverride by lazy { - plugin.customGameManager.settings.kits.kits["venom"] - ?: CustomGameSettings.KitOverride() + companion object { + const val DEFAULT_SHIELD_DURATION_TICKS = 160L + const val DEFAULT_SHIELD_CAPACITY = 15.0 } + // ── Live config accessors (typed KitOverride fields) ────────────────────── + + /** + * Ticks the shield stays active before expiring automatically. + * Source: typed field `shield_duration_ticks`. + */ + private val shieldDurationTicks: Long + get() = override().shieldDurationTicks + + /** + * Total damage the shield absorbs before it breaks. + * Source: typed field `shield_capacity`. + */ + private val shieldCapacity: Double + get() = override().shieldCapacity + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() @@ -112,7 +146,7 @@ class VenomKit : Kit() { } } - // ── Optional lifecycle hooks ────────────────────────────────────────────── + // ── Lifecycle hooks ─────────────────────────────────────────────────────── override fun onRemove( player: Player @@ -122,11 +156,15 @@ class VenomKit : Kit() { shield.particleTask.cancel() activeShields.remove( player.uniqueId ) } - val items = cachedItems.remove( player.uniqueId ) ?: return - items.forEach { player.inventory.remove( it ) } + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } - private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) { + // ========================================================================= + // AGGRESSIVE active – Deafening Beam + // ========================================================================= + + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance @@ -160,19 +198,24 @@ class VenomKit : Kit() { ) { target -> target.addPotionEffects(listOf( PotionEffect( PotionEffectType.BLINDNESS, 100, 0 ), - PotionEffect( PotionEffectType.WITHER, 100, 0 ) + PotionEffect( PotionEffectType.WITHER, 100, 0 ) )) target.damage( 4.0, player ) player.world.playSound( target.location, Sound.ENTITY_DRAGON_FIREBALL_EXPLODE, 1f, 0.8f ) } - player.sendActionBar(player.trans( "kits.venom.messages.wither_beam" )) + player.sendActionBar( player.trans( "kits.venom.messages.wither_beam" ) ) return AbilityResult.Success } } - private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) { + // ========================================================================= + // DEFENSIVE active – Shield of Darkness + // ========================================================================= + + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance @@ -195,33 +238,38 @@ class VenomKit : Kit() { player: Player ): AbilityResult { - if (activeShields.containsKey( player.uniqueId )) + if ( activeShields.containsKey( player.uniqueId ) ) return AbilityResult.ConditionNotMet( "Shield is already active!" ) + // Snapshot config values at activation time so a mid-round change + // does not alter an already-running shield's duration or capacity. + val capturedDurationTicks = shieldDurationTicks + val capturedCapacity = shieldCapacity + player.playSound( player.location, Sound.ENTITY_BLAZE_AMBIENT, 1f, 0.5f ) - val particleTask = object : BukkitRunnable() { - + val particleTask = object : BukkitRunnable() + { var rotation = 0.0 override fun run() { if ( !player.isOnline || - !plugin.gameManager.alivePlayers.contains( player.uniqueId ) || - !activeShields.containsKey( player.uniqueId )) + !plugin.gameManager.alivePlayers.contains( player.uniqueId ) || + !activeShields.containsKey( player.uniqueId ) ) { this.cancel() return } - val loc = player.location + val loc = player.location val radius = 1.2 for ( i in 0 until 8 ) { val angle = ( 2 * Math.PI * i / 8 ) + rotation - val x = cos( angle ) * radius - val z = sin( angle ) * radius + val x = cos( angle ) * radius + val z = sin( angle ) * radius loc.world.spawnParticle( Particle.LARGE_SMOKE, @@ -233,27 +281,33 @@ class VenomKit : Kit() { } }.runTaskTimer( plugin, 0L, 2L ) - val expireTask = object : BukkitRunnable() { + val expireTask = object : BukkitRunnable() + { override fun run() { - if (activeShields.containsKey( player.uniqueId )) + if ( activeShields.containsKey( player.uniqueId ) ) breakShield( player ) } - }.runTaskLater( plugin, kitOverride.shieldDurationTicks ) + }.runTaskLater( plugin, capturedDurationTicks ) activeShields[ player.uniqueId ] = ActiveShield( - remainingCapacity = kitOverride.shieldCapacity, - expireTask = expireTask, - particleTask = particleTask + remainingCapacity = capturedCapacity, + expireTask = expireTask, + particleTask = particleTask ) - player.sendActionBar(player.trans( "kits.venom.messages.shield_activate" )) + player.sendActionBar( player.trans( "kits.venom.messages.shield_activate" ) ) return AbilityResult.Success } } - private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) { + // ========================================================================= + // DEFENSIVE passive – absorb incoming damage while shield is active + // ========================================================================= + + private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance @@ -268,16 +322,21 @@ class VenomKit : Kit() { attacker: Player, event: EntityDamageByEntityEvent ) { - activeShields[victim.uniqueId]?.apply { + activeShields[ victim.uniqueId ]?.apply { remainingCapacity -= event.damage - event.damage = if (event.isCritical) 3.0 else 2.0 - if (remainingCapacity <= 0) breakShield(victim) + event.damage = if ( event.isCritical ) 3.0 else 2.0 + if ( remainingCapacity <= 0 ) breakShield( victim ) } ?: return } } - private class NoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) { + // ========================================================================= + // AGGRESSIVE passive – stub (no passive ability) + // ========================================================================= + + private class NoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { override val name: String get() = "None" @@ -287,7 +346,7 @@ class VenomKit : Kit() { } - // ── Helper methods ────────────────────────────────────────────── + // ── Shared helpers ──────────────────────────────────────────────────────── private fun breakShield( player: Player @@ -298,7 +357,7 @@ class VenomKit : Kit() { shield.particleTask.cancel() player.world.playSound( player.location, Sound.ENTITY_WITHER_BREAK_BLOCK, 1f, 1f ) - player.sendActionBar(player.trans( "kits.venom.messages.shield_break" )) + player.sendActionBar( player.trans( "kits.venom.messages.shield_break" ) ) } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt index 472de19..cbc37a5 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/VoodooKit.kt @@ -1,7 +1,6 @@ package club.mcscrims.speedhg.kit.impl import club.mcscrims.speedhg.SpeedHG -import club.mcscrims.speedhg.config.CustomGameSettings import club.mcscrims.speedhg.kit.Kit import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.ability.AbilityResult @@ -26,83 +25,130 @@ import java.util.* import java.util.concurrent.ConcurrentHashMap /** - * ## Voodoo + * ## VoodooKit * - * | Playstyle | Active | Passive | - * |-------------|-----------------------------------------------------|---------------------------------------------| - * | AGGRESSIVE | Root enemy if HP < 50 % (5 s hold) | 20 % chance to apply Wither on hit | - * | DEFENSIVE | Curse nearby enemies for 15 s (Slow + MiningFatigue)| Speed + Regen while cursed enemies are nearby| + * | Playstyle | Active | Passive | + * |-------------|----------------------------------------------------------|------------------------------------------------| + * | AGGRESSIVE | Root enemy if HP < 50 % (5 s hold) | 20 % chance to apply Wither on hit | + * | DEFENSIVE | Curse nearby enemies for [curseDurationMs] ms | Speed + Regen while cursed enemies are nearby | * * ### Root mechanic (AGGRESSIVE) - * Zeros horizontal velocity every tick for 5 s + applies Slowness 127 so the + * Zeroes horizontal velocity every tick for 5 s + applies Slowness 127 so the * player cannot walk even if the velocity reset misses a frame. * * ### Curse mechanic (DEFENSIVE) - * Cursed players are stored in [cursedExpiry] (UUID → expiry timestamp). - * A per-player repeating task (started in [DefensivePassive.onActivate]) checks - * every second: cleans expired curses, applies debuffs to still-cursed enemies, - * and grants Speed + Regen to the Voodoo player if at least one cursed enemy - * is within 10 blocks. + * Cursed players are stored in [cursedExpiry] (UUID → expiry timestamp ms). + * A per-player repeating task checks every second: cleans expired curses, + * applies Slowness + MiningFatigue to still-cursed enemies, and grants + * Speed + Regen to the Voodoo player if at least one cursed enemy is within + * 10 blocks. + * + * ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`) + * + * The curse duration is a **typed field** in [CustomGameSettings.KitOverride] + * and is therefore accessed directly rather than through `extras`. + * + * | JSON-Schlüssel | Typ | Default | Beschreibung | + * |---------------------|------|------------|-----------------------------------------------| + * | `curse_duration_ms` | Long | `15_000` | How long a curse lasts in milliseconds | */ -class VoodooKit : Kit() { +class VoodooKit : Kit() +{ private val plugin get() = SpeedHG.instance - override val id = "voodoo" + override val id: String + get() = "voodoo" + override val displayName: Component - get() = plugin.languageManager.getDefaultComponent("kits.voodoo.name", mapOf()) + get() = plugin.languageManager.getDefaultComponent( "kits.voodoo.name", mapOf() ) + override val lore: List - get() = plugin.languageManager.getDefaultRawMessageList("kits.voodoo.lore") - override val icon = Material.WITHER_ROSE + get() = plugin.languageManager.getDefaultRawMessageList( "kits.voodoo.lore" ) - /** Tracks active curses: victim UUID → System.currentTimeMillis() expiry. */ - internal val cursedExpiry: MutableMap = ConcurrentHashMap() + override val icon: Material + get() = Material.WITHER_ROSE - private val kitOverride: CustomGameSettings.KitOverride by lazy { - plugin.customGameManager.settings.kits.kits["voodoo"] - ?: CustomGameSettings.KitOverride() + companion object { + const val DEFAULT_CURSE_DURATION_MS = 15_000L } - // ── Cached ability instances ────────────────────────────────────────────── + // ── Live config accessor (typed KitOverride field) ──────────────────────── + + /** + * Duration of each applied curse in milliseconds. + * Source: typed field `curse_duration_ms`. + */ + private val curseDurationMs: Long + get() = override().curseDurationMs + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** Tracks active curses: victim UUID → expiry timestamp (ms). */ + internal val cursedExpiry: MutableMap = ConcurrentHashMap() + + // ── Cached ability instances (avoid allocating per event call) ──────────── private val aggressiveActive = AggressiveActive() private val defensiveActive = DefensiveActive() private val aggressivePassive = AggressivePassive() private val defensivePassive = DefensivePassive() - override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) { + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressiveActive Playstyle.DEFENSIVE -> defensiveActive } - override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) { + + override fun getPassiveAbility( + playstyle: Playstyle + ): PassiveAbility = when( playstyle ) + { Playstyle.AGGRESSIVE -> aggressivePassive Playstyle.DEFENSIVE -> defensivePassive } + // ── Item distribution ───────────────────────────────────────────────────── + override val cachedItems = ConcurrentHashMap>() - override fun giveItems(player: Player, playstyle: Playstyle) { - val (mat, active) = when (playstyle) { + override fun giveItems( + player: Player, + playstyle: Playstyle + ) { + val ( mat, active ) = when( playstyle ) + { Playstyle.AGGRESSIVE -> Material.WITHER_ROSE to aggressiveActive Playstyle.DEFENSIVE -> Material.SOUL_TORCH to defensiveActive } - val item = ItemBuilder(mat) - .name(active.name) - .lore(listOf(active.description)) + + val item = ItemBuilder( mat ) + .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) { - cursedExpiry.remove(player.uniqueId) - cachedItems.remove(player.uniqueId)?.forEach { player.inventory.remove(it) } + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onRemove( + player: Player + ) { + cursedExpiry.remove( player.uniqueId ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } } // ========================================================================= // AGGRESSIVE active – root enemy if below 50 % HP // ========================================================================= - private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance @@ -110,62 +156,78 @@ class VoodooKit : Kit() { get() = "voodoo" override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.items.root.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.root.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.items.root.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.root.description" ) + override val hardcodedHitsRequired: Int get() = 15 - override val triggerMaterial = Material.WITHER_ROSE - override fun execute(player: Player): AbilityResult { - val target = player.getTargetEntity(6) as? Player - ?: return AbilityResult.ConditionNotMet("No player in line of sight!") + override val triggerMaterial: Material + get() = Material.WITHER_ROSE - if (!plugin.gameManager.alivePlayers.contains(target.uniqueId)) - return AbilityResult.ConditionNotMet("Target is not alive!") + override fun execute( + player: Player + ): AbilityResult + { + val target = player.getTargetEntity( 6 ) as? Player + ?: return AbilityResult.ConditionNotMet( "No player in line of sight!" ) - val maxHp = target.getAttribute(Attribute.GENERIC_MAX_HEALTH)?.value ?: 20.0 - if (target.health > maxHp * 0.5) - return AbilityResult.ConditionNotMet("Target must be below 50 % health!") + if ( !plugin.gameManager.alivePlayers.contains( target.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Target is not alive!" ) + + val maxHp = target.getAttribute( Attribute.GENERIC_MAX_HEALTH )?.value ?: 20.0 + if ( target.health > maxHp * 0.5 ) + return AbilityResult.ConditionNotMet( "Target must be below 50 % health!" ) // ── Immobilise ──────────────────────────────────────────────────── - target.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, 5 * 20, 127, false, false, true)) - target.addPotionEffect(PotionEffect(PotionEffectType.MINING_FATIGUE, 5 * 20, 127, false, false, false)) - target.addPotionEffect(PotionEffect(PotionEffectType.GLOWING, 5 * 20, 0, false, false, false)) + target.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 5 * 20, 127, false, false, true ) ) + target.addPotionEffect( PotionEffect( PotionEffectType.MINING_FATIGUE, 5 * 20, 127, false, false, false ) ) + target.addPotionEffect( PotionEffect( PotionEffectType.GLOWING, 5 * 20, 0, false, false, false ) ) // Zero horizontal velocity every tick for 5 seconds (100 ticks) - object : BukkitRunnable() { + object : BukkitRunnable() + { var ticks = 0 - override fun run() { - if (ticks++ >= 100 || !target.isOnline || - !plugin.gameManager.alivePlayers.contains(target.uniqueId)) + + override fun run() + { + if ( ticks++ >= 100 || !target.isOnline || + !plugin.gameManager.alivePlayers.contains( target.uniqueId ) ) { - target.removePotionEffect(PotionEffectType.GLOWING) - cancel(); return + target.removePotionEffect( PotionEffectType.GLOWING ) + cancel() + return } val v = target.velocity - target.velocity = v.clone().setX(0.0).setZ(0.0) - .let { if (it.y > 0.0) it.setY(0.0) else it } + target.velocity = v.clone().setX( 0.0 ).setZ( 0.0 ) + .let { if ( it.y > 0.0 ) it.setY( 0.0 ) else it } } - }.runTaskTimer(plugin, 0L, 1L) + }.runTaskTimer( plugin, 0L, 1L ) - player.playSound(player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.5f) - target.playSound(target.location, Sound.ENTITY_WITHER_HURT, 1f, 0.5f) - target.world.spawnParticle(Particle.SOUL, target.location.clone().add(0.0, 1.0, 0.0), - 20, 0.4, 0.6, 0.4, 0.02) + player.playSound( player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.5f ) + target.playSound( target.location, Sound.ENTITY_WITHER_HURT, 1f, 0.5f ) + target.world.spawnParticle( + Particle.SOUL, + target.location.clone().add( 0.0, 1.0, 0.0 ), + 20, 0.4, 0.6, 0.4, 0.02 + ) - player.sendActionBar(player.trans("kits.voodoo.messages.root_activated")) - target.sendActionBar(target.trans("kits.voodoo.messages.root_received")) + player.sendActionBar( player.trans( "kits.voodoo.messages.root_activated" ) ) + target.sendActionBar( target.trans( "kits.voodoo.messages.root_received" ) ) return AbilityResult.Success } + } // ========================================================================= // DEFENSIVE active – curse nearby enemies // ========================================================================= - private inner class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) { + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance @@ -173,119 +235,169 @@ class VoodooKit : Kit() { get() = "voodoo" override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.items.curse.name") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.curse.name" ) + override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.items.curse.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.curse.description" ) + override val hardcodedHitsRequired: Int get() = 10 - override val triggerMaterial = Material.SOUL_TORCH - override fun execute(player: Player): AbilityResult { + override val triggerMaterial: Material + get() = Material.SOUL_TORCH + + override fun execute( + player: Player + ): AbilityResult + { val targets = player.world - .getNearbyEntities(player.location, 8.0, 8.0, 8.0) + .getNearbyEntities( player.location, 8.0, 8.0, 8.0 ) .filterIsInstance() - .filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + .filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } - if (targets.isEmpty()) - return AbilityResult.ConditionNotMet("No enemies within 8 blocks!") + if ( targets.isEmpty() ) + return AbilityResult.ConditionNotMet( "No enemies within 8 blocks!" ) + + // Snapshot the curse duration at activation time + val capturedCurseDurationMs = curseDurationMs + + val expiry = System.currentTimeMillis() + capturedCurseDurationMs - val expiry = System.currentTimeMillis() + kitOverride.curseDurationMs targets.forEach { t -> - cursedExpiry[t.uniqueId] = expiry - t.addPotionEffect(PotionEffect(PotionEffectType.GLOWING, 15 * 20, 0, false, true, false)) - t.sendActionBar(t.trans("kits.voodoo.messages.curse_received")) - t.world.spawnParticle(Particle.SOUL_FIRE_FLAME, t.location.clone().add(0.0, 1.0, 0.0), - 10, 0.3, 0.4, 0.3, 0.05) + cursedExpiry[ t.uniqueId ] = expiry + t.addPotionEffect( + PotionEffect( PotionEffectType.GLOWING, 15 * 20, 0, false, true, false ) + ) + t.sendActionBar( t.trans( "kits.voodoo.messages.curse_received" ) ) + t.world.spawnParticle( + Particle.SOUL_FIRE_FLAME, + t.location.clone().add( 0.0, 1.0, 0.0 ), + 10, 0.3, 0.4, 0.3, 0.05 + ) } - player.playSound(player.location, Sound.ENTITY_WITHER_AMBIENT, 1f, 0.3f) - player.sendActionBar(player.trans("kits.voodoo.messages.curse_cast", - mapOf("count" to targets.size.toString()))) + player.playSound( player.location, Sound.ENTITY_WITHER_AMBIENT, 1f, 0.3f ) + player.sendActionBar( + player.trans( + "kits.voodoo.messages.curse_cast", + mapOf( "count" to targets.size.toString() ) + ) + ) return AbilityResult.Success } + } // ========================================================================= // AGGRESSIVE passive – 20 % Wither on hit // ========================================================================= - private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) { + private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) + { private val plugin get() = SpeedHG.instance private val rng = Random() override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.passive.aggressive.name") - override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.passive.aggressive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.aggressive.name" ) - override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) { - if (rng.nextDouble() >= 0.20) return - victim.addPotionEffect(PotionEffect(PotionEffectType.WITHER, 3 * 20, 0)) - attacker.playSound(attacker.location, Sound.ENTITY_WITHER_AMBIENT, 0.5f, 1.8f) - victim.world.spawnParticle(Particle.SOUL, victim.location.clone().add(0.0, 1.0, 0.0), - 5, 0.2, 0.3, 0.2, 0.0) + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.aggressive.description" ) + + override fun onHitEnemy( + attacker: Player, + victim: Player, + event: EntityDamageByEntityEvent + ) { + if ( rng.nextDouble() >= 0.20 ) return + + victim.addPotionEffect( PotionEffect( PotionEffectType.WITHER, 3 * 20, 0 ) ) + attacker.playSound( attacker.location, Sound.ENTITY_WITHER_AMBIENT, 0.5f, 1.8f ) + victim.world.spawnParticle( + Particle.SOUL, + victim.location.clone().add( 0.0, 1.0, 0.0 ), + 5, 0.2, 0.3, 0.2, 0.0 + ) } + } // ========================================================================= - // DEFENSIVE passive – buff while cursed enemies are nearby + // DEFENSIVE passive – Speed + Regen while cursed enemies are nearby // ========================================================================= - private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) { + private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) + { private val plugin get() = SpeedHG.instance private val tasks: MutableMap = ConcurrentHashMap() override val name: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.passive.defensive.name") - override val description: String - get() = plugin.languageManager.getDefaultRawMessage("kits.voodoo.passive.defensive.description") + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.defensive.name" ) - override fun onActivate(player: Player) { - val task = object : BukkitRunnable() { - override fun run() { - if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) { - cancel(); return + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.defensive.description" ) + + override fun onActivate( + player: Player + ) { + val task = object : BukkitRunnable() + { + override fun run() + { + if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) + { + cancel() + return } runCatching { tickPassive( player ) } .onFailure { plugin.logger.severe( "[VoodooKit] tickPassive error: ${it.message}" ) } } - }.runTaskTimer(plugin, 0L, 20L) - tasks[player.uniqueId] = task + }.runTaskTimer( plugin, 0L, 20L ) + + tasks[ player.uniqueId ] = task } - override fun onDeactivate(player: Player) { - tasks.remove(player.uniqueId)?.cancel() + override fun onDeactivate( + player: Player + ) { + tasks.remove( player.uniqueId )?.cancel() } - private fun tickPassive(voodooPlayer: Player) { + private fun tickPassive( + voodooPlayer: Player + ) { val now = System.currentTimeMillis() // ── Expire stale curses ─────────────────────────────────────────── - cursedExpiry.entries.removeIf { (uuid, expiry) -> - if (now > expiry) { - Bukkit.getPlayer(uuid)?.removePotionEffect(PotionEffectType.GLOWING) + cursedExpiry.entries.removeIf { ( uuid, expiry ) -> + if ( now > expiry ) + { + Bukkit.getPlayer( uuid )?.removePotionEffect( PotionEffectType.GLOWING ) true - } else false + } + else false } - // ── Apply debuffs to all still-cursed + alive players ───────────── + // ── Apply debuffs to all still-cursed alive players ─────────────── val cursedNearby = cursedExpiry.keys - .mapNotNull { Bukkit.getPlayer(it) } - .filter { it.isOnline && plugin.gameManager.alivePlayers.contains(it.uniqueId) } + .mapNotNull { Bukkit.getPlayer( it ) } + .filter { it.isOnline && plugin.gameManager.alivePlayers.contains( it.uniqueId ) } .onEach { cursed -> - cursed.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, 30, 0)) - cursed.addPotionEffect(PotionEffect(PotionEffectType.MINING_FATIGUE, 30, 0)) + cursed.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 30, 0 ) ) + cursed.addPotionEffect( PotionEffect( PotionEffectType.MINING_FATIGUE, 30, 0 ) ) } - .filter { it.location.distanceSquared(voodooPlayer.location) <= 100.0 } // ≤ 10 blocks + .filter { it.location.distanceSquared( voodooPlayer.location ) <= 100.0 } // ≤ 10 blocks - // ── Buff voodoo player if cursed enemy nearby ───────────────────── - if (cursedNearby.isNotEmpty()) { - voodooPlayer.addPotionEffect(PotionEffect(PotionEffectType.SPEED, 30, 0)) - voodooPlayer.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 30, 0)) + // ── Buff voodoo player if a cursed enemy is nearby ──────────────── + if ( cursedNearby.isNotEmpty() ) + { + voodooPlayer.addPotionEffect( PotionEffect( PotionEffectType.SPEED, 30, 0 ) ) + voodooPlayer.addPotionEffect( PotionEffect( PotionEffectType.REGENERATION, 30, 0 ) ) } } + } + } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt b/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt index c3f0a7c..b912c85 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt @@ -37,7 +37,7 @@ class ItemBuilder( name: String ): ItemBuilder { - itemStack.editMeta { it.displayName(Component.text( name )) } + itemStack.editMeta { it.displayName(mm.deserialize( name )) } return this }