From f248182e84c0000ff3e254cddb96057f8e13bcd8 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Thu, 16 Apr 2026 23:26:47 +0200 Subject: [PATCH] Add new perks and kit 4 new perks have been added: - Ironclad - Scorch - Tenacity - Tracker 1 new kit has been added: - Alchemist --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 8 + .../mcscrims/speedhg/kit/impl/AlchemistKit.kt | 430 ++++++++++++++++++ .../kit/listener/KitEventDispatcher.kt | 11 +- .../speedhg/perk/impl/IroncladPerk.kt | 133 ++++++ .../mcscrims/speedhg/perk/impl/ScorchPerk.kt | 135 ++++++ .../speedhg/perk/impl/TenacityPerk.kt | 110 +++++ .../mcscrims/speedhg/perk/impl/TrackerPerk.kt | 148 ++++++ src/main/resources/languages/en_US.yml | 32 ++ 8 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/kit/impl/AlchemistKit.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/perk/impl/IroncladPerk.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScorchPerk.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/perk/impl/TenacityPerk.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/perk/impl/TrackerPerk.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 1e9d425..f10f26a 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -38,11 +38,15 @@ import club.mcscrims.speedhg.perk.impl.EvasionPerk import club.mcscrims.speedhg.perk.impl.FeatherweightPerk import club.mcscrims.speedhg.perk.impl.GhostPerk 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.MomentumPerk import club.mcscrims.speedhg.perk.impl.OraclePerk import club.mcscrims.speedhg.perk.impl.PyromaniacPerk 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.listener.PerkEventDispatcher import club.mcscrims.speedhg.ranking.RankingManager @@ -255,11 +259,15 @@ class SpeedHG : JavaPlugin() { perkManager.registerPerk( FeatherweightPerk() ) perkManager.registerPerk( GhostPerk() ) perkManager.registerPerk( GourmetPerk() ) + perkManager.registerPerk( IroncladPerk() ) perkManager.registerPerk( LastStandPerk() ) perkManager.registerPerk( MomentumPerk() ) perkManager.registerPerk( OraclePerk() ) perkManager.registerPerk( PyromaniacPerk() ) perkManager.registerPerk( ScavengerPerk() ) + perkManager.registerPerk( ScorchPerk() ) + perkManager.registerPerk( TenacityPerk() ) + perkManager.registerPerk( TrackerPerk() ) perkManager.registerPerk( VampirePerk() ) } diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AlchemistKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AlchemistKit.kt new file mode 100644 index 0000000..f7c24ac --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/AlchemistKit.kt @@ -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 + 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> = 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>() + + 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" ) ) + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt index e5d4180..f02d77d 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/listener/KitEventDispatcher.kt @@ -7,6 +7,7 @@ import club.mcscrims.speedhg.kit.KitMetaData import club.mcscrims.speedhg.kit.Playstyle import club.mcscrims.speedhg.kit.ability.AbilityResult 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.BlackPantherKit import club.mcscrims.speedhg.kit.impl.IceMageKit @@ -125,6 +126,13 @@ class KitEventDispatcher( Pair( victim.uniqueId, System.currentTimeMillis() ) } + // ── 2. Alchemist hit tracking ──────────────────────────────────────── + if ( attackerKit is AlchemistKit && + attackerPlaystyle == Playstyle.AGGRESSIVE ) + { + attackerKit.trackHit( attacker, victim ) + } + // ── 3. Attacker passive hook ───────────────────────────────────────── attackerKit.getPassiveAbility( attackerPlaystyle ) .onHitEnemy( attacker, victim, event ) @@ -167,8 +175,9 @@ class KitEventDispatcher( val active = kit.getActiveAbility( playstyle ) // 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.MILK_BUCKET || itemInHand.type == Material.ENDER_PEARL ) return if ( itemInHand.type != active.triggerMaterial ) return diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/IroncladPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/IroncladPerk.kt new file mode 100644 index 0000000..6fde187 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/IroncladPerk.kt @@ -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 + 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 = ConcurrentHashMap.newKeySet() + + /** UUID → timestamp (ms) of the last Resistance proc. */ + private val lastProc: MutableMap = 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" ) ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScorchPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScorchPerk.kt new file mode 100644 index 0000000..55a54db --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/ScorchPerk.kt @@ -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 + 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 = 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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TenacityPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TenacityPerk.kt new file mode 100644 index 0000000..02976e9 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TenacityPerk.kt @@ -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 + get() = plugin.languageManager.getDefaultRawMessageList( "perks.tenacity.lore" ) + + override val icon = Material.ANVIL + + /** UUIDs for which this perk is currently active. */ + private val activePlayers: MutableSet = 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 + ) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TrackerPerk.kt b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TrackerPerk.kt new file mode 100644 index 0000000..fc2e67c --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/perk/impl/TrackerPerk.kt @@ -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 + get() = plugin.languageManager.getDefaultRawMessageList( "perks.tracker.lore" ) + + override val icon = Material.COMPASS + + /** UUID of killer → running glow-removal task. */ + private val glowTasks: MutableMap = ConcurrentHashMap() + + /** UUIDs that currently have the glowing effect applied by this perk. */ + private val glowedPlayers: MutableSet = 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() + } + +} \ No newline at end of file diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 6305d1f..96d3949 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -411,6 +411,38 @@ perks: - 'incoming projectiles.' message: '💨 Evaded! The projectile missed you!' + tracker: + name: 'Tracker' + lore: + - ' ' + - 'On kill: all remaining enemies' + - 'are highlighted with Glowing for 5 s.' + message: '👁 Tracking all enemies!' + + tenacity: + name: 'Tenacity' + lore: + - ' ' + - 'Every hit you receive counts as' + - 'an extra charge hit for your ability.' + + scorch: + name: 'Scorch' + lore: + - ' ' + - 'Hitting enemies in direct sunlight' + - 'deals +1.5 bonus damage.' + message: '☀ Scorch!' + + ironclad: + name: 'Ironclad' + lore: + - ' ' + - 'Taking a melee hit grants' + - 'Resistance I for 1.5 s.' + - '(1 s cooldown)' + message: '🛡 Ironclad!' + # ══════════════════════════════════════════════════════════════════════════════ # SpeedHG · en_US.yml — kits section (overhauled) #