From 9a34be8f8fcc79c497d80a32f1f6fbce2cd1fd06 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Fri, 17 Apr 2026 03:02:08 +0200 Subject: [PATCH] Add new kits Added 3 new kits: - Digger - Switcher - Trickster --- build.gradle.kts | 2 + .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 3 + .../club/mcscrims/speedhg/kit/KitManager.kt | 5 + .../mcscrims/speedhg/kit/impl/DiggerKit.kt | 636 +++++++++++++++++ .../mcscrims/speedhg/kit/impl/SwitcherKit.kt | 402 +++++++++++ .../mcscrims/speedhg/kit/impl/TricksterKit.kt | 651 ++++++++++++++++++ src/main/resources/languages/en_US.yml | 70 +- src/main/resources/plugin.yml | 1 + 8 files changed, 1769 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/kit/impl/SwitcherKit.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt diff --git a/build.gradle.kts b/build.gradle.kts index 959c356..2a46194 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,7 @@ repositories { maven("https://repo.codemc.io/repository/maven-public/") maven("https://repo.lunarclient.dev") maven("https://maven.enginehub.org/repo/") + maven("https://repo.citizensnpcs.co/") } dependencies { @@ -40,6 +41,7 @@ dependencies { compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-core:7.2.17-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT") + compileOnly("net.citizensnpcs:citizens-main:2.0.36-SNAPSHOT") } tasks { diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index a446ce0..89a8686 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -236,6 +236,7 @@ class SpeedHG : JavaPlugin() { kitManager.registerKit( BackupKit() ) kitManager.registerKit( BlackPantherKit() ) kitManager.registerKit( BlitzcrankKit() ) + kitManager.registerKit( DiggerKit() ) kitManager.registerKit( GladiatorKit() ) kitManager.registerKit( GoblinKit() ) kitManager.registerKit( IceMageKit() ) @@ -243,8 +244,10 @@ class SpeedHG : JavaPlugin() { kitManager.registerKit( PuppetKit() ) kitManager.registerKit( RattlesnakeKit() ) kitManager.registerKit( SpieloKit() ) + kitManager.registerKit( SwitcherKit() ) kitManager.registerKit( TeslaKit() ) kitManager.registerKit( TheWorldKit() ) + kitManager.registerKit( TricksterKit() ) kitManager.registerKit( TridentKit() ) kitManager.registerKit( VenomKit() ) kitManager.registerKit( VoodooKit() ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt index 0e98172..ab74e47 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt @@ -3,6 +3,7 @@ package club.mcscrims.speedhg.kit import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.kit.charge.PlayerChargeData import org.bukkit.entity.Player +import org.bukkit.event.Listener import org.bukkit.inventory.ItemStack import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -49,6 +50,10 @@ class KitManager( kit: Kit ) { registeredKits[kit.id] = kit + + if ( kit is Listener ) + plugin.server.pluginManager.registerEvents( kit, plugin ) + plugin.logger.info("[KitManager] Registered kit: ${kit.id}") } diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt new file mode 100644 index 0000000..f305062 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt @@ -0,0 +1,636 @@ +package club.mcscrims.speedhg.kit.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.kit.Kit +import club.mcscrims.speedhg.kit.Playstyle +import club.mcscrims.speedhg.kit.ability.AbilityResult +import club.mcscrims.speedhg.kit.ability.ActiveAbility +import club.mcscrims.speedhg.kit.ability.PassiveAbility +import club.mcscrims.speedhg.util.ItemBuilder +import club.mcscrims.speedhg.util.trans +import net.kyori.adventure.text.Component +import org.bukkit.Bukkit +import org.bukkit.GameMode +import org.bukkit.HeightMap +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.inventory.ItemStack +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.scheduler.BukkitRunnable +import org.bukkit.scheduler.BukkitTask +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * ## DiggerKit (Earth Mage) + * + * A burrowing kit that puts the player underground as a `SPECTATOR` ghost, + * telegraphing their position to enemies via dirt-crack particles at the + * surface directly above them. + * + * ## Playstyle Variants + * + * | Playstyle | Duration | While underground | On surfacing | + * |--------------|----------|---------------------------------|---------------------------------------------------------| + * | `AGGRESSIVE` | 5 s | Night Vision | AoE 4 dmg + knockup (Y = 0.8) in 3-block radius | + * | `DEFENSIVE` | 8 s | Regeneration II | Absorption I (2 hearts) for 10 s — no AoE | + * + * ## State + * + * `activeBurrows` maps each burrowed player's UUID to a [BurrowSession] that + * holds both the particle-trail task and the surfacing-expiry task. A single + * `terminateBurrow()` call is the only exit path — all safety checks funnel into it, + * including `PlayerQuitEvent` and `onRemove`. + * + * ## Safety + * + * The kit registers itself as a `Listener` solely to intercept `PlayerQuitEvent` + * for burrowed players so `SPECTATOR` mode is never left behind on reconnect. The + * GameMode is also force-reset inside every `terminateBurrow()` call, guarding + * against mid-round game-state changes. + * + * ## Constants + * + * | Constant | Value | Description | + * |-------------------------------|------------|-------------------------------------------------| + * | `DEFAULT_COOLDOWN_MS` | `30_000L` | Strict per-player cooldown (ms) | + * | `AGGRESSIVE_DURATION_TICKS` | `100` | Underground time — AGGRESSIVE (5 s) | + * | `DEFENSIVE_DURATION_TICKS` | `160` | Underground time — DEFENSIVE (8 s) | + * | `SURFACE_AoE_RADIUS` | `3.0` | Radius of the emergence AoE (blocks) | + * | `SURFACE_AOE_DAMAGE` | `4.0` | HP dealt to enemies on AGGRESSIVE surface | + * | `SURFACE_KNOCKUP_Y` | `0.8` | Y-velocity of the knockup on AGGRESSIVE surface | + * | `ABSORPTION_DURATION_TICKS` | `200` | Absorption I duration — DEFENSIVE (10 s) | + * | `PARTICLE_INTERVAL_TICKS` | `4L` | How often dirt particles fire (every 4 ticks) | + * | `BURROW_DEPTH` | `1.5` | Blocks below feet that the player is teleported | + */ +class DiggerKit : Kit(), Listener +{ + + private val plugin get() = SpeedHG.instance + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** Active burrow sessions per burrowed player UUID. */ + internal val activeBurrows: MutableMap = ConcurrentHashMap() + + /** Shared cooldown map — referenced by both inner ability classes. */ + internal val cooldowns: MutableMap = ConcurrentHashMap() + + // ── Session data class ──────────────────────────────────────────────────── + + /** + * Bundles all runtime state for one active burrow. + * + * @param particleTask Repeating task that spawns dirt-crack particles at the surface. + * @param expiryTask One-shot task that calls [surfacePlayer] after the duration ends. + * @param playstyle Snapshotted at activation — governs surface behaviour. + * @param tricksterUUID Owner UUID; used inside the [PlayerQuitEvent] handler. + */ + data class BurrowSession( + val particleTask: BukkitTask, + val expiryTask: BukkitTask, + val playstyle: Playstyle, + val tricksterUUID: UUID + ) + + // ── Companion constants ─────────────────────────────────────────────────── + + companion object + { + const val DEFAULT_COOLDOWN_MS = 30_000L + + /** 5 seconds in ticks — AGGRESSIVE underground duration. */ + const val AGGRESSIVE_DURATION_TICKS = 100L + + /** 8 seconds in ticks — DEFENSIVE underground duration. */ + const val DEFENSIVE_DURATION_TICKS = 160L + + /** Blocks radius for the AGGRESSIVE emergence AoE. */ + const val SURFACE_AOE_RADIUS = 3.0 + + /** HP dealt to enemies caught in the emergence AoE. */ + const val SURFACE_AOE_DAMAGE = 4.0 + + /** Upward Y-velocity applied to enemies on AGGRESSIVE emergence. */ + const val SURFACE_KNOCKUP_Y = 0.8 + + /** 10 seconds in ticks — DEFENSIVE Absorption I duration. */ + const val ABSORPTION_DURATION_TICKS = 200 + + /** Dirt-crack particles fire every 4 ticks (5 times/second). */ + const val PARTICLE_INTERVAL_TICKS = 4L + + /** How far below the player's feet the burrow teleport drops them. */ + const val BURROW_DEPTH = 1.5 + } + + // ── Live config accessors ───────────────────────────────────────────────── + + private val cooldownMs: Long + get() = override().getLong( "cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS + + private val aggressiveDurationTicks: Long + get() = override().getLong( "aggressive_duration_ticks" ) ?: AGGRESSIVE_DURATION_TICKS + + private val defensiveDurationTicks: Long + get() = override().getLong( "defensive_duration_ticks" ) ?: DEFENSIVE_DURATION_TICKS + + private val surfaceAoeRadius: Double + get() = override().getDouble( "surface_aoe_radius" ) ?: SURFACE_AOE_RADIUS + + private val surfaceAoeDamage: Double + get() = override().getDouble( "surface_aoe_damage" ) ?: SURFACE_AOE_DAMAGE + + private val surfaceKnockupY: Double + get() = override().getDouble( "surface_knockup_y" ) ?: SURFACE_KNOCKUP_Y + + private val absorptionDurationTicks: Int + get() = override().getInt( "absorption_duration_ticks" ) ?: ABSORPTION_DURATION_TICKS + + private val particleIntervalTicks: Long + get() = override().getLong( "particle_interval_ticks" ) ?: PARTICLE_INTERVAL_TICKS + + private val burrowDepth: Double + get() = override().getDouble( "burrow_depth" ) ?: BURROW_DEPTH + + // ── 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 ) + + // ── Identity ────────────────────────────────────────────────────────────── + + override val id: String + get() = "digger" + + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.digger.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.digger.lore" ) + + override val icon: Material + get() = Material.DIAMOND_SHOVEL + + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { + Playstyle.AGGRESSIVE -> aggressiveActive + Playstyle.DEFENSIVE -> defensiveActive + } + + 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 active = getActiveAbility( playstyle ) + + val shovel = ItemBuilder( Material.IRON_SHOVEL ) + .name( active.name ) + .lore(listOf( active.description )) + .unbreakable( true ) + .build() + + cachedItems[ player.uniqueId ] = listOf( shovel ) + player.inventory.addItem( shovel ) + } + + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + /** + * Called by [KitManager.removeKit] at round end or player elimination. + * Terminates any active burrow silently so `SPECTATOR` mode is never orphaned. + */ + override fun onRemove( + player: Player + ) { + cooldowns.remove( player.uniqueId ) + terminateBurrow( player.uniqueId, restoreGameMode = true ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } + } + + // ── Safety: intercept quit while underground ────────────────────────────── + + /** + * Paper calls `onRemove` only if the player is still online when the kit is + * removed. If they disconnect while burrowed, `SPECTATOR` would persist on + * reconnect. This handler catches that case, cancels the tasks, and lets the + * normal `GameManager.onQuit` handle elimination. + * + * `restoreGameMode = false` here because Paper resets `GameMode` to the + * default on reconnect — we just need to clean up the session data and tasks. + */ + @EventHandler( priority = EventPriority.MONITOR ) + fun onPlayerQuit( + event: PlayerQuitEvent + ) { + if ( activeBurrows.containsKey( event.player.uniqueId ) ) + terminateBurrow( event.player.uniqueId, restoreGameMode = false ) + } + + // ── Core burrow logic ───────────────────────────────────────────────────── + + /** + * Puts [player] underground: + * 1. Teleports them [BURROW_DEPTH] blocks below their current feet. + * 2. Sets `GameMode` to `SPECTATOR` so they pass through blocks. + * 3. Starts a repeating particle task that marks their position at the surface. + * 4. Schedules the expiry task that calls [surfacePlayer] after [durationTicks]. + * + * Effects (`Night Vision` or `Regeneration II`) are applied by the calling + * ability before [startBurrow] is invoked, keeping this method playstyle-agnostic. + */ + private fun startBurrow( + player: Player, + playstyle: Playstyle, + durationTicks: Long + ) { + // 1. Teleport underground + val burrowLoc = player.location.clone().subtract( 0.0, burrowDepth, 0.0 ) + player.teleport( burrowLoc ) + player.gameMode = GameMode.SPECTATOR + + // 2. Particle trail — fires every PARTICLE_INTERVAL_TICKS on the main thread + val dirtData = Material.DIRT.createBlockData() + + val particleTask = object : BukkitRunnable() + { + override fun run() + { + if ( !player.isOnline || !activeBurrows.containsKey( player.uniqueId ) ) + { + cancel() + return + } + + val loc = player.location + + // Find the highest solid block directly above the player + val surfaceLoc = loc.world?.getHighestBlockAt( + loc.blockX, + loc.blockZ, + HeightMap.MOTION_BLOCKING_NO_LEAVES + )?.location?.clone() ?: return + + // Spawn at the top surface of that block (+ 0.1 for visibility) + surfaceLoc.y = surfaceLoc.blockY + 1.1 + + loc.world?.spawnParticle( + Particle.BLOCK, + surfaceLoc, + 12, + 0.3, 0.1, 0.3, + 0.0, + dirtData + ) + loc.world?.playSound( + surfaceLoc, + Sound.BLOCK_GRAVEL_STEP, + 0.4f, + 0.6f + ( Math.random() * 0.4 ).toFloat() + ) + } + }.runTaskTimer( plugin, 0L, particleIntervalTicks ) + + // 3. Expiry task — surfaces the player after the configured duration + val expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( activeBurrows.containsKey( player.uniqueId ) ) + surfacePlayer( player ) + }, durationTicks ) + + activeBurrows[ player.uniqueId ] = BurrowSession( + particleTask = particleTask, + expiryTask = expiryTask, + playstyle = playstyle, + tricksterUUID = player.uniqueId + ) + } + + /** + * Surfaces [player] at their current XZ column's highest block, resets + * `GameMode` to `SURVIVAL`, and applies playstyle-specific emergence effects. + * + * Always cancels the session tasks before applying effects to guarantee + * effects fire exactly once even if called from multiple paths simultaneously. + */ + private fun surfacePlayer( + player: Player + ) { + val session = activeBurrows.remove( player.uniqueId ) ?: return + session.particleTask.cancel() + session.expiryTask.cancel() + + if ( !player.isOnline ) return + + // Teleport to the highest solid block at the player's current XZ + val currentLoc = player.location + val surfaceBlock = currentLoc.world?.getHighestBlockAt( + currentLoc.blockX, + currentLoc.blockZ, + HeightMap.MOTION_BLOCKING_NO_LEAVES + ) + + val surfaceLoc = surfaceBlock?.location?.clone()?.apply { + y += 1.0 + yaw = currentLoc.yaw + pitch = currentLoc.pitch + } ?: currentLoc.clone().apply { y += burrowDepth } + + player.gameMode = GameMode.SURVIVAL + player.teleport( surfaceLoc ) + + // Remove underground effects regardless of playstyle + player.removePotionEffect( PotionEffectType.NIGHT_VISION ) + player.removePotionEffect( PotionEffectType.REGENERATION ) + + // Emergence visual + sound + surfaceLoc.world?.spawnParticle( + Particle.BLOCK, + surfaceLoc.clone().add( 0.0, 0.5, 0.0 ), + 30, + 0.4, 0.4, 0.4, + 0.1, + Material.DIRT.createBlockData() + ) + surfaceLoc.world?.spawnParticle( + Particle.EXPLOSION, + surfaceLoc, + 3, 0.3, 0.2, 0.3, 0.0 + ) + surfaceLoc.world?.playSound( surfaceLoc, Sound.BLOCK_ROOTED_DIRT_BREAK, 1.2f, 0.7f ) + surfaceLoc.world?.playSound( surfaceLoc, Sound.ENTITY_PLAYER_ATTACK_SWEEP, 0.8f, 0.9f ) + + when( session.playstyle ) + { + Playstyle.AGGRESSIVE -> applyAggressiveSurface( player, surfaceLoc ) + Playstyle.DEFENSIVE -> applyDefensiveSurface( player ) + } + } + + /** + * AGGRESSIVE emergence: + * All alive enemies within [SURFACE_AOE_RADIUS] blocks take [SURFACE_AOE_DAMAGE] + * HP and are knocked upward with a Y-velocity of [SURFACE_KNOCKUP_Y]. + */ + private fun applyAggressiveSurface( + player: Player, + surfaceLoc: org.bukkit.Location + ) { + val capturedRadius = surfaceAoeRadius + val capturedDamage = surfaceAoeDamage + val capturedKnockup = surfaceKnockupY + + surfaceLoc.world + ?.getNearbyEntities( surfaceLoc, capturedRadius, capturedRadius, capturedRadius ) + ?.filterIsInstance() + ?.filter { it.uniqueId != player.uniqueId } + ?.filter { plugin.gameManager.alivePlayers.contains( it.uniqueId ) } + ?.forEach { enemy -> + enemy.damage( capturedDamage, player ) + + val knockback = enemy.velocity.clone() + knockback.y = capturedKnockup + enemy.velocity = knockback + + enemy.world.spawnParticle( + Particle.CRIT, + enemy.location.clone().add( 0.0, 1.0, 0.0 ), + 8, 0.2, 0.2, 0.2, 0.0 + ) + enemy.sendActionBar( enemy.trans( "kits.digger.messages.surface_hit" ) ) + } + + player.sendActionBar( player.trans( "kits.digger.messages.surfaced_aggressive" ) ) + } + + /** + * DEFENSIVE emergence: + * The Digger receives Absorption I (2 hearts) for [ABSORPTION_DURATION_TICKS] ticks. + * No AoE effect. + */ + private fun applyDefensiveSurface( + player: Player + ) { + player.addPotionEffect( + PotionEffect( PotionEffectType.ABSORPTION, absorptionDurationTicks, 0, false, true, true ) + ) + player.sendActionBar( player.trans( "kits.digger.messages.surfaced_defensive" ) ) + } + + /** + * Single exit point for all burrow-termination paths. + * + * Cancels both tasks and, if [restoreGameMode] is true, forces the player + * back to `SURVIVAL`. The [restoreGameMode] flag is `false` only on + * `PlayerQuitEvent` where Paper handles the mode on reconnect automatically. + */ + private fun terminateBurrow( + uuid: UUID, + restoreGameMode: Boolean + ) { + val session = activeBurrows.remove( uuid ) ?: return + session.particleTask.cancel() + session.expiryTask.cancel() + + if ( restoreGameMode ) + { + val player = Bukkit.getPlayer( uuid ) ?: return + if ( !player.isOnline ) return + + player.gameMode = GameMode.SURVIVAL + player.removePotionEffect( PotionEffectType.NIGHT_VISION ) + player.removePotionEffect( PotionEffectType.REGENERATION ) + + // Teleport to surface so the player isn't left inside a block + val loc = player.location + val surfaceBlock = loc.world?.getHighestBlockAt( + loc.blockX, + loc.blockZ, + HeightMap.MOTION_BLOCKING_NO_LEAVES + ) + surfaceBlock?.let { block -> + val surfaceLoc = block.location.clone().apply { + y = block.y + 1.0 + yaw = loc.yaw + pitch = loc.pitch + } + player.teleport( surfaceLoc ) + } + } + } + + // ========================================================================= + // AGGRESSIVE active – 5 s borrow + Night Vision + AoE surface + // ========================================================================= + + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { + + private val plugin get() = SpeedHG.instance + + override val kitId: String + get() = "digger" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.digger.items.shovel.aggressive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.digger.items.shovel.aggressive.description" ) + + /** Cooldown-only — no hit-charge mechanic. */ + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.IRON_SHOVEL + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + if ( activeBurrows.containsKey( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Already burrowed!" ) + + // Apply Night Vision for the full underground duration + player.addPotionEffect( + PotionEffect( + PotionEffectType.NIGHT_VISION, + aggressiveDurationTicks.toInt() + 20, + 0, + false, + false + ) + ) + + startBurrow( player, Playstyle.AGGRESSIVE, aggressiveDurationTicks ) + + cooldowns[ player.uniqueId ] = now + + player.playSound( player.location, Sound.BLOCK_ROOTED_DIRT_PLACE, 1f, 0.5f ) + player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.6f, 0.6f ) + player.sendActionBar( player.trans( "kits.digger.messages.burrowed_aggressive" ) ) + + return AbilityResult.Success + } + + } + + // ========================================================================= + // DEFENSIVE active – 8 s burrow + Regeneration II + Absorption on surface + // ========================================================================= + + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { + + private val plugin get() = SpeedHG.instance + + override val kitId: String + get() = "digger" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.digger.items.shovel.defensive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.digger.items.shovel.defensive.description" ) + + /** Cooldown-only — no hit-charge mechanic. */ + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.IRON_SHOVEL + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + if ( activeBurrows.containsKey( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Already burrowed!" ) + + // Apply Regeneration II for the full underground duration + player.addPotionEffect( + PotionEffect( + PotionEffectType.REGENERATION, + defensiveDurationTicks.toInt() + 20, + 1, + false, + false + ) + ) + + startBurrow( player, Playstyle.DEFENSIVE, defensiveDurationTicks ) + + cooldowns[ player.uniqueId ] = now + + player.playSound( player.location, Sound.BLOCK_ROOTED_DIRT_PLACE, 1f, 0.5f ) + player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.6f, 0.4f ) + player.sendActionBar( player.trans( "kits.digger.messages.burrowed_defensive" ) ) + + return AbilityResult.Success + } + + } + + // ========================================================================= + // Shared no-passive stubs + // ========================================================================= + + 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/SwitcherKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SwitcherKit.kt new file mode 100644 index 0000000..35c6977 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/SwitcherKit.kt @@ -0,0 +1,402 @@ +package club.mcscrims.speedhg.kit.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.kit.Kit +import club.mcscrims.speedhg.kit.Playstyle +import club.mcscrims.speedhg.kit.ability.AbilityResult +import club.mcscrims.speedhg.kit.ability.ActiveAbility +import club.mcscrims.speedhg.kit.ability.PassiveAbility +import club.mcscrims.speedhg.util.ItemBuilder +import club.mcscrims.speedhg.util.trans +import net.kyori.adventure.text.Component +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.Player +import org.bukkit.entity.Snowball +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.entity.ProjectileHitEvent +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * ## SwitcherKit + * + * A kit built around a single teleport-swap snowball. + * The shooter throws a tagged snowball; on hitting an enemy player, + * both player locations are instantly transposed. + * + * ## Playstyle Variants + * + * | Playstyle | Shooter receives | Enemy receives | + * |---------------|-------------------------------------------|-------------------------| + * | `AGGRESSIVE` | Speed II for 3 s | Blindness I for 3 s | + * | `DEFENSIVE` | Resistance II + Regeneration I for 4 s | *(nothing)* | + * + * ## Constants + * + * | Constant | Value | Description | + * |-----------------------------|------------|------------------------------------------------| + * | `DEFAULT_COOLDOWN_MS` | `15_000L` | Milliseconds between ability uses | + * | `DEFAULT_HITS_REQUIRED` | `10` | Melee hits needed to charge the snowball | + * | `AGGRESSIVE_EFFECT_TICKS` | `60` | Ticks (3 s) for aggressive post-swap buffs | + * | `DEFENSIVE_EFFECT_TICKS` | `80` | Ticks (4 s) for defensive post-swap buffs | + * + * ## State + * + * `activeCooldowns` tracks the last-use timestamp per shooter UUID so that + * the `execute` path can gate re-use independently of the charge system. + * All state is cleaned up in `onRemove`. + * + * The PDC key `switcher_snowball` is set on every launched snowball so + * the `ProjectileHitEvent` handler in this kit can claim only its own + * projectiles and ignore everything else. + */ +class SwitcherKit : Kit(), Listener +{ + + private val plugin get() = SpeedHG.instance + + // ── PDC key – identifies snowballs belonging to this kit ────────────────── + + private val snowballKey: NamespacedKey + get() = NamespacedKey( plugin, "switcher_snowball" ) + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** Last activation timestamp per shooter: UUID → epoch ms. */ + internal val activeCooldowns: MutableMap = ConcurrentHashMap() + + // ── Companion constants ─────────────────────────────────────────────────── + + companion object + { + const val DEFAULT_COOLDOWN_MS = 15_000L + + /** 3 seconds in ticks – aggressive post-swap buff duration. */ + const val AGGRESSIVE_EFFECT_TICKS = 60 + + /** 4 seconds in ticks – defensive post-swap buff duration. */ + const val DEFENSIVE_EFFECT_TICKS = 80 + } + + // ── Live config accessors ───────────────────────────────────────────────── + + private val cooldownMs: Long + get() = override().getLong( "cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS + + private val aggressiveEffectTicks: Int + get() = override().getInt( "aggressive_effect_ticks" ) ?: AGGRESSIVE_EFFECT_TICKS + + private val defensiveEffectTicks: Int + get() = override().getInt( "defensive_effect_ticks" ) ?: DEFENSIVE_EFFECT_TICKS + + // ── 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 ) + + // ── Identity ────────────────────────────────────────────────────────────── + + override val id: String + get() = "switcher" + + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.switcher.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.switcher.lore" ) + + override val icon: Material + get() = Material.ENDER_PEARL + + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { + Playstyle.AGGRESSIVE -> aggressiveActive + Playstyle.DEFENSIVE -> defensiveActive + } + + 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 active = getActiveAbility( playstyle ) + + val snowball = ItemBuilder( Material.SNOWBALL ) + .name( active.name ) + .lore(listOf( active.description )) + .build() + + cachedItems[ player.uniqueId ] = listOf( snowball ) + player.inventory.addItem( snowball ) + } + + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onRemove( + player: Player + ) { + activeCooldowns.remove( player.uniqueId ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } + } + + // ── Shared swap logic (called from the ProjectileHitEvent handler) ──────── + + /** + * Executes the location swap and applies playstyle-dependent post-swap effects. + * + * @param shooter The player who threw the snowball. + * @param enemy The player who was struck. + * @param playstyle The shooter's active [Playstyle]. + */ + private fun performSwap( + shooter: Player, + enemy: Player, + playstyle: Playstyle + ) { + val shooterLoc = shooter.location.clone() + val enemyLoc = enemy.location.clone() + + // Preserve the shooter's look direction after teleport + shooterLoc.yaw = shooter.location.yaw + shooterLoc.pitch = shooter.location.pitch + enemyLoc.yaw = enemy.location.yaw + enemyLoc.pitch = enemy.location.pitch + + shooter.teleport( enemyLoc ) + enemy.teleport( shooterLoc ) + + // Visual + audio feedback at both landing spots + shooter.world.spawnParticle( + Particle.PORTAL, + shooter.location.clone().add( 0.0, 1.0, 0.0 ), + 30, 0.3, 0.6, 0.3, 0.2 + ) + enemy.world.spawnParticle( + Particle.PORTAL, + enemy.location.clone().add( 0.0, 1.0, 0.0 ), + 30, 0.3, 0.6, 0.3, 0.2 + ) + shooter.world.playSound( shooter.location, Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1.2f ) + enemy.world.playSound( enemy.location, Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 0.9f ) + + when( playstyle ) + { + Playstyle.AGGRESSIVE -> + { + val capturedTicks = aggressiveEffectTicks + shooter.addPotionEffect( PotionEffect( PotionEffectType.SPEED, capturedTicks, 1 ) ) + enemy.addPotionEffect( PotionEffect( PotionEffectType.BLINDNESS, capturedTicks, 0 ) ) + + shooter.sendActionBar( shooter.trans( "kits.switcher.messages.swap_aggressive_shooter" ) ) + enemy.sendActionBar( enemy.trans( "kits.switcher.messages.swap_aggressive_enemy" ) ) + } + + Playstyle.DEFENSIVE -> + { + val capturedTicks = defensiveEffectTicks + shooter.addPotionEffect( PotionEffect( PotionEffectType.RESISTANCE, capturedTicks, 1 ) ) + shooter.addPotionEffect( PotionEffect( PotionEffectType.REGENERATION, capturedTicks, 0 ) ) + + shooter.sendActionBar( shooter.trans( "kits.switcher.messages.swap_defensive_shooter" ) ) + enemy.sendActionBar( enemy.trans( "kits.switcher.messages.swap_defensive_enemy" ) ) + } + } + } + + // ── ProjectileHitEvent – only handles tagged SwitcherKit snowballs ──────── + + @EventHandler + fun onSwitcherSnowballHit( + event: ProjectileHitEvent + ) { + if ( !isIngame() ) return + + val projectile = event.entity as? Snowball ?: return + if ( !projectile.persistentDataContainer.has( snowballKey, PersistentDataType.BYTE ) ) return + + // Always remove the projectile after handling so it doesn't deal default damage + projectile.remove() + + val enemy = event.hitEntity as? Player ?: return + val shooter = projectile.shooter as? Player ?: return + if ( shooter == enemy ) return + if ( !plugin.gameManager.alivePlayers.contains( enemy.uniqueId ) ) return + + val playstyle = plugin.kitManager.getSelectedPlaystyle( shooter ) + + // Defer one tick so the snowball entity is fully despawned before teleport + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !shooter.isOnline || !enemy.isOnline ) return@runTaskLater + performSwap( shooter, enemy, playstyle ) + }, 1L ) + } + + // ========================================================================= + // AGGRESSIVE active – swap snowball + Speed II on hit + // ========================================================================= + + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { + + private val plugin get() = SpeedHG.instance + + /** Per-player cooldown: UUID → last-use epoch ms. */ + private val cooldowns: MutableMap = activeCooldowns + + override val kitId: String + get() = "switcher" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.switcher.items.snowball.aggressive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.switcher.items.snowball.aggressive.description" ) + + override val hardcodedHitsRequired: Int + get() = 10 + + override val triggerMaterial: Material + get() = Material.SNOWBALL + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + launchSwitcherSnowball( player ) + cooldowns[ player.uniqueId ] = now + player.sendActionBar( player.trans( "kits.switcher.messages.snowball_thrown" ) ) + return AbilityResult.Success + } + + } + + // ========================================================================= + // DEFENSIVE active – swap snowball + Resistance II + Regen I on self + // ========================================================================= + + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { + + private val plugin get() = SpeedHG.instance + + /** Shares the kit-level cooldown map so both actives gate together. */ + private val cooldowns: MutableMap = activeCooldowns + + override val kitId: String + get() = "switcher" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.switcher.items.snowball.defensive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.switcher.items.snowball.defensive.description" ) + + override val hardcodedHitsRequired: Int + get() = 10 + + override val triggerMaterial: Material + get() = Material.SNOWBALL + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + launchSwitcherSnowball( player ) + cooldowns[ player.uniqueId ] = now + player.sendActionBar( player.trans( "kits.switcher.messages.snowball_thrown" ) ) + return AbilityResult.Success + } + + } + + // ── Shared snowball launcher ────────────────────────────────────────────── + + /** + * Spawns a tagged [Snowball] from [player]'s eye location in the direction + * they are looking. The PDC tag is what lets [onSwitcherSnowballHit] claim + * exactly these projectiles and no others. + */ + private fun launchSwitcherSnowball( + player: Player + ) { + val ball = player.world.spawn( player.eyeLocation, Snowball::class.java ) + ball.shooter = player + ball.velocity = player.location.direction.multiply( 2.0 ) + ball.persistentDataContainer.set( snowballKey, PersistentDataType.BYTE, 1.toByte() ) + + player.world.playSound( player.location, Sound.ENTITY_SNOWBALL_THROW, 1f, 1f ) + } + + // ── Helper methods ──────────────────────────────────────────────────────── + + private fun isIngame(): Boolean + { + return plugin.gameManager.currentState == GameState.INVINCIBILITY || + plugin.gameManager.currentState == GameState.INGAME + } + + // ========================================================================= + // Shared no-passive stubs + // ========================================================================= + + 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/TricksterKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt new file mode 100644 index 0000000..66af02b --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt @@ -0,0 +1,651 @@ +package club.mcscrims.speedhg.kit.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import club.mcscrims.speedhg.kit.Kit +import club.mcscrims.speedhg.kit.Playstyle +import club.mcscrims.speedhg.kit.ability.AbilityResult +import club.mcscrims.speedhg.kit.ability.ActiveAbility +import club.mcscrims.speedhg.kit.ability.PassiveAbility +import club.mcscrims.speedhg.util.ItemBuilder +import club.mcscrims.speedhg.util.trans +import net.citizensnpcs.api.CitizensAPI +import net.citizensnpcs.api.npc.NPC +import net.citizensnpcs.api.trait.trait.Equipment +import net.citizensnpcs.api.trait.trait.Equipment.EquipmentSlot +import net.citizensnpcs.trait.SkinTrait +import net.kyori.adventure.text.Component +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.EntityType +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageByEntityEvent +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.concurrent.ConcurrentHashMap + +/** + * ## TricksterKit + * + * A deception kit built around a Citizens NPC decoy that mimics the caster's + * skin and name. While the decoy exists, the Trickster is invisible and mobile. + * + * ## Playstyle Variants + * + * | Playstyle | During invisibility | NPC hit triggers | Expiry (5 s) triggers | + * |---------------|----------------------------------|----------------------------------------------------------|----------------------------------------------------| + * | `AGGRESSIVE` | Speed II (5 s) | NPC removed → fake explosion → 4 dmg + Slowness I (3 s) to nearby enemies; Trickster loses invis, gains Strength I (3 s) | Same explosion + Strength I | + * | `DEFENSIVE` | Speed II + Regeneration II (5 s) | NPC removed → cooldown reduced by 50 % | NPC silently removed, no bonus | + * + * ## State + * + * `activeDecoys` maps the Trickster's UUID to a [DecoySession] which holds the + * NPC reference, the expiry task, and the activation timestamp needed for the + * defensive 50 % cooldown reduction. All state is cleaned up in `onRemove` and + * inside the session itself on any termination path. + * + * ## NPC Lifecycle + * + * Citizens NPCs are spawned via [CitizensAPI.getNPCRegistry] and destroyed via + * [NPC.destroy]. The kit registers itself as a Bukkit [Listener] so it can + * intercept [EntityDamageByEntityEvent] for NPC entities independently of + * `KitEventDispatcher` (which only routes Player-to-Player damage). + * + * ## Constants + * + * | Constant | Value | Description | + * |-----------------------------|-----------|----------------------------------------------| + * | `DEFAULT_COOLDOWN_MS` | `25_000L` | Strict per-player cooldown in milliseconds | + * | `DECOY_DURATION_TICKS` | `100` | NPC lifetime in ticks (5 seconds) | + * | `INVIS_DURATION_TICKS` | `100` | Invisibility + Speed II duration (5 s) | + * | `REGEN_DURATION_TICKS` | `100` | Regeneration II duration — DEF only (5 s) | + * | `EXPLOSION_RADIUS` | `3.0` | Radius in blocks for the fake explosion AoE | + * | `EXPLOSION_DAMAGE` | `4.0` | HP dealt to enemies caught in the explosion | + * | `STRENGTH_DURATION_TICKS` | `60` | Strength I duration after NPC triggered (3 s)| + * | `SLOWNESS_DURATION_TICKS` | `60` | Slowness I duration on explosion victims (3 s)| + */ +class TricksterKit : Kit(), Listener +{ + + private val plugin get() = SpeedHG.instance + + // ── Kit-level state ─────────────────────────────────────────────────────── + + /** + * Active decoy sessions per Trickster UUID. + * Cleaned up on every termination path and in [onRemove]. + */ + internal val activeDecoys: MutableMap = ConcurrentHashMap() + + /** + * Shared cooldown map referenced by both [AggressiveActive] and [DefensiveActive]. + * Storing it at the kit level lets the defensive 50 % reduction mutate it directly. + */ + internal val cooldowns: MutableMap = ConcurrentHashMap() + + // ── Session data class ──────────────────────────────────────────────────── + + /** + * Bundles all runtime data for a single active decoy deployment. + * + * @param npc The Citizens NPC entity acting as the decoy. + * @param expiryTask The BukkitTask that destroys the NPC after 5 s. + * @param activatedAt Epoch ms when the ability fired (for cooldown reduction). + * @param playstyle The Trickster's playstyle at activation time (snapshotted). + * @param tricksterUUID UUID of the owning player — needed inside Listener callbacks. + */ + data class DecoySession( + val npc: NPC, + var expiryTask: BukkitTask, + val activatedAt: Long, + val playstyle: Playstyle, + val tricksterUUID: UUID + ) + + // ── Companion constants ─────────────────────────────────────────────────── + + companion object + { + const val DEFAULT_COOLDOWN_MS = 25_000L + + /** 5 seconds in ticks — NPC and invisibility lifetime. */ + const val DECOY_DURATION_TICKS = 100 + + const val INVIS_DURATION_TICKS = 100 + const val REGEN_DURATION_TICKS = 100 + + /** Blocks radius for the fake explosion AoE. */ + const val EXPLOSION_RADIUS = 3.0 + + /** HP dealt to each enemy inside the explosion radius. */ + const val EXPLOSION_DAMAGE = 4.0 + + /** 3 seconds in ticks — post-explosion Strength I on the Trickster. */ + const val STRENGTH_DURATION_TICKS = 60 + + /** 3 seconds in ticks — Slowness I on explosion victims. */ + const val SLOWNESS_DURATION_TICKS = 60 + } + + // ── Live config accessors ───────────────────────────────────────────────── + + private val cooldownMs: Long + get() = override().getLong( "cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS + + private val decoyDurationTicks: Int + get() = override().getInt( "decoy_duration_ticks" ) ?: DECOY_DURATION_TICKS + + private val invisDurationTicks: Int + get() = override().getInt( "invis_duration_ticks" ) ?: INVIS_DURATION_TICKS + + private val regenDurationTicks: Int + get() = override().getInt( "regen_duration_ticks" ) ?: REGEN_DURATION_TICKS + + private val explosionRadius: Double + get() = override().getDouble( "explosion_radius" ) ?: EXPLOSION_RADIUS + + private val explosionDamage: Double + get() = override().getDouble( "explosion_damage" ) ?: EXPLOSION_DAMAGE + + private val strengthDurationTicks: Int + get() = override().getInt( "strength_duration_ticks" ) ?: STRENGTH_DURATION_TICKS + + private val slownessDurationTicks: Int + get() = override().getInt( "slowness_duration_ticks" ) ?: SLOWNESS_DURATION_TICKS + + // ── 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 ) + + // ── Identity ────────────────────────────────────────────────────────────── + + override val id: String + get() = "trickster" + + override val displayName: Component + get() = plugin.languageManager.getDefaultComponent( "kits.trickster.name", mapOf() ) + + override val lore: List + get() = plugin.languageManager.getDefaultRawMessageList( "kits.trickster.lore" ) + + override val icon: Material + get() = Material.BLAZE_ROD + + // ── Playstyle routing ───────────────────────────────────────────────────── + + override fun getActiveAbility( + playstyle: Playstyle + ): ActiveAbility = when( playstyle ) + { + Playstyle.AGGRESSIVE -> aggressiveActive + Playstyle.DEFENSIVE -> defensiveActive + } + + 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 active = getActiveAbility( playstyle ) + + val blazeRod = ItemBuilder( Material.BLAZE_ROD ) + .name( active.name ) + .lore(listOf( active.description )) + .build() + + cachedItems[ player.uniqueId ] = listOf( blazeRod ) + player.inventory.addItem( blazeRod ) + } + + // ── Lifecycle hooks ─────────────────────────────────────────────────────── + + override fun onRemove( + player: Player + ) { + cooldowns.remove( player.uniqueId ) + terminateDecoy( player.uniqueId, silent = true ) + cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) } + } + + // ── NPC hit interception ────────────────────────────────────────────────── + + /** + * Intercepts melee hits on Citizens NPC entities. + * + * Citizens NPCs are not [Player] instances, so `KitEventDispatcher` never + * routes these events. We handle them here by matching the NPC's entity ID + * against every live [DecoySession]. + * + * The event is cancelled to prevent Citizens from processing damage further — + * the NPC is about to be destroyed anyway. + */ + @EventHandler( priority = EventPriority.HIGH, ignoreCancelled = true ) + fun onNPCHit( + event: EntityDamageByEntityEvent + ) { + if ( !isIngame() ) return + + val attacker = event.damager as? Player ?: return + + // Find a session whose NPC entity matches the damaged entity + val session = activeDecoys.values.firstOrNull { s -> + s.npc.isSpawned && s.npc.entity?.entityId == event.entity.entityId + } ?: return + + // An enemy hit our NPC — cancel the hit itself so Citizens doesn't react + event.isCancelled = true + + // Guard: attacker must be an alive game participant, not the owner + if ( attacker.uniqueId == session.tricksterUUID ) return + if ( !plugin.gameManager.alivePlayers.contains( attacker.uniqueId ) ) return + + val trickster = Bukkit.getPlayer( session.tricksterUUID ) ?: run { + terminateDecoy( session.tricksterUUID, silent = true ) + return + } + + when( session.playstyle ) + { + Playstyle.AGGRESSIVE -> triggerAggressiveExplosion( trickster, session ) + Playstyle.DEFENSIVE -> triggerDefensiveNPCHit( trickster, session ) + } + } + + // ── Core shared NPC logic ───────────────────────────────────────────────── + + /** + * Spawns a Citizens NPC at [location] with [player]'s name and skin, + * then schedules the [DecoySession] expiry task. + * + * The NPC's skin is applied via Citizens' `SkinTrait` using the player's + * name, which Citizens resolves asynchronously from Mojang's API. + * Equipment is copied from the player's armour slots so the decoy + * looks visually identical. + * + * @return The constructed [DecoySession], already stored in [activeDecoys]. + */ + private fun spawnDecoy( + player: Player, + location: Location, + playstyle: Playstyle + ): DecoySession + { + val registry = CitizensAPI.getNPCRegistry() + val npc = registry.createNPC( EntityType.PLAYER, player.name ) + + // Mirror the player's skin by name — Citizens fetches it from Mojang + val skinTrait = npc.getOrAddTrait( SkinTrait::class.java ) + skinTrait.setSkinName( player.name, false ) + + // Mirror the player's worn armour for visual authenticity + val equipment = npc.getOrAddTrait( Equipment::class.java ) + equipment.set( EquipmentSlot.HELMET, player.inventory.helmet ?: ItemStack( Material.AIR ) ) + equipment.set( EquipmentSlot.CHESTPLATE, player.inventory.chestplate ?: ItemStack( Material.AIR ) ) + equipment.set( EquipmentSlot.LEGGINGS, player.inventory.leggings ?: ItemStack( Material.AIR ) ) + equipment.set( EquipmentSlot.BOOTS, player.inventory.boots ?: ItemStack( Material.AIR ) ) + + npc.spawn( location ) + + val now = System.currentTimeMillis() + + // Placeholder task — replaced immediately below; needed for data class construction + val session = DecoySession( + npc = npc, + expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> }, 1L ), + activatedAt = now, + playstyle = playstyle, + tricksterUUID = player.uniqueId + ) + + // Replace placeholder with the real expiry task + session.expiryTask.cancel() + val capturedDurationTicks = decoyDurationTicks.toLong() + + val expiryTask = Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !activeDecoys.containsKey( player.uniqueId ) ) return@runTaskLater + onDecoyExpired( player, session ) + }, capturedDurationTicks ) + + val finalSession = session.copy( expiryTask = expiryTask ) + activeDecoys[ player.uniqueId ] = finalSession + return finalSession + } + + /** + * Called when the decoy's 5-second lifetime elapses naturally. + * Playstyle-specific expiry behaviour is applied here. + */ + private fun onDecoyExpired( + player: Player, + session: DecoySession + ) { + destroyNPC( session.npc ) + activeDecoys.remove( player.uniqueId ) + + if ( !player.isOnline ) return + + when( session.playstyle ) + { + Playstyle.AGGRESSIVE -> + { + // Time ran out — still trigger the explosion from the decoy's last location + val npcLoc = session.npc.storedLocation ?: player.location + triggerFakeExplosion( player, npcLoc ) + player.removePotionEffect( PotionEffectType.INVISIBILITY ) + player.addPotionEffect( PotionEffect( PotionEffectType.STRENGTH, strengthDurationTicks, 0 ) ) + player.sendActionBar( player.trans( "kits.trickster.messages.decoy_expired_aggressive" ) ) + } + + Playstyle.DEFENSIVE -> + { + // Silent expiry — no bonus, just clean up + player.sendActionBar( player.trans( "kits.trickster.messages.decoy_expired_defensive" ) ) + } + } + } + + /** + * Destroys a Citizens NPC safely, handling the case where it may already + * have been despawned or removed by another code path. + */ + private fun destroyNPC( + npc: NPC + ) { + try + { + if ( npc.isSpawned ) npc.despawn() + npc.destroy() + } + catch ( _: Exception ) + { + // NPC was already removed — no action needed + } + } + + /** + * Removes the active decoy for [tricksterUUID] without any gameplay effect. + * Used in [onRemove] and emergency cleanup paths. + * + * @param silent If true, no ActionBar is sent to the player. + */ + private fun terminateDecoy( + tricksterUUID: UUID, + silent: Boolean + ) { + val session = activeDecoys.remove( tricksterUUID ) ?: return + session.expiryTask.cancel() + destroyNPC( session.npc ) + } + + // ── Playstyle-specific outcome handlers ─────────────────────────────────── + + /** + * AGGRESSIVE NPC-hit outcome: + * Fake explosion at the NPC's location, enemies in radius take 4 dmg + Slowness I. + * Trickster loses Invisibility immediately and gains Strength I for 3 s. + */ + private fun triggerAggressiveExplosion( + trickster: Player, + session: DecoySession + ) { + val npcLoc = session.npc.storedLocation ?: trickster.location + + terminateDecoy( trickster.uniqueId, silent = true ) + + triggerFakeExplosion( trickster, npcLoc ) + + trickster.removePotionEffect( PotionEffectType.INVISIBILITY ) + trickster.addPotionEffect( PotionEffect( PotionEffectType.STRENGTH, strengthDurationTicks, 0 ) ) + trickster.sendActionBar( trickster.trans( "kits.trickster.messages.decoy_triggered_aggressive" ) ) + } + + /** + * DEFENSIVE NPC-hit outcome: + * NPC is silently removed. The Trickster's cooldown is reduced by 50 %. + * Elapsed time since activation is added back, cutting remaining wait in half. + */ + private fun triggerDefensiveNPCHit( + trickster: Player, + session: DecoySession + ) { + terminateDecoy( trickster.uniqueId, silent = true ) + + // Cooldown reduction: shift lastUse forward so remaining wait is halved. + // If the full cooldown is 25 s and 3 s have elapsed, the player would normally + // wait 22 s more. After 50 % reduction they wait 11 s → lastUse is set to + // (now - 14 s), i.e. 11 s from now the cooldown expires. + val now = System.currentTimeMillis() + val elapsed = now - session.activatedAt + val capturedCooldown = cooldownMs + val newLastUse = now - ( capturedCooldown - ( capturedCooldown - elapsed ) / 2 ) + cooldowns[ trickster.uniqueId ] = newLastUse + + trickster.sendActionBar( trickster.trans( "kits.trickster.messages.decoy_triggered_defensive" ) ) + } + + /** + * Spawns a convincing fake explosion at [epicentre]: + * - Visual: `EXPLOSION_LARGE` + `SMOKE_LARGE` particle burst + * - Audio: `ENTITY_GENERIC_EXPLODE` sound (no block damage) + * - Effect: 4 HP damage + Slowness I to every alive enemy within [EXPLOSION_RADIUS] + * + * The [trickster] is passed as the damage source so kill credit is assigned correctly. + */ + private fun triggerFakeExplosion( + trickster: Player, + epicentre: Location + ) { + // ── Particles ───────────────────────────────────────────────────────── + epicentre.world?.spawnParticle( + Particle.EXPLOSION, + epicentre, + 6, 0.4, 0.4, 0.4, 0.0 + ) + epicentre.world?.spawnParticle( + Particle.LARGE_SMOKE, + epicentre, + 20, 0.5, 0.5, 0.5, 0.05 + ) + epicentre.world?.spawnParticle( + Particle.FLASH, + epicentre, + 1, 0.0, 0.0, 0.0, 0.0 + ) + + // ── Sound ───────────────────────────────────────────────────────────── + epicentre.world?.playSound( epicentre, Sound.ENTITY_GENERIC_EXPLODE, 1.5f, 1.0f ) + epicentre.world?.playSound( epicentre, Sound.ENTITY_FIREWORK_ROCKET_BLAST, 1f, 0.8f ) + + // ── AoE damage ──────────────────────────────────────────────────────── + val capturedRadius = explosionRadius + val capturedDamage = explosionDamage + val capturedSlownessTicks = slownessDurationTicks + + epicentre.world + ?.getNearbyEntities( epicentre, capturedRadius, capturedRadius, capturedRadius ) + ?.filterIsInstance() + ?.filter { it.uniqueId != trickster.uniqueId } + ?.filter { plugin.gameManager.alivePlayers.contains( it.uniqueId ) } + ?.forEach { enemy -> + enemy.damage( capturedDamage, trickster ) + enemy.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, capturedSlownessTicks, 0 ) ) + enemy.world.spawnParticle( + Particle.CRIT, + enemy.location.clone().add( 0.0, 1.0, 0.0 ), + 8, 0.2, 0.3, 0.2, 0.0 + ) + } + } + + // ========================================================================= + // AGGRESSIVE active – Decoy + Speed II; explosion on hit or expiry + // ========================================================================= + + private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) + { + + private val plugin get() = SpeedHG.instance + + override val kitId: String + get() = "trickster" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.trickster.items.blazerod.aggressive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.trickster.items.blazerod.aggressive.description" ) + + /** Cooldown-only — no hit-charge mechanic. */ + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.BLAZE_ROD + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + // Guard: only one decoy at a time + if ( activeDecoys.containsKey( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Decoy already active!" ) + + val spawnLoc = player.location.clone() + + // Apply effects first so the NPC spawns while the player goes invis + player.addPotionEffect( PotionEffect( PotionEffectType.INVISIBILITY, invisDurationTicks, 0, false, false ) ) + player.addPotionEffect( PotionEffect( PotionEffectType.SPEED, invisDurationTicks, 1, false, false ) ) + + spawnDecoy( player, spawnLoc, Playstyle.AGGRESSIVE ) + + cooldowns[ player.uniqueId ] = now + + player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.8f, 1.3f ) + player.sendActionBar( player.trans( "kits.trickster.messages.decoy_deployed" ) ) + + return AbilityResult.Success + } + + } + + // ========================================================================= + // DEFENSIVE active – Decoy + Speed II + Regen II; cooldown halved on hit + // ========================================================================= + + private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) + { + + private val plugin get() = SpeedHG.instance + + override val kitId: String + get() = "trickster" + + override val name: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.trickster.items.blazerod.defensive.name" ) + + override val description: String + get() = plugin.languageManager.getDefaultRawMessage( "kits.trickster.items.blazerod.defensive.description" ) + + /** Cooldown-only — no hit-charge mechanic. */ + override val hardcodedHitsRequired: Int + get() = 0 + + override val triggerMaterial: Material + get() = Material.BLAZE_ROD + + override fun execute( + player: Player + ): AbilityResult + { + val now = System.currentTimeMillis() + val lastUse = cooldowns[ player.uniqueId ] ?: 0L + val capturedCool = cooldownMs + + if ( now - lastUse < capturedCool ) + { + val secsLeft = ( capturedCool - ( now - lastUse ) ) / 1_000 + return AbilityResult.ConditionNotMet( "Cooldown: ${secsLeft}s" ) + } + + // Guard: only one decoy at a time + if ( activeDecoys.containsKey( player.uniqueId ) ) + return AbilityResult.ConditionNotMet( "Decoy already active!" ) + + val spawnLoc = player.location.clone() + + // Defensive gets Regen II in addition to the shared Speed II + Invis + player.addPotionEffect( PotionEffect( PotionEffectType.INVISIBILITY, invisDurationTicks, 0, false, false ) ) + player.addPotionEffect( PotionEffect( PotionEffectType.SPEED, invisDurationTicks, 1, false, false ) ) + player.addPotionEffect( PotionEffect( PotionEffectType.REGENERATION, regenDurationTicks, 1, false, false ) ) + + spawnDecoy( player, spawnLoc, Playstyle.DEFENSIVE ) + + cooldowns[ player.uniqueId ] = now + + player.playSound( player.location, Sound.ENTITY_ENDERMAN_TELEPORT, 0.8f, 0.9f ) + player.sendActionBar( player.trans( "kits.trickster.messages.decoy_deployed_defensive" ) ) + + return AbilityResult.Success + } + + } + + // ── Helper methods ──────────────────────────────────────────────────────── + + private fun isIngame(): Boolean + { + return plugin.gameManager.currentState == GameState.INVINCIBILITY || + plugin.gameManager.currentState == GameState.INGAME + } + + // ========================================================================= + // Shared no-passive stubs + // ========================================================================= + + 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/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 97cd155..eead4c0 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -871,6 +871,7 @@ kits: gamble_neutral: '😐 Meh. Something landed. Spin again!' gamble_good: '🎉 JACKPOT! Lady Luck is on your side. Enjoy your reward!' + # ── Alchemist ────────────────────────────────────────────────────────────────── alchemist: name: 'Alchemist' lore: @@ -905,4 +906,71 @@ kits: toxic_skin_proc: '☠ Toxic Skin! Attacker poisoned!' brew_speed: '⚗ Brew: Speed II active!' brew_strength: '⚗ Brew: Strength I active!' - brew_regen: '⚗ Brew: Regen I active!' \ No newline at end of file + brew_regen: '⚗ Brew: Regen I active!' + + # ── Switcher ────────────────────────────────────────────────────────────────── + switcher: + name: 'Switcher' + lore: + - ' ' + - '[AGG] Swap places with an enemy — they get Blindness I, you get Speed II.' + - '[DEF] Swap places with an enemy — gain Resistance II + Regen I.' + items: + snowball: + aggressive: + name: '🔀 Switcher Orb' + description: 'Throw to swap locations with an enemy — they receive Blindness I for 3 s, you gain Speed II' + defensive: + name: '🔀 Switcher Orb' + description: 'Throw to swap locations with an enemy — gain Resistance II + Regen I for 4 s' + messages: + snowball_thrown: '🔀 Switcher Orb launched!' + swap_aggressive_shooter: '⚡ Swapped! Speed II active for 3 seconds!' + swap_aggressive_enemy: '🔀 You were swapped! Blindness I applied for 3 seconds!' + swap_defensive_shooter: '🔀 Swapped! Resistance II + Regen I active for 4 seconds!' + swap_defensive_enemy: '🔀 You were swapped!' + + # ── Trickster ───────────────────────────────────────────────────────────────── + trickster: + name: 'Trickster' + lore: + - ' ' + - '[AGG] Decoy explodes on hit or after 5 s4 dmg + Slowness I nearby. You gain Strength I.' + - '[DEF] Decoy hit halves your cooldown. Bonus Regen II while invisible.' + items: + blazerod: + aggressive: + name: '🔥 Phantom Decoy' + description: 'Go invisible + Speed II for 5 s. Decoy explodes on hit or expiry — dealing damage + Slowness nearby' + defensive: + name: '🔥 Phantom Decoy' + description: 'Go invisible + Speed II + Regen II for 5 s. Enemy hitting your decoy halves your cooldown' + messages: + decoy_deployed: '🎭 Decoy deployed! Stay hidden!' + decoy_deployed_defensive: '🎭 Decoy deployed! Regen II active!' + decoy_triggered_aggressive: '💥 Decoy detonated! Strength I active!' + decoy_triggered_defensive: '🎭 Decoy hit! Cooldown halved!' + decoy_expired_aggressive: '💥 Decoy expired — explosion triggered!' + decoy_expired_defensive: '🎭 Decoy faded.' + + # ── Digger ──────────────────────────────────────────────────────────────────── + digger: + name: 'Digger' + lore: + - ' ' + - '[AGG] Burrow underground for 5 s with Night Vision. Surface for 4 dmg + knockup in 3 blocks.' + - '[DEF] Burrow for 8 s with Regen II. Surface for 2 Absorption hearts (10 s).' + items: + shovel: + aggressive: + name: '⛏ Earth Buster' + description: 'Burrow underground for 5 s (Night Vision). Emerge dealing 4 dmg + knockup to nearby enemies' + defensive: + name: '⛏ Earth Shelter' + description: 'Burrow underground for 8 s (Regen II). Emerge with Absorption I for 10 s' + messages: + burrowed_aggressive: '⛏ Burrowing! Night Vision active — emerge to blast!' + burrowed_defensive: '⛏ Burrowing! Regen II active — emerge refreshed!' + surfaced_aggressive: '💥 Surfaced! Enemies in range were blasted!' + surfaced_defensive: '🛡 Surfaced! Absorption I active for 10 seconds!' + surface_hit: '⛏ A Digger burst out of the ground beneath you!' \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 8d3dc21..2782975 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,6 +7,7 @@ depend: - "WorldEdit" - "Apollo-Bukkit" - "McScrims-CoreSystem" + - "Citizens" permissions: speedhg.bypass: