Add new kits
Added 3 new kits: - Digger - Switcher - Trickster
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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() )
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
|
||||
636
src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt
Normal file
636
src/main/kotlin/club/mcscrims/speedhg/kit/impl/DiggerKit.kt
Normal 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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
402
src/main/kotlin/club/mcscrims/speedhg/kit/impl/SwitcherKit.kt
Normal file
402
src/main/kotlin/club/mcscrims/speedhg/kit/impl/SwitcherKit.kt
Normal 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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
651
src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt
Normal file
651
src/main/kotlin/club/mcscrims/speedhg/kit/impl/TricksterKit.kt
Normal 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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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:
|
||||
@@ -905,4 +906,71 @@ kits:
|
||||
toxic_skin_proc: '<green>☠ Toxic Skin! Attacker poisoned!</green>'
|
||||
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>'
|
||||
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>'
|
||||
@@ -7,6 +7,7 @@ depend:
|
||||
- "WorldEdit"
|
||||
- "Apollo-Bukkit"
|
||||
- "McScrims-CoreSystem"
|
||||
- "Citizens"
|
||||
|
||||
permissions:
|
||||
speedhg.bypass:
|
||||
|
||||
Reference in New Issue
Block a user