Add new perks and kit

4 new perks have been added:
- Ironclad
- Scorch
- Tenacity
- Tracker

1 new kit has been added:
- Alchemist
This commit is contained in:
TDSTOS
2026-04-16 23:26:47 +02:00
parent 400a6c274a
commit f248182e84
8 changed files with 1006 additions and 1 deletions

View File

@@ -38,11 +38,15 @@ import club.mcscrims.speedhg.perk.impl.EvasionPerk
import club.mcscrims.speedhg.perk.impl.FeatherweightPerk import club.mcscrims.speedhg.perk.impl.FeatherweightPerk
import club.mcscrims.speedhg.perk.impl.GhostPerk import club.mcscrims.speedhg.perk.impl.GhostPerk
import club.mcscrims.speedhg.perk.impl.GourmetPerk import club.mcscrims.speedhg.perk.impl.GourmetPerk
import club.mcscrims.speedhg.perk.impl.IroncladPerk
import club.mcscrims.speedhg.perk.impl.LastStandPerk import club.mcscrims.speedhg.perk.impl.LastStandPerk
import club.mcscrims.speedhg.perk.impl.MomentumPerk import club.mcscrims.speedhg.perk.impl.MomentumPerk
import club.mcscrims.speedhg.perk.impl.OraclePerk import club.mcscrims.speedhg.perk.impl.OraclePerk
import club.mcscrims.speedhg.perk.impl.PyromaniacPerk import club.mcscrims.speedhg.perk.impl.PyromaniacPerk
import club.mcscrims.speedhg.perk.impl.ScavengerPerk import club.mcscrims.speedhg.perk.impl.ScavengerPerk
import club.mcscrims.speedhg.perk.impl.ScorchPerk
import club.mcscrims.speedhg.perk.impl.TenacityPerk
import club.mcscrims.speedhg.perk.impl.TrackerPerk
import club.mcscrims.speedhg.perk.impl.VampirePerk import club.mcscrims.speedhg.perk.impl.VampirePerk
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.ranking.RankingManager
@@ -255,11 +259,15 @@ class SpeedHG : JavaPlugin() {
perkManager.registerPerk( FeatherweightPerk() ) perkManager.registerPerk( FeatherweightPerk() )
perkManager.registerPerk( GhostPerk() ) perkManager.registerPerk( GhostPerk() )
perkManager.registerPerk( GourmetPerk() ) perkManager.registerPerk( GourmetPerk() )
perkManager.registerPerk( IroncladPerk() )
perkManager.registerPerk( LastStandPerk() ) perkManager.registerPerk( LastStandPerk() )
perkManager.registerPerk( MomentumPerk() ) perkManager.registerPerk( MomentumPerk() )
perkManager.registerPerk( OraclePerk() ) perkManager.registerPerk( OraclePerk() )
perkManager.registerPerk( PyromaniacPerk() ) perkManager.registerPerk( PyromaniacPerk() )
perkManager.registerPerk( ScavengerPerk() ) perkManager.registerPerk( ScavengerPerk() )
perkManager.registerPerk( ScorchPerk() )
perkManager.registerPerk( TenacityPerk() )
perkManager.registerPerk( TrackerPerk() )
perkManager.registerPerk( VampirePerk() ) perkManager.registerPerk( VampirePerk() )
} }

View File

@@ -0,0 +1,430 @@
package club.mcscrims.speedhg.kit.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.kit.Kit
import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.ability.ActiveAbility
import club.mcscrims.speedhg.kit.ability.PassiveAbility
import club.mcscrims.speedhg.util.ItemBuilder
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Bukkit
import org.bukkit.Color
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.AreaEffectCloud
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.inventory.ItemStack
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
/**
* ## AlchemistKit
*
* A saboteur kit that fights with potions — weakening enemies on hit and
* rewarding kills with unpredictable self-buffs.
*
* | Playstyle | Active Ability | Passive |
* |-------------|---------------------------------------------------------------------|-----------------------------------------------|
* | AGGRESSIVE | **Toxic Flask** — hurls a lingering Poison II cloud at the last-hit enemy's position | **Brew on Kill** — random buff on kill (Speed II / Strength I / Regen I) |
* | DEFENSIVE | **Antidote Burst** — removes all negative effects, grants Resistance I | **Toxic Skin** — every attacker receives Poison I for 3 s |
*
* ## Konfiguration (via `SPEEDHG_CUSTOM_SETTINGS`)
*
* | JSON-Schlüssel | Typ | Default | Beschreibung |
* |-----------------------------|--------|-----------|------------------------------------------------|
* | `flask_cloud_duration` | Int | `60` | Duration of the Toxic Flask cloud in ticks |
* | `flask_cloud_radius` | Double | `3.0` | Radius of the Toxic Flask cloud in blocks |
* | `flask_hit_window_ms` | Long | `10_000` | How long a last-hit target stays valid (ms) |
* | `brew_buff_duration_ticks` | Int | `100` | Duration of the Brew on Kill random buff |
* | `antidote_resistance_ticks` | Int | `80` | Duration of Resistance I after Antidote Burst |
* | `toxic_skin_poison_ticks` | Int | `60` | Duration of Poison I applied to attackers |
*
* ### Toxic Flask Mechanic
* The `lastHitEnemy` map stores the UUID and timestamp of the last player the
* Alchemist hit in melee. On ability activation, the cloud is spawned at the
* **enemy's current location** if they are still alive and within the hit window.
* If the window has expired, [AbilityResult.ConditionNotMet] is returned and
* the charge is refunded automatically.
*
* ### Brew on Kill Mechanic
* On each kill, one of three buffs is selected at random with equal probability:
* - **Speed II** — for fast repositioning after a kill
* - **Strength I** — for immediate follow-up pressure
* - **Regeneration I** — for sustain in multi-fight scenarios
*
* ### Toxic Skin Mechanic
* Triggered via [onHitByEnemy]. Every attacker that lands a melee hit on the
* Defensive Alchemist receives Poison I. No cooldown — intentional, as Poison
* cannot stack and only refreshes the duration.
*/
class AlchemistKit : Kit()
{
private val plugin get() = SpeedHG.instance
override val id: String
get() = "alchemist"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "kits.alchemist.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "kits.alchemist.lore" )
override val icon: Material
get() = Material.BREWING_STAND
// ── Internal state ────────────────────────────────────────────────────────
/** alchemistUUID → ( enemyUUID, timestamp-ms of the last qualifying melee hit ) */
internal val lastHitEnemy: MutableMap<UUID, Pair<UUID, Long>> = ConcurrentHashMap()
// ── Defaults ─────────────────────────────────────────────────────────────
companion object {
const val DEFAULT_FLASK_CLOUD_DURATION = 60 // 3 seconds
const val DEFAULT_FLASK_CLOUD_RADIUS = 3.0
const val DEFAULT_FLASK_HIT_WINDOW_MS = 10_000L
const val DEFAULT_BREW_BUFF_DURATION_TICKS = 100 // 5 seconds
const val DEFAULT_ANTIDOTE_RESISTANCE_TICKS = 80 // 4 seconds
const val DEFAULT_TOXIC_SKIN_POISON_TICKS = 60 // 3 seconds
}
// ── Live config accessors ─────────────────────────────────────────────────
private val flaskCloudDuration: Int
get() = override().getInt( "flask_cloud_duration" ) ?: DEFAULT_FLASK_CLOUD_DURATION
private val flaskCloudRadius: Double
get() = override().getDouble( "flask_cloud_radius" ) ?: DEFAULT_FLASK_CLOUD_RADIUS
private val flaskHitWindowMs: Long
get() = override().getLong( "flask_hit_window_ms" ) ?: DEFAULT_FLASK_HIT_WINDOW_MS
private val brewBuffDurationTicks: Int
get() = override().getInt( "brew_buff_duration_ticks" ) ?: DEFAULT_BREW_BUFF_DURATION_TICKS
private val antidoteResistanceTicks: Int
get() = override().getInt( "antidote_resistance_ticks" ) ?: DEFAULT_ANTIDOTE_RESISTANCE_TICKS
private val toxicSkinPoisonTicks: Int
get() = override().getInt( "toxic_skin_poison_ticks" ) ?: DEFAULT_TOXIC_SKIN_POISON_TICKS
// ── Cached ability instances ──────────────────────────────────────────────
private val aggressiveActive = AggressiveActive()
private val defensiveActive = DefensiveActive()
private val aggressivePassive = AggressivePassive()
private val defensivePassive = DefensivePassive()
// ── Playstyle routing ─────────────────────────────────────────────────────
override fun getActiveAbility(
playstyle: Playstyle
): ActiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressiveActive
Playstyle.DEFENSIVE -> defensiveActive
}
override fun getPassiveAbility(
playstyle: Playstyle
): PassiveAbility = when( playstyle )
{
Playstyle.AGGRESSIVE -> aggressivePassive
Playstyle.DEFENSIVE -> defensivePassive
}
// ── Item distribution ─────────────────────────────────────────────────────
override val cachedItems = ConcurrentHashMap<UUID, List<ItemStack>>()
override fun giveItems(
player: Player,
playstyle: Playstyle
) {
val item = when( playstyle )
{
Playstyle.AGGRESSIVE -> ItemBuilder( Material.SPLASH_POTION )
.name( aggressiveActive.name )
.lore( listOf( aggressiveActive.description ) )
.build()
Playstyle.DEFENSIVE -> ItemBuilder( Material.MILK_BUCKET )
.name( defensiveActive.name )
.lore( listOf( defensiveActive.description ) )
.build()
}
cachedItems[ player.uniqueId ] = listOf( item )
player.inventory.addItem( item )
}
// ── Lifecycle hooks ───────────────────────────────────────────────────────
override fun onRemove(
player: Player
) {
lastHitEnemy.remove( player.uniqueId )
cachedItems.remove( player.uniqueId )?.forEach { player.inventory.remove( it ) }
}
// ── Last-hit tracking (dispatched by KitEventDispatcher) ─────────────────
// Called externally from KitEventDispatcher — see note in onMeleeHit
fun trackHit(
attacker: Player,
victim: Player
) {
lastHitEnemy[ attacker.uniqueId ] = Pair( victim.uniqueId, System.currentTimeMillis() )
}
// =========================================================================
// AGGRESSIVE active — Toxic Flask
// =========================================================================
private inner class AggressiveActive : ActiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId: String
get() = "alchemist"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.items.flask.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.items.flask.description" )
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial: Material
get() = Material.SPLASH_POTION
override fun execute(
player: Player
): AbilityResult
{
val now = System.currentTimeMillis()
val pair = lastHitEnemy[ player.uniqueId ]
?: run {
player.sendActionBar( player.trans( "kits.alchemist.messages.no_target" ) )
return AbilityResult.ConditionNotMet( "no_target" )
}
val ( enemyUUID, hitTime ) = pair
if ( now - hitTime > flaskHitWindowMs )
{
lastHitEnemy.remove( player.uniqueId )
player.sendActionBar( player.trans( "kits.alchemist.messages.target_expired" ) )
return AbilityResult.ConditionNotMet( "target_expired" )
}
val enemy = Bukkit.getPlayer( enemyUUID ) ?: run {
player.sendActionBar( player.trans( "kits.alchemist.messages.no_target" ) )
return AbilityResult.ConditionNotMet( "target_offline" )
}
if ( !plugin.gameManager.alivePlayers.contains( enemy.uniqueId ) )
{
player.sendActionBar( player.trans( "kits.alchemist.messages.no_target" ) )
return AbilityResult.ConditionNotMet( "target_dead" )
}
// Snapshot config values at activation time
val capturedRadius = flaskCloudRadius
val capturedDuration = flaskCloudDuration
// Spawn lingering Poison II cloud at the enemy's current position
val cloudLocation = enemy.location.clone().add( 0.0, 0.1, 0.0 )
val cloud = enemy.world.spawn( cloudLocation, AreaEffectCloud::class.java ) { aec ->
aec.duration = capturedDuration
aec.radius = capturedRadius.toFloat()
aec.radiusOnUse = -0.05f
aec.radiusPerTick = -( capturedRadius.toFloat() / capturedDuration.toFloat() )
aec.reapplicationDelay = 20
aec.color = Color.fromRGB( 0x4A, 0xC2, 0x1A ) // toxic green
aec.addCustomEffect(
PotionEffect( PotionEffectType.POISON, 3 * 20, 1, false, true, true ),
true
)
aec.source = player
}
// Visual + audio feedback
cloudLocation.world.spawnParticle(
Particle.ENCHANT,
cloudLocation.clone().add( 0.0, 0.5, 0.0 ),
30, capturedRadius * 0.5, 0.3, capturedRadius * 0.5, 0.02
)
player.playSound( player.location, Sound.ENTITY_SPLASH_POTION_THROW, 0.9f, 0.7f )
player.playSound( player.location, Sound.ENTITY_SPLASH_POTION_BREAK, 0.8f, 0.8f )
player.sendActionBar( player.trans( "kits.alchemist.messages.flask_thrown" ) )
return AbilityResult.Success
}
}
// =========================================================================
// DEFENSIVE active — Antidote Burst
// =========================================================================
private inner class DefensiveActive : ActiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val kitId: String
get() = "alchemist"
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.items.antidote.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.items.antidote.description" )
override val hardcodedHitsRequired: Int
get() = 15
override val triggerMaterial: Material
get() = Material.MILK_BUCKET
override fun execute(
player: Player
): AbilityResult
{
val capturedResistanceTicks = antidoteResistanceTicks
// Remove all active negative effects
NEGATIVE_EFFECTS.forEach { player.removePotionEffect( it ) }
// Grant Resistance I
player.addPotionEffect(
PotionEffect( PotionEffectType.RESISTANCE, capturedResistanceTicks, 0, false, false, true )
)
// Visual: white cleanse burst
player.world.spawnParticle(
Particle.ENCHANT,
player.location.clone().add( 0.0, 1.0, 0.0 ),
25, 0.4, 0.6, 0.4, 0.1
)
player.playSound( player.location, Sound.ENTITY_GENERIC_DRINK, 0.9f, 1.3f )
player.playSound( player.location, Sound.BLOCK_ENCHANTMENT_TABLE_USE, 0.5f, 1.8f )
player.sendActionBar( player.trans( "kits.alchemist.messages.antidote_used" ) )
return AbilityResult.Success
}
private val NEGATIVE_EFFECTS = listOf(
PotionEffectType.POISON,
PotionEffectType.WITHER,
PotionEffectType.SLOWNESS,
PotionEffectType.WEAKNESS,
PotionEffectType.BLINDNESS,
PotionEffectType.NAUSEA,
PotionEffectType.MINING_FATIGUE,
PotionEffectType.HUNGER
)
}
// =========================================================================
// AGGRESSIVE passive — Brew on Kill
// =========================================================================
private inner class AggressivePassive : PassiveAbility( Playstyle.AGGRESSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.passive.aggressive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.passive.aggressive.description" )
override fun onKillEnemy(
killer: Player,
victim: Player
) {
val capturedDuration = brewBuffDurationTicks
// Pick a random brew — equal 1/3 probability each
val roll = Random.nextInt( 3 )
val ( effectType, amplifier, messageKey ) = when( roll )
{
0 -> Triple( PotionEffectType.SPEED, 1, "kits.alchemist.messages.brew_speed" )
1 -> Triple( PotionEffectType.STRENGTH, 0, "kits.alchemist.messages.brew_strength" )
else -> Triple( PotionEffectType.REGENERATION, 0, "kits.alchemist.messages.brew_regen" )
}
killer.addPotionEffect(
PotionEffect( effectType, capturedDuration, amplifier, false, true, true )
)
killer.world.spawnParticle(
Particle.ENCHANT,
killer.location.clone().add( 0.0, 1.5, 0.0 ),
15, 0.3, 0.4, 0.3, 0.05
)
killer.playSound( killer.location, Sound.ENTITY_GENERIC_DRINK, 0.8f, 1.5f )
killer.sendActionBar( killer.trans( messageKey ) )
}
}
// =========================================================================
// DEFENSIVE passive — Toxic Skin
// =========================================================================
private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE )
{
private val plugin get() = SpeedHG.instance
override val name: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.passive.defensive.name" )
override val description: String
get() = plugin.languageManager.getDefaultRawMessage( "kits.alchemist.passive.defensive.description" )
override fun onHitByEnemy(
victim: Player,
attacker: Player,
event: EntityDamageByEntityEvent
) {
val capturedPoisonTicks = toxicSkinPoisonTicks
attacker.addPotionEffect(
PotionEffect( PotionEffectType.POISON, capturedPoisonTicks, 0, false, true, true )
)
// Brief green particle puff on the victim to show the proc
victim.world.spawnParticle(
Particle.ENCHANT,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
6, 0.2, 0.3, 0.2, 0.02
)
victim.playSound( victim.location, Sound.ENTITY_SLIME_HURT, 0.5f, 1.8f )
victim.sendActionBar( victim.trans( "kits.alchemist.messages.toxic_skin_proc" ) )
}
}
}

View File

@@ -7,6 +7,7 @@ import club.mcscrims.speedhg.kit.KitMetaData
import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.Playstyle
import club.mcscrims.speedhg.kit.ability.AbilityResult import club.mcscrims.speedhg.kit.ability.AbilityResult
import club.mcscrims.speedhg.kit.charge.ChargeState import club.mcscrims.speedhg.kit.charge.ChargeState
import club.mcscrims.speedhg.kit.impl.AlchemistKit
import club.mcscrims.speedhg.kit.impl.AnchorKit import club.mcscrims.speedhg.kit.impl.AnchorKit
import club.mcscrims.speedhg.kit.impl.BlackPantherKit import club.mcscrims.speedhg.kit.impl.BlackPantherKit
import club.mcscrims.speedhg.kit.impl.IceMageKit import club.mcscrims.speedhg.kit.impl.IceMageKit
@@ -125,6 +126,13 @@ class KitEventDispatcher(
Pair( victim.uniqueId, System.currentTimeMillis() ) Pair( victim.uniqueId, System.currentTimeMillis() )
} }
// ── 2. Alchemist hit tracking ────────────────────────────────────────
if ( attackerKit is AlchemistKit &&
attackerPlaystyle == Playstyle.AGGRESSIVE )
{
attackerKit.trackHit( attacker, victim )
}
// ── 3. Attacker passive hook ───────────────────────────────────────── // ── 3. Attacker passive hook ─────────────────────────────────────────
attackerKit.getPassiveAbility( attackerPlaystyle ) attackerKit.getPassiveAbility( attackerPlaystyle )
.onHitEnemy( attacker, victim, event ) .onHitEnemy( attacker, victim, event )
@@ -167,8 +175,9 @@ class KitEventDispatcher(
val active = kit.getActiveAbility( playstyle ) val active = kit.getActiveAbility( playstyle )
// Allow throwable items (potions, ender pearls, etc.) to pass through // Allow throwable items (potions, ender pearls, etc.) to pass through
if ( itemInHand.type == Material.SPLASH_POTION || if ( itemInHand.type == Material.SPLASH_POTION ||
itemInHand.type == Material.LINGERING_POTION || itemInHand.type == Material.LINGERING_POTION ||
itemInHand.type == Material.MILK_BUCKET ||
itemInHand.type == Material.ENDER_PEARL ) return itemInHand.type == Material.ENDER_PEARL ) return
if ( itemInHand.type != active.triggerMaterial ) return if ( itemInHand.type != active.triggerMaterial ) return

View File

@@ -0,0 +1,133 @@
package club.mcscrims.speedhg.perk.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.perk.Perk
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## Ironclad
*
* Every incoming melee hit briefly grants **Resistance I** to the player.
* An internal per-player cooldown prevents the effect from being refreshed
* on every rapid successive hit, preserving balance in fast combat.
*
* ### Mechanic Summary
* | Property | Default | Config key |
* |-----------------------|----------------|-------------------------------|
* | Resistance duration | `30` (1.5 s) | `ironclad_resistance_ticks` |
* | Proc cooldown | `1_000` ms | `ironclad_cooldown_ms` |
*
* ### Technical Notes
* The effect is applied with `force = true` so an already-running Resistance I
* instance is overwritten and the duration is refreshed on each valid proc.
* On [onDeactivate] the effect is explicitly removed via `removePotionEffect`
* to ensure no residual buff lingers after the round ends.
*
* The cooldown is tracked per-UUID in [lastProc] using `System.currentTimeMillis()`.
* The map is cleaned up in [onDeactivate].
*/
class IroncladPerk : Perk()
{
private val plugin get() = SpeedHG.instance
override val id = "ironclad"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "perks.ironclad.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "perks.ironclad.lore" )
override val icon = Material.IRON_CHESTPLATE
/** UUIDs for which this perk is currently active. */
private val activePlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
/** UUID → timestamp (ms) of the last Resistance proc. */
private val lastProc: MutableMap<UUID, Long> = ConcurrentHashMap()
// ── Defaults ─────────────────────────────────────────────────────────────
companion object {
const val DEFAULT_RESISTANCE_TICKS = 30 // 1.5 seconds
const val DEFAULT_COOLDOWN_MS = 1_000L // 1 second
}
// ── Live config accessors ─────────────────────────────────────────────────
private val extras
get() = plugin.customGameManager.settings.kits.kits[ id ]
private val resistanceTicks: Int
get() = extras?.getInt( "ironclad_resistance_ticks" ) ?: DEFAULT_RESISTANCE_TICKS
private val cooldownMs: Long
get() = extras?.getLong( "ironclad_cooldown_ms" ) ?: DEFAULT_COOLDOWN_MS
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onActivate(
player: Player
) {
activePlayers += player.uniqueId
}
override fun onDeactivate(
player: Player
) {
activePlayers -= player.uniqueId
lastProc.remove( player.uniqueId )
player.removePotionEffect( PotionEffectType.RESISTANCE )
}
// ── Combat hook ───────────────────────────────────────────────────────────
override fun onHitByEnemy(
victim: Player,
attacker: Player,
event: EntityDamageByEntityEvent
) {
if ( !activePlayers.contains( victim.uniqueId ) ) return
val now = System.currentTimeMillis()
val lastUsed = lastProc[ victim.uniqueId ] ?: 0L
if ( now - lastUsed < cooldownMs ) return
lastProc[ victim.uniqueId ] = now
val capturedTicks = resistanceTicks
victim.addPotionEffect(
PotionEffect( PotionEffectType.RESISTANCE, capturedTicks, 0, false, false, true )
)
victim.world.spawnParticle(
Particle.BLOCK,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
10,
0.3, 0.4, 0.3,
0.05,
Material.IRON_BLOCK.createBlockData()
)
victim.playSound(
victim.location,
Sound.ITEM_ARMOR_EQUIP_IRON,
0.6f,
1.2f
)
victim.sendActionBar( victim.trans( "perks.ironclad.message" ) )
}
}

View File

@@ -0,0 +1,135 @@
package club.mcscrims.speedhg.perk.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.perk.Perk
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## Scorch
*
* Melee hits against enemies standing in **direct sunlight** deal bonus damage.
* This subtly influences positioning — enemies are incentivized to fight in
* shade or indoors.
*
* ### Mechanic Summary
* | Property | Default | Config key |
* |------------------|---------|------------------------|
* | Bonus damage | `1.5` | `scorch_bonus_damage` |
*
* ### Sunlight Detection
* A target is considered to be in direct sunlight when **all** of the following
* conditions are met:
*
* 1. `victim.location.block.lightFromSky >= 15` — no overhead block occlusion.
* 2. `!victim.world.hasStorm()` — no rain or thunderstorm active.
* 3. The world environment is `NORMAL` — Nether and End are excluded.
*
* ### Technical Notes
* This perk is **stateless** — it requires no `onActivate`/`onDeactivate` tracking
* beyond the `activePlayers` set. The damage bonus is applied by directly modifying
* `event.damage` inside `onHitEnemy`, which runs at MONITOR priority in the
* [PerkEventDispatcher], ensuring it stacks correctly with other modifiers.
*/
class ScorchPerk : Perk()
{
private val plugin get() = SpeedHG.instance
override val id = "scorch"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "perks.scorch.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "perks.scorch.lore" )
override val icon = Material.BLAZE_POWDER
/** UUIDs for which this perk is currently active. */
private val activePlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
// ── Defaults ─────────────────────────────────────────────────────────────
companion object {
const val DEFAULT_BONUS_DAMAGE = 1.5
}
// ── Live config accessor ──────────────────────────────────────────────────
private val extras
get() = plugin.customGameManager.settings.kits.kits[ id ]
private val bonusDamage: Double
get() = extras?.getDouble( "scorch_bonus_damage" ) ?: DEFAULT_BONUS_DAMAGE
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onActivate(
player: Player
) {
activePlayers += player.uniqueId
}
override fun onDeactivate(
player: Player
) {
activePlayers -= player.uniqueId
}
// ── Combat hook ───────────────────────────────────────────────────────────
override fun onHitEnemy(
attacker: Player,
victim: Player,
event: EntityDamageByEntityEvent
) {
if ( !activePlayers.contains( attacker.uniqueId ) ) return
if ( !isInSunlight( victim ) ) return
val capturedBonus = bonusDamage
event.damage += capturedBonus
// Subtle flame particle burst on the victim to confirm the proc
victim.world.spawnParticle(
Particle.FLAME,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
6, 0.2, 0.3, 0.2, 0.04
)
attacker.playSound(
attacker.location,
Sound.ENTITY_BLAZE_SHOOT,
0.4f,
1.6f
)
attacker.sendActionBar( attacker.trans( "perks.scorch.message" ) )
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Returns `true` if [player] is standing in direct sunlight.
*
* Conditions:
* - Sky light level at the player's block position is at maximum (`>= 15`)
* - No active storm in the world
* - World environment is `NORMAL` (excludes Nether and End)
*/
private fun isInSunlight(
player: Player
): Boolean
{
val world = player.world
if ( world.environment != org.bukkit.World.Environment.NORMAL ) return false
if ( world.hasStorm() ) return false
return player.location.block.lightFromSky >= 15
}
}

View File

@@ -0,0 +1,110 @@
package club.mcscrims.speedhg.perk.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.perk.Perk
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.event.entity.EntityDamageByEntityEvent
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## Tenacity
*
* Every incoming melee hit counts as an additional charge-hit for the player's
* kit active ability. The harder you are pressured, the faster your ability loads.
*
* ### Mechanic Summary
* | Property | Default | Config key |
* |----------------------|---------|------------|
* | Extra hits per hit | `1` | — |
*
* The bonus hit is registered via [KitManager.getChargeData] and only applied
* while the ability is in `CHARGING` state — i.e. it has no effect when the
* ability is already `READY`, preventing "wasted" charges.
*
* ### Technical Notes
* This perk intentionally has no configurable value — the mechanic is binary
* (exactly 1 bonus hit per incoming melee hit). The natural balancing comes from
* the kit's own `hitsRequired` value: a kit with 15 hits required benefits
* significantly, while a kit with 5 hits required barely notices the bonus.
*
* Kits that use `NoActive` (e.g. [BackupKit]) have `hitsRequired == 0` and
* therefore always return `ChargeState.READY` — [PlayerChargeData.registerHit]
* is a no-op in this case.
*/
class TenacityPerk : Perk()
{
private val plugin get() = SpeedHG.instance
override val id = "tenacity"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "perks.tenacity.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "perks.tenacity.lore" )
override val icon = Material.ANVIL
/** UUIDs for which this perk is currently active. */
private val activePlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onActivate(
player: Player
) {
activePlayers += player.uniqueId
}
override fun onDeactivate(
player: Player
) {
activePlayers -= player.uniqueId
}
// ── Combat hook ───────────────────────────────────────────────────────────
override fun onHitByEnemy(
victim: Player,
attacker: Player,
event: EntityDamageByEntityEvent
) {
if ( !activePlayers.contains( victim.uniqueId ) ) return
val chargeData = plugin.kitManager.getChargeData( victim ) ?: return
// Only register if still charging — no effect when ability is already ready
if ( chargeData.isReady ) return
val justCharged = chargeData.registerHit()
if ( justCharged )
{
// Ability just became ready via Tenacity — give visual + audio feedback
victim.sendActionBar( victim.trans( "kits.ability_charged" ) )
victim.playSound( victim.location, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.7f, 1.4f )
victim.world.spawnParticle(
Particle.CRIT,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
8, 0.3, 0.3, 0.3, 0.05
)
}
else
{
// Subtle spark to confirm the bonus hit registered
victim.world.spawnParticle(
Particle.CRIT,
victim.location.clone().add( 0.0, 1.0, 0.0 ),
3, 0.2, 0.2, 0.2, 0.0
)
}
}
}

View File

@@ -0,0 +1,148 @@
package club.mcscrims.speedhg.perk.impl
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.perk.Perk
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.Component
import org.bukkit.Bukkit
import org.bukkit.Material
import org.bukkit.Particle
import org.bukkit.Sound
import org.bukkit.entity.Player
import org.bukkit.scheduler.BukkitTask
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* ## Tracker
*
* On kill, all remaining alive players are highlighted with the **Glowing** effect
* for a short duration — visible only to the killer.
*
* ### Mechanic Summary
* | Property | Default | Config key |
* |------------------|---------------|------------------------|
* | Glow duration | `100` (5 s) | `tracker_glow_ticks` |
*
* ### Technical Notes
* Glowing is applied server-side via `Player.addPotionEffect(GLOWING)`. Because
* Bukkit's glowing effect is visible to **all** players, we instead use the
* `Player.showPlayer` / `Player.hidePlayer` approach combined with a per-killer
* team that has the glow flag set — this keeps the highlight client-side only.
*
* Since a full scoreboard-team solution adds significant overhead, this
* implementation uses the simpler `PotionEffectType.GLOWING` approach and
* accepts that the glow is briefly visible to all. For a truly private glow,
* a per-player scoreboard team would be required.
*
* Active glow tasks are stored in [glowTasks] and cancelled in [onDeactivate]
* to guarantee cleanup even if the round ends before the timer expires.
*/
class TrackerPerk : Perk()
{
private val plugin get() = SpeedHG.instance
override val id = "tracker"
override val displayName: Component
get() = plugin.languageManager.getDefaultComponent( "perks.tracker.name", mapOf() )
override val lore: List<String>
get() = plugin.languageManager.getDefaultRawMessageList( "perks.tracker.lore" )
override val icon = Material.COMPASS
/** UUID of killer → running glow-removal task. */
private val glowTasks: MutableMap<UUID, BukkitTask> = ConcurrentHashMap()
/** UUIDs that currently have the glowing effect applied by this perk. */
private val glowedPlayers: MutableSet<UUID> = ConcurrentHashMap.newKeySet()
// ── Defaults ─────────────────────────────────────────────────────────────
companion object {
const val DEFAULT_GLOW_TICKS = 5 * 20
}
// ── Live config accessor ──────────────────────────────────────────────────
private val extras
get() = plugin.customGameManager.settings.kits.kits[ id ]
private val glowTicks: Int
get() = extras?.getInt( "tracker_glow_ticks" ) ?: DEFAULT_GLOW_TICKS
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onActivate(
player: Player
) {}
override fun onDeactivate(
player: Player
) {
// Cancel any pending removal task for this player
glowTasks.remove( player.uniqueId )?.cancel()
// Remove glowing from all targets this player may have highlighted
removeAllGlows()
}
// ── Kill hook ─────────────────────────────────────────────────────────────
override fun onKillEnemy(
killer: Player,
victim: Player
) {
// Cancel any already-running glow task for this killer (consecutive kills)
glowTasks.remove( killer.uniqueId )?.cancel()
val targets = plugin.gameManager.alivePlayers
.mapNotNull { Bukkit.getPlayer( it ) }
.filter { it.uniqueId != killer.uniqueId }
if ( targets.isEmpty() ) return
val capturedGlowTicks = glowTicks
// Apply glowing to all alive enemies
targets.forEach { target ->
target.isGlowing = true
glowedPlayers += target.uniqueId
}
killer.sendActionBar( killer.trans( "perks.tracker.message" ) )
killer.playSound( killer.location, Sound.BLOCK_NOTE_BLOCK_PLING, 0.8f, 1.4f )
killer.world.spawnParticle(
Particle.ENCHANT,
killer.location.clone().add( 0.0, 1.5, 0.0 ),
20, 0.4, 0.4, 0.4, 0.8
)
// Schedule glow removal
val task = Bukkit.getScheduler().runTaskLater( plugin, { ->
targets.forEach { target ->
if ( target.isOnline )
{
target.isGlowing = false
glowedPlayers -= target.uniqueId
}
}
glowTasks.remove( killer.uniqueId )
}, capturedGlowTicks.toLong() )
glowTasks[ killer.uniqueId ] = task
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun removeAllGlows()
{
glowedPlayers.toList().forEach { uuid ->
Bukkit.getPlayer( uuid )?.isGlowing = false
}
glowedPlayers.clear()
}
}

View File

@@ -411,6 +411,38 @@ perks:
- '<gray>incoming projectiles.</gray>' - '<gray>incoming projectiles.</gray>'
message: '<aqua>💨 Evaded! The projectile missed you!</aqua>' message: '<aqua>💨 Evaded! The projectile missed you!</aqua>'
tracker:
name: '<gradient:gold:yellow><bold>Tracker</bold></gradient>'
lore:
- ' '
- '<gray>On kill: all remaining enemies</gray>'
- '<gray>are highlighted with <yellow>Glowing</yellow> for 5 s.</gray>'
message: '<gold>👁 Tracking all enemies!</gold>'
tenacity:
name: '<gradient:aqua:white><bold>Tenacity</bold></gradient>'
lore:
- ' '
- '<gray>Every hit you <red>receive</red> counts as</gray>'
- '<gray>an extra <yellow>charge hit</yellow> for your ability.</gray>'
scorch:
name: '<gradient:gold:red><bold>Scorch</bold></gradient>'
lore:
- ' '
- '<gray>Hitting enemies in <yellow>direct sunlight</yellow></gray>'
- '<gray>deals <red>+1.5</red> bonus damage.</gray>'
message: '<gold>☀ Scorch!</gold>'
ironclad:
name: '<gradient:white:gray><bold>Ironclad</bold></gradient>'
lore:
- ' '
- '<gray>Taking a melee hit grants</gray>'
- '<white>Resistance I</white> <gray>for 1.5 s.</gray>'
- '<dark_gray>(1 s cooldown)</dark_gray>'
message: '<gray>🛡 Ironclad!</gray>'
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
# SpeedHG · en_US.yml — kits section (overhauled) # SpeedHG · en_US.yml — kits section (overhauled)
# #