Add kit system, KitManager and Goblin kit

Introduce a full kit framework and supporting utilities.

- Add core kit API: Kit, Playstyle, ActiveAbility, PassiveAbility, AbilityResult, charge state and PlayerChargeData.
- Implement KitManager for registration, lobby selections, apply/remove/clear lifecycle and charge tracking.
- Add KitEventDispatcher listener to centralize kit event handling (interact, hits, move) and integrate passive/active hooks.
- Provide example kit implementations: GoblinKit (functional abilities) and TemplateKit (reference).
- Add utilities: ItemBuilder and WorldEditUtils (WorldEdit-based sphere creation).
- Integrate into plugin: SpeedHG now initialises KitManager, registers kits and KitEventDispatcher, applies kits at game start and clears on end.
- LanguageManager: add default message/list/component helpers.
- Build changes: bump Kotlin plugin to 2.2.0 and add WorldEdit compileOnly deps; also expose legacySerializer in Extensions.

These changes implement the kit feature set (items, abilities, charge/recharge flow) and wire it into the game lifecycle.
This commit is contained in:
TDSTOS
2026-03-25 02:27:53 +01:00
parent e411879b20
commit 9d6bd6a6b8
18 changed files with 1330 additions and 6 deletions

View File

@@ -2,8 +2,8 @@ plugins {
id("java") id("java")
id("maven-publish") id("maven-publish")
id("com.github.johnrengelman.shadow") version "8.1.1" id("com.github.johnrengelman.shadow") version "8.1.1"
kotlin("jvm") version libs.versions.kotlin kotlin("jvm") version "2.2.0"
kotlin("kapt") version libs.versions.kotlin kotlin("kapt") version "2.2.0"
} }
group = "club.mcscrims" group = "club.mcscrims"
@@ -17,11 +17,16 @@ repositories {
maven("https://libraries.minecraft.net/") maven("https://libraries.minecraft.net/")
maven("https://repo.codemc.io/repository/maven-public/") maven("https://repo.codemc.io/repository/maven-public/")
maven("https://repo.lunarclient.dev") maven("https://repo.lunarclient.dev")
maven("https://maven.enginehub.org/repo/")
} }
dependencies { dependencies {
implementation("fr.mrmicky:fastboard:2.1.3") implementation("fr.mrmicky:fastboard:2.1.3")
compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") 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 { tasks {

View File

@@ -3,7 +3,12 @@ package club.mcscrims.speedhg
import club.mcscrims.speedhg.config.LanguageManager import club.mcscrims.speedhg.config.LanguageManager
import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.GameManager
import club.mcscrims.speedhg.game.modules.AntiRunningManager 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.ConnectListener
import club.mcscrims.speedhg.listener.GameStateListener
import club.mcscrims.speedhg.listener.SoupListener
import club.mcscrims.speedhg.scoreboard.ScoreboardManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager
import org.bukkit.Bukkit import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
@@ -29,6 +34,9 @@ class SpeedHG : JavaPlugin() {
lateinit var scoreboardManager: ScoreboardManager lateinit var scoreboardManager: ScoreboardManager
private set private set
lateinit var kitManager: KitManager
private set
override fun onEnable() override fun onEnable()
{ {
instance = this instance = this
@@ -41,6 +49,11 @@ class SpeedHG : JavaPlugin() {
scoreboardManager = ScoreboardManager( this ) scoreboardManager = ScoreboardManager( this )
kitManager = KitManager( this )
// Register kits
kitManager.registerKit( GoblinKit() )
registerListener() registerListener()
logger.info("SpeedHG wurde geladen!") logger.info("SpeedHG wurde geladen!")
@@ -48,6 +61,7 @@ class SpeedHG : JavaPlugin() {
override fun onDisable() override fun onDisable()
{ {
kitManager.clearAll()
super.onDisable() super.onDisable()
} }
@@ -56,6 +70,9 @@ class SpeedHG : JavaPlugin() {
val pm = Bukkit.getPluginManager() val pm = Bukkit.getPluginManager()
pm.registerEvents( ConnectListener(), this ) pm.registerEvents( ConnectListener(), this )
pm.registerEvents( GameStateListener(), this )
pm.registerEvents( SoupListener(), this )
pm.registerEvents(KitEventDispatcher( this, kitManager ), this )
} }
} }

View File

@@ -65,6 +65,13 @@ class LanguageManager(
return langMap?.get( key ) ?: "<red>Missing Key: $key</red>" return langMap?.get( key ) ?: "<red>Missing Key: $key</red>"
} }
fun getDefaultRawMessage(
key: String
): String
{
return languages[ defaultLanguage ]?.get( key ) ?: "<red>Missing Key: $key</red>"
}
fun getRawMessageList( fun getRawMessageList(
player: Player, player: Player,
key: String key: String
@@ -79,7 +86,16 @@ class LanguageManager(
val file = File( plugin.dataFolder, "languages/$locale.yml" ) val file = File( plugin.dataFolder, "languages/$locale.yml" )
val config = YamlConfiguration.loadConfiguration( file ) val config = YamlConfiguration.loadConfiguration( file )
return if (config.contains( key )) config.getStringList( key ) else listOf("<red>Missing List: $key</red>") return if (config.contains( key )) config.getStringList( key ) else listOf( "<red>Missing List: $key</red>" )
}
fun getDefaultRawMessageList(
key: String
): List<String>
{
val file = File( plugin.dataFolder, "languages/$defaultLanguage.yml" )
val config = YamlConfiguration.loadConfiguration( file )
return if (config.contains( key )) config.getStringList( key ) else listOf( "<red>Missing List: $key</red>" )
} }
fun getComponent( fun getComponent(
@@ -93,4 +109,14 @@ class LanguageManager(
return miniMessage.deserialize( raw, *tags.toTypedArray() ) return miniMessage.deserialize( raw, *tags.toTypedArray() )
} }
fun getDefaultComponent(
key: String,
placeholders: Map<String, String>
): Component
{
val raw = languages[ defaultLanguage ]?.get( key ) ?: "<red>Missing Key: $key</red>"
val tags = placeholders.map { (k, v) -> Placeholder.parsed( k, v ) }
return miniMessage.deserialize( raw, *tags.toTypedArray() )
}
} }

View File

@@ -171,8 +171,7 @@ class GameManager(
teleportRandomly( player, world, startBorder / 2 ) teleportRandomly( player, world, startBorder / 2 )
// TODO: Kit Items geben plugin.kitManager.applyKit( player ) // verteilt Items + ruft onAssign + passive.onActivate
// plugin.kitManager.giveKit( player )
player.inventory.addItem(ItemStack( Material.COMPASS )) player.inventory.addItem(ItemStack( Material.COMPASS ))
player.sendMsg( "game.started" ) player.sendMsg( "game.started" )
@@ -244,6 +243,7 @@ class GameManager(
) { ) {
setGameState( GameState.ENDING ) setGameState( GameState.ENDING )
timer = 15 timer = 15
plugin.kitManager.clearAll()
Bukkit.getOnlinePlayers().forEach { p -> Bukkit.getOnlinePlayers().forEach { p ->
p.showTitle(Title.title( p.showTitle(Title.title(

View File

@@ -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<String>
/** 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 ) {}
}

View File

@@ -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<String, Kit>()
// Lobby selections — set before the round starts
private val selectedKits = ConcurrentHashMap<UUID, Kit>()
private val selectedPlaystyles = ConcurrentHashMap<UUID, Playstyle>()
// Live charge state — populated by applyKit, cleared by removeKit/clearAll
private val chargeData = ConcurrentHashMap<UUID, PlayerChargeData>()
// -------------------------------------------------------------------------
// Kit registration
// -------------------------------------------------------------------------
fun registerKit(
kit: Kit
) {
registeredKits[kit.id] = kit
plugin.logger.info("[KitManager] Registered kit: ${kit.id}")
}
fun getRegisteredKits(): Collection<Kit> = 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]
}

View File

@@ -0,0 +1,8 @@
package club.mcscrims.speedhg.kit
enum class Playstyle(
val displayName: String
) {
AGGRESSIVE( "Aggressive" ),
DEFENSIVE( "Defensive" )
}

View File

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

View File

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

View File

@@ -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<Event>` 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 ) {}
}

View File

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

View File

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

View File

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

View File

@@ -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<UUID>
// 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<Player>()
// 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
}
}
}

View File

@@ -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<Event>` 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
}
}

View File

@@ -8,7 +8,7 @@ import org.bukkit.entity.Player
private val langManager get() = SpeedHG.instance.languageManager private val langManager get() = SpeedHG.instance.languageManager
private val legacySerializer = LegacyComponentSerializer.builder() val legacySerializer = LegacyComponentSerializer.builder()
.character('§') .character('§')
.hexColors() .hexColors()
.useUnusualXRepeatedCharacterHexFormat() .useUnusualXRepeatedCharacterHexFormat()

View File

@@ -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<String>
): ItemBuilder
{
itemStack.editMeta {
val cLore = lore.stream()
.map( this::color )
.map( Component::text )
.toList()
it.lore( cLore as List<Component> )
}
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 )
}
}

View File

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