Add new kits

Added 3 new kits:
- Digger
- Switcher
- Trickster
This commit is contained in:
TDSTOS
2026-04-17 03:02:08 +02:00
parent d129b192c7
commit 9a34be8f8f
8 changed files with 1769 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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() )

View File

@@ -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}")
}

View File

@@ -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<UUID, BurrowSession> = ConcurrentHashMap()
/** Shared cooldown map — referenced by both inner ability classes. */
internal val cooldowns: MutableMap<UUID, Long> = 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<String>
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<UUID, List<ItemStack>>()
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<Player>()
?.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"
}
}

View File

@@ -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<UUID, Long> = 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<String>
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<UUID, List<ItemStack>>()
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<UUID, Long> = 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<UUID, Long> = 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"
}
}

View File

@@ -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<UUID, DecoySession> = 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<UUID, Long> = 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<String>
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<UUID, List<ItemStack>>()
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<Player>()
?.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"
}
}

View File

@@ -871,6 +871,7 @@ kits:
gamble_neutral: '<yellow>😐 Meh.</yellow> <gray>Something landed. Spin again!</gray>'
gamble_good: '<gold><bold>🎉 JACKPOT!</bold></gold> <green>Lady Luck is on your side. Enjoy your reward!</green>'
# ── Alchemist ──────────────────────────────────────────────────────────────────
alchemist:
name: '<gradient:dark_purple:green><bold>Alchemist</bold></gradient>'
lore:
@@ -906,3 +907,70 @@ kits:
brew_speed: '<yellow>⚗ Brew: <yellow>Speed II</yellow> active!</yellow>'
brew_strength: '<red>⚗ Brew: <red>Strength I</red> active!</red>'
brew_regen: '<green>⚗ Brew: <green>Regen I</green> active!</green>'
# ── Switcher ──────────────────────────────────────────────────────────────────
switcher:
name: '<gradient:dark_aqua:light_purple><bold>Switcher</bold></gradient>'
lore:
- ' '
- '<dark_gray>[AGG]</dark_gray> <gray>Swap places with an enemy — they get <red>Blindness I</red>, you get <green>Speed II</green>.</gray>'
- '<dark_aqua>[DEF]</dark_aqua> <gray>Swap places with an enemy — gain <green>Resistance II</green> + <green>Regen I</green>.</gray>'
items:
snowball:
aggressive:
name: '<light_purple>🔀 Switcher Orb</light_purple>'
description: 'Throw to swap locations with an enemy — they receive Blindness I for 3 s, you gain Speed II'
defensive:
name: '<dark_aqua>🔀 Switcher Orb</dark_aqua>'
description: 'Throw to swap locations with an enemy — gain Resistance II + Regen I for 4 s'
messages:
snowball_thrown: '<light_purple>🔀 Switcher Orb launched!</light_purple>'
swap_aggressive_shooter: '<green>⚡ Swapped! <yellow>Speed II</yellow> active for 3 seconds!</green>'
swap_aggressive_enemy: '<red>🔀 You were swapped! <red>Blindness I</red> applied for 3 seconds!</red>'
swap_defensive_shooter: '<dark_aqua>🔀 Swapped! <green>Resistance II</green> + <green>Regen I</green> active for 4 seconds!</dark_aqua>'
swap_defensive_enemy: '<gray>🔀 You were swapped!</gray>'
# ── Trickster ─────────────────────────────────────────────────────────────────
trickster:
name: '<gradient:dark_purple:gold><bold>Trickster</bold></gradient>'
lore:
- ' '
- '<dark_gray>[AGG]</dark_gray> <gray>Decoy explodes on hit or after <yellow>5 s</yellow> — <red>4 dmg + Slowness I</red> nearby. You gain <green>Strength I</green>.</gray>'
- '<dark_aqua>[DEF]</dark_aqua> <gray>Decoy hit halves your cooldown. Bonus <green>Regen II</green> while invisible.</gray>'
items:
blazerod:
aggressive:
name: '<gold>🔥 Phantom Decoy</gold>'
description: 'Go invisible + Speed II for 5 s. Decoy explodes on hit or expiry — dealing damage + Slowness nearby'
defensive:
name: '<dark_aqua>🔥 Phantom Decoy</dark_aqua>'
description: 'Go invisible + Speed II + Regen II for 5 s. Enemy hitting your decoy halves your cooldown'
messages:
decoy_deployed: '<gold>🎭 Decoy deployed! Stay hidden!</gold>'
decoy_deployed_defensive: '<dark_aqua>🎭 Decoy deployed! <green>Regen II</green> active!</dark_aqua>'
decoy_triggered_aggressive: '<gold>💥 Decoy detonated! <green>Strength I</green> active!</gold>'
decoy_triggered_defensive: '<dark_aqua>🎭 Decoy hit! <green>Cooldown halved!</green></dark_aqua>'
decoy_expired_aggressive: '<gold>💥 Decoy expired — explosion triggered!</gold>'
decoy_expired_defensive: '<dark_aqua>🎭 Decoy faded.</dark_aqua>'
# ── Digger ────────────────────────────────────────────────────────────────────
digger:
name: '<gradient:dark_green:gold><bold>Digger</bold></gradient>'
lore:
- ' '
- '<dark_gray>[AGG]</dark_gray> <gray>Burrow underground for <yellow>5 s</yellow> with <green>Night Vision</green>. Surface for <red>4 dmg + knockup</red> in <yellow>3 blocks</yellow>.</gray>'
- '<dark_aqua>[DEF]</dark_aqua> <gray>Burrow for <yellow>8 s</yellow> with <green>Regen II</green>. Surface for <green>2 Absorption hearts</green> (<yellow>10 s</yellow>).</gray>'
items:
shovel:
aggressive:
name: '<gold>⛏ Earth Buster</gold>'
description: 'Burrow underground for 5 s (Night Vision). Emerge dealing 4 dmg + knockup to nearby enemies'
defensive:
name: '<dark_aqua>⛏ Earth Shelter</dark_aqua>'
description: 'Burrow underground for 8 s (Regen II). Emerge with Absorption I for 10 s'
messages:
burrowed_aggressive: '<gold>⛏ Burrowing! <green>Night Vision</green> active — emerge to blast!</gold>'
burrowed_defensive: '<dark_aqua>⛏ Burrowing! <green>Regen II</green> active — emerge refreshed!</dark_aqua>'
surfaced_aggressive: '<gold>💥 Surfaced! Enemies in range were blasted!</gold>'
surfaced_defensive: '<dark_aqua>🛡 Surfaced! <green>Absorption I</green> active for 10 seconds!</dark_aqua>'
surface_hit: '<red>⛏ A Digger burst out of the ground beneath you!</red>'

View File

@@ -7,6 +7,7 @@ depend:
- "WorldEdit"
- "Apollo-Bukkit"
- "McScrims-CoreSystem"
- "Citizens"
permissions:
speedhg.bypass: