diff --git a/build.gradle.kts b/build.gradle.kts
index 0481fb6..4f8a550 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,8 +2,8 @@ plugins {
id("java")
id("maven-publish")
id("com.github.johnrengelman.shadow") version "8.1.1"
- kotlin("jvm") version libs.versions.kotlin
- kotlin("kapt") version libs.versions.kotlin
+ kotlin("jvm") version "2.2.0"
+ kotlin("kapt") version "2.2.0"
}
group = "club.mcscrims"
@@ -17,11 +17,16 @@ repositories {
maven("https://libraries.minecraft.net/")
maven("https://repo.codemc.io/repository/maven-public/")
maven("https://repo.lunarclient.dev")
+ maven("https://maven.enginehub.org/repo/")
}
dependencies {
implementation("fr.mrmicky:fastboard:2.1.3")
+
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")
}
tasks {
diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt
index ec89680..ce6240e 100644
--- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt
+++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt
@@ -3,7 +3,12 @@ package club.mcscrims.speedhg
import club.mcscrims.speedhg.config.LanguageManager
import club.mcscrims.speedhg.game.GameManager
import club.mcscrims.speedhg.game.modules.AntiRunningManager
+import club.mcscrims.speedhg.kit.KitManager
+import club.mcscrims.speedhg.kit.impl.GoblinKit
+import club.mcscrims.speedhg.kit.listener.KitEventDispatcher
import club.mcscrims.speedhg.listener.ConnectListener
+import club.mcscrims.speedhg.listener.GameStateListener
+import club.mcscrims.speedhg.listener.SoupListener
import club.mcscrims.speedhg.scoreboard.ScoreboardManager
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
@@ -29,6 +34,9 @@ class SpeedHG : JavaPlugin() {
lateinit var scoreboardManager: ScoreboardManager
private set
+ lateinit var kitManager: KitManager
+ private set
+
override fun onEnable()
{
instance = this
@@ -41,6 +49,11 @@ class SpeedHG : JavaPlugin() {
scoreboardManager = ScoreboardManager( this )
+ kitManager = KitManager( this )
+
+ // Register kits
+ kitManager.registerKit( GoblinKit() )
+
registerListener()
logger.info("SpeedHG wurde geladen!")
@@ -48,6 +61,7 @@ class SpeedHG : JavaPlugin() {
override fun onDisable()
{
+ kitManager.clearAll()
super.onDisable()
}
@@ -56,6 +70,9 @@ class SpeedHG : JavaPlugin() {
val pm = Bukkit.getPluginManager()
pm.registerEvents( ConnectListener(), this )
+ pm.registerEvents( GameStateListener(), this )
+ pm.registerEvents( SoupListener(), this )
+ pm.registerEvents(KitEventDispatcher( this, kitManager ), this )
}
}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/config/LanguageManager.kt b/src/main/kotlin/club/mcscrims/speedhg/config/LanguageManager.kt
index 6d22a9e..3c75b8e 100644
--- a/src/main/kotlin/club/mcscrims/speedhg/config/LanguageManager.kt
+++ b/src/main/kotlin/club/mcscrims/speedhg/config/LanguageManager.kt
@@ -65,6 +65,13 @@ class LanguageManager(
return langMap?.get( key ) ?: "Missing Key: $key"
}
+ fun getDefaultRawMessage(
+ key: String
+ ): String
+ {
+ return languages[ defaultLanguage ]?.get( key ) ?: "Missing Key: $key"
+ }
+
fun getRawMessageList(
player: Player,
key: String
@@ -79,7 +86,16 @@ class LanguageManager(
val file = File( plugin.dataFolder, "languages/$locale.yml" )
val config = YamlConfiguration.loadConfiguration( file )
- return if (config.contains( key )) config.getStringList( key ) else listOf("Missing List: $key")
+ return if (config.contains( key )) config.getStringList( key ) else listOf( "Missing List: $key" )
+ }
+
+ fun getDefaultRawMessageList(
+ key: String
+ ): List
+ {
+ val file = File( plugin.dataFolder, "languages/$defaultLanguage.yml" )
+ val config = YamlConfiguration.loadConfiguration( file )
+ return if (config.contains( key )) config.getStringList( key ) else listOf( "Missing List: $key" )
}
fun getComponent(
@@ -93,4 +109,14 @@ class LanguageManager(
return miniMessage.deserialize( raw, *tags.toTypedArray() )
}
+ fun getDefaultComponent(
+ key: String,
+ placeholders: Map
+ ): Component
+ {
+ val raw = languages[ defaultLanguage ]?.get( key ) ?: "Missing Key: $key"
+ val tags = placeholders.map { (k, v) -> Placeholder.parsed( k, v ) }
+ return miniMessage.deserialize( raw, *tags.toTypedArray() )
+ }
+
}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt
index 8080bb5..c29963e 100644
--- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt
+++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt
@@ -171,8 +171,7 @@ class GameManager(
teleportRandomly( player, world, startBorder / 2 )
- // TODO: Kit Items geben
- // plugin.kitManager.giveKit( player )
+ plugin.kitManager.applyKit( player ) // verteilt Items + ruft onAssign + passive.onActivate
player.inventory.addItem(ItemStack( Material.COMPASS ))
player.sendMsg( "game.started" )
@@ -244,6 +243,7 @@ class GameManager(
) {
setGameState( GameState.ENDING )
timer = 15
+ plugin.kitManager.clearAll()
Bukkit.getOnlinePlayers().forEach { p ->
p.showTitle(Title.title(
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt
new file mode 100644
index 0000000..80f5b64
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/Kit.kt
@@ -0,0 +1,87 @@
+package club.mcscrims.speedhg.kit
+
+import club.mcscrims.speedhg.kit.ability.ActiveAbility
+import club.mcscrims.speedhg.kit.ability.PassiveAbility
+import net.kyori.adventure.text.Component
+import org.bukkit.Material
+import org.bukkit.entity.Player
+
+/**
+ * Base class for every kit in SpeedHG.
+ *
+ * ## How to create a new kit
+ * 1. Extend this class.
+ * 2. Implement all abstract members.
+ * 3. For each [Playstyle], create a private inner class extending [ActiveAbility]
+ * and another extending [PassiveAbility].
+ * 4. Return the correct instance from [getActiveAbility] / [getPassiveAbility]
+ * using a `when(playstyle)` expression.
+ * 5. Register the kit via `plugin.kitManager.registerKit(YourKit())` in [SpeedHG.onEnable].
+ *
+ * See [TemplateKit] for a fully annotated example.
+ */
+abstract class Kit {
+
+ /** Unique snake_case identifier (e.g. `"warrior"`, `"archer"`). */
+ abstract val id: String
+
+ /** Coloured display name shown in the kit selection GUI. */
+ abstract val displayName: Component
+
+ /** Short lore lines shown on the item in the kit GUI. */
+ abstract val lore: List
+
+ /** Icon used in the kit selection GUI. */
+ abstract val icon: Material
+
+ // -------------------------------------------------------------------------
+ // Playstyle-specific abilities — implement with a `when` expression
+ // -------------------------------------------------------------------------
+
+ /**
+ * Return the [ActiveAbility] for the given [playstyle].
+ *
+ * ```kotlin
+ * override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
+ * Playstyle.AGGRESSIVE -> AggressiveActive()
+ * Playstyle.DEFENSIVE -> DefensiveActive()
+ * }
+ * ```
+ *
+ * **Performance note:** This is called frequently by [KitEventDispatcher].
+ * Prefer returning a cached singleton (`private val aggressiveActive = AggressiveActive()`)
+ * over allocating a new instance on each call.
+ */
+ abstract fun getActiveAbility( playstyle: Playstyle ): ActiveAbility
+
+ /** Return the [PassiveAbility] for the given [playstyle]. Same caching advice applies. */
+ abstract fun getPassiveAbility( playstyle: Playstyle ): PassiveAbility
+
+ // -------------------------------------------------------------------------
+ // Item distribution
+ // -------------------------------------------------------------------------
+
+ /**
+ * Give the player their kit-specific items at game start (after teleportation).
+ * The standard HG items (soup, compass, etc.) are already given by [GameManager].
+ * Only add kit-exclusive items here.
+ */
+ abstract fun giveItems( player: Player, playstyle: Playstyle )
+
+ // -------------------------------------------------------------------------
+ // Lifecycle hooks (optional)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Called once per round when the kit is applied to [player].
+ * Use for permanent potion effects, scoreboard objectives, repeating tasks, etc.
+ * The matching [PassiveAbility.onActivate] is called immediately after this.
+ */
+ open fun onAssign( player: Player, playstyle: Playstyle ) {}
+
+ /**
+ * Called when the kit is removed (game over / round reset).
+ * The matching [PassiveAbility.onDeactivate] is called immediately before this.
+ */
+ open fun onRemove( player: Player ) {}
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt
new file mode 100644
index 0000000..76c4ebb
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/KitManager.kt
@@ -0,0 +1,144 @@
+package club.mcscrims.speedhg.kit
+
+import club.mcscrims.speedhg.SpeedHG
+import club.mcscrims.speedhg.kit.charge.PlayerChargeData
+import org.bukkit.entity.Player
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Manages kit registration, player selections, and the charge state for all online players.
+ *
+ * ## Typical usage flow
+ * ```
+ * // During plugin startup
+ * kitManager.registerKit(WarriorKit())
+ * kitManager.registerKit(ArcherKit())
+ *
+ * // During lobby (e.g. GUI click)
+ * kitManager.selectKit(player, kitManager.getKit("warrior")!!)
+ * kitManager.selectPlaystyle(player, Playstyle.AGGRESSIVE)
+ *
+ * // When the game round starts (replace the TODO in GameManager.startGame)
+ * kitManager.applyKit(player) // gives items, runs onAssign / passive.onActivate
+ *
+ * // When the round ends
+ * kitManager.clearAll() // runs onRemove / passive.onDeactivate for every player
+ * ```
+ */
+class KitManager(
+ private val plugin: SpeedHG
+) {
+
+ // All kits available this session, keyed by Kit.id
+ private val registeredKits = ConcurrentHashMap()
+
+ // Lobby selections — set before the round starts
+ private val selectedKits = ConcurrentHashMap()
+ private val selectedPlaystyles = ConcurrentHashMap()
+
+ // Live charge state — populated by applyKit, cleared by removeKit/clearAll
+ private val chargeData = ConcurrentHashMap()
+
+ // -------------------------------------------------------------------------
+ // Kit registration
+ // -------------------------------------------------------------------------
+
+ fun registerKit(
+ kit: Kit
+ ) {
+ registeredKits[kit.id] = kit
+ plugin.logger.info("[KitManager] Registered kit: ${kit.id}")
+ }
+
+ fun getRegisteredKits(): Collection = registeredKits.values
+
+ fun getKit(id: String): Kit? = registeredKits[id]
+
+ // -------------------------------------------------------------------------
+ // Player selections (lobby phase)
+ // -------------------------------------------------------------------------
+
+ fun selectKit(
+ player: Player,
+ kit: Kit
+ ) {
+ selectedKits[player.uniqueId] = kit
+ }
+
+ /**
+ * Set the playstyle for a player.
+ * Defaults to [Playstyle.DEFENSIVE] if never called.
+ */
+ fun selectPlaystyle(
+ player: Player,
+ playstyle: Playstyle
+ ) {
+ selectedPlaystyles[player.uniqueId] = playstyle
+ }
+
+ fun getSelectedKit( player: Player ): Kit? = selectedKits[player.uniqueId]
+
+ fun getSelectedPlaystyle( player: Player ): Playstyle =
+ selectedPlaystyles[player.uniqueId] ?: Playstyle.DEFENSIVE
+
+ // -------------------------------------------------------------------------
+ // Game lifecycle
+ // -------------------------------------------------------------------------
+
+ /**
+ * Apply the player's selected kit at game start.
+ *
+ * Call this from [GameManager.startGame] for every online player, **after** teleportation.
+ * If the player has not selected a kit, this is a no-op.
+ */
+ fun applyKit(
+ player: Player
+ ) {
+ val kit = selectedKits[player.uniqueId] ?: return
+ val playstyle = getSelectedPlaystyle( player )
+ val active = kit.getActiveAbility( playstyle )
+
+ chargeData[player.uniqueId] = PlayerChargeData( active.hitsRequired )
+
+ kit.onAssign( player, playstyle )
+ kit.getPassiveAbility( playstyle ).onActivate( player )
+ kit.giveItems( player, playstyle )
+ }
+
+ /**
+ * Remove and clean up a player's kit (round over, player eliminated, etc.).
+ * Safe to call even if the player has no kit assigned.
+ */
+ fun removeKit(
+ player: Player
+ ) {
+ val kit = selectedKits[player.uniqueId] ?: return
+ val playstyle = getSelectedPlaystyle( player )
+
+ kit.getPassiveAbility( playstyle ).onDeactivate( player )
+ kit.onRemove( player )
+
+ chargeData.remove( player.uniqueId )
+ }
+
+ /**
+ * Remove all kits and clear every selection.
+ * Call this at the end of each round, **before** resetting other game state.
+ */
+ fun clearAll()
+ {
+ selectedKits.keys.toList().forEach { uuid ->
+ plugin.server.getPlayer( uuid )?.let { removeKit( it ) }
+ }
+ selectedKits.clear()
+ selectedPlaystyles.clear()
+ chargeData.clear()
+ }
+
+ // -------------------------------------------------------------------------
+ // Charge access (used by KitEventDispatcher)
+ // -------------------------------------------------------------------------
+
+ fun getChargeData( player: Player ): PlayerChargeData? = chargeData[player.uniqueId]
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/Playstyle.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/Playstyle.kt
new file mode 100644
index 0000000..6a567be
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/Playstyle.kt
@@ -0,0 +1,8 @@
+package club.mcscrims.speedhg.kit
+
+enum class Playstyle(
+ val displayName: String
+) {
+ AGGRESSIVE( "Aggressive" ),
+ DEFENSIVE( "Defensive" )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/ability/AbilityResult.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/AbilityResult.kt
new file mode 100644
index 0000000..38c0779
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/AbilityResult.kt
@@ -0,0 +1,23 @@
+package club.mcscrims.speedhg.kit.ability
+
+/**
+ * Return type for [ActiveAbility.execute].
+ *
+ * - [Success] → Ability fired, KitEventDispatcher keeps the charge consumed.
+ * - [ConditionNotMet] → Ability couldn't fire (e.g. no targets nearby).
+ * KitEventDispatcher *refunds* the charge automatically.
+ */
+sealed class AbilityResult {
+
+ /** The ability executed successfully. */
+ object Success : AbilityResult()
+
+ /**
+ * A runtime condition was not satisfied (e.g. no enemy in range).
+ * The dispatcher refunds the charge so the player can try again.
+ *
+ * @param reason Short message shown to the player via ActionBar.
+ */
+ data class ConditionNotMet( val reason: String ) : AbilityResult()
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt
new file mode 100644
index 0000000..b80d993
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/ActiveAbility.kt
@@ -0,0 +1,54 @@
+package club.mcscrims.speedhg.kit.ability
+
+import club.mcscrims.speedhg.kit.Playstyle
+import org.bukkit.Material
+import org.bukkit.entity.Player
+
+/**
+ * An ability that the player actively triggers by right-clicking [triggerMaterial].
+ *
+ * ## Charge flow
+ * 1. Game starts → charge state is READY (one free use).
+ * 2. Player right-clicks → [KitEventDispatcher] checks if READY, then calls [execute].
+ * 3. Charge transitions to CHARGING; the player must land [hitsRequired] melee hits.
+ * 4. Each qualifying hit calls [PlayerChargeData.registerHit].
+ * 5. When full → [onFullyCharged] is called and state returns to READY.
+ *
+ * If [execute] returns [AbilityResult.ConditionNotMet] the dispatcher automatically
+ * refunds the charge.
+ */
+abstract class ActiveAbility(
+ val playstyle: Playstyle
+) {
+
+ abstract val name: String
+ abstract val description: String
+
+ /**
+ * Melee hits required to recharge after one use.
+ * Set to 0 for an unlimited / always-ready ability (e.g. in debug kits).
+ */
+ abstract val hitsRequired: Int
+
+ /**
+ * The item material that triggers this ability on right-click.
+ * The item must be in the player's main hand.
+ */
+ abstract val triggerMaterial: Material
+
+ /**
+ * Execute the ability. Called only when [PlayerChargeData.isReady] is true.
+ * The dispatcher has already called [PlayerChargeData.consume] before this runs.
+ *
+ * Return [AbilityResult.Success] to keep the charge consumed.
+ * Return [AbilityResult.ConditionNotMet] to trigger an automatic charge refund.
+ */
+ abstract fun execute( player: Player ): AbilityResult
+
+ /**
+ * Called when the ability's charge completes ([hitsRequired] hits landed).
+ * Override for sounds, particles, ActionBar feedback, etc.
+ */
+ open fun onFullyCharged( player: Player ) {}
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/ability/PassiveAbility.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/PassiveAbility.kt
new file mode 100644
index 0000000..3646f0d
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/ability/PassiveAbility.kt
@@ -0,0 +1,65 @@
+package club.mcscrims.speedhg.kit.ability
+
+import club.mcscrims.speedhg.kit.Playstyle
+import org.bukkit.entity.Player
+import org.bukkit.event.entity.EntityDamageByEntityEvent
+import org.bukkit.event.player.PlayerInteractEvent
+import org.bukkit.event.player.PlayerMoveEvent
+
+/**
+ * A passive ability that runs silently in the background.
+ *
+ * [KitEventDispatcher] is the single registered Bukkit [Listener].
+ * It calls the appropriate hook on the relevant player's PassiveAbility
+ * for every event, so you never register your own listeners here.
+ *
+ * Override **only** the hooks you need — all defaults are no-ops.
+ *
+ * ## Adding new event hooks
+ * 1. Add an `open fun on` stub here.
+ * 2. Add the corresponding `@EventHandler` in [KitEventDispatcher] that
+ * looks up the player's kit and calls this stub.
+ */
+abstract class PassiveAbility(
+ val playstyle: Playstyle
+) {
+
+ abstract val name: String
+ abstract val description: String
+
+ // -------------------------------------------------------------------------
+ // Lifecycle
+ // -------------------------------------------------------------------------
+
+ /** Called once when a game round starts and this kit is applied to [player]. */
+ open fun onActivate( player: Player ) {}
+
+ /** Called when the round ends or the kit is removed. Cancel tasks here. */
+ open fun onDeactivate( player: Player ) {}
+
+ // -------------------------------------------------------------------------
+ // Combat hooks
+ // -------------------------------------------------------------------------
+
+ /**
+ * [attacker] (this player) just hit [victim] with a melee attack.
+ * Called AFTER [PlayerChargeData.registerHit] has already been incremented.
+ */
+ open fun onHitEnemy( attacker: Player, victim: Player, event: EntityDamageByEntityEvent ) {}
+
+ /** [victim] (this player) just received a melee hit from [attacker]. */
+ open fun onHitByEnemy( victim: Player, attacker: Player, event: EntityDamageByEntityEvent ) {}
+
+ // -------------------------------------------------------------------------
+ // Interaction hooks (called for non-trigger-material right-clicks)
+ // -------------------------------------------------------------------------
+
+ open fun onInteract( player: Player, event: PlayerInteractEvent ) {}
+
+ // -------------------------------------------------------------------------
+ // Movement hook
+ // -------------------------------------------------------------------------
+
+ open fun onMove( player: Player, event: PlayerMoveEvent ) {}
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/charge/ChargeState.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/charge/ChargeState.kt
new file mode 100644
index 0000000..78c8c54
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/charge/ChargeState.kt
@@ -0,0 +1,14 @@
+package club.mcscrims.speedhg.kit.charge
+
+enum class ChargeState {
+ /**
+ * Ability is ready. The next right-click will fire it.
+ */
+ READY,
+
+ /**
+ * Ability was used. Waiting for [PlayerChargeData.hitsRequired]
+ * melee hits before returning to [READY]
+ */
+ CHARGING
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/charge/PlayerChargeData.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/charge/PlayerChargeData.kt
new file mode 100644
index 0000000..bbbde1a
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/charge/PlayerChargeData.kt
@@ -0,0 +1,89 @@
+package club.mcscrims.speedhg.kit.charge
+
+/**
+ * Tracks the ability charge for a single player.
+ *
+ * One instance is created per player in [KitManager.applyKit] and destroyed in [removeKit]
+ *
+ * ## State machine
+ * ```
+ * READY ──[consume()]──► CHARGING ──[registerHit() × hitsRequired]──► READY
+ * ```
+ *
+ * @param hitsRequired Number of melee hits needed to recharge. 0 = alway READY.
+ */
+class PlayerChargeData(
+ val hitsRequired: Int
+) {
+
+ var state: ChargeState = ChargeState.READY
+ private set
+
+ private var currentHits: Int = 0
+
+ val isReady: Boolean
+ get() = state == ChargeState.READY
+
+ val hitsRemaining: Int
+ get() = ( hitsRequired - currentHits ).coerceAtLeast( 0 )
+
+ /**
+ * Progress towards the next charge as a value in [0f, 1f].
+ * Always 1f when [hitsRequired] is 0.
+ */
+ val progress: Float
+ get() = if ( hitsRequired == 0 ) 1f
+ else ( currentHits.toFloat() / hitsRequired.toFloat() ).coerceIn( 0f, 1f )
+
+ /**
+ * Consume a ready charge (ability is about to fire).
+ *
+ * @return `true` if the charge was consumed, `false` if already [ChargeState.CHARGING]
+ */
+ fun consume(): Boolean
+ {
+ if ( state != ChargeState.READY ) return false
+ // Edge case: ability has no charge cost → never leave READY state
+ if ( hitsRequired == 0 ) return true
+ state = ChargeState.CHARGING
+ currentHits = 0
+ return true
+ }
+
+ /**
+ * Register one qualifying melee hit.
+ *
+ * @return `true` if this hit completed the charge (state is now READY again).
+ * `false` if more hits are needed or the ability wasn't in CHARGING state.
+ */
+ fun registerHit(): Boolean
+ {
+ if ( state != ChargeState.CHARGING ) return false
+ currentHits++
+ return if ( currentHits >= hitsRequired )
+ {
+ currentHits = 0
+ state = ChargeState.READY
+ true
+ }
+ else false
+ }
+
+ /**
+ * Refund a charge — used by [KitEventDispatcher] when [AbilityResult.ConditionNotMet]
+ * is returned, so the player doesn't lose their charge for a failed use.
+ */
+ fun refund()
+ {
+ state = ChargeState.READY
+ currentHits = 0
+ }
+
+ /** Full reset to initial state (game start / round reset). */
+ fun reset()
+ {
+ state = ChargeState.READY
+ currentHits = 0
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt
new file mode 100644
index 0000000..d557b9f
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/GoblinKit.kt
@@ -0,0 +1,231 @@
+package club.mcscrims.speedhg.kit.impl
+
+import club.mcscrims.speedhg.SpeedHG
+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.WorldEditUtils
+import club.mcscrims.speedhg.util.legacySerializer
+import club.mcscrims.speedhg.util.trans
+import net.kyori.adventure.text.Component
+import org.bukkit.Bukkit
+import org.bukkit.Material
+import org.bukkit.Sound
+import org.bukkit.entity.Player
+import org.bukkit.inventory.ItemStack
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+class GoblinKit : Kit() {
+
+ private val plugin = SpeedHG.instance
+
+ override val id: String
+ get() = "goblin"
+
+ override val displayName: Component
+ get() = plugin.languageManager.getDefaultComponent( "kits.goblin.name", mapOf() )
+
+ override val lore: List
+ get() = plugin.languageManager.getDefaultRawMessageList( "kits.goblin.lore" )
+
+ override val icon: Material
+ get() = Material.MOSSY_COBBLESTONE
+
+ // ── Cached ability instances (avoid allocating per event call) ────────────
+ private val aggressiveActive = AggressiveActive()
+ private val defensiveActive = DefensiveActive()
+ private val aggressiveNoPassive = AggressiveNoPassive()
+ private val defensiveNoPassive = DefensiveNoPassive()
+
+ // ── 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 -> aggressiveNoPassive
+ Playstyle.DEFENSIVE -> defensiveNoPassive
+ }
+
+ // ── Item distribution ─────────────────────────────────────────────────────
+
+ private val cachedItems = ConcurrentHashMap>()
+
+ override fun giveItems(
+ player: Player,
+ playstyle: Playstyle
+ ) {
+ when( playstyle )
+ {
+ Playstyle.AGGRESSIVE ->
+ {
+ val stealItem = ItemBuilder( Material.GLASS )
+ .name( aggressiveActive.name )
+ .lore(listOf( aggressiveActive.description ))
+ .build()
+
+ cachedItems[ player.uniqueId ] = listOf( stealItem )
+ player.inventory.addItem( stealItem )
+ }
+
+ Playstyle.DEFENSIVE ->
+ {
+ val bunkerItem = ItemBuilder( Material.MOSSY_COBBLESTONE )
+ .name( defensiveActive.name )
+ .lore(listOf( defensiveActive.description ))
+ .build()
+
+ cachedItems[ player.uniqueId ] = listOf( bunkerItem )
+ player.inventory.addItem( bunkerItem )
+ }
+ }
+ }
+
+ // ── Optional lifecycle hooks ──────────────────────────────────────────────
+
+ override fun onRemove(
+ player: Player
+ ) {
+ val items = cachedItems[ player.uniqueId ] ?: return
+ player.inventory.removeAll { items.contains( it ) }
+ }
+
+ private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
+
+ override val name: String
+ get() = plugin.languageManager.getDefaultRawMessage( "kits.goblin.items.steal.name" )
+
+ override val description: String
+ get() = plugin.languageManager.getDefaultRawMessage( "kits.goblin.items.steal.description" )
+
+ override val hitsRequired: Int
+ get() = 15
+
+ override val triggerMaterial: Material
+ get() = Material.GLASS
+
+ override fun execute(
+ player: Player
+ ): AbilityResult
+ {
+ val lineOfSight = player.getTargetEntity( 3 ) as? Player
+ ?: return AbilityResult.ConditionNotMet( "No player in line of sight" )
+
+ val targetKit = plugin.kitManager.getSelectedKit( lineOfSight )
+ ?: return AbilityResult.ConditionNotMet( "Target has no kit" )
+
+ val currentKit = plugin.kitManager.getSelectedKit( player )
+ ?: return AbilityResult.ConditionNotMet( "Error while copying kit" )
+
+ plugin.kitManager.removeKit( player )
+ plugin.kitManager.selectKit( player, targetKit )
+ plugin.kitManager.applyKit( player )
+
+ Bukkit.getScheduler().runTaskLater( plugin, { ->
+ plugin.kitManager.removeKit( player )
+ plugin.kitManager.selectKit( player, currentKit )
+ plugin.kitManager.applyKit( player )
+ }, 20L * 60)
+
+ player.playSound( player.location, Sound.ENTITY_EVOKER_CAST_SPELL, 1f, 1.5f )
+ player.sendActionBar(player.trans( "kits.goblin.messages.stole_kit", "kit" to legacySerializer.serialize( targetKit.displayName )))
+
+ return AbilityResult.Success
+ }
+
+ override fun onFullyCharged(
+ player: Player
+ ) {
+ player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
+ player.sendActionBar(player.trans( "kits.goblin.messages.steal_kit_charged" ))
+ }
+
+ }
+
+ private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
+
+ override val name: String
+ get() = plugin.languageManager.getDefaultRawMessage( "kits.goblin.items.bunker.name" )
+
+ override val description: String
+ get() = plugin.languageManager.getDefaultRawMessage( "kits.goblin.items.bunker.description" )
+
+ override val hitsRequired: Int
+ get() = 15
+
+ override val triggerMaterial: Material
+ get() = Material.MOSSY_COBBLESTONE
+
+ override fun execute(
+ player: Player
+ ): AbilityResult
+ {
+ val world = player.world
+ val location = player.location
+
+ WorldEditUtils.createSphere(
+ world,
+ location,
+ 10.0,
+ false,
+ Material.MOSSY_COBBLESTONE
+ )
+
+ Bukkit.getScheduler().runTaskLater( plugin, { ->
+ WorldEditUtils.createSphere(
+ world,
+ location,
+ 10.0,
+ false,
+ Material.AIR
+ )
+ }, 20L * 15 )
+
+ player.playSound( player.location, Sound.BLOCK_PISTON_EXTEND, 1f, 0.8f )
+ player.sendActionBar(player.trans( "kits.goblin.messages.spawn_bunker" ))
+
+ return AbilityResult.Success
+ }
+
+ override fun onFullyCharged(
+ player: Player
+ ) {
+ player.playSound( player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f )
+ player.sendActionBar(player.trans( "kits.goblin.messages.bunker_charged" ))
+ }
+
+ }
+
+ private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) {
+
+ override val name: String
+ get() = "None"
+
+ override val description: String
+ get() = "None"
+
+ }
+
+ private class DefensiveNoPassive : PassiveAbility( Playstyle.DEFENSIVE ) {
+
+ override val name: String
+ get() = "None"
+
+ override val description: String
+ get() = "None"
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt
new file mode 100644
index 0000000..d76850b
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TemplateKit.kt
@@ -0,0 +1,227 @@
+package club.mcscrims.speedhg.kit.impl
+
+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 net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+import org.bukkit.Material
+import org.bukkit.Sound
+import org.bukkit.entity.Player
+import org.bukkit.event.entity.EntityDamageByEntityEvent
+import org.bukkit.inventory.ItemStack
+import org.bukkit.potion.PotionEffect
+import org.bukkit.potion.PotionEffectType
+
+/**
+ * ──────────────────────────────────────────────────────────────────────────────
+ * TEMPLATE KIT — reference implementation, do not use in production
+ * ──────────────────────────────────────────────────────────────────────────────
+ *
+ * Copy this file, rename the class, change the abilities, and register your kit:
+ * ```kotlin
+ * // In SpeedHG.onEnable():
+ * kitManager.registerKit(YourKit())
+ * ```
+ *
+ * ## Playstyle overview (Template)
+ *
+ * | Playstyle | Active | Passive |
+ * |-------------|----------------------------|----------------------------|
+ * | AGGRESSIVE | Power Strike (10 hits) | Bloodlust – speed on hit |
+ * | DEFENSIVE | Iron Skin (5 hits) | Fortitude – 10% dmg reduc. |
+ */
+class TemplateKit : Kit() {
+
+ override val id = "template"
+ override val displayName: Component = Component.text("Template Kit", NamedTextColor.YELLOW)
+ override val lore = listOf("An example kit.", "Replace with your own.")
+ override val icon = Material.NETHER_STAR
+
+ // ── Cached ability instances (avoid allocating per event call) ────────────
+ private val aggressiveActive = AggressiveActive()
+ private val defensiveActive = DefensiveActive()
+ private val aggressivePassive = AggressivePassive()
+ private val defensivePassive = DefensivePassive()
+
+ // ── 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 fun giveItems(player: Player, playstyle: Playstyle) {
+ // Slot 8 = ability trigger item (always present)
+ player.inventory.setItem(8, ItemStack(Material.BLAZE_ROD))
+
+ when (playstyle) {
+ Playstyle.AGGRESSIVE -> {
+ // e.g. extra offensive item
+ }
+ Playstyle.DEFENSIVE -> {
+ // e.g. extra defensive item
+ }
+ }
+ }
+
+ // ── Optional lifecycle hooks ──────────────────────────────────────────────
+
+ override fun onAssign(player: Player, playstyle: Playstyle) {
+ // Example: a kit that always gives permanent speed I
+ // player.addPotionEffect(PotionEffect(PotionEffectType.SPEED, Int.MAX_VALUE, 0, false, false, true))
+ }
+
+ override fun onRemove(player: Player) {
+ // Undo anything done in onAssign
+ // player.removePotionEffect(PotionEffectType.SPEED)
+ }
+
+ // =========================================================================
+ // AGGRESSIVE active ability
+ // =========================================================================
+
+ /**
+ * Power Strike — marks the player so their next hit deals 1.5× damage.
+ *
+ * Demonstrates:
+ * - Returning [AbilityResult.Success]
+ * - Returning [AbilityResult.ConditionNotMet] (no example here, but shown below)
+ * - [onFullyCharged] for charge-complete feedback
+ */
+ private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
+
+ override val name = "Power Strike"
+ override val description = "Your next melee attack deals 1.5× damage."
+ override val hitsRequired = 10
+ override val triggerMaterial = Material.BLAZE_ROD
+
+ // In a real kit, store pending-strike players in a companion Set
+ // and apply the bonus in onHitEnemy / a damage event listener.
+
+ override fun execute(player: Player): AbilityResult {
+ // Example: guard clause returning ConditionNotMet
+ // val nearbyEnemies = player.getNearbyEntities(10.0, 10.0, 10.0).filterIsInstance()
+ // if (nearbyEnemies.isEmpty()) return AbilityResult.ConditionNotMet("No enemies nearby!")
+
+ // TODO: add player.uniqueId to a "powerStrikePending" set
+
+ player.playSound(player.location, Sound.ENTITY_BLAZE_SHOOT, 1f, 1.2f)
+ player.sendActionBar(Component.text("⚔ Power Strike ready!", NamedTextColor.RED))
+ return AbilityResult.Success
+ }
+
+ override fun onFullyCharged(player: Player) {
+ player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
+ player.sendActionBar(Component.text("⚡ Ability recharged!", NamedTextColor.GREEN))
+ }
+ }
+
+ // =========================================================================
+ // DEFENSIVE active ability
+ // =========================================================================
+
+ /**
+ * Iron Skin — grants Resistance I for 4 seconds.
+ *
+ * Demonstrates a simpler ability with fewer required hits (5 vs 10).
+ */
+ private inner class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
+
+ override val name = "Iron Skin"
+ override val description = "Gain Resistance I for 4 seconds."
+ override val hitsRequired = 5
+ override val triggerMaterial = Material.BLAZE_ROD
+
+ override fun execute(player: Player): AbilityResult {
+ player.addPotionEffect(
+ PotionEffect(
+ PotionEffectType.RESISTANCE,
+ /* duration */ 4 * 20,
+ /* amplifier */ 0,
+ /* ambient */ false,
+ /* particles */ false,
+ /* icon */ true
+ )
+ )
+ player.playSound(player.location, Sound.ITEM_TOTEM_USE, 0.8f, 1.5f)
+ player.sendActionBar(Component.text("🛡 Iron Skin active!", NamedTextColor.AQUA))
+ return AbilityResult.Success
+ }
+
+ override fun onFullyCharged(player: Player) {
+ player.playSound(player.location, Sound.BLOCK_ANVIL_USE, 0.8f, 1.5f)
+ player.sendActionBar(Component.text("⚡ Ability recharged!", NamedTextColor.GREEN))
+ }
+ }
+
+ // =========================================================================
+ // AGGRESSIVE passive — Bloodlust
+ // =========================================================================
+
+ /**
+ * Bloodlust — grants Speed I for 2 seconds after landing a hit.
+ *
+ * Demonstrates [onHitEnemy] and [onActivate] / [onDeactivate] usage.
+ */
+ private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) {
+
+ override val name = "Bloodlust"
+ override val description = "Gain Speed I for 2 s after hitting an enemy."
+
+ override fun onActivate(player: Player) {
+ // Called once at game start.
+ // Start any repeating BukkitTasks here and store the returned BukkitTask
+ // so you can cancel it in onDeactivate. Example:
+ // task = Bukkit.getScheduler().runTaskTimer(plugin, { checkCooldowns(player) }, 0L, 10L)
+ }
+
+ override fun onDeactivate(player: Player) {
+ // task?.cancel()
+ }
+
+ // NOTE: Called AFTER the charge counter has already been incremented.
+ override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
+ attacker.addPotionEffect(
+ PotionEffect(
+ PotionEffectType.SPEED,
+ /* duration */ 2 * 20,
+ /* amplifier */ 0,
+ /* ambient */ false,
+ /* particles */ false,
+ /* icon */ false
+ )
+ )
+ }
+ }
+
+ // =========================================================================
+ // DEFENSIVE passive — Fortitude
+ // =========================================================================
+
+ /**
+ * Fortitude — reduces all incoming melee damage by 10%.
+ *
+ * Demonstrates [onHitByEnemy] — the simplest passive pattern.
+ */
+ private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) {
+
+ override val name = "Fortitude"
+ override val description = "Take 10% less damage from melee attacks."
+
+ // onActivate / onDeactivate are no-ops for this passive (default impl. is fine)
+
+ override fun onHitByEnemy(victim: Player, attacker: Player, event: EntityDamageByEntityEvent) {
+ event.damage *= 0.90
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt
new file mode 100644
index 0000000..ba76445
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt
@@ -0,0 +1,181 @@
+package club.mcscrims.speedhg.kit.listener
+
+import club.mcscrims.speedhg.SpeedHG
+import club.mcscrims.speedhg.game.GameState
+import club.mcscrims.speedhg.kit.KitManager
+import club.mcscrims.speedhg.kit.ability.AbilityResult
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+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.event.player.PlayerInteractEvent
+import org.bukkit.event.player.PlayerMoveEvent
+import org.bukkit.inventory.EquipmentSlot
+
+/**
+ * The *single* Bukkit [Listener] responsible for all kit-related event handling.
+ *
+ * ## Design rationale
+ * Instead of letting each [PassiveAbility] register its own Listener (which creates
+ * unpredictable ordering and hard-to-debug registration leaks), this dispatcher is
+ * registered once and delegates to the correct kit ability per-player.
+ *
+ * ## Extending with new event types
+ * 1. Add a new `@EventHandler` here.
+ * 2. Add the corresponding `open fun on` stub to [PassiveAbility].
+ * 3. Call the stub from the new handler.
+ * No other files need to change.
+ *
+ * Register this in [SpeedHG.onEnable]:
+ * ```kotlin
+ * server.pluginManager.registerEvents(KitEventDispatcher(this, kitManager), this)
+ * ```
+ */
+class KitEventDispatcher(
+ private val plugin: SpeedHG,
+ private val kitManager: KitManager,
+) : Listener {
+
+ // =========================================================================
+ // Hit tracking + charge system + passive combat hook
+ // =========================================================================
+
+ /**
+ * MONITOR priority — runs after all other damage modifiers are applied,
+ * so passive abilities that read [EntityDamageByEntityEvent.damage] see the
+ * final value.
+ */
+ @EventHandler(
+ priority = EventPriority.MONITOR,
+ ignoreCancelled = true
+ )
+ fun onMeleeHit(
+ event: EntityDamageByEntityEvent
+ ) {
+ val attacker = event.damager as? Player ?: return
+ val victim = event.entity as? Player ?: return
+
+ if ( !isIngame() ) return
+
+ val attackerKit = kitManager.getSelectedKit( attacker ) ?: return
+ val attackerPlaystyle = kitManager.getSelectedPlaystyle( attacker )
+ val chargeData = kitManager.getChargeData( attacker ) ?: return
+
+ // ── 1. Increment charge counter ──────────────────────────────────────
+ val justFullyCharged = chargeData.registerHit()
+ if ( justFullyCharged ) {
+ attackerKit.getActiveAbility( attackerPlaystyle ).onFullyCharged( attacker )
+ }
+
+ // ── 2. Attacker passive hook ─────────────────────────────────────────
+ attackerKit.getPassiveAbility( attackerPlaystyle )
+ .onHitEnemy( attacker, victim, event )
+
+ // ── 3. Victim passive hook ────────────────────────────────────────────
+ val victimKit = kitManager.getSelectedKit( victim ) ?: return
+ victimKit.getPassiveAbility(kitManager.getSelectedPlaystyle( victim ))
+ .onHitByEnemy( victim, attacker, event )
+ }
+
+ // =========================================================================
+ // Active ability trigger + passive interact hook
+ // =========================================================================
+
+ /**
+ * HIGH priority so we cancel the event (suppressing block/item interactions)
+ * before HIGHEST or MONITOR listeners see it.
+ */
+ @EventHandler(
+ priority = EventPriority.HIGH,
+ ignoreCancelled = true
+ )
+ fun onInteract(
+ event: PlayerInteractEvent
+ ) {
+ val player = event.player
+
+ // Only main-hand right-clicks — ignore left-click and off-hand duplicates
+ if ( event.hand != EquipmentSlot.HAND ) return
+ if ( !event.action.isRightClick ) return
+ if ( !isIngame() ) return
+
+ val kit = kitManager.getSelectedKit( player ) ?: return
+ val playstyle = kitManager.getSelectedPlaystyle( player )
+
+ // ── Always call the passive interact hook first ───────────────────────
+ kit.getPassiveAbility( playstyle ).onInteract( player, event )
+
+ // ── Check whether the item in hand is the ability trigger ─────────────
+ val itemInHand = player.inventory.itemInMainHand
+ val active = kit.getActiveAbility( playstyle )
+
+ if ( itemInHand.type != active.triggerMaterial ) return
+
+ event.isCancelled = true // prevent vanilla block interaction on ability item
+
+ val chargeData = kitManager.getChargeData( player ) ?: return
+
+ if ( !chargeData.isReady ) {
+ player.sendActionBar(
+ Component.text(
+ "⚡ ${chargeData.hitsRemaining} hits to recharge!",
+ NamedTextColor.RED
+ )
+ )
+ return
+ }
+
+ // ── Consume the charge, then execute ─────────────────────────────────
+ chargeData.consume()
+
+ when ( val result = active.execute( player ))
+ {
+ is AbilityResult.Success -> { /* ability provides its own feedback */ }
+ is AbilityResult.ConditionNotMet -> {
+ chargeData.refund() // player keeps their charge
+ player.sendActionBar(
+ Component.text(result.reason, NamedTextColor.YELLOW)
+ )
+ }
+ }
+ }
+
+ // =========================================================================
+ // Passive movement hook
+ // =========================================================================
+
+ /**
+ * MONITOR priority — reads the final resolved movement.
+ * Call [event.isCancelled] = true inside [PassiveAbility.onMove] if needed.
+ * If you do, switch this handler to a lower priority.
+ */
+ @EventHandler(
+ priority = EventPriority.MONITOR,
+ ignoreCancelled = true
+ )
+ fun onMove(
+ event: PlayerMoveEvent
+ ) {
+ if ( !isIngame() ) return
+
+ val player = event.player
+ val kit = kitManager.getSelectedKit( player ) ?: return
+
+ kit.getPassiveAbility(kitManager.getSelectedPlaystyle( player ))
+ .onMove( player, event )
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private fun isIngame(): Boolean = when ( plugin.gameManager.currentState )
+ {
+ GameState.INGAME, GameState.INVINCIBILITY -> true
+ else -> false
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/util/Extensions.kt b/src/main/kotlin/club/mcscrims/speedhg/util/Extensions.kt
index 4d7bc6c..3cdd8bf 100644
--- a/src/main/kotlin/club/mcscrims/speedhg/util/Extensions.kt
+++ b/src/main/kotlin/club/mcscrims/speedhg/util/Extensions.kt
@@ -8,7 +8,7 @@ import org.bukkit.entity.Player
private val langManager get() = SpeedHG.instance.languageManager
-private val legacySerializer = LegacyComponentSerializer.builder()
+val legacySerializer = LegacyComponentSerializer.builder()
.character('§')
.hexColors()
.useUnusualXRepeatedCharacterHexFormat()
diff --git a/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt b/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt
new file mode 100644
index 0000000..3bbe954
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/util/ItemBuilder.kt
@@ -0,0 +1,116 @@
+package club.mcscrims.speedhg.util
+
+import net.kyori.adventure.text.Component
+import org.bukkit.ChatColor
+import org.bukkit.Material
+import org.bukkit.enchantments.Enchantment
+import org.bukkit.inventory.ItemFlag
+import org.bukkit.inventory.ItemStack
+
+class ItemBuilder(
+ private val itemStack: ItemStack
+) {
+
+ constructor(
+ type: Material
+ ) : this(
+ ItemStack( type )
+ )
+
+ constructor(
+ type: Material,
+ amount: Int
+ ) : this(
+ ItemStack( type, amount )
+ )
+
+ constructor() : this(
+ ItemStack( Material.STONE )
+ )
+
+ fun name(
+ name: String
+ ): ItemBuilder
+ {
+ itemStack.editMeta { it.displayName(Component.text( name )) }
+ return this
+ }
+
+ fun name(
+ name: Component
+ ): ItemBuilder
+ {
+ itemStack.editMeta { it.displayName( name ) }
+ return this
+ }
+
+ fun lore(
+ lore: List
+ ): ItemBuilder
+ {
+ itemStack.editMeta {
+ val cLore = lore.stream()
+ .map( this::color )
+ .map( Component::text )
+ .toList()
+
+ it.lore( cLore as List )
+ }
+ return this
+ }
+
+ fun unbreakable(
+ unbreakable: Boolean
+ ): ItemBuilder
+ {
+ itemStack.editMeta { it.isUnbreakable = unbreakable }
+ return this
+ }
+
+ fun amount(
+ amount: Int
+ ): ItemBuilder
+ {
+ itemStack.amount = amount
+ return this
+ }
+
+ fun enchant(
+ ench: Enchantment
+ ): ItemBuilder
+ {
+ enchant( ench, 1 )
+ return this
+ }
+
+ fun enchant(
+ ench: Enchantment,
+ level: Int
+ ): ItemBuilder
+ {
+ itemStack.editMeta { it.addEnchant( ench, level, true ) }
+ itemFlag( ItemFlag.HIDE_ENCHANTS )
+ return this
+ }
+
+ fun itemFlag(
+ flag: ItemFlag
+ ): ItemBuilder
+ {
+ itemStack.editMeta { it.addItemFlags( flag ) }
+ return this
+ }
+
+ fun build(): ItemStack
+ {
+ return itemStack
+ }
+
+ private fun color(
+ string: String
+ ): String
+ {
+ return ChatColor.translateAlternateColorCodes( '&', string )
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/club/mcscrims/speedhg/util/WorldEditUtils.kt b/src/main/kotlin/club/mcscrims/speedhg/util/WorldEditUtils.kt
new file mode 100644
index 0000000..847dfea
--- /dev/null
+++ b/src/main/kotlin/club/mcscrims/speedhg/util/WorldEditUtils.kt
@@ -0,0 +1,37 @@
+package club.mcscrims.speedhg.util
+
+import com.sk89q.worldedit.WorldEdit
+import com.sk89q.worldedit.bukkit.BukkitAdapter
+import com.sk89q.worldedit.util.SideEffectSet
+import org.bukkit.Location
+import org.bukkit.Material
+import org.bukkit.World
+import org.bukkit.inventory.ItemStack
+
+object WorldEditUtils {
+
+ fun createSphere(
+ world: World,
+ startLocation: Location,
+ radius: Double,
+ filled: Boolean,
+ block: Material
+ ) = try {
+ val editSession = WorldEdit.getInstance().newEditSessionBuilder()
+ .world(BukkitAdapter.adapt( world )).maxBlocks( -1 ).build()
+
+ editSession.sideEffectApplier = SideEffectSet.defaults()
+
+ editSession.makeSphere(
+ BukkitAdapter.asBlockVector( startLocation ),
+ BukkitAdapter.asBlockState(ItemStack( block )),
+ radius, filled
+ )
+
+ editSession.commit()
+ editSession.close()
+ } catch ( e: Exception ) {
+ e.printStackTrace()
+ }
+
+}
\ No newline at end of file