Add live config overrides and refactor kits

Introduce runtime-configurable settings and defaults across multiple kits and clean up implementations. Kits (Armorer, Backup, Gladiator, Goblin, IceMage, Puppet, and others) now expose companion-object default constants and read values via override() (SPEEDHG_CUSTOM_SETTINGS) with typed accessors. Activation and tick-time behaviour snapshot these values so mid-round config changes don't produce inconsistent effects. Also includes general refactors: clearer getters, small API/formatting cleanup, improved no-op ability placeholders, and minor behavior protections (e.g. disallow stealing Backup kit).
This commit is contained in:
TDSTOS
2026-04-12 05:06:43 +02:00
parent 411b77cc8d
commit 5a828a3993
13 changed files with 2016 additions and 970 deletions

View File

@@ -19,9 +19,9 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* ## Armorer
* ## ArmorerKit
*
* Upgrades the player's **Chestplate + Boots** every 2 kills.
* Upgrades the player's **Chestplate + Boots** every [killsPerTier] kills.
*
* | Tier | Kills | Material |
* |------|-------|-----------|
@@ -32,22 +32,31 @@ import java.util.concurrent.ConcurrentHashMap
* When a piece breaks ([onItemBreak]), it is immediately replaced with the
* current tier so the player is never left without armor.
*
* - **AGGRESSIVE passive**: +Strength I (5 s) for every kill.
* - **DEFENSIVE passive**: +Protection I enchant on iron/diamond pieces.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------|-----|---------|-----------------------------------------------------------|
* | `kills_per_tier` | Int | `2` | Number of kills required to advance one armor tier |
* | `strength_ticks` | Int | `100` | Duration in ticks of Strength I granted on kill (aggressive) |
*/
class ArmorerKit : Kit() {
class ArmorerKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id = "armorer"
override val id: String
get() = "armorer"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.armorer.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.armorer.lore" )
override val icon = Material.IRON_CHESTPLATE
// ── Kill tracking ─────────────────────────────────────────────────────────
internal val killCounts: MutableMap<UUID, Int> = ConcurrentHashMap()
override val icon: Material
get() = Material.IRON_CHESTPLATE
companion object {
private val CHESTPLATE_TIERS = listOf(
@@ -65,65 +74,126 @@ class ArmorerKit : Kit() {
Sound.ITEM_ARMOR_EQUIP_IRON,
Sound.ITEM_ARMOR_EQUIP_DIAMOND
)
const val DEFAULT_KILLS_PER_TIER = 2
const val DEFAULT_STRENGTH_TICKS = 100
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Number of kills required to advance one armor tier.
* JSON key: `kills_per_tier`
*/
private val killsPerTier: Int
get() = override().getInt( "kills_per_tier" ) ?: DEFAULT_KILLS_PER_TIER
/**
* Duration in ticks of Strength I applied to the killer on each kill (aggressive).
* JSON key: `strength_ticks`
*/
private val strengthTicks: Int
get() = override().getInt( "strength_ticks" ) ?: DEFAULT_STRENGTH_TICKS
// ── Kill tracking ─────────────────────────────────────────────────────────
internal val killCounts: MutableMap<UUID, Int> = ConcurrentHashMap()
// ── Cached ability instances ──────────────────────────────────────────────
private val aggressiveActive = NoActive( Playstyle.AGGRESSIVE )
private val defensiveActive = NoActive( Playstyle.DEFENSIVE )
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) {
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) { /* armor is given in onAssign */ }
override fun giveItems(
player: Player,
playstyle: Playstyle
) { /* armor is given in onAssign */ }
override fun onAssign(player: Player, playstyle: Playstyle) {
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onAssign(
player: Player,
playstyle: Playstyle
) {
killCounts[ player.uniqueId ] = 0
setArmorTier( player, tier = 0, playstyle )
}
override fun onRemove(player: Player) {
override fun onRemove(
player: Player
) {
killCounts.remove( player.uniqueId )
}
// ── Kit-level kill hook (upgrades armor) ──────────────────────────────────
override fun onKillEnemy(killer: Player, victim: Player) {
override fun onKillEnemy(
killer: Player,
victim: Player
) {
val newKills = killCounts.compute( killer.uniqueId ) { _, v -> ( v ?: 0 ) + 1 } ?: return
val tier = (newKills / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex)
val prevTier = ((newKills - 1) / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex)
if (tier > prevTier) {
// Snapshot the tier threshold at kill time
val capturedKillsPerTier = killsPerTier
val tier = ( newKills / capturedKillsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex )
val prevTier = ( ( newKills - 1 ) / capturedKillsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex )
if ( tier > prevTier )
{
val playstyle = plugin.kitManager.getSelectedPlaystyle( killer )
setArmorTier( killer, tier, playstyle )
killer.playSound( killer.location, EQUIP_SOUNDS[ tier ], 1f, 1f )
killer.sendActionBar(killer.trans("kits.armorer.messages.armor_upgraded",
mapOf("tier" to (tier + 1).toString())))
killer.sendActionBar(
killer.trans(
"kits.armorer.messages.armor_upgraded",
mapOf( "tier" to ( tier + 1 ).toString() )
)
)
}
}
// ── Auto-replace on armor break ───────────────────────────────────────────
override fun onItemBreak(player: Player, brokenItem: ItemStack) {
override fun onItemBreak(
player: Player,
brokenItem: ItemStack
) {
val kills = killCounts[ player.uniqueId ] ?: return
val tier = (kills / 2).coerceAtMost(CHESTPLATE_TIERS.lastIndex)
val tier = ( kills / killsPerTier ).coerceAtMost( CHESTPLATE_TIERS.lastIndex )
val playstyle = plugin.kitManager.getSelectedPlaystyle( player )
when {
brokenItem.type.name.endsWith("_CHESTPLATE") -> {
when
{
brokenItem.type.name.endsWith( "_CHESTPLATE" ) ->
{
player.inventory.chestplate = buildPiece( CHESTPLATE_TIERS[ tier ], tier, playstyle )
player.sendActionBar( player.trans( "kits.armorer.messages.armor_replaced" ) )
}
brokenItem.type.name.endsWith("_BOOTS") -> {
brokenItem.type.name.endsWith( "_BOOTS" ) ->
{
player.inventory.boots = buildPiece( BOOT_TIERS[ tier ], tier, playstyle )
player.sendActionBar( player.trans( "kits.armorer.messages.armor_replaced" ) )
}
@@ -132,20 +202,28 @@ class ArmorerKit : Kit() {
// ── Helpers ───────────────────────────────────────────────────────────────
private fun setArmorTier(player: Player, tier: Int, playstyle: Playstyle) {
private fun setArmorTier(
player: Player,
tier: Int,
playstyle: Playstyle
) {
player.inventory.chestplate = buildPiece( CHESTPLATE_TIERS[ tier ], tier, playstyle )
player.inventory.boots = buildPiece( BOOT_TIERS[ tier ], tier, playstyle )
}
/**
* Builds an armor ItemStack, adding Protection I for DEFENSIVE builds
* Builds an armor [ItemStack], adding Protection I for DEFENSIVE builds
* starting at iron tier (tier ≥ 1).
*/
private fun buildPiece(material: Material, tier: Int, playstyle: Playstyle): ItemStack {
private fun buildPiece(
material: Material,
tier: Int,
playstyle: Playstyle
): ItemStack
{
val item = ItemStack( material )
if (playstyle == Playstyle.DEFENSIVE && tier >= 1) {
if ( playstyle == Playstyle.DEFENSIVE && tier >= 1 )
item.editMeta { it.addEnchant( Enchantment.PROTECTION, 1, true ) }
}
return item
}
@@ -153,45 +231,75 @@ class ArmorerKit : Kit() {
// AGGRESSIVE passive Strength I on every kill
// =========================================================================
private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.aggressive.description" )
override fun onKillEnemy(killer: Player, victim: Player) {
killer.addPotionEffect(PotionEffect(PotionEffectType.STRENGTH, 5 * 20, 0))
override fun onKillEnemy(
killer: Player,
victim: Player
) {
// Snapshot at kill time so a mid-round config change is consistent
val capturedStrengthTicks = strengthTicks
killer.addPotionEffect( PotionEffect( PotionEffectType.STRENGTH, capturedStrengthTicks, 0 ) )
killer.playSound( killer.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.4f )
}
}
// =========================================================================
// DEFENSIVE passive Protection enchant is sufficient; no extra hook needed
// DEFENSIVE passive Protection enchant is applied at tier; no extra hook needed
// =========================================================================
private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.armorer.passive.defensive.description" )
}
// =========================================================================
// Shared no-ability active
// Shared no-active placeholder
// =========================================================================
inner class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) {
override val kitId: String = "armorer"
override val name = "None"
override val description = "None"
override val triggerMaterial = Material.BARRIER
override val hardcodedHitsRequired: Int = 0
override fun execute(player: Player) = AbilityResult.Success
inner class NoActive(
playstyle: Playstyle
) : ActiveAbility( playstyle )
{
override val kitId: String
get() = "armorer"
override val name: String
get() = "None"
override val description: String
get() = "None"
override val triggerMaterial: Material
get() = Material.BARRIER
override val hardcodedHitsRequired: Int
get() = 0
override fun execute(
player: Player
): AbilityResult = AbilityResult.Success
}
}

View File

@@ -13,7 +13,20 @@ import org.bukkit.inventory.ItemStack
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class BackupKit : Kit() {
/**
* ## BackupKit
*
* A stub kit that currently has no active or passive abilities.
* All ability slots return no-op implementations.
*
* This kit intentionally exposes no configurable constants — there is nothing
* to override via `SPEEDHG_CUSTOM_SETTINGS` at this time. When abilities are
* added in the future, their constants should be placed in the `companion object`
* and wired through `override().getInt( ... ) ?: DEFAULT_*` following the
* standard pattern.
*/
class BackupKit : Kit()
{
private val plugin get() = SpeedHG.instance
@@ -57,9 +70,15 @@ class BackupKit : Kit() {
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems( player: Player, playstyle: Playstyle ) {}
override fun giveItems(
player: Player,
playstyle: Playstyle
) {}
private class NoActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) {
private class NoActive(
playstyle: Playstyle
) : ActiveAbility( playstyle )
{
override val kitId: String
get() = "backup"
@@ -76,11 +95,16 @@ class BackupKit : Kit() {
override val triggerMaterial: Material
get() = Material.BARRIER
override fun execute( player: Player ): AbilityResult { return AbilityResult.Success }
override fun execute(
player: Player
): AbilityResult = AbilityResult.Success
}
private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) {
private class NoPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
override val name: String
get() = "None"

View File

@@ -25,7 +25,24 @@ import org.bukkit.scheduler.BukkitRunnable
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class GladiatorKit : Kit() {
/**
* ## GladiatorKit
*
* Both playstyles share the same active ability — challenging a nearby enemy to a
* 1v1 duel inside a glass cylinder spawned high above the world.
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the typed [CustomGameSettings.KitOverride] fields.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |------------------------|-----|---------|---------------------------------------------------|
* | `arena_radius` | Int | `11` | Radius of the glass arena cylinder (blocks) |
* | `arena_height` | Int | `7` | Height of the glass arena cylinder (blocks) |
* | `wither_after_seconds` | Int | `180` | Seconds before Wither IV is applied to both players|
*/
class GladiatorKit : Kit()
{
private val plugin get() = SpeedHG.instance
@@ -41,11 +58,35 @@ class GladiatorKit : Kit() {
override val icon: Material
get() = Material.IRON_BARS
private val kitOverride: CustomGameSettings.KitOverride by lazy {
plugin.customGameManager.settings.kits.kits["gladiator"]
?: CustomGameSettings.KitOverride()
companion object {
const val DEFAULT_ARENA_RADIUS = 11
const val DEFAULT_ARENA_HEIGHT = 7
const val DEFAULT_WITHER_AFTER_SECONDS = 180
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Radius of the gladiator arena cylinder in blocks.
* Source: typed field `arena_radius`.
*/
private val arenaRadius: Int
get() = override().arenaRadius
/**
* Height of the gladiator arena cylinder in blocks.
* Source: typed field `arena_height`.
*/
private val arenaHeight: Int
get() = override().arenaHeight
/**
* Seconds into the fight before Wither IV is applied to force a resolution.
* Source: typed field `wither_after_seconds`.
*/
private val witherAfterSeconds: Int
get() = override().witherAfterSeconds
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AllActive( Playstyle.AGGRESSIVE )
private val defensiveActive = AllActive( Playstyle.DEFENSIVE )
@@ -96,7 +137,10 @@ class GladiatorKit : Kit() {
items.forEach { player.inventory.remove( it ) }
}
private inner class AllActive( playstyle: Playstyle ) : ActiveAbility( playstyle ) {
private inner class AllActive(
playstyle: Playstyle
) : ActiveAbility( playstyle )
{
private val plugin get() = SpeedHG.instance
@@ -110,7 +154,7 @@ class GladiatorKit : Kit() {
get() = plugin.languageManager.getDefaultRawMessage( "kits.gladiator.items.ironBars.description" )
override val hardcodedHitsRequired: Int
get() = 15
get() = DEFAULT_WITHER_AFTER_SECONDS
override val triggerMaterial: Material
get() = Material.IRON_BARS
@@ -126,18 +170,23 @@ class GladiatorKit : Kit() {
lineOfSight.hasMetadata( KitMetaData.IN_GLADIATOR.getKey() ) )
return AbilityResult.ConditionNotMet( "Already in gladiator fight" )
val radius = kitOverride.arenaRadius
val height = kitOverride.arenaHeight
// Snapshot config values at activation time
val capturedRadius = arenaRadius
val capturedHeight = arenaHeight
player.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true ) )
lineOfSight.setMetadata( KitMetaData.IN_GLADIATOR.getKey(), FixedMetadataValue( plugin, true ) )
val gladiatorRegion = getGladiatorLocation(player.location.clone().add( 0.0, 64.0, 0.0 ), radius, height + 2 )
val gladiatorRegion = getGladiatorLocation(
player.location.clone().add( 0.0, 64.0, 0.0 ),
capturedRadius,
capturedHeight + 2
)
val center = BukkitAdapter.adapt( player.world, gladiatorRegion.center )
WorldEditUtils.createCylinder( player.world, center, radius - 1, true, 1, Material.WHITE_STAINED_GLASS )
WorldEditUtils.createCylinder( player.world, center, radius - 1, false, height, Material.WHITE_STAINED_GLASS )
WorldEditUtils.createCylinder( player.world, center.clone().add( 0.0, height - 1.0, 0.0 ), radius -1, true, 1, Material.WHITE_STAINED_GLASS )
WorldEditUtils.createCylinder( player.world, center, capturedRadius - 1, true, 1, Material.WHITE_STAINED_GLASS )
WorldEditUtils.createCylinder( player.world, center, capturedRadius - 1, false, capturedHeight, Material.WHITE_STAINED_GLASS )
WorldEditUtils.createCylinder( player.world, center.clone().add( 0.0, capturedHeight - 1.0, 0.0 ), capturedRadius - 1, true, 1, Material.WHITE_STAINED_GLASS )
Bukkit.getScheduler().runTaskLater( plugin, { ->
for ( vector3 in gladiatorRegion )
@@ -148,14 +197,23 @@ class GladiatorKit : Kit() {
}
}, 5L )
val gladiatorFight = GladiatorFight( gladiatorRegion, player, lineOfSight, radius, height )
val gladiatorFight = GladiatorFight(
gladiatorRegion,
player,
lineOfSight,
capturedRadius,
capturedHeight
)
gladiatorFight.runTaskTimer( plugin, 0, 20 )
return AbilityResult.Success
}
}
private class NoPassive( playstyle: Playstyle ) : PassiveAbility( playstyle ) {
private class NoPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
override val name: String
get() = "None"
@@ -183,7 +241,15 @@ class GladiatorKit : Kit() {
)
return if ( !hasEnoughSpace( region ) )
getGladiatorLocation(location.add(if ( random.nextBoolean() ) -10.0 else 10.0, 5.0, if ( random.nextBoolean() ) -10.0 else 10.0 ), radius, height )
getGladiatorLocation(
location.add(
if ( random.nextBoolean() ) -10.0 else 10.0,
5.0,
if ( random.nextBoolean() ) -10.0 else 10.0
),
radius,
height
)
else region
}
@@ -216,7 +282,8 @@ class GladiatorKit : Kit() {
val enemy: Player,
val radius: Int,
val height: Int
) : BukkitRunnable() {
) : BukkitRunnable()
{
private val plugin get() = SpeedHG.instance
@@ -257,7 +324,10 @@ class GladiatorKit : Kit() {
{
timer++
if ( timer > kitOverride.witherAfterSeconds )
// Snapshot at tick time so a mid-fight config change is consistent
val capturedWitherAfterSeconds = witherAfterSeconds
if ( timer > capturedWitherAfterSeconds )
{
gladiator.addPotionEffect( PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 ) )
enemy.addPotionEffect( PotionEffect( PotionEffectType.WITHER, Int.MAX_VALUE, 2 ) )

View File

@@ -21,7 +21,25 @@ import org.bukkit.scheduler.BukkitTask
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class GoblinKit : Kit() {
/**
* ## GoblinKit
*
* | Playstyle | Active | Passive |
* |-------------|------------------------------------------------------------------------|---------|
* | AGGRESSIVE | **Steal** copies the target's kit for [stealDuration] seconds | |
* | DEFENSIVE | **Bunker** spawns a hollow [bunkerRadius]-block cobblestone sphere | |
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the typed [CustomGameSettings.KitOverride] fields.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-------------------------|--------|---------|-------------------------------------------------------|
* | `steal_duration_seconds`| Int | `60` | Seconds the stolen kit is active before reverting |
* | `bunker_radius` | Double | `10.0` | Radius of the hollow cobblestone bunker sphere |
*/
class GoblinKit : Kit()
{
private val plugin get() = SpeedHG.instance
@@ -37,11 +55,27 @@ class GoblinKit : Kit() {
override val icon: Material
get() = Material.MOSSY_COBBLESTONE
private val kitOverride: CustomGameSettings.KitOverride by lazy {
plugin.customGameManager.settings.kits.kits["goblin"]
?: CustomGameSettings.KitOverride()
companion object {
const val DEFAULT_STEAL_DURATION_SECONDS = 60
const val DEFAULT_BUNKER_RADIUS = 10.0
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Seconds the stolen kit remains active before reverting to the player's own kit.
* Source: typed field `steal_duration_seconds`.
*/
private val stealDuration: Int
get() = override().stealDuration
/**
* Radius of the hollow cobblestone bunker sphere in blocks.
* Source: typed field `bunker_radius`.
*/
private val bunkerRadius: Double
get() = override().bunkerRadius
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
@@ -110,7 +144,8 @@ class GoblinKit : Kit() {
items.forEach { player.inventory.remove( it ) }
}
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -138,10 +173,15 @@ class GoblinKit : Kit() {
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 targetKit = plugin.kitManager.getSelectedKit( lineOfSight )
?: return AbilityResult.ConditionNotMet( "Target has no kit" )
val targetPlaystyle = plugin.kitManager.getSelectedPlaystyle( lineOfSight )
val currentKit = plugin.kitManager.getSelectedKit( player ) ?: return AbilityResult.ConditionNotMet( "Error while copying kit" )
if ( targetKit is BackupKit )
return AbilityResult.ConditionNotMet( "Backup kit cannot be stolen" )
val currentKit = plugin.kitManager.getSelectedKit( player )
?: return AbilityResult.ConditionNotMet( "Error while copying kit" )
val currentPlaystyle = plugin.kitManager.getSelectedPlaystyle( player )
activeStealTasks.remove( player.uniqueId )
@@ -151,9 +191,12 @@ class GoblinKit : Kit() {
plugin.kitManager.selectPlaystyle( player, targetPlaystyle )
plugin.kitManager.applyKit( player )
// Snapshot the duration at activation time
val capturedStealDuration = stealDuration
val task = Bukkit.getScheduler().runTaskLater( plugin, { ->
activeStealTasks.remove( player.uniqueId )
// Nur wiederherstellen, wenn Spieler noch alive und Spiel läuft
// Only restore if player is still alive and game is running
if ( plugin.gameManager.alivePlayers.contains( player.uniqueId ) &&
plugin.gameManager.currentState == GameState.INGAME )
{
@@ -162,12 +205,14 @@ class GoblinKit : Kit() {
plugin.kitManager.selectPlaystyle( player, currentPlaystyle )
plugin.kitManager.applyKit( player )
}
}, 20L * kitOverride.stealDuration)
}, 20L * capturedStealDuration )
activeStealTasks[ player.uniqueId ] = task
player.playSound( player.location, Sound.ENTITY_EVOKER_CAST_SPELL, 1f, 1.5f )
player.sendActionBar(player.trans( "kits.goblin.messages.stole_kit", mapOf(), "kit" to targetKit.displayName ))
player.sendActionBar(
player.trans( "kits.goblin.messages.stole_kit", mapOf(), "kit" to targetKit.displayName )
)
return AbilityResult.Success
}
@@ -180,7 +225,8 @@ class GoblinKit : Kit() {
}
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -206,10 +252,13 @@ class GoblinKit : Kit() {
val world = player.world
val location = player.location
// Snapshot the radius at activation time
val capturedBunkerRadius = bunkerRadius
WorldEditUtils.createSphere(
world,
location,
kitOverride.bunkerRadius,
capturedBunkerRadius,
false,
Material.MOSSY_COBBLESTONE
)
@@ -218,7 +267,7 @@ class GoblinKit : Kit() {
WorldEditUtils.createSphere(
world,
location,
kitOverride.bunkerRadius,
capturedBunkerRadius,
false,
Material.AIR
)
@@ -232,7 +281,8 @@ class GoblinKit : Kit() {
}
private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) {
private class AggressiveNoPassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
override val name: String
get() = "None"
@@ -242,7 +292,8 @@ class GoblinKit : Kit() {
}
private class DefensiveNoPassive : PassiveAbility( Playstyle.DEFENSIVE ) {
private class DefensiveNoPassive : PassiveAbility( Playstyle.DEFENSIVE )
{
override val name: String
get() = "None"

View File

@@ -20,7 +20,28 @@ import org.bukkit.potion.PotionEffectType
import java.util.*
import java.util.concurrent.ConcurrentHashMap
class IceMageKit : Kit() {
/**
* ## IceMageKit
*
* | Playstyle | Active | Passive |
* |-------------|-------------------------------------------------------------------|-----------------------------------------------------|
* | AGGRESSIVE | | Speed I in ice biomes; [slowChance] Slowness on hit |
* | DEFENSIVE | **Snowball** throws a 360° ring of frozen snowballs | |
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |---------------------|--------|---------|-----------------------------------------------------------------|
* | `slow_chance_denom` | Int | `3` | `1-in-N` chance to apply Slowness on melee hit (aggressive) |
* | `snowball_count` | Int | `16` | Number of snowballs fired in the 360° ring (defensive) |
* | `snowball_speed` | Double | `1.5` | Launch speed of each snowball |
* | `freeze_ticks` | Int | `60` | Freeze ticks applied to enemies hit by a snowball |
* | `slow_ticks` | Int | `40` | Slowness II ticks applied to enemies hit by a snowball |
*/
class IceMageKit : Kit()
{
private val plugin get() = SpeedHG.instance
@@ -36,6 +57,52 @@ class IceMageKit : Kit() {
override val icon: Material
get() = Material.SNOWBALL
companion object {
const val DEFAULT_SLOW_CHANCE_DENOM = 3
const val DEFAULT_SNOWBALL_COUNT = 16
const val DEFAULT_SNOWBALL_SPEED = 1.5
const val DEFAULT_FREEZE_TICKS = 60
const val DEFAULT_SLOW_TICKS = 40
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Denominator of the `1-in-N` Slowness-on-hit chance for the aggressive passive.
* A value of `3` means roughly a 33 % chance.
* JSON key: `slow_chance_denom`
*/
private val slowChanceDenom: Int
get() = override().getInt( "slow_chance_denom" ) ?: DEFAULT_SLOW_CHANCE_DENOM
/**
* Number of snowballs launched in the 360° ring by the defensive active.
* JSON key: `snowball_count`
*/
private val snowballCount: Int
get() = override().getInt( "snowball_count" ) ?: DEFAULT_SNOWBALL_COUNT
/**
* Launch speed of each snowball in the ring.
* JSON key: `snowball_speed`
*/
private val snowballSpeed: Double
get() = override().getDouble( "snowball_speed" ) ?: DEFAULT_SNOWBALL_SPEED
/**
* Freeze ticks applied to enemies hit by an IceMage snowball.
* JSON key: `freeze_ticks`
*/
private val freezeTicks: Int
get() = override().getInt( "freeze_ticks" ) ?: DEFAULT_FREEZE_TICKS
/**
* Slowness II ticks applied to enemies hit by an IceMage snowball.
* JSON key: `slow_ticks`
*/
private val slowTicks: Int
get() = override().getInt( "slow_ticks" ) ?: DEFAULT_SLOW_TICKS
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
@@ -89,7 +156,8 @@ class IceMageKit : Kit() {
items.forEach { player.inventory.remove( it ) }
}
private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
override val kitId: String
get() = "icemage"
@@ -115,7 +183,8 @@ class IceMageKit : Kit() {
}
private class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -145,7 +214,8 @@ class IceMageKit : Kit() {
}
private class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE ) {
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
private val random = Random()
@@ -181,13 +251,17 @@ class IceMageKit : Kit() {
victim: Player,
event: EntityDamageByEntityEvent
) {
if (random.nextInt( 3 ) < 1 )
victim.addPotionEffect(PotionEffect( PotionEffectType.SLOWNESS, 60, 0 ))
// Snapshot at hit time for consistency
val capturedDenom = slowChanceDenom
if ( random.nextInt( capturedDenom ) < 1 )
victim.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, slowTicks, 0 ) )
}
}
private class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) {
private class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
override val name: String
get() = "None"

View File

@@ -19,7 +19,7 @@ import org.bukkit.inventory.ItemStack
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.scheduler.BukkitTask
import java.util.UUID
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
@@ -27,87 +27,177 @@ import java.util.concurrent.ConcurrentHashMap
*
* | Playstyle | Fähigkeit |
* |-------------|----------------------------------------------------------------------------------------|
* | AGGRESSIVE | **Life Drain** saugt 4 ♥/s pro Gegner in der Nähe (max. 8 ♥, 2 s). Sneak: Cancel. |
* | DEFENSIVE | **Puppeteer's Fear** Blindness + Slowness III an alle Nahkämpfer für 4 Sekunden. |
* | AGGRESSIVE | **Life Drain** saugt [healPerEnemyPerSHp] HP/s pro Gegner (max. [maxTotalHealHp], [drainDurationTicks] ticks). Sneak: Cancel. |
* | DEFENSIVE | **Puppeteer's Fear** Blindness + Slowness III an alle Nahkämpfer in [fearRadius] Blöcken für [fearDurationTicks] Ticks. |
*
* ### Cancel-Mechanismus (Aggressive):
* `onToggleSneak` (Hook in [Kit]) wird aufgerufen, wenn der Spieler die Shift-Taste drückt.
* Falls ein Drain-Task aktiv ist, wird er sofort beendet. Das Laden (Charge-State: CHARGING)
* läuft weiter der Spieler bekommt keine Erstattung, da die Fähigkeit bereits angefangen hat.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* ### Drain-Timing:
* Der Task feuert alle 20 Ticks (= 1 s) genau zweimal (0s + 1s → insgesamt 2 Sekunden).
* Pro Feuer: `min(8 × numEnemies, 16 totalHealed_hp)` HP wird auf den Caster übertragen.
* Healing: Direkt über `player.health = (player.health + healAmount).coerceAtMost(maxHp)`.
* Drain: Jeder Gegner nimmt `DRAIN_HP_PER_ENEMY_PER_SECOND` Schaden.
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------------|--------|---------|------------------------------------------------------------|
* | `drain_radius` | Double | `7.0` | Radius in blocks in which enemies are drained |
* | `drain_duration_ticks` | Long | `40` | Total drain duration in ticks (2 seconds) |
* | `drain_tick_interval` | Long | `20` | Ticks between each drain pulse |
* | `heal_per_enemy_per_s_hp` | Double | `8.0` | HP healed per nearby enemy per drain pulse |
* | `max_total_heal_hp` | Double | `16.0` | Maximum total HP the caster can heal per activation |
* | `drain_dmg_per_enemy_per_s` | Double | `4.0` | Damage dealt to each enemy per drain pulse |
* | `fear_radius` | Double | `7.0` | Radius in blocks for the Fear ability |
* | `fear_duration_ticks` | Int | `80` | Duration in ticks of Blindness and Slowness from Fear |
*/
class PuppetKit : Kit() {
class PuppetKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id = "puppet"
override val id: String
get() = "puppet"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.puppet.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.puppet.lore" )
override val icon = Material.PHANTOM_MEMBRANE
// Laufende Drain-Tasks: PlayerUUID → BukkitTask
internal val activeDrainTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
override val icon: Material
get() = Material.PHANTOM_MEMBRANE
companion object {
const val DRAIN_RADIUS = 7.0
const val DRAIN_DURATION_TICKS = 40L // 2 Sekunden
const val DRAIN_TICK_INTERVAL = 20L // pro Sekunde einmal
const val HEAL_PER_ENEMY_PER_S_HP = 8.0 // 4 Herzen = 8 HP
const val MAX_TOTAL_HEAL_HP = 16.0 // 8 Herzen = 16 HP
const val DRAIN_DMG_PER_ENEMY_PER_S = 4.0 // Gegner verlieren 2 Herzen/s
const val FEAR_RADIUS = 7.0
const val FEAR_DURATION_TICKS = 80 // 4 Sekunden
const val DEFAULT_DRAIN_RADIUS = 7.0
const val DEFAULT_DRAIN_DURATION_TICKS = 40L
const val DEFAULT_DRAIN_TICK_INTERVAL = 20L
const val DEFAULT_HEAL_PER_ENEMY_PER_S_HP = 8.0
const val DEFAULT_MAX_TOTAL_HEAL_HP = 16.0
const val DEFAULT_DRAIN_DMG_PER_ENEMY_PER_S = 4.0
const val DEFAULT_FEAR_RADIUS = 7.0
const val DEFAULT_FEAR_DURATION_TICKS = 80
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Radius in blocks in which nearby enemies are included in the drain.
* JSON key: `drain_radius`
*/
private val drainRadius: Double
get() = override().getDouble( "drain_radius" ) ?: DEFAULT_DRAIN_RADIUS
/**
* Total duration of the drain in ticks.
* JSON key: `drain_duration_ticks`
*/
private val drainDurationTicks: Long
get() = override().getLong( "drain_duration_ticks" ) ?: DEFAULT_DRAIN_DURATION_TICKS
/**
* Ticks between each drain pulse (heal + damage tick).
* JSON key: `drain_tick_interval`
*/
private val drainTickInterval: Long
get() = override().getLong( "drain_tick_interval" ) ?: DEFAULT_DRAIN_TICK_INTERVAL
/**
* HP healed per nearby enemy per drain pulse.
* JSON key: `heal_per_enemy_per_s_hp`
*/
private val healPerEnemyPerSHp: Double
get() = override().getDouble( "heal_per_enemy_per_s_hp" ) ?: DEFAULT_HEAL_PER_ENEMY_PER_S_HP
/**
* Maximum total HP the caster can heal across the full drain duration.
* JSON key: `max_total_heal_hp`
*/
private val maxTotalHealHp: Double
get() = override().getDouble( "max_total_heal_hp" ) ?: DEFAULT_MAX_TOTAL_HEAL_HP
/**
* Damage dealt to each drained enemy per pulse.
* JSON key: `drain_dmg_per_enemy_per_s`
*/
private val drainDmgPerEnemyPerS: Double
get() = override().getDouble( "drain_dmg_per_enemy_per_s" ) ?: DEFAULT_DRAIN_DMG_PER_ENEMY_PER_S
/**
* Radius in blocks for the Puppeteer's Fear ability.
* JSON key: `fear_radius`
*/
private val fearRadius: Double
get() = override().getDouble( "fear_radius" ) ?: DEFAULT_FEAR_RADIUS
/**
* Duration in ticks of Blindness and Slowness applied by Fear.
* JSON key: `fear_duration_ticks`
*/
private val fearDurationTicks: Int
get() = override().getInt( "fear_duration_ticks" ) ?: DEFAULT_FEAR_DURATION_TICKS
// ── Running drain tasks: PlayerUUID → BukkitTask ──────────────────────────
internal val activeDrainTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = NoPassive( Playstyle.DEFENSIVE )
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
val (mat, active) = when (playstyle) {
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
val ( mat, active ) = when( playstyle )
{
Playstyle.AGGRESSIVE -> Material.PHANTOM_MEMBRANE to aggressiveActive
Playstyle.DEFENSIVE -> Material.BLAZE_ROD to defensiveActive
}
val item = ItemBuilder( mat )
.name( active.name )
.lore(listOf( active.description ))
.build()
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onRemove(player: Player) {
// Laufenden Drain abbrechen (z.B. bei Spielende)
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
activeDrainTasks.remove( player.uniqueId )?.cancel()
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
/**
* Sneak → bricht einen laufenden Drain ab.
* Wird von [KitEventDispatcher.onPlayerToggleSneak] aufgerufen.
* Sneak → cancels a running drain.
* Dispatched by [KitEventDispatcher.onPlayerToggleSneak].
*/
override fun onToggleSneak(player: Player, isSneaking: Boolean) {
override fun onToggleSneak(
player: Player,
isSneaking: Boolean
) {
if ( !isSneaking ) return
val task = activeDrainTasks.remove( player.uniqueId ) ?: return
task.cancel()
@@ -119,83 +209,82 @@ class PuppetKit : Kit() {
// AGGRESSIVE active Life Drain
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "puppet"
override val kitId: String
get() = "puppet"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.drain.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.drain.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.PHANTOM_MEMBRANE
override fun execute(player: Player): AbilityResult {
// Sicherheit: kein doppelter Drain (kann eigentlich nicht passieren, da
// Charge in CHARGING-State ist, aber defensiv trotzdem prüfen)
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial: Material
get() = Material.PHANTOM_MEMBRANE
override fun execute(
player: Player
): AbilityResult
{
// Defensive guard: a double-drain cannot happen in practice because the
// charge enters CHARGING state, but we protect against it explicitly.
if ( activeDrainTasks.containsKey( player.uniqueId ) )
return AbilityResult.ConditionNotMet( "Drain already active!" )
// Sofort prüfen ob Gegner in der Nähe sind
val initialEnemies = player.world
.getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains(it.uniqueId) }
if (initialEnemies.isEmpty())
return AbilityResult.ConditionNotMet(
plugin.languageManager.getDefaultRawMessage("kits.puppet.messages.no_enemies")
)
// Snapshot all config values at activation time so a mid-round change
// cannot alter an already-running drain unexpectedly.
val capturedDrainRadius = drainRadius
val capturedDrainDurationTicks = drainDurationTicks
val capturedDrainTickInterval = drainTickInterval
val capturedHealPerEnemy = healPerEnemyPerSHp
val capturedMaxTotalHeal = maxTotalHealHp
val capturedDrainDmg = drainDmgPerEnemyPerS
var totalHealedHp = 0.0
var ticksFired = 0
var ticksElapsed = 0L
val task = Bukkit.getScheduler().runTaskTimer( plugin, { ->
ticksFired++
// Task selbst beenden wenn: offline, tot, max Heilung erreicht, Zeit abgelaufen
if (!player.isOnline ||
!plugin.gameManager.alivePlayers.contains(player.uniqueId) ||
totalHealedHp >= MAX_TOTAL_HEAL_HP ||
ticksFired * DRAIN_TICK_INTERVAL > DRAIN_DURATION_TICKS) {
if ( ticksElapsed >= capturedDrainDurationTicks ||
totalHealedHp >= capturedMaxTotalHeal )
{
activeDrainTasks.remove( player.uniqueId )?.cancel()
return@runTaskTimer
}
val currentEnemies = player.world
.getNearbyEntities(player.location, DRAIN_RADIUS, DRAIN_RADIUS, DRAIN_RADIUS)
ticksElapsed += capturedDrainTickInterval
val enemies = player.world
.getNearbyEntities(
player.location,
capturedDrainRadius,
capturedDrainRadius,
capturedDrainRadius
)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
if (currentEnemies.isEmpty()) {
activeDrainTasks.remove(player.uniqueId)?.cancel()
return@runTaskTimer
if ( enemies.isEmpty() ) return@runTaskTimer
enemies.forEach { enemy ->
enemy.damage( capturedDrainDmg, player )
}
// Heilmenge: 4♥ pro Gegner, gedeckelt auf verbleibendes Maximum
val potentialHeal = HEAL_PER_ENEMY_PER_S_HP * currentEnemies.size
val actualHeal = potentialHeal.coerceAtMost(MAX_TOTAL_HEAL_HP - totalHealedHp)
val remainingHealBudget = capturedMaxTotalHeal - totalHealedHp
val rawHeal = capturedHealPerEnemy * enemies.size
val actualHeal = rawHeal.coerceAtMost( remainingHealBudget )
// Gegner entwässern
currentEnemies.forEach { enemy ->
enemy.damage(DRAIN_DMG_PER_ENEMY_PER_S, player)
// Partikel-Sog: von Gegner zur Puppeteer-Position
enemy.world.spawnParticle(
Particle.CRIMSON_SPORE,
enemy.location.clone().add(0.0, 1.3, 0.0),
8, 0.3, 0.3, 0.3, 0.02
)
}
// Caster heilen
val maxHp = player.getAttribute( Attribute.GENERIC_MAX_HEALTH )?.value ?: 20.0
player.health = ( player.health + actualHeal ).coerceAtMost( maxHp )
totalHealedHp += actualHeal
// Audio-Visual Feedback
// Audio-visual feedback
player.world.spawnParticle(
Particle.HEART,
player.location.clone().add( 0.0, 2.0, 0.0 ),
@@ -205,12 +294,12 @@ class PuppetKit : Kit() {
player.sendActionBar(
player.trans(
"kits.puppet.messages.draining",
"healed" to "%.1f".format(totalHealedHp / 2.0), // in Herzen
"max" to (MAX_TOTAL_HEAL_HP / 2.0).toInt().toString()
"healed" to "%.1f".format( totalHealedHp / 2.0 ),
"max" to ( capturedMaxTotalHeal / 2.0 ).toInt().toString()
)
)
}, 0L, DRAIN_TICK_INTERVAL)
}, 0L, capturedDrainTickInterval )
activeDrainTasks[ player.uniqueId ] = task
@@ -218,27 +307,48 @@ class PuppetKit : Kit() {
player.sendActionBar( player.trans( "kits.puppet.messages.drain_start" ) )
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active Puppeteer's Fear (Blindness + Slowness)
// =========================================================================
private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "puppet"
override val kitId: String
get() = "puppet"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.fear.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.puppet.items.fear.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.BLAZE_ROD
override fun execute(player: Player): AbilityResult {
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial: Material
get() = Material.BLAZE_ROD
override fun execute(
player: Player
): AbilityResult
{
// Snapshot config values at activation time
val capturedFearRadius = fearRadius
val capturedFearDurationTicks = fearDurationTicks
val targets = player.world
.getNearbyEntities(player.location, FEAR_RADIUS, FEAR_RADIUS, FEAR_RADIUS)
.getNearbyEntities(
player.location,
capturedFearRadius,
capturedFearRadius,
capturedFearRadius
)
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
@@ -249,10 +359,10 @@ class PuppetKit : Kit() {
targets.forEach { target ->
target.addPotionEffect(
PotionEffect(PotionEffectType.BLINDNESS, FEAR_DURATION_TICKS, 0, false, false, true)
PotionEffect( PotionEffectType.BLINDNESS, capturedFearDurationTicks, 0, false, false, true )
)
target.addPotionEffect(
PotionEffect(PotionEffectType.SLOWNESS, FEAR_DURATION_TICKS, 2, false, false, true)
PotionEffect( PotionEffectType.SLOWNESS, capturedFearDurationTicks, 2, false, false, true )
)
target.sendActionBar( target.trans( "kits.puppet.messages.feared" ) )
target.world.spawnParticle(
@@ -272,10 +382,24 @@ class PuppetKit : Kit() {
)
return AbilityResult.Success
}
}
class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {
override val name = "None"
override val description = "None"
// =========================================================================
// Shared no-passive placeholder
// =========================================================================
class NoPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
override val name: String
get() = "None"
override val description: String
get() = "None"
}
}

View File

@@ -1,7 +1,6 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
@@ -24,83 +23,153 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* ## Rattlesnake
* ## RattlesnakeKit
*
* | Playstyle | Active | Passive |
* |-------------|------------------------------------------------|--------------------------------------|
* | AGGRESSIVE | Sneak-charged pounce (310 blocks) | Poison II on pounce-hit |
* |-------------|-------------------------------------------------|-----------------------------------|
* | AGGRESSIVE | Sneak-charged pounce ([pounceMinRange][pounceMaxRange] blocks) | Poison II on pounce-hit |
* | DEFENSIVE | | 25 % counter-venom on being hit |
*
* Both playstyles receive **Speed II** at game start.
* Both playstyles receive **Speed II** permanently at game start.
*
* ### Pounce mechanics
* 1. Hold sneak (up to 3 s) → charge builds linearly from 3 to 10 blocks.
* 2. Right-click the SLIME_BALL to launch. A 1.5 s timeout task is scheduled.
* 3. If [onHitEnemy] fires while the player is marked as *pouncing*: apply Poison II
* to 1 target (before Feast) or 3 targets (after Feast) and clear the flag.
* 4. If the timeout fires first (miss): apply Nausea + Slowness to nearby enemies.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* The five pounce-related values are stored as **typed fields** in
* [CustomGameSettings.KitOverride] and are therefore accessed directly as
* properties rather than through `extras`.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------|--------|------------|-------------------------------------------------------|
* | `pounce_cooldown_ms` | Long | `20_000` | Cooldown between pounce uses in milliseconds |
* | `pounce_max_sneak_ms` | Long | `3_000` | Maximum sneak duration that contributes to range |
* | `pounce_min_range` | Double | `3.0` | Minimum pounce range (blocks) at zero sneak charge |
* | `pounce_max_range` | Double | `10.0` | Maximum pounce range (blocks) at full sneak charge |
* | `pounce_timeout_ticks`| Long | `30` | Ticks before a pounce-in-flight times out (miss) |
*/
class RattlesnakeKit : Kit() {
class RattlesnakeKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id = "rattlesnake"
override val id: String
get() = "rattlesnake"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.rattlesnake.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.rattlesnake.lore" )
override val icon = Material.SLIME_BALL
// ── Shared state (accessed by inner ability classes via outer-class reference) ─
override val icon: Material
get() = Material.SLIME_BALL
companion object {
const val DEFAULT_POUNCE_COOLDOWN_MS = 20_000L
const val DEFAULT_MAX_SNEAK_MS = 3_000L
const val DEFAULT_POUNCE_MIN_RANGE = 3.0
const val DEFAULT_POUNCE_MAX_RANGE = 10.0
const val DEFAULT_POUNCE_TIMEOUT_TICKS = 30L
}
// ── Live config accessors (typed KitOverride fields) ──────────────────────
/**
* Cooldown between pounce uses in milliseconds.
* Source: typed field `pounce_cooldown_ms`.
*/
private val pounceCooldownMs: Long
get() = override().pounceCooldownMs
/**
* Maximum sneak duration (ms) whose charge contributes to pounce range.
* Source: typed field `pounce_max_sneak_ms`.
*/
private val maxSneakMs: Long
get() = override().pounceMaxSneakMs
/**
* Minimum pounce range in blocks at zero sneak charge.
* Source: typed field `pounce_min_range`.
*/
private val pounceMinRange: Double
get() = override().pounceMinRange
/**
* Maximum pounce range in blocks at full sneak charge.
* Source: typed field `pounce_max_range`.
*/
private val pounceMaxRange: Double
get() = override().pounceMaxRange
/**
* Ticks after launch before a pounce that hasn't connected is treated as a miss.
* Source: typed field `pounce_timeout_ticks`.
*/
private val pounceTimeoutTicks: Long
get() = override().pounceTimeoutTicks
// ── Shared mutable state (accessed by inner ability classes) ──────────────
internal val sneakStartTimes: MutableMap<UUID, Long> = ConcurrentHashMap()
internal val pouncingPlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
internal val lastPounceUse: MutableMap<UUID, Long> = ConcurrentHashMap()
companion object {
private fun override() = SpeedHG.instance.customGameManager.settings.kits.kits["rattlesnake"]
?: CustomGameSettings.KitOverride()
private val POUNCE_COOLDOWN_MS = override().pounceCooldownMs
private val MAX_SNEAK_MS = override().pounceMaxSneakMs
private val MIN_RANGE = override().pounceMinRange
private val MAX_RANGE = override().pounceMaxRange
private val POUNCE_TIMEOUT_TICKS = override().pounceTimeoutTicks
}
// ── Cached ability instances ──────────────────────────────────────────────
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) {
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
if ( playstyle != Playstyle.AGGRESSIVE ) return
val item = ItemBuilder( Material.SLIME_BALL )
.name( aggressiveActive.name )
.lore(listOf( aggressiveActive.description ))
.build()
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onAssign(player: Player, playstyle: Playstyle) {
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onAssign(
player: Player,
playstyle: Playstyle
) {
player.addPotionEffect(
PotionEffect( PotionEffectType.SPEED, Int.MAX_VALUE, 1, false, false, true )
)
}
override fun onRemove(player: Player) {
override fun onRemove(
player: Player
) {
player.removePotionEffect( PotionEffectType.SPEED )
sneakStartTimes.remove( player.uniqueId )
pouncingPlayers.remove( player.uniqueId )
@@ -108,7 +177,10 @@ class RattlesnakeKit : Kit() {
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
override fun onToggleSneak(player: Player, isSneaking: Boolean) {
override fun onToggleSneak(
player: Player,
isSneaking: Boolean
) {
if ( plugin.kitManager.getSelectedPlaystyle( player ) != Playstyle.AGGRESSIVE ) return
if ( isSneaking ) sneakStartTimes[ player.uniqueId ] = System.currentTimeMillis()
}
@@ -117,7 +189,8 @@ class RattlesnakeKit : Kit() {
// AGGRESSIVE active sneak-charged pounce
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -126,26 +199,43 @@ class RattlesnakeKit : Kit() {
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.items.pounce.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.items.pounce.description" )
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial = Material.SLIME_BALL
override fun execute(player: Player): AbilityResult {
override val triggerMaterial: Material
get() = Material.SLIME_BALL
override fun execute(
player: Player
): AbilityResult
{
if ( !player.isSneaking )
return AbilityResult.ConditionNotMet( "Sneak while activating the pounce!" )
val now = System.currentTimeMillis()
if (now - (lastPounceUse[player.uniqueId] ?: 0L) < POUNCE_COOLDOWN_MS) {
val remaining = ((POUNCE_COOLDOWN_MS - (now - (lastPounceUse[player.uniqueId] ?: 0L))) / 1000)
// Snapshot all config values at activation time
val capturedPounceCooldownMs = pounceCooldownMs
val capturedMaxSneakMs = maxSneakMs
val capturedPounceMinRange = pounceMinRange
val capturedPounceMaxRange = pounceMaxRange
val capturedPounceTimeoutTicks = pounceTimeoutTicks
if ( now - ( lastPounceUse[ player.uniqueId ] ?: 0L ) < capturedPounceCooldownMs )
{
val remaining = ( capturedPounceCooldownMs - ( now - ( lastPounceUse[ player.uniqueId ] ?: 0L ) ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${remaining}s remaining" )
}
// Sneak duration → range (3 10 blocks)
// Sneak duration → range (minmax blocks)
val sneakDuration = ( now - ( sneakStartTimes[ player.uniqueId ] ?: now ) )
.coerceIn(0L, MAX_SNEAK_MS)
val range = MIN_RANGE + (sneakDuration.toDouble() / MAX_SNEAK_MS) * (MAX_RANGE - MIN_RANGE)
.coerceIn( 0L, capturedMaxSneakMs )
val range = capturedPounceMinRange +
( sneakDuration.toDouble() / capturedMaxSneakMs ) * ( capturedPounceMaxRange - capturedPounceMinRange )
val target = player.world
.getNearbyEntities( player.location, range, range, range )
@@ -154,7 +244,7 @@ class RattlesnakeKit : Kit() {
.minByOrNull { it.location.distanceSquared( player.location ) }
?: return AbilityResult.ConditionNotMet( "No enemies within ${range.toInt()} blocks!" )
// ── Launch ───────────────────────────────────────────────────────
// ── Launch ───────────────────────────────────────────────────────
val launchVec: Vector = target.location.toVector()
.subtract( player.location.toVector() )
.normalize()
@@ -180,38 +270,46 @@ class RattlesnakeKit : Kit() {
enemy.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 3 * 20, 0 ) )
}
player.sendActionBar( player.trans( "kits.rattlesnake.messages.pounce_miss" ) )
}, POUNCE_TIMEOUT_TICKS)
}, capturedPounceTimeoutTicks )
return AbilityResult.Success
}
}
// =========================================================================
// AGGRESSIVE passive pounce-hit processing
// =========================================================================
private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.aggressive.description" )
/**
* Called AFTER the normal damage has been applied.
* Called AFTER normal damage has been applied.
* If the attacker is currently pouncing, consume the flag and apply Poison II
* to 1 target (before Feast) or up to 3 targets (after Feast).
*/
override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
override fun onHitEnemy(
attacker: Player,
victim: Player,
event: EntityDamageByEntityEvent
) {
if ( !pouncingPlayers.remove( attacker.uniqueId ) ) return // not a pounce-hit
val maxTargets = if ( plugin.gameManager.feastManager.hasSpawned ) 3 else 1
val targets = buildList {
add( victim )
if (maxTargets > 1) {
if ( maxTargets > 1 )
{
victim.world
.getNearbyEntities( victim.location, 4.0, 4.0, 4.0 )
.filterIsInstance<Player>()
@@ -223,47 +321,74 @@ class RattlesnakeKit : Kit() {
}
targets.forEach { t ->
t.addPotionEffect(PotionEffect(PotionEffectType.POISON, 8 * 20, 1)) // Poison II
t.world.spawnParticle(Particle.ITEM_SLIME, t.location.clone().add(0.0, 1.0, 0.0),
12, 0.4, 0.4, 0.4, 0.0)
t.addPotionEffect( PotionEffect( PotionEffectType.POISON, 8 * 20, 1 ) )
t.world.spawnParticle(
Particle.ITEM_SLIME,
t.location.clone().add( 0.0, 1.0, 0.0 ),
12, 0.4, 0.4, 0.4, 0.0
)
}
attacker.playSound( attacker.location, Sound.ENTITY_SLIME_ATTACK, 1f, 0.7f )
attacker.sendActionBar(
attacker.trans("kits.rattlesnake.messages.pounce_hit",
mapOf("count" to targets.size.toString()))
attacker.trans(
"kits.rattlesnake.messages.pounce_hit",
mapOf( "count" to targets.size.toString() )
)
)
}
}
// =========================================================================
// DEFENSIVE active no active ability
// =========================================================================
private class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
override val kitId: String = "rattlesnake"
override val name = "None"
override val description = "None"
override val hardcodedHitsRequired: Int = 0
override val triggerMaterial = Material.BARRIER
override fun execute(player: Player) = AbilityResult.Success
private class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
override val kitId: String
get() = "rattlesnake"
override val name: String
get() = "None"
override val description: String
get() = "None"
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial: Material
get() = Material.BARRIER
override fun execute(
player: Player
): AbilityResult = AbilityResult.Success
}
// =========================================================================
// DEFENSIVE passive counter-venom (25 % proc on being hit)
// =========================================================================
private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.rattlesnake.passive.defensive.description" )
override fun onHitByEnemy(victim: Player, attacker: Player, event: EntityDamageByEntityEvent) {
override fun onHitByEnemy(
victim: Player,
attacker: Player,
event: EntityDamageByEntityEvent
) {
if ( rng.nextDouble() >= 0.25 ) return
attacker.addPotionEffect( PotionEffect( PotionEffectType.POISON, 3 * 20, 0 ) ) // Poison I, 3 s
@@ -271,5 +396,7 @@ class RattlesnakeKit : Kit() {
victim.playSound( victim.location, Sound.ENTITY_SLIME_HURT, 0.8f, 1.8f )
victim.sendActionBar( victim.trans( "kits.rattlesnake.messages.venom_proc" ) )
}
}
}

View File

@@ -38,8 +38,7 @@ import java.util.concurrent.ConcurrentHashMap
* | Playstyle | Beschreibung |
* |-------------|---------------------------------------------------------------------------------|
* | AGGRESSIVE | Gambeln per Knopfdruck Items, Events oder **Instant Death** möglich |
* | DEFENSIVE | Öffnet eine Slot-Maschinen-GUI (nur wenn kein Feind in der Nähe) sicherer: |
* | | keine Dia-Armor, kein Instant-Death-Outcome |
* | DEFENSIVE | Öffnet eine Slot-Maschinen-GUI (nur wenn kein Feind in [safeRadius] Blöcken) |
*
* ### Aggressive Outcome-Wahrscheinlichkeiten
* | 5 % | Instant Death |
@@ -48,76 +47,116 @@ import java.util.concurrent.ConcurrentHashMap
* | 20 % | Neutrale Items |
* | 50 % | Positive Items (inkl. möglicher Dia-Armor) |
*
* ### Defensive Slot-Maschinen-GUI
* Öffnet sich nur wenn kein Feind in [SAFE_RADIUS] Blöcken ist.
* Gleiche Outcome-Tabelle, ABER ohne Instant-Death und ohne Dia-Armor.
* Die GUI animiert drei Walzen nacheinander, bevor das Ergebnis feststeht.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* ### Integration
* Die [SlotMachineGui] nutzt einen eigenen [InventoryHolder]. Der Click-Dispatch
* läuft über den zentralen [MenuListener] dafür muss in [MenuListener.onInventoryClick]
* ein zusätzlicher Branch ergänzt werden:
* ```kotlin
* val spieloHolder = event.inventory.holder as? SpieloKit.SlotMachineGui ?: ...
* spieloHolder.onClick(event)
* ```
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |----------------------|--------|------------|----------------------------------------------------------|
* | `active_cooldown_ms` | Long | `12_000` | Cooldown between aggressive gamble uses in milliseconds |
* | `safe_radius` | Double | `12.0` | Radius (blocks) checked for enemies before opening GUI |
*/
class SpieloKit : Kit() {
class SpieloKit : Kit()
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
private val mm = MiniMessage.miniMessage()
override val id = "spielo"
override val id: String
get() = "spielo"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.spielo.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.spielo.lore" )
override val icon = Material.GOLD_NUGGET
// Blockiert Doppel-Trigger während eine Animation läuft
internal val gamblingPlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
// Cooldowns für den Aggressive-Automaten
private val activeCooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
override val icon: Material
get() = Material.GOLD_NUGGET
companion object {
const val ACTIVE_COOLDOWN_MS = 12_000L // 12 s zwischen Aggressive-Uses
const val SAFE_RADIUS = 12.0 // Feind-Radius für Defensive-GUI-Sperrung
const val DEFAULT_ACTIVE_COOLDOWN_MS = 12_000L
const val DEFAULT_SAFE_RADIUS = 12.0
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Cooldown between aggressive gamble uses in milliseconds.
* JSON key: `active_cooldown_ms`
*/
private val activeCooldownMs: Long
get() = override().getLong( "active_cooldown_ms" ) ?: DEFAULT_ACTIVE_COOLDOWN_MS
/**
* Radius in blocks checked for enemies before the defensive slot-machine GUI opens.
* If an enemy is within this radius the quick animation fires instead.
* JSON key: `safe_radius`
*/
private val safeRadius: Double
get() = override().getDouble( "safe_radius" ) ?: DEFAULT_SAFE_RADIUS
// ── Kit-level state ───────────────────────────────────────────────────────
/** Blocks double-trigger while an animation is running. */
internal val gamblingPlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
/** Cooldown timestamps for the aggressive instant-gamble. */
private val activeCooldowns: MutableMap<UUID, Long> = ConcurrentHashMap()
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = NoPassive( Playstyle.DEFENSIVE )
override fun getActiveAbility(playstyle: Playstyle) = when (playstyle) {
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
val (mat, active) = when (playstyle) {
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
val ( mat, active ) = when( playstyle )
{
Playstyle.AGGRESSIVE -> Material.GOLD_NUGGET to aggressiveActive
Playstyle.DEFENSIVE -> Material.GOLD_BLOCK to defensiveActive
}
val item = ItemBuilder( mat )
.name( active.name )
.lore(listOf( active.description ))
.build()
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onRemove(player: Player) {
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
gamblingPlayers.remove( player.uniqueId )
activeCooldowns.remove( player.uniqueId )
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
@@ -127,33 +166,49 @@ class SpieloKit : Kit() {
// AGGRESSIVE active Sofort-Gamble (Instant-Death möglich)
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "spielo"
override val kitId: String
get() = "spielo"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.automat.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.automat.description" )
override val hardcodedHitsRequired = 12
override val triggerMaterial = Material.GOLD_NUGGET
override fun execute(player: Player): AbilityResult {
override val hardcodedHitsRequired: Int
get() = 12
override val triggerMaterial: Material
get() = Material.GOLD_NUGGET
override fun execute(
player: Player
): AbilityResult
{
if ( gamblingPlayers.contains( player.uniqueId ) )
return AbilityResult.ConditionNotMet( "Slotmachine is already running!" )
val now = System.currentTimeMillis()
val lastUse = activeCooldowns[ player.uniqueId ] ?: 0L
if (now - lastUse < ACTIVE_COOLDOWN_MS) {
val secLeft = (ACTIVE_COOLDOWN_MS - (now - lastUse)) / 1000
// Snapshot the cooldown at activation time
val capturedCooldownMs = activeCooldownMs
if ( now - lastUse < capturedCooldownMs )
{
val secLeft = ( capturedCooldownMs - ( now - lastUse ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${secLeft}s" )
}
activeCooldowns[ player.uniqueId ] = now
gamblingPlayers.add( player.uniqueId )
// Kurze Sound-Animation (0,8 s) → dann Ergebnis
// Short sound animation (≈ 0.9 s) → then resolve
playQuickAnimation( player ) {
gamblingPlayers.remove( player.uniqueId )
if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@playQuickAnimation
@@ -162,34 +217,48 @@ class SpieloKit : Kit() {
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active Slot-Maschinen-GUI öffnen
// =========================================================================
private inner class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId = "spielo"
override val kitId: String
get() = "spielo"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.slotautomat.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.spielo.items.slotautomat.description" )
override val hardcodedHitsRequired = 15
override val triggerMaterial = Material.GOLD_BLOCK
override fun execute(player: Player): AbilityResult {
// Prüfen ob ein Feind zu nah ist
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial: Material
get() = Material.GOLD_BLOCK
override fun execute(
player: Player
): AbilityResult
{
if ( gamblingPlayers.contains( player.uniqueId ) )
return AbilityResult.ConditionNotMet( "Slotmachine is already running!" )
// Snapshot the radius at activation time
val capturedSafeRadius = safeRadius
val enemyNearby = plugin.gameManager.alivePlayers
.asSequence()
.filter { it != player.uniqueId }
.mapNotNull { Bukkit.getPlayer( it ) }
.any { it.location.distanceSquared(player.location) <= SAFE_RADIUS * SAFE_RADIUS }
if (gamblingPlayers.contains(player.uniqueId))
return AbilityResult.ConditionNotMet("Slotmachine is already running!")
.any { it.location.distanceSquared( player.location ) <= capturedSafeRadius * capturedSafeRadius }
if ( enemyNearby )
{
@@ -204,6 +273,7 @@ class SpieloKit : Kit() {
SlotMachineGui( player ).open()
return AbilityResult.Success
}
}
// =========================================================================
@@ -225,13 +295,11 @@ class SpieloKit : Kit() {
* 2. Spieler klickt Slot 22 ("Drehen") → Animation startet, Button wird gelb.
* 3. Walzen stoppen gestaffelt (Walze 1 → 2 → 3).
* 4. Nach dem letzten Stop: Outcome auflösen, GUI schließen.
*
* Der Click-Dispatch muss im [MenuListener] ergänzt werden:
* ```kotlin
* (event.inventory.holder as? SpieloKit.SlotMachineGui)?.onClick(event)
* ```
*/
inner class SlotMachineGui(private val player: Player) : InventoryHolder {
inner class SlotMachineGui(
private val player: Player
) : InventoryHolder
{
private val inv: Inventory = Bukkit.createInventory(
this, 27,
@@ -241,7 +309,6 @@ class SpieloKit : Kit() {
private val reelSlots = intArrayOf( 11, 13, 15 )
private val spinButton = 22
// Symbole die auf den Walzen erscheinen (nur visuell kein Einfluss auf Outcome)
private val reelSymbols = listOf(
Material.GOLD_NUGGET, Material.EMERALD, Material.IRON_INGOT,
Material.GOLDEN_APPLE, Material.MUSHROOM_STEW, Material.EXPERIENCE_BOTTLE,
@@ -253,21 +320,25 @@ class SpieloKit : Kit() {
override fun getInventory(): Inventory = inv
fun open() {
fun open()
{
drawLayout()
player.openInventory( inv )
}
private fun drawLayout() {
private fun drawLayout()
{
val filler = buildFiller()
repeat( 27 ) { inv.setItem( it, filler ) }
reelSlots.forEach { inv.setItem( it, buildReelItem( reelSymbols.random() ) ) }
inv.setItem( spinButton, buildSpinButton( spinning = false ) )
}
// ── Event-Hooks (aufgerufen von MenuListener) ────────────────────────
// ── Event hooks (dispatched from MenuListener) ────────────────────────
fun onClick(event: InventoryClickEvent) {
fun onClick(
event: InventoryClickEvent
) {
event.isCancelled = true
if ( isSpinning ) return
if ( event.rawSlot != spinButton ) return
@@ -279,51 +350,56 @@ class SpieloKit : Kit() {
startSpinAnimation()
}
/** Aufgerufen wenn Inventar geschlossen wird (z.B. ESC). */
fun onClose() {
/** Called when the inventory is closed (e.g. ESC). */
fun onClose()
{
lastAnimTask?.cancel()
// Charge nur zurückgeben wenn noch nicht gedreht wurde
if (!isSpinning) {
gamblingPlayers.remove(player.uniqueId)
}
// Wenn isSpinning == true läuft die Animation noch Cleanup in onAllReelsStopped
// Only refund the lock if the player never actually spun
if ( !isSpinning ) gamblingPlayers.remove( player.uniqueId )
// If isSpinning == true the animation is still running — cleanup in onAllReelsStopped
}
// ── Animation ─────────────────────────────────────────────────────────
/**
* Startet die gestaffelte Walzen-Animation.
* Walze 1 stoppt nach 8 Frames, Walze 2 nach 12, Walze 3 nach 16.
* Jeder Frame dauert 2 Ticks (0,1 s). Starts sind versetzt (+5 Ticks pro Walze).
* Starts the staggered reel animation.
* Reel 1 stops after 8 frames, reel 2 after 12, reel 3 after 16.
* Each frame takes 2 ticks (0.1 s). Starts are staggered (+5 ticks per reel).
*/
private fun startSpinAnimation() {
private fun startSpinAnimation()
{
val framesPerReel = intArrayOf( 8, 12, 16 )
val startDelays = longArrayOf( 0L, 5L, 10L )
val ticksPerFrame = 2L
var stoppedReels = 0
for (reelIdx in 0..2) {
for ( reelIdx in 0..2 )
{
val slot = reelSlots[ reelIdx ]
val maxFrames = framesPerReel[ reelIdx ]
var frame = 0
val task = object : BukkitRunnable() {
val task = object : BukkitRunnable()
{
override fun run()
{
if (!player.isOnline) {
this.cancel(); return
}
if ( !player.isOnline ) { this.cancel(); return }
frame++
if (frame <= maxFrames) {
// Zufälliges Walzen-Symbol während Rotation
if ( frame <= maxFrames )
{
inv.setItem( slot, buildReelItem( reelSymbols.random() ) )
val pitch = ( 0.6f + frame * 0.07f ).coerceAtMost( 2.0f )
player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.4f, pitch )
} else {
// Einrasten finales Symbol (zufällig, rein visuell)
}
else
{
inv.setItem( slot, buildReelItem( reelSymbols.random() ) )
player.playSound(player.location, Sound.BLOCK_NOTE_BLOCK_CHIME, 0.9f, 1.1f + reelIdx * 0.2f)
player.playSound(
player.location,
Sound.BLOCK_NOTE_BLOCK_CHIME,
0.9f, 1.1f + reelIdx * 0.2f
)
stoppedReels++
if ( stoppedReels == 3 ) onAllReelsStopped()
@@ -337,28 +413,32 @@ class SpieloKit : Kit() {
}
}
private fun onAllReelsStopped() {
private fun onAllReelsStopped()
{
player.playSound( player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.7f, 1.5f )
// Kurze Pause, dann Outcome auslösen und GUI schließen
Bukkit.getScheduler().runTaskLater( plugin, { ->
gamblingPlayers.remove( player.uniqueId )
if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@runTaskLater
player.closeInventory()
// Defensive: kein Instant-Death, keine Dia-Armor
resolveOutcome( player, allowInstantDeath = false, allowDiamondArmor = false )
}, 20L )
}
// ── Item-Builder ─────────────────────────────────────────────────────
// ── Item builders ─────────────────────────────────────────────────────
private fun buildReelItem(material: Material) = ItemStack(material).also { item ->
private fun buildReelItem(
material: Material
) = ItemStack( material ).also { item ->
item.editMeta { meta ->
meta.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) )
}
}
private fun buildSpinButton(spinning: Boolean): ItemStack {
private fun buildSpinButton(
spinning: Boolean
): ItemStack
{
val mat = if ( spinning ) Material.YELLOW_CONCRETE else Material.LIME_CONCRETE
val name = if ( spinning )
mm.deserialize( "<yellow><bold>⟳ Spins...</bold></yellow>" )
@@ -368,7 +448,8 @@ class SpieloKit : Kit() {
return ItemStack( mat ).also { item ->
item.editMeta { meta ->
meta.displayName( name.decoration( TextDecoration.ITALIC, false ) )
if (!spinning) {
if ( !spinning )
{
meta.lore(listOf(
Component.empty(),
mm.deserialize( "<gray>Click to spin the reels." )
@@ -385,21 +466,27 @@ class SpieloKit : Kit() {
meta.displayName( Component.text( " " ).decoration( TextDecoration.ITALIC, false ) )
}
}
}
// =========================================================================
// Outcome-Auflösung gemeinsam für Aggressive und Defensive
// Outcome resolution shared by both playstyles
// =========================================================================
/**
* Löst das Gamble-Ergebnis auf.
* @param allowInstantDeath true = Aggressive (5 % Instant Death möglich)
* @param allowDiamondArmor true = Aggressive (Dia-Armor in Loot möglich)
* Resolves the gamble result.
* @param allowInstantDeath `true` = aggressive (5 % instant death possible)
* @param allowDiamondArmor `true` = aggressive (diamond armor in loot pool)
*/
fun resolveOutcome(player: Player, allowInstantDeath: Boolean, allowDiamondArmor: Boolean) {
fun resolveOutcome(
player: Player,
allowInstantDeath: Boolean,
allowDiamondArmor: Boolean
) {
val roll = rng.nextDouble()
when {
when
{
allowInstantDeath && roll < 0.05 -> triggerInstantDeath( player )
allowInstantDeath && roll < 0.20 -> triggerRandomDisaster( player )
roll < ( if ( allowInstantDeath ) 0.30 else 0.10 ) -> applyNegativeEffect( player )
@@ -408,21 +495,24 @@ class SpieloKit : Kit() {
}
}
// ── Einzelne Outcome-Typen ────────────────────────────────────────────────
// ── Individual outcome types ──────────────────────────────────────────────
private fun triggerInstantDeath(player: Player) {
private fun triggerInstantDeath(
player: Player
) {
player.world.spawnParticle( Particle.EXPLOSION, player.location, 5, 0.5, 0.5, 0.5, 0.0 )
player.world.playSound( player.location, Sound.ENTITY_WITHER_SPAWN, 1f, 1.5f )
player.sendActionBar( player.trans( "kits.spielo.messages.instant_death" ) )
// Einen Tick später töten damit das ActionBar-Paket noch ankommt
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) ) return@runTaskLater
player.health = 0.0
}, 3L )
}
private fun triggerRandomDisaster(player: Player) {
private fun triggerRandomDisaster(
player: Player
) {
val disaster = listOf(
MeteorDisaster(), TornadoDisaster(), EarthquakeDisaster(), ThunderDisaster()
).random()
@@ -437,7 +527,9 @@ class SpieloKit : Kit() {
player.sendActionBar( player.trans( "kits.spielo.messages.gamble_event" ) )
}
private fun applyNegativeEffect(player: Player) {
private fun applyNegativeEffect(
player: Player
) {
val outcomes: List<() -> Unit> = listOf(
{ player.addPotionEffect( PotionEffect( PotionEffectType.SLOWNESS, 6 * 20, 1 ) ) },
{ player.addPotionEffect( PotionEffect( PotionEffectType.MINING_FATIGUE, 6 * 20, 1 ) ) },
@@ -456,12 +548,14 @@ class SpieloKit : Kit() {
player.sendActionBar( player.trans( "kits.spielo.messages.gamble_bad" ) )
}
private fun giveNeutralItems(player: Player) {
private fun giveNeutralItems(
player: Player
) {
val items = listOf(
ItemStack( Material.ARROW, rng.nextInt( 5 ) + 3 ),
ItemStack( Material.BREAD, rng.nextInt( 4 ) + 2 ),
ItemStack( Material.IRON_INGOT, rng.nextInt( 3 ) + 1 ),
ItemStack(Material.COBBLESTONE, rng.nextInt(8) + 4),
ItemStack( Material.COBBLESTONE, rng.nextInt( 8 ) + 4 )
)
player.inventory.addItem( items.random() )
@@ -469,7 +563,10 @@ class SpieloKit : Kit() {
player.sendActionBar( player.trans( "kits.spielo.messages.gamble_neutral" ) )
}
private fun givePositiveItems(player: Player, allowDiamondArmor: Boolean) {
private fun givePositiveItems(
player: Player,
allowDiamondArmor: Boolean
) {
data class LootEntry( val item: ItemStack, val weight: Int )
val pool = buildList {
@@ -481,11 +578,10 @@ class SpieloKit : Kit() {
add( LootEntry( buildSplashPotion( PotionEffectType.STRENGTH, 200, 0 ), 8 ) )
add( LootEntry( buildSplashPotion( PotionEffectType.SPEED, 400, 0 ), 8 ) )
add( LootEntry( buildSplashPotion( PotionEffectType.REGENERATION, 160, 1 ), 8 ) )
// Eisen-Rüstung: immer möglich
add( LootEntry( ItemStack( Material.IRON_CHESTPLATE ), 4 ) )
add( LootEntry( ItemStack( Material.IRON_HELMET ), 4 ) )
// Dia-Rüstung: nur Aggressive
if (allowDiamondArmor) {
if ( allowDiamondArmor )
{
add( LootEntry( ItemStack( Material.DIAMOND_CHESTPLATE ), 2 ) )
add( LootEntry( ItemStack( Material.DIAMOND_HELMET ), 2 ) )
}
@@ -506,21 +602,33 @@ class SpieloKit : Kit() {
}
// =========================================================================
// Stubs
// Shared no-passive placeholder
// =========================================================================
class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {
override val name = "None"
override val description = "None"
class NoPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
override val name: String
get() = "None"
override val description: String
get() = "None"
}
// =========================================================================
// Hilfsmethoden
// Shared helpers
// =========================================================================
/** Klicker-Sounds mit steigendem Pitch, danach Callback. */
private fun playQuickAnimation(player: Player, onFinish: () -> Unit) {
for (i in 0..5) {
/** Click-sounds with rising pitch, then fires [onFinish] callback. */
private fun playQuickAnimation(
player: Player,
onFinish: () -> Unit
) {
for ( i in 0..5 )
{
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline ) return@runTaskLater
player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_HAT, 0.9f, 0.5f + i * 0.25f )
@@ -534,11 +642,15 @@ class SpieloKit : Kit() {
Bukkit.getScheduler().runTaskLater( plugin, Runnable( onFinish ), 18L )
}
private fun buildSplashPotion(type: PotionEffectType, duration: Int, amplifier: Int) =
ItemStack(Material.SPLASH_POTION).also { potion ->
private fun buildSplashPotion(
type: PotionEffectType,
duration: Int,
amplifier: Int
) = ItemStack( Material.SPLASH_POTION ).also { potion ->
potion.editMeta { meta ->
if ( meta is org.bukkit.inventory.meta.PotionMeta )
meta.addCustomEffect( PotionEffect( type, duration, amplifier ), true )
}
}
}

View File

@@ -16,8 +16,6 @@ import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.scheduler.BukkitTask
import org.bukkit.util.Vector
import java.util.Random
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -28,87 +26,137 @@ import kotlin.math.sin
* ## TeslaKit
*
* | Playstyle | Active | Passive |
* |-------------|---------------------------------------------------|------------------------------------------------|
* | AGGRESSIVE | 5 Blitze im 5-Block-Radius (1.5 ♥ pro Treffer) | Rückschlag + Brandschaden-Aura alle 3 s (klein)|
* | DEFENSIVE | | Rückschlag + Brandschaden-Aura alle 3 s (groß) |
* |-------------|---------------------------------------------------|--------------------------------------------------|
* | AGGRESSIVE | [lightningBoltCount] bolts in [lightningRadius] blocks ([lightningDamage] HP each) | [auraChance] % counter-shock on hit |
* | DEFENSIVE | | [auraChance] % counter-shock on hit |
*
* **Höhen-Einschränkung**: Beide Mechaniken deaktivieren sich ab Y > [MAX_HEIGHT_Y]
* (~50 Blöcke über Meeresspiegel). Tesla braucht Erdkontakt.
* **Height restriction**: Both mechanics deactivate above Y > [MAX_HEIGHT_Y].
* Tesla needs ground contact.
*
* ### Technische Lösung Visueller Blitz + manueller Schaden":
* `world.strikeLightningEffect()` erzeugt nur Partikel/Sound keinen Block-/Entity-Schaden.
* Direkt danach werden Spieler im 1,5-Block-Radius per `entity.damage()` manuell getroffen.
* Das verhindert ungewollte Nebeneffekte (Feuer, Dorfbewohner-Schaden, eigener Tod durch
* zufälligen Blitzschlag).
* ### Technical note "Visual lightning + manual damage":
* `world.strikeLightningEffect()` produces only particles/sound — no block or entity damage.
* Players within 1.5 blocks of the strike are then damaged manually via `entity.damage()`.
* This prevents unwanted side-effects (fire, villager conversion, self-death from random bolts).
*
* ### Passive Aura:
* Ein `BukkitRunnable` (gestartet in `onActivate`, gestoppt in `onDeactivate`) prüft alle
* [AURA_INTERVAL_TICKS] Ticks, ob Gegner in [AURA_RADIUS] Blöcken sind. Falls ja → Velocity-Push
* nach außen + `fireTicks`. Aggressive-Playstyle hat schwächeren Rückschlag, Defensive stärkeren.
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------|--------|---------|--------------------------------------------------------|
* | `lightning_radius` | Double | `5.0` | Radius (blocks) in which bolts are scattered |
* | `lightning_damage` | Double | `3.0` | HP damage per player hit by a bolt |
* | `lightning_bolt_count`| Int | `5` | Number of bolts fired per activation |
* | `bolt_stagger_ticks` | Long | `8` | Ticks between each successive bolt |
* | `aura_chance` | Double | `0.05` | Probability (01) of the counter-shock passive firing |
* | `aura_fire_ticks` | Int | `60` | Fire ticks applied to the attacker by the counter-shock|
*/
class TeslaKit : Kit() {
class TeslaKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id: String
get() = "tesla"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.tesla.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.tesla.lore" )
override val icon: Material
get() = Material.LIGHTNING_ROD
companion object {
/**
* ~50 Blöcke über Meeresspiegel ( Y ≈ 63 + 50 = 113 )
* Oberhalb dieser Grenze sind beide Fähigkeiten deaktiviert.
*/
/** ~50 blocks above sea level (Y ≈ 63 + 50 = 113). Above this both abilities deactivate. */
const val MAX_HEIGHT_Y = 113.0
// Aggressive Active
const val LIGHTNING_RADIUS = 5.0
const val LIGHTNING_DAMAGE = 3.0
const val LIGHTNING_BOLT_COUNT = 5
const val BOLT_STAGGER_TICKS = 8L
// Passive Aura
const val AURA_CHANCE = 0.05
const val AURA_FIRE_TICKS = 60
const val DEFAULT_LIGHTNING_RADIUS = 5.0
const val DEFAULT_LIGHTNING_DAMAGE = 3.0
const val DEFAULT_LIGHTNING_BOLT_COUNT = 5
const val DEFAULT_BOLT_STAGGER_TICKS = 8L
const val DEFAULT_AURA_CHANCE = 0.05
const val DEFAULT_AURA_FIRE_TICKS = 60
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Radius in blocks within which lightning bolts are randomly scattered.
* JSON key: `lightning_radius`
*/
private val lightningRadius: Double
get() = override().getDouble( "lightning_radius" ) ?: DEFAULT_LIGHTNING_RADIUS
/**
* HP damage dealt to each player struck by a bolt (within 1.5 blocks of impact).
* JSON key: `lightning_damage`
*/
private val lightningDamage: Double
get() = override().getDouble( "lightning_damage" ) ?: DEFAULT_LIGHTNING_DAMAGE
/**
* Total number of bolts fired per activation.
* JSON key: `lightning_bolt_count`
*/
private val lightningBoltCount: Int
get() = override().getInt( "lightning_bolt_count" ) ?: DEFAULT_LIGHTNING_BOLT_COUNT
/**
* Ticks between each successive bolt in the staggered sequence.
* JSON key: `bolt_stagger_ticks`
*/
private val boltStaggerTicks: Long
get() = override().getLong( "bolt_stagger_ticks" ) ?: DEFAULT_BOLT_STAGGER_TICKS
/**
* Probability (0.01.0) that the counter-shock passive fires on being hit.
* JSON key: `aura_chance`
*/
private val auraChance: Double
get() = override().getDouble( "aura_chance" ) ?: DEFAULT_AURA_CHANCE
/**
* Fire ticks applied to the attacker when the counter-shock passive procs.
* JSON key: `aura_fire_ticks`
*/
private val auraFireTicks: Int
get() = override().getInt( "aura_fire_ticks" ) ?: DEFAULT_AURA_FIRE_TICKS
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = NoActive( Playstyle.DEFENSIVE )
private val aggressivePassive = TeslaPassive(
playstyle = Playstyle.AGGRESSIVE
)
private val defensivePassive = TeslaPassive(
playstyle = Playstyle.DEFENSIVE
)
private val aggressivePassive = TeslaPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = TeslaPassive( Playstyle.DEFENSIVE )
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
) = when (playstyle) {
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(
playstyle: Playstyle
) = when (playstyle) {
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
if ( playstyle != Playstyle.AGGRESSIVE )
return
if ( playstyle != Playstyle.AGGRESSIVE ) return
val item = ItemBuilder( Material.LIGHTNING_ROD )
.name( aggressiveActive.name )
@@ -119,6 +167,8 @@ class TeslaKit : Kit() {
player.inventory.addItem( item )
}
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
@@ -126,22 +176,27 @@ class TeslaKit : Kit() {
}
// =========================================================================
// AGGRESSIVE active gestaffelte Blitze im Nahbereich
// AGGRESSIVE active staggered lightning bolts in melee range
// =========================================================================
private class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val kitId: String
get() = "tesla"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.items.rod.description" )
override val triggerMaterial: Material
get() = Material.LIGHTNING_ROD
override val hardcodedHitsRequired: Int
get() = 15
@@ -156,32 +211,37 @@ class TeslaKit : Kit() {
val world = player.world
repeat( LIGHTNING_BOLT_COUNT ) { index ->
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline )
return@runTaskLater
// Snapshot all config values at activation time so a mid-round
// config change cannot alter a bolt sequence already in flight.
val capturedRadius = lightningRadius
val capturedDamage = lightningDamage
val capturedBoltCount = lightningBoltCount
val capturedStaggerTicks = boltStaggerTicks
repeat( capturedBoltCount ) { index ->
Bukkit.getScheduler().runTaskLater( plugin, { ->
if ( !player.isOnline ) return@runTaskLater
// Zufällige Position innerhalb des Radius
val angle = rng.nextDouble() * 2.0 * Math.PI
val dist = rng.nextDouble() * LIGHTNING_RADIUS
val dist = rng.nextDouble() * capturedRadius
val strikeLoc = player.location.clone().add(
cos( angle ) * dist,
0.0,
sin( angle ) * dist
)
// Oberfläche bestimmen (Blitze sollen am Boden landen)
// Land the bolt on the surface
strikeLoc.y = world.getHighestBlockYAt( strikeLoc ).toDouble() + 1.0
// Nur visueller Effekt KEIN Block-/Feuer-Schaden
// Visual only — no block/fire damage
world.strikeLightningEffect( strikeLoc )
// Manueller Schaden an Spielern im Nahbereich des Einschlags
// Manual damage to nearby players
world.getNearbyEntities( strikeLoc, 1.5, 1.5, 1.5 )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { victim ->
victim.damage( LIGHTNING_DAMAGE, player )
victim.damage( capturedDamage, player )
victim.world.spawnParticle(
Particle.ELECTRIC_SPARK,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
@@ -194,7 +254,7 @@ class TeslaKit : Kit() {
12, 0.3, 0.2, 0.3, 0.08
)
}, index * BOLT_STAGGER_TICKS )
}, index * capturedStaggerTicks )
}
player.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 1.3f )
@@ -205,18 +265,20 @@ class TeslaKit : Kit() {
}
// =========================================================================
// Passive Aura Rückschlag + Brandschaden im Umkreis (beide Playstyles)
// Passive counter-shock aura (both playstyles)
// =========================================================================
class TeslaPassive(
inner class TeslaPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle ) {
) : PassiveAbility( playstyle )
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.tesla.passive.description" )
@@ -225,23 +287,23 @@ class TeslaKit : Kit() {
attacker: Player,
event: EntityDamageByEntityEvent
) {
if ( rng.nextDouble() > AURA_CHANCE )
return
// Snapshot at hit time so a config change mid-round is consistent
if ( rng.nextDouble() > auraChance ) return
attacker.fireTicks = AURA_FIRE_TICKS
val capturedFireTicks = auraFireTicks
attacker.fireTicks = capturedFireTicks
attacker.world.spawnParticle(
Particle.ELECTRIC_SPARK,
attacker.location.clone().add( 0.0, 1.0, 0.0 ),
10, 0.3, 0.4, 0.3, 0.06
)
victim.world.spawnParticle(
Particle.ELECTRIC_SPARK,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
6, 0.6, 0.6, 0.6, 0.02
)
victim.world.playSound(
victim.location,
Sound.ENTITY_LIGHTNING_BOLT_IMPACT,
@@ -251,15 +313,34 @@ class TeslaKit : Kit() {
}
// ── Kein Active für Defensive ─────────────────────────────────────────────
// =========================================================================
// Defensive no-active placeholder
// =========================================================================
private class NoActive(
playstyle: Playstyle
) : ActiveAbility( playstyle )
{
override val kitId: String
get() = "tesla"
override val name: String
get() = "None"
override val description: String
get() = "None"
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial: Material
get() = Material.BARRIER
override fun execute(
player: Player
): AbilityResult = AbilityResult.Success
private class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) {
override val kitId = "tesla"
override val name = "None"
override val description = "None"
override val hardcodedHitsRequired = 0
override val triggerMaterial = Material.BARRIER
override fun execute(player: Player) = AbilityResult.Success
}
}

View File

@@ -27,63 +27,124 @@ import java.util.concurrent.ConcurrentHashMap
/**
* ## TridentKit
*
* | Playstyle | Fähigkeit |
* |-------------|----------------------------------------------------------------------------|
* | AGGRESSIVE | **Dive**: 3 Charges Hochsprung, bei Landung schlägt Blitz ein |
* | DEFENSIVE | **Parry**: 20 % Chance Angreifer abprallen + Slowness I (2 s) |
* | Playstyle | Active | Passive |
* |-------------|-----------------------------------------------------------------------------|------------------------------------------------|
* | AGGRESSIVE | **Dive** [maxDiveCharges] charges; launches up, lightning strikes on land | |
* | DEFENSIVE | | **Parry** [parryChance] % bounce + Slowness |
*
* ### Dive-Mechanismus
* `hitsRequired = 0` → Fähigkeit ist immer READY; interne [diveCharges] verwalten
* die 3 Sprünge einer Sequenz. Coodown [SEQUENCE_COOLDOWN_MS] gilt nur zwischen
* vollständigen Sequenzen (wenn alle Charges verbraucht wurden).
* ### Dive mechanic
* `hitsRequired = 0` → ability is always READY; internal [diveCharges] manage
* the per-sequence uses. [sequenceCooldownMs] applies only between full sequences
* (when all charges are exhausted).
*
* Jeder Charge-Verbrauch startet einen 1-Tick-Monitor:
* 1. Warte auf Velocity-Wechsel (aufwärts → abwärts)
* 2. Sobald Block unterhalb solid → [triggerLightningStrike]
* Each charge consumption starts a 1-tick landing monitor:
* 1. Wait for velocity to flip (ascending → descending).
* 2. Once the block below is solid → [triggerLightningStrike].
*
* ### Parry-Mechanismus
* [onHitByEnemy] mit 20 % Chance + Dreizack-Check (Haupt- oder Offhand).
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* All values read from the `extras` map with companion-object defaults as fallback.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-------------------------|--------|------------|------------------------------------------------------|
* | `max_dive_charges` | Int | `3` | Number of dive charges per sequence |
* | `sequence_cooldown_ms` | Long | `25_000` | Cooldown between full sequences in milliseconds |
* | `lightning_radius` | Double | `3.5` | Radius (blocks) of the landing lightning strike |
* | `lightning_damage` | Double | `4.0` | HP damage dealt to enemies hit by the lightning |
* | `parry_chance` | Double | `0.20` | Probability (01) of the parry passive firing |
* | `parry_slowness_ticks` | Int | `40` | Slowness I ticks applied to the attacker on parry |
*/
class TridentKit : Kit() {
class TridentKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id: String
get() = "trident"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.trident.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.trident.lore" )
override val icon: Material
get() = Material.TRIDENT
/** Verbleibende Dive-Charges: 0 = neue Sequenz erforderlich. */
companion object {
const val DEFAULT_MAX_DIVE_CHARGES = 3
const val DEFAULT_SEQUENCE_COOLDOWN_MS = 25_000L
const val DEFAULT_LIGHTNING_RADIUS = 3.5
const val DEFAULT_LIGHTNING_DAMAGE = 4.0
const val DEFAULT_PARRY_CHANCE = 0.20
const val DEFAULT_PARRY_SLOWNESS_TICKS = 40
}
// ── Live config accessors ─────────────────────────────────────────────────
/**
* Number of dive charges granted at the start of each sequence.
* JSON key: `max_dive_charges`
*/
private val maxDiveCharges: Int
get() = override().getInt( "max_dive_charges" ) ?: DEFAULT_MAX_DIVE_CHARGES
/**
* Cooldown in milliseconds between full dive sequences.
* JSON key: `sequence_cooldown_ms`
*/
private val sequenceCooldownMs: Long
get() = override().getLong( "sequence_cooldown_ms" ) ?: DEFAULT_SEQUENCE_COOLDOWN_MS
/**
* Radius in blocks of the lightning strike triggered on landing.
* JSON key: `lightning_radius`
*/
private val lightningRadius: Double
get() = override().getDouble( "lightning_radius" ) ?: DEFAULT_LIGHTNING_RADIUS
/**
* HP damage dealt to each player hit by the landing lightning strike.
* JSON key: `lightning_damage`
*/
private val lightningDamage: Double
get() = override().getDouble( "lightning_damage" ) ?: DEFAULT_LIGHTNING_DAMAGE
/**
* Probability (0.01.0) of the defensive parry passive firing on being hit.
* JSON key: `parry_chance`
*/
private val parryChance: Double
get() = override().getDouble( "parry_chance" ) ?: DEFAULT_PARRY_CHANCE
/**
* Duration in ticks of Slowness I applied to the attacker when parried.
* JSON key: `parry_slowness_ticks`
*/
private val parrySlownessTicks: Int
get() = override().getInt( "parry_slowness_ticks" ) ?: DEFAULT_PARRY_SLOWNESS_TICKS
// ── Kit-level state ───────────────────────────────────────────────────────
/** Remaining dive charges per player: 0 = new sequence required. */
internal val diveCharges: MutableMap<UUID, Int> = ConcurrentHashMap()
private val diveMonitors: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
private val lastSequenceTime: MutableMap<UUID, Long> = ConcurrentHashMap()
/** Players who have recently launched a dive and should not receive fall damage. */
/** Players who have recently launched a dive and must not receive fall damage. */
internal val noFallDamagePlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
companion object {
const val MAX_DIVE_CHARGES = 3
const val SEQUENCE_COOLDOWN_MS = 25_000L // Cooldown zwischen vollst. Sequenzen
const val LIGHTNING_RADIUS = 3.5 // Blöcke um den Einschlagpunkt
const val LIGHTNING_DAMAGE = 4.0 // 2 Herzen
const val PARRY_CHANCE = 0.20 // 20 %
const val PARRY_SLOWNESS_TICKS = 40 // 2 Sekunden
}
// ── Gecachte Instanzen ────────────────────────────────────────────────────
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = DiveActive()
private val defensiveActive = NoActive( Playstyle.DEFENSIVE )
private val aggressivePassive = NoPassive( Playstyle.AGGRESSIVE )
private val defensivePassive = ParryPassive()
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
) = when( playstyle )
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
@@ -91,12 +152,14 @@ class TridentKit : Kit() {
override fun getPassiveAbility(
playstyle: Playstyle
) = when( playstyle )
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(
@@ -126,6 +189,8 @@ class TridentKit : Kit() {
player.inventory.addItem( trident )
}
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
@@ -137,7 +202,7 @@ class TridentKit : Kit() {
}
// =========================================================================
// Dive: Landungs-Monitor
// Dive: landing monitor
// =========================================================================
private fun startDiveMonitor(
@@ -151,7 +216,7 @@ class TridentKit : Kit() {
val task = Bukkit.getScheduler().runTaskTimer( plugin, { ->
elapsed++
// Safety-Timeout: 10 Sekunden
// Safety timeout: 10 seconds
if ( elapsed > 200 || !player.isOnline ||
!plugin.gameManager.alivePlayers.contains( player.uniqueId ) )
{
@@ -180,7 +245,7 @@ class TridentKit : Kit() {
diveMonitors.remove( player.uniqueId )?.cancel()
}
}
}, 4L, 1L ) // 4 Ticks Anlauf (verhindert Sofort-Trigger auf dem Boden)
}, 4L, 1L ) // 4-tick head-start prevents instant ground trigger
diveMonitors[ player.uniqueId ] = task
}
@@ -196,14 +261,18 @@ class TridentKit : Kit() {
world.spawnParticle( Particle.ELECTRIC_SPARK, loc, 45, 1.2, 0.5, 1.2, 0.2 )
world.spawnParticle( Particle.EXPLOSION, loc, 3, 0.4, 0.2, 0.4, 0.0 )
world.getNearbyEntities( loc, LIGHTNING_RADIUS, LIGHTNING_RADIUS, LIGHTNING_RADIUS )
// Snapshot at landing time so a mid-sequence config change is consistent
val capturedRadius = lightningRadius
val capturedDamage = lightningDamage
world.getNearbyEntities( loc, capturedRadius, capturedRadius, capturedRadius )
.filterIsInstance<Player>()
.filter { it != player && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
.forEach { enemy ->
enemy.damage( LIGHTNING_DAMAGE, player )
enemy.addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS, 40, 0, false, false, true
))
enemy.damage( capturedDamage, player )
enemy.addPotionEffect(
PotionEffect( PotionEffectType.SLOWNESS, 40, 0, false, false, true )
)
}
val remaining = diveCharges.getOrDefault( player.uniqueId, 0 )
@@ -213,21 +282,26 @@ class TridentKit : Kit() {
}
// =========================================================================
// AGGRESSIVE active Dive-Charges
// AGGRESSIVE active Dive charges
// =========================================================================
private inner class DiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
private inner class DiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId: String
get() = "trident"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.items.trident.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.items.trident.aggressive.description" )
override val triggerMaterial: Material
get() = Material.TRIDENT
override val hardcodedHitsRequired: Int
get() = 0
@@ -238,16 +312,20 @@ class TridentKit : Kit() {
val now = System.currentTimeMillis()
val charges = diveCharges.getOrDefault( player.uniqueId, 0 )
// Snapshot config values at activation time
val capturedMaxCharges = maxDiveCharges
val capturedCooldownMs = sequenceCooldownMs
if ( charges <= 0 )
{
val lastSeq = lastSequenceTime[ player.uniqueId ] ?: 0L
if ( now - lastSeq < SEQUENCE_COOLDOWN_MS )
if ( now - lastSeq < capturedCooldownMs )
{
val secLeft = ( SEQUENCE_COOLDOWN_MS - ( now - lastSeq )) / 1000
val secLeft = ( capturedCooldownMs - ( now - lastSeq ) ) / 1000
return AbilityResult.ConditionNotMet( "Cooldown: ${secLeft}s" )
}
lastSequenceTime[ player.uniqueId ] = now
diveCharges[ player.uniqueId ] = MAX_DIVE_CHARGES - 1
diveCharges[ player.uniqueId ] = capturedMaxCharges - 1
}
else diveCharges[ player.uniqueId ] = charges - 1
@@ -255,19 +333,16 @@ class TridentKit : Kit() {
noFallDamagePlayers.add( player.uniqueId )
val remaining = diveCharges.getOrDefault( player.uniqueId, 0 )
player.sendActionBar(player.trans( "kits.trident.messages.dive_launched", "charges" to remaining.toString() ))
player.sendActionBar(
player.trans( "kits.trident.messages.dive_launched", "charges" to remaining.toString() )
)
player.world.spawnParticle(
Particle.ELECTRIC_SPARK,
player.location.clone().add( 0.0, 0.5, 0.0 ),
15, 0.3, 0.2, 0.3, 0.12
)
player.playSound(
player.location,
Sound.ENTITY_LIGHTNING_BOLT_IMPACT,
0.7f, 1.6f
)
player.playSound( player.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 0.7f, 1.6f )
startDiveMonitor( player )
return AbilityResult.Success
@@ -276,16 +351,18 @@ class TridentKit : Kit() {
}
// =========================================================================
// DEFENSIVE passive Parry (20 %)
// DEFENSIVE passive Parry
// =========================================================================
private inner class ParryPassive : PassiveAbility( Playstyle.DEFENSIVE ) {
private inner class ParryPassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.trident.passive.defensive.description" )
@@ -294,28 +371,30 @@ class TridentKit : Kit() {
attacker: Player,
event: EntityDamageByEntityEvent
) {
if ( rng.nextDouble() >= PARRY_CHANCE ) return
// Snapshot at hit time
if ( rng.nextDouble() >= parryChance ) return
val mainType = victim.inventory.itemInMainHand.type
val offType = victim.inventory.itemInOffHand.type
if ( mainType != Material.TRIDENT && offType != Material.TRIDENT ) return
val capturedSlownessTicks = parrySlownessTicks
attacker.velocity = attacker.location.toVector()
.subtract( victim.location.toVector() )
.normalize()
.multiply( 1.7 )
.setY( 0.45 )
attacker.addPotionEffect(PotionEffect(
PotionEffectType.SLOWNESS, PARRY_SLOWNESS_TICKS, 0, false, false, true
))
attacker.addPotionEffect(
PotionEffect( PotionEffectType.SLOWNESS, capturedSlownessTicks, 0, false, false, true )
)
victim.world.spawnParticle(
Particle.SWEEP_ATTACK,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
6, 0.3, 0.3, 0.3, 0.0
)
victim.world.playSound( victim.location, Sound.ITEM_SHIELD_BLOCK, 1f, 0.65f )
victim.world.playSound( victim.location, Sound.ENTITY_LIGHTNING_BOLT_IMPACT, 0.3f, 1.9f )
@@ -325,20 +404,45 @@ class TridentKit : Kit() {
}
// ── Stubs ────────────────────────────────────────────────────────────────
// ── Stubs ────────────────────────────────────────────────────────────────
private class NoActive(
playstyle: Playstyle
) : ActiveAbility( playstyle )
{
override val kitId: String
get() = "trident"
override val name: String
get() = "None"
override val description: String
get() = "None"
override val hardcodedHitsRequired: Int
get() = 0
override val triggerMaterial: Material
get() = Material.BARRIER
override fun execute(
player: Player
): AbilityResult = AbilityResult.Success
private class NoActive(playstyle: Playstyle) : ActiveAbility(playstyle) {
override val kitId = "trident"
override val name = "None"
override val description = "None"
override val hardcodedHitsRequired = 0
override val triggerMaterial = Material.BARRIER
override fun execute(player: Player) = AbilityResult.Success
}
private class NoPassive(playstyle: Playstyle) : PassiveAbility(playstyle) {
override val name = "None"
override val description = "None"
private class NoPassive(
playstyle: Playstyle
) : PassiveAbility( playstyle )
{
override val name: String
get() = "None"
override val description: String
get() = "None"
}
}

View File

@@ -1,7 +1,6 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
@@ -26,10 +25,29 @@ import java.util.concurrent.ConcurrentHashMap
import kotlin.math.cos
import kotlin.math.sin
class VenomKit : Kit() {
/**
* ## VenomKit
*
* | Playstyle | Active | Passive |
* |-------------|--------------------------------------------------------------|-------------------------------------------------|
* | AGGRESSIVE | **Deafening Beam** dragon-breath raycast, Blindness + Wither | |
* | DEFENSIVE | **Shield of Darkness** absorbs [shieldCapacity] damage for [shieldDurationTicks] ticks | Absorb hits while shield is active |
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* The two shield parameters are **typed fields** in [CustomGameSettings.KitOverride]
* and are therefore accessed directly rather than through `extras`.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-------------------------|--------|---------|-------------------------------------------------------|
* | `shield_duration_ticks` | Long | `160` | Ticks the shield stays active before expiring |
* | `shield_capacity` | Double | `15.0` | Total damage the shield can absorb before breaking |
*/
class VenomKit : Kit()
{
data class ActiveShield(
var remainingCapacity: Double = 15.0,
var remainingCapacity: Double,
val expireTask: BukkitTask,
val particleTask: BukkitTask
)
@@ -49,11 +67,27 @@ class VenomKit : Kit() {
override val icon: Material
get() = Material.SPIDER_EYE
private val kitOverride: CustomGameSettings.KitOverride by lazy {
plugin.customGameManager.settings.kits.kits["venom"]
?: CustomGameSettings.KitOverride()
companion object {
const val DEFAULT_SHIELD_DURATION_TICKS = 160L
const val DEFAULT_SHIELD_CAPACITY = 15.0
}
// ── Live config accessors (typed KitOverride fields) ──────────────────────
/**
* Ticks the shield stays active before expiring automatically.
* Source: typed field `shield_duration_ticks`.
*/
private val shieldDurationTicks: Long
get() = override().shieldDurationTicks
/**
* Total damage the shield absorbs before it breaks.
* Source: typed field `shield_capacity`.
*/
private val shieldCapacity: Double
get() = override().shieldCapacity
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
@@ -112,7 +146,7 @@ class VenomKit : Kit() {
}
}
// ── Optional lifecycle hooks ──────────────────────────────────────────────
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
@@ -122,11 +156,15 @@ class VenomKit : Kit() {
shield.particleTask.cancel()
activeShields.remove( player.uniqueId )
}
val items = cachedItems.remove( player.uniqueId ) ?: return
items.forEach { player.inventory.remove( it ) }
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE ) {
// =========================================================================
// AGGRESSIVE active Deafening Beam
// =========================================================================
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -172,7 +210,12 @@ class VenomKit : Kit() {
}
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE ) {
// =========================================================================
// DEFENSIVE active Shield of Darkness
// =========================================================================
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -198,10 +241,15 @@ class VenomKit : Kit() {
if ( activeShields.containsKey( player.uniqueId ) )
return AbilityResult.ConditionNotMet( "Shield is already active!" )
// Snapshot config values at activation time so a mid-round change
// does not alter an already-running shield's duration or capacity.
val capturedDurationTicks = shieldDurationTicks
val capturedCapacity = shieldCapacity
player.playSound( player.location, Sound.ENTITY_BLAZE_AMBIENT, 1f, 0.5f )
val particleTask = object : BukkitRunnable() {
val particleTask = object : BukkitRunnable()
{
var rotation = 0.0
override fun run()
@@ -233,16 +281,17 @@ class VenomKit : Kit() {
}
}.runTaskTimer( plugin, 0L, 2L )
val expireTask = object : BukkitRunnable() {
val expireTask = object : BukkitRunnable()
{
override fun run()
{
if ( activeShields.containsKey( player.uniqueId ) )
breakShield( player )
}
}.runTaskLater( plugin, kitOverride.shieldDurationTicks )
}.runTaskLater( plugin, capturedDurationTicks )
activeShields[ player.uniqueId ] = ActiveShield(
remainingCapacity = kitOverride.shieldCapacity,
remainingCapacity = capturedCapacity,
expireTask = expireTask,
particleTask = particleTask
)
@@ -253,7 +302,12 @@ class VenomKit : Kit() {
}
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) {
// =========================================================================
// DEFENSIVE passive absorb incoming damage while shield is active
// =========================================================================
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -277,7 +331,12 @@ class VenomKit : Kit() {
}
private class NoPassive : PassiveAbility( Playstyle.AGGRESSIVE ) {
// =========================================================================
// AGGRESSIVE passive stub (no passive ability)
// =========================================================================
private class NoPassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
override val name: String
get() = "None"
@@ -287,7 +346,7 @@ class VenomKit : Kit() {
}
// ── Helper methods ──────────────────────────────────────────────
// ── Shared helpers ────────────────────────────────────────────────────────
private fun breakShield(
player: Player

View File

@@ -1,7 +1,6 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
@@ -26,74 +25,120 @@ import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* ## Voodoo
* ## VoodooKit
*
* | Playstyle | Active | Passive |
* |-------------|-----------------------------------------------------|---------------------------------------------|
* |-------------|----------------------------------------------------------|------------------------------------------------|
* | AGGRESSIVE | Root enemy if HP < 50 % (5 s hold) | 20 % chance to apply Wither on hit |
* | DEFENSIVE | Curse nearby enemies for 15 s (Slow + MiningFatigue)| Speed + Regen while cursed enemies are nearby|
* | DEFENSIVE | Curse nearby enemies for [curseDurationMs] ms | Speed + Regen while cursed enemies are nearby |
*
* ### Root mechanic (AGGRESSIVE)
* Zeros horizontal velocity every tick for 5 s + applies Slowness 127 so the
* Zeroes horizontal velocity every tick for 5 s + applies Slowness 127 so the
* player cannot walk even if the velocity reset misses a frame.
*
* ### Curse mechanic (DEFENSIVE)
* Cursed players are stored in [cursedExpiry] (UUID → expiry timestamp).
* A per-player repeating task (started in [DefensivePassive.onActivate]) checks
* every second: cleans expired curses, applies debuffs to still-cursed enemies,
* and grants Speed + Regen to the Voodoo player if at least one cursed enemy
* is within 10 blocks.
* Cursed players are stored in [cursedExpiry] (UUID → expiry timestamp ms).
* A per-player repeating task checks every second: cleans expired curses,
* applies Slowness + MiningFatigue to still-cursed enemies, and grants
* Speed + Regen to the Voodoo player if at least one cursed enemy is within
* 10 blocks.
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* The curse duration is a **typed field** in [CustomGameSettings.KitOverride]
* and is therefore accessed directly rather than through `extras`.
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |---------------------|------|------------|-----------------------------------------------|
* | `curse_duration_ms` | Long | `15_000` | How long a curse lasts in milliseconds |
*/
class VoodooKit : Kit() {
class VoodooKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id = "voodoo"
override val id: String
get() = "voodoo"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.voodoo.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.voodoo.lore" )
override val icon = Material.WITHER_ROSE
/** Tracks active curses: victim UUID → System.currentTimeMillis() expiry. */
internal val cursedExpiry: MutableMap<UUID, Long> = ConcurrentHashMap()
override val icon: Material
get() = Material.WITHER_ROSE
private val kitOverride: CustomGameSettings.KitOverride by lazy {
plugin.customGameManager.settings.kits.kits["voodoo"]
?: CustomGameSettings.KitOverride()
companion object {
const val DEFAULT_CURSE_DURATION_MS = 15_000L
}
// ── Cached ability instances ──────────────────────────────────────────────
// ── Live config accessor (typed KitOverride field) ────────────────────────
/**
* Duration of each applied curse in milliseconds.
* Source: typed field `curse_duration_ms`.
*/
private val curseDurationMs: Long
get() = override().curseDurationMs
// ── Kit-level state ───────────────────────────────────────────────────────
/** Tracks active curses: victim UUID → expiry timestamp (ms). */
internal val cursedExpiry: MutableMap<UUID, Long> = ConcurrentHashMap()
// ── Cached ability instances (avoid allocating per event call) ────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
override fun getActiveAbility (playstyle: Playstyle) = when (playstyle) {
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(playstyle: Playstyle) = when (playstyle) {
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(player: Player, playstyle: Playstyle) {
val (mat, active) = when (playstyle) {
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
val ( mat, active ) = when( playstyle )
{
Playstyle.AGGRESSIVE -> Material.WITHER_ROSE to aggressiveActive
Playstyle.DEFENSIVE -> Material.SOUL_TORCH to defensiveActive
}
val item = ItemBuilder( mat )
.name( active.name )
.lore(listOf( active.description ))
.build()
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
override fun onRemove(player: Player) {
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
cursedExpiry.remove( player.uniqueId )
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
@@ -102,7 +147,8 @@ class VoodooKit : Kit() {
// AGGRESSIVE active root enemy if below 50 % HP
// =========================================================================
private inner class AggressiveActive : ActiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -111,13 +157,20 @@ class VoodooKit : Kit() {
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.root.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.root.description" )
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial = Material.WITHER_ROSE
override fun execute(player: Player): AbilityResult {
override val triggerMaterial: Material
get() = Material.WITHER_ROSE
override fun execute(
player: Player
): AbilityResult
{
val target = player.getTargetEntity( 6 ) as? Player
?: return AbilityResult.ConditionNotMet( "No player in line of sight!" )
@@ -134,14 +187,18 @@ class VoodooKit : Kit() {
target.addPotionEffect( PotionEffect( PotionEffectType.GLOWING, 5 * 20, 0, false, false, false ) )
// Zero horizontal velocity every tick for 5 seconds (100 ticks)
object : BukkitRunnable() {
object : BukkitRunnable()
{
var ticks = 0
override fun run() {
override fun run()
{
if ( ticks++ >= 100 || !target.isOnline ||
!plugin.gameManager.alivePlayers.contains( target.uniqueId ) )
{
target.removePotionEffect( PotionEffectType.GLOWING )
cancel(); return
cancel()
return
}
val v = target.velocity
target.velocity = v.clone().setX( 0.0 ).setZ( 0.0 )
@@ -151,21 +208,26 @@ class VoodooKit : Kit() {
player.playSound( player.location, Sound.ENTITY_WITHER_SHOOT, 1f, 0.5f )
target.playSound( target.location, Sound.ENTITY_WITHER_HURT, 1f, 0.5f )
target.world.spawnParticle(Particle.SOUL, target.location.clone().add(0.0, 1.0, 0.0),
20, 0.4, 0.6, 0.4, 0.02)
target.world.spawnParticle(
Particle.SOUL,
target.location.clone().add( 0.0, 1.0, 0.0 ),
20, 0.4, 0.6, 0.4, 0.02
)
player.sendActionBar( player.trans( "kits.voodoo.messages.root_activated" ) )
target.sendActionBar( target.trans( "kits.voodoo.messages.root_received" ) )
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active curse nearby enemies
// =========================================================================
private inner class DefensiveActive : ActiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
@@ -174,13 +236,20 @@ class VoodooKit : Kit() {
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.curse.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.items.curse.description" )
override val hardcodedHitsRequired: Int
get() = 10
override val triggerMaterial = Material.SOUL_TORCH
override fun execute(player: Player): AbilityResult {
override val triggerMaterial: Material
get() = Material.SOUL_TORCH
override fun execute(
player: Player
): AbilityResult
{
val targets = player.world
.getNearbyEntities( player.location, 8.0, 8.0, 8.0 )
.filterIsInstance<Player>()
@@ -189,89 +258,129 @@ class VoodooKit : Kit() {
if ( targets.isEmpty() )
return AbilityResult.ConditionNotMet( "No enemies within 8 blocks!" )
val expiry = System.currentTimeMillis() + kitOverride.curseDurationMs
// Snapshot the curse duration at activation time
val capturedCurseDurationMs = curseDurationMs
val expiry = System.currentTimeMillis() + capturedCurseDurationMs
targets.forEach { t ->
cursedExpiry[ t.uniqueId ] = expiry
t.addPotionEffect(PotionEffect(PotionEffectType.GLOWING, 15 * 20, 0, false, true, false))
t.addPotionEffect(
PotionEffect( PotionEffectType.GLOWING, 15 * 20, 0, false, true, false )
)
t.sendActionBar( t.trans( "kits.voodoo.messages.curse_received" ) )
t.world.spawnParticle(Particle.SOUL_FIRE_FLAME, t.location.clone().add(0.0, 1.0, 0.0),
10, 0.3, 0.4, 0.3, 0.05)
t.world.spawnParticle(
Particle.SOUL_FIRE_FLAME,
t.location.clone().add( 0.0, 1.0, 0.0 ),
10, 0.3, 0.4, 0.3, 0.05
)
}
player.playSound( player.location, Sound.ENTITY_WITHER_AMBIENT, 1f, 0.3f )
player.sendActionBar(player.trans("kits.voodoo.messages.curse_cast",
mapOf("count" to targets.size.toString())))
player.sendActionBar(
player.trans(
"kits.voodoo.messages.curse_cast",
mapOf( "count" to targets.size.toString() )
)
)
return AbilityResult.Success
}
}
// =========================================================================
// AGGRESSIVE passive 20 % Wither on hit
// =========================================================================
private inner class AggressivePassive : PassiveAbility(Playstyle.AGGRESSIVE) {
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
private val rng = Random()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.aggressive.description" )
override fun onHitEnemy(attacker: Player, victim: Player, event: EntityDamageByEntityEvent) {
override fun onHitEnemy(
attacker: Player,
victim: Player,
event: EntityDamageByEntityEvent
) {
if ( rng.nextDouble() >= 0.20 ) return
victim.addPotionEffect( PotionEffect( PotionEffectType.WITHER, 3 * 20, 0 ) )
attacker.playSound( attacker.location, Sound.ENTITY_WITHER_AMBIENT, 0.5f, 1.8f )
victim.world.spawnParticle(Particle.SOUL, victim.location.clone().add(0.0, 1.0, 0.0),
5, 0.2, 0.3, 0.2, 0.0)
victim.world.spawnParticle(
Particle.SOUL,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
5, 0.2, 0.3, 0.2, 0.0
)
}
}
// =========================================================================
// DEFENSIVE passive buff while cursed enemies are nearby
// DEFENSIVE passive Speed + Regen while cursed enemies are nearby
// =========================================================================
private inner class DefensivePassive : PassiveAbility(Playstyle.DEFENSIVE) {
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
private val tasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.voodoo.passive.defensive.description" )
override fun onActivate(player: Player) {
val task = object : BukkitRunnable() {
override fun run() {
if (!player.isOnline || !plugin.gameManager.alivePlayers.contains(player.uniqueId)) {
cancel(); return
override fun onActivate(
player: Player
) {
val task = object : BukkitRunnable()
{
override fun run()
{
if ( !player.isOnline || !plugin.gameManager.alivePlayers.contains( player.uniqueId ) )
{
cancel()
return
}
runCatching { tickPassive( player ) }
.onFailure { plugin.logger.severe( "[VoodooKit] tickPassive error: ${it.message}" ) }
}
}.runTaskTimer( plugin, 0L, 20L )
tasks[ player.uniqueId ] = task
}
override fun onDeactivate(player: Player) {
override fun onDeactivate(
player: Player
) {
tasks.remove( player.uniqueId )?.cancel()
}
private fun tickPassive(voodooPlayer: Player) {
private fun tickPassive(
voodooPlayer: Player
) {
val now = System.currentTimeMillis()
// ── Expire stale curses ───────────────────────────────────────────
cursedExpiry.entries.removeIf { ( uuid, expiry ) ->
if (now > expiry) {
if ( now > expiry )
{
Bukkit.getPlayer( uuid )?.removePotionEffect( PotionEffectType.GLOWING )
true
} else false
}
else false
}
// ── Apply debuffs to all still-cursed + alive players ─────────────
// ── Apply debuffs to all still-cursed alive players ───────────────
val cursedNearby = cursedExpiry.keys
.mapNotNull { Bukkit.getPlayer( it ) }
.filter { it.isOnline && plugin.gameManager.alivePlayers.contains( it.uniqueId ) }
@@ -281,11 +390,14 @@ class VoodooKit : Kit() {
}
.filter { it.location.distanceSquared( voodooPlayer.location ) <= 100.0 } // ≤ 10 blocks
// ── Buff voodoo player if cursed enemy nearby ─────────────────────
if (cursedNearby.isNotEmpty()) {
// ── Buff voodoo player if a cursed enemy is nearby ────────────────
if ( cursedNearby.isNotEmpty() )
{
voodooPlayer.addPotionEffect( PotionEffect( PotionEffectType.SPEED, 30, 0 ) )
voodooPlayer.addPotionEffect( PotionEffect( PotionEffectType.REGENERATION, 30, 0 ) )
}
}
}
}

View File

@@ -37,7 +37,7 @@ class ItemBuilder(
name: String
): ItemBuilder
{
itemStack.editMeta { it.displayName(Component.text( name )) }
itemStack.editMeta { it.displayName(mm.deserialize( name )) }
return this
}