From 1382de63fc03136726fb4aa77fad38a957cdb13b Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Sat, 4 Apr 2026 07:13:39 +0200 Subject: [PATCH] Add natural disasters system Introduce a natural-disasters feature: add DisasterManager, an abstract NaturalDisaster base class, and four concrete implementations (Earthquake, Meteor, Thunder, Tornado). Integrate the manager into SpeedHG (property, initialization/start) so disasters run on a timed cycle with guards for game state, grace period and per-disaster validation. Implementations: Meteor spawns a non-destructive LargeFireball impact, Earthquake applies nausea/impulses and particles, Thunder triggers multiple lightning strikes with fire chance, Tornado creates a particle vortex and pulls nearby players (uses a coroutine for precomputation). Add localization keys for disaster warnings in en_US.yml. Safety/cancel logic and flight/timeout cutoffs included to avoid leaks or invalid triggers. --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 7 + .../speedhg/disaster/DisasterManager.kt | 127 +++++++++++++++ .../speedhg/disaster/NaturalDisaster.kt | 80 ++++++++++ .../disaster/impl/EarthquakeDisaster.kt | 111 +++++++++++++ .../speedhg/disaster/impl/MeteorDisaster.kt | 148 ++++++++++++++++++ .../speedhg/disaster/impl/ThunderDisaster.kt | 85 ++++++++++ .../speedhg/disaster/impl/TornadoDisaster.kt | 140 +++++++++++++++++ src/main/resources/languages/en_US.yml | 14 ++ 8 files changed, 712 insertions(+) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/DisasterManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/NaturalDisaster.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/impl/EarthquakeDisaster.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/impl/MeteorDisaster.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/impl/ThunderDisaster.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/disaster/impl/TornadoDisaster.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index d49c4e7..012b86e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -10,6 +10,7 @@ import club.mcscrims.speedhg.config.CustomGameSettings import club.mcscrims.speedhg.config.LanguageManager import club.mcscrims.speedhg.database.DatabaseManager import club.mcscrims.speedhg.database.StatsManager +import club.mcscrims.speedhg.disaster.DisasterManager import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.PodiumManager import club.mcscrims.speedhg.game.modules.AntiRunningManager @@ -83,6 +84,9 @@ class SpeedHG : JavaPlugin() { lateinit var podiumManager: PodiumManager private set + lateinit var disasterManager: DisasterManager + private set + override fun onLoad() { instance = this @@ -124,6 +128,9 @@ class SpeedHG : JavaPlugin() { perkManager = PerkManager( this ) perkManager.initialize() + disasterManager = DisasterManager( this ) + disasterManager.start() + registerKits() registerPerks() registerCommands() diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/DisasterManager.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/DisasterManager.kt new file mode 100644 index 0000000..c040475 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/DisasterManager.kt @@ -0,0 +1,127 @@ +package club.mcscrims.speedhg.disaster + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import org.bukkit.Bukkit +import org.bukkit.scheduler.BukkitTask +import java.util.Random + +/** + * Orchestriert das Naturkatastrophen-System. + * + * ## Zyklus (alle [CHECK_INTERVAL_TICKS] Ticks ≈ 45 s) + * 1. Guard: Nur während INGAME und nach [GRACE_PERIOD_SECONDS] Sekunden Kampfphase. + * 2. Zufälligen lebenden Spieler wählen. + * 3. Alle Disasters filtern, für die [NaturalDisaster.canTrigger] = true. + * 4. Eines zufällig auswählen und dessen [NaturalDisaster.probability] würfeln. + * 5. Erfolg → [NaturalDisaster.warn] → Delay → [NaturalDisaster.trigger]. + * + * ## Gesamthäufigkeit anpassen + * - [CHECK_INTERVAL_TICKS] erhöhen → seltener global. + * - Individuelle [NaturalDisaster.probability] senken → ein Typ seltener, + * andere gleich häufig. + * + * ## Integration in SpeedHG.kt + * ```kotlin + * // onEnable(): + * disasterManager = DisasterManager(this) + * disasterManager.start() + * + * // onDisable(): + * if (::disasterManager.isInitialized) disasterManager.stop() + * ``` + */ +class DisasterManager( + private val plugin: SpeedHG +) { + + companion object { + /** Prüfintervall in Ticks (900 = 45 s). */ + private const val CHECK_INTERVAL_TICKS = 900L + + /** + * Schonfrist nach Kampfstart in Sekunden. + * Disasters feuern erst, wenn [GameManager.timer] > dieser Wert. + * Gibt Spielern Zeit, Abstand zu gewinnen. + */ + private const val GRACE_PERIOD_SECONDS = 30 + } + + /** + * Alle registrierten Disasters. + * Reihenfolge irrelevant — Auswahl erfolgt zufällig unter den Eligible. + * Füge hier neue Disasters hinzu. + */ + private val disasters: List = listOf( + + ) + + private val rng = Random() + private var cycleTask: BukkitTask? = null + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /** + * Startet den perpetuellen Zyklus. Sicher mehrfach aufrufbar — + * vorherige Task wird automatisch beendet. + */ + fun start() + { + stop() + cycleTask = Bukkit.getScheduler().runTaskTimer( + plugin, + Runnable { tick() }, + CHECK_INTERVAL_TICKS, + CHECK_INTERVAL_TICKS + ) + plugin.logger.info("[DisasterManager] Naturkatastrophen-System gestartet.") + } + + /** Beendet den Zyklus. Kein laufendes Disaster wird abgebrochen. */ + fun stop() + { + cycleTask?.cancel() + cycleTask = null + } + + // ── Kern-Tick ───────────────────────────────────────────────────────────── + + private fun tick() + { + // ── 1. Guard ────────────────────────────────────────────────────────── + if ( plugin.gameManager.currentState != GameState.INGAME ) return + if ( plugin.gameManager.timer < GRACE_PERIOD_SECONDS ) return + + // ── 2. Zufälligen lebenden Spieler wählen ───────────────────────────── + val alive = plugin.gameManager.alivePlayers + .mapNotNull { Bukkit.getPlayer( it ) } + .filter { it.isOnline } + if ( alive.isEmpty() ) return + + val target = alive.random() + + // ── 3. Eligible Disasters sammeln ───────────────────────────────────── + val eligible = disasters.filter { it.canTrigger( target ) } + if ( eligible.isEmpty() ) return + + // ── 4. Zufällig auswählen + Wahrscheinlichkeit würfeln ──────────────── + val chosen = eligible.random() + if ( rng.nextDouble() >= chosen.probability ) return + + plugin.logger.info( + "[DisasterManager] '${chosen.id}' wird ausgelöst für: ${target.name}" + ) + + // ── 5. Warnen → Delay → Auslösen ───────────────────────────────────── + chosen.warn( target ) + + Bukkit.getScheduler().runTaskLater( plugin, { -> + if ( !target.isOnline ) return@runTaskLater + if (!plugin.gameManager.alivePlayers.contains( target.uniqueId )) return@runTaskLater + if ( plugin.gameManager.currentState != GameState.INGAME ) return@runTaskLater + + chosen.trigger( plugin, target ) + }, chosen.warningDelayTicks ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/NaturalDisaster.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/NaturalDisaster.kt new file mode 100644 index 0000000..5f27889 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/NaturalDisaster.kt @@ -0,0 +1,80 @@ +package club.mcscrims.speedhg.disaster + +import club.mcscrims.speedhg.SpeedHG +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.title.Title +import org.bukkit.entity.Player +import java.time.Duration + +/** + * Abstrakte Basis jeder Naturkatastrophe in SpeedHG. + * + * ## Neues Disaster hinzufügen + * 1. Diese Klasse erweitern und alle abstrakten Members implementieren. + * 2. In [DisasterManager.disasters] eintragen. + * + * ## Wahrscheinlichkeit tunen + * [probability] wird einmal pro Check-Zyklus gerollt (alle ~45 s): + * + * | Wert | Bedeutung | + * |-------|-------------------------------------------------| + * | 1.0 | Immer, wenn Bedingung erfüllt | + * | 0.5 | ~50 % – ca. einmal pro ~90 s | + * | 0.25 | Selten – ca. einmal alle ~3 Min | + * | 0.1 | Sehr selten – ca. einmal alle ~7 Min | + * + * Kombiniere niedrige [probability] mit kurzen [warningDelayTicks] für maximalen Schockeffekt. + */ +abstract class NaturalDisaster { + + abstract val id: String + + /** + * Wahrscheinlichkeit [0.0–1.0], dass das Disaster auslöst, wenn + * [canTrigger] true zurückgibt. Siehe KDoc-Tabelle oben. + */ + abstract val probability: Double + + /** + * Ticks zwischen Warning-Title und [trigger]-Aufruf. + * Standard: 80 Ticks = 4 Sekunden Reaktionszeit. + */ + open val warningDelayTicks: Long = 80L + + /** MiniMessage-String für die große Titelzeile. */ + abstract val warningTitle: String + + /** MiniMessage-String für die kleinere Untertitelzeile. */ + abstract val warningSubtitle: String + + protected val mm: MiniMessage = MiniMessage.miniMessage() + + /** + * Gibt true zurück, wenn das Biom/die Höhe des Spielers + * für dieses Disaster qualifiziert. Läuft auf dem Main-Thread. + */ + abstract fun canTrigger(player: Player): Boolean + + /** + * Führt das Disaster aus. Wird auf dem Main-Thread aufgerufen, + * [warningDelayTicks] Ticks nach [warn]. + * Der Spieler ist zu diesem Zeitpunkt bereits re-validiert + * (online, lebendig, Spielzustand = INGAME). + */ + abstract fun trigger(plugin: SpeedHG, player: Player) + + /** Zeigt den Warning-Title an. */ + fun warn(player: Player) { + player.showTitle( + Title.title( + mm.deserialize(warningTitle), + mm.deserialize(warningSubtitle), + Title.Times.times( + Duration.ofMillis(200), + Duration.ofSeconds(3), + Duration.ofMillis(500) + ) + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/EarthquakeDisaster.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/EarthquakeDisaster.kt new file mode 100644 index 0000000..a8566c4 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/EarthquakeDisaster.kt @@ -0,0 +1,111 @@ +package club.mcscrims.speedhg.disaster.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.disaster.NaturalDisaster +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.Player +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.scheduler.BukkitTask +import org.bukkit.util.Vector +import java.util.Random + +/** + * ## Erdbeben (Badlands-Biome) + * + * Simuliert seismische Aktivität durch: + * - [PotionEffectType.NAUSEA] + [PotionEffectType.SLOWNESS] für die gesamte Dauer. + * - Zufällige kleine Velocity-Impulse alle [IMPULSE_EVERY] Ticks (Stolper-Effekt). + * - Abwechselnde Stone-Break / Anvil-Sounds für atmosphärisches Rumble. + * - Block-Crack-Partikel am Boden. + */ +class EarthquakeDisaster : NaturalDisaster() { + + override val id = "earthquake" + override val probability = 0.55 + override val warningTitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.earthquake.warning-main" ) + override val warningSubtitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.earthquake.warning-sub" ) + + companion object { + private const val DURATION_TICKS = 100 // 5 Sekunden + private const val IMPULSE_EVERY = 5 // Ticks zwischen Rucklern + private const val IMPULSE_STRENGTH = 0.35 + private const val VERTICAL_CHANCE = 0.12 // Wahrscheinlichkeit hochzuschlagen + } + + private val eligibleBiomes = setOf( "BADLANDS", "ERODED_BADLANDS", "WOODED_BADLANDS" ) + private val stoneData = Material.STONE.createBlockData() + + override fun canTrigger( + player: Player + ): Boolean + { + val biome = player.location.block.biome.name.uppercase() + return eligibleBiomes.any { biome.contains( it ) } + } + + override fun trigger( + plugin: SpeedHG, + player: Player + ) { + val rng = Random() + + player.addPotionEffect(PotionEffect( + PotionEffectType.NAUSEA, DURATION_TICKS, 0, false, false, false + )) + + player.addPotionEffect(PotionEffect( + PotionEffectType.SLOWNESS, DURATION_TICKS, 1, false, false, false + )) + + player.playSound( player.location, Sound.BLOCK_ANVIL_LAND, 1f, 0.3f ) + + var tick = 0 + var shakeTask: BukkitTask? = null + + shakeTask = Bukkit.getScheduler().runTaskTimer( plugin, { -> + tick++ + + if ( tick > DURATION_TICKS || + !player.isOnline || + !plugin.gameManager.alivePlayers.contains( player.uniqueId )) + { + shakeTask?.cancel() + return@runTaskTimer + } + + if ( tick % IMPULSE_EVERY == 0 ) + { + val impulse = Vector( + ( rng.nextDouble() - 0.5 ) * IMPULSE_STRENGTH, + if ( rng.nextDouble() < VERTICAL_CHANCE ) 0.28 else 0.0, + ( rng.nextDouble() - 0.5 ) * IMPULSE_STRENGTH + ) + player.velocity = player.velocity.add( impulse ) + + val sound = if ( rng.nextBoolean() ) Sound.BLOCK_STONE_BREAK + else Sound.BLOCK_DEEPSLATE_BREAK + player.playSound( + player.location, sound, + 0.6f, 0.2f + rng.nextFloat() * 0.4f + ) + } + + if ( tick % 3 == 0 ) + { + player.world.spawnParticle( + Particle.BLOCK, + player.location.clone().subtract( 0.0, 0.1, 0.0 ), + 6, + 0.7, 0.05, 0.7, + 0.0, + stoneData + ) + } + }, 0L, 1L) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/MeteorDisaster.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/MeteorDisaster.kt new file mode 100644 index 0000000..18f4a1e --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/MeteorDisaster.kt @@ -0,0 +1,148 @@ +package club.mcscrims.speedhg.disaster.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.disaster.NaturalDisaster +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.LargeFireball +import org.bukkit.entity.Player +import org.bukkit.scheduler.BukkitTask +import java.util.Random + +/** + * ## Meteor (Savanne-Biome) + * + * Eine [LargeFireball] spawnt 35 Blöcke schräg über dem Zielspieler, + * fliegt mit Flammen-/Rauch-Trail in Richtung des Zielorts und + * erzeugt beim Aufprall: + * - Explosion (Power [EXPLOSION_POWER], ohne Block-Zerstörung → Kartenintegrität). + * - [Particle.EXPLOSION] + [Particle.FLAME]-Burst + [Particle.LARGE_SMOKE]. + * + * ### Aufprall-Erkennung + * Ein 1×/Tick-Monitor prüft, ob der Feuerball einem Solid-Block direkt + * benachbart ist. Durch `yield = 0f` wird die Vanilla-Explosion unterdrückt + * — der Impact wird vollständig manuell gehandhabt. + * + * Der Monitor cancelt sich nach [MAX_FLIGHT_TICKS] selbst (Safety gegen Memory Leaks + * bei Meteoren, die ins Void oder über den Rand fliegen). + */ +class MeteorDisaster : NaturalDisaster() { + + override val id = "meteor" + override val probability = 0.40 + override val warningTitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.meteor.warning-main" ) + override val warningSubtitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.meteor.warning-sub" ) + + companion object { + private const val SPAWN_HEIGHT_ABOVE = 35.0 + private const val SPAWN_LATERAL_RANGE = 14.0 // Schräger Einfall + private const val FIREBALL_SPEED = 1.4 + private const val EXPLOSION_POWER = 3.5f + private const val MAX_FLIGHT_TICKS = 350L // 17,5 s Sicherheits-Cutoff + } + + private val eligibleBiomes = setOf("SAVANNA", "SAVANNA_PLATEAU", "WINDSWEPT_SAVANNA") + + override fun canTrigger(player: Player): Boolean { + val biome = player.location.block.biome.name.uppercase() + return eligibleBiomes.any { biome.contains(it) } + } + + override fun trigger(plugin: SpeedHG, player: Player) { + val rng = Random() + val target = player.location.clone() + val world = target.world ?: return + + // Spawn-Punkt: schräg versetzt + hoch über dem Ziel + val spawnLoc = Location( + world, + target.x + (rng.nextDouble() - 0.5) * SPAWN_LATERAL_RANGE, + target.y + SPAWN_HEIGHT_ABOVE, + target.z + (rng.nextDouble() - 0.5) * SPAWN_LATERAL_RANGE + ) + + // Zielort leicht jittern → Spieler muss ausweichen, nicht nur stehen bleiben + val impactTarget = target.clone().add( + (rng.nextDouble() - 0.5) * 4.0, + 0.0, + (rng.nextDouble() - 0.5) * 4.0 + ) + + val direction = impactTarget.toVector() + .subtract(spawnLoc.toVector()) + .normalize() + .multiply(FIREBALL_SPEED) + + // Feuerball spawnen; yield = 0f unterdrückt Vanilla-Explosion + val fireball = world.spawn(spawnLoc, LargeFireball::class.java) { fb -> + fb.direction = direction + fb.velocity = direction.clone() + fb.setIsIncendiary( false ) + @Suppress("DEPRECATION") + fb.yield = 0f + } + + var trailTask: BukkitTask? = null + var monitorTask: BukkitTask? = null + var monitorTicks = 0L + + // ── Trail: Flammen + Rauch am Feuerball ─────────────────────────────── + trailTask = Bukkit.getScheduler().runTaskTimer(plugin, Runnable { + if (fireball.isDead) { trailTask?.cancel(); return@Runnable } + val loc = fireball.location + world.spawnParticle(Particle.FLAME, loc, 5, 0.2, 0.2, 0.2, 0.06) + world.spawnParticle(Particle.LARGE_SMOKE, loc, 3, 0.15, 0.15, 0.15, 0.02) + world.spawnParticle(Particle.CRIT, loc, 2, 0.25, 0.25, 0.25, 0.0) + }, 0L, 1L) + + // ── Monitor: Aufprall-Erkennung ─────────────────────────────────────── + monitorTask = Bukkit.getScheduler().runTaskTimer(plugin, Runnable { + monitorTicks++ + + // Safety-Cutoff + Dead-Check → garantiertes Cancel + if (fireball.isDead || monitorTicks > MAX_FLIGHT_TICKS) { + trailTask.cancel() + monitorTask?.cancel() + if (!fireball.isDead) fireball.remove() + return@Runnable + } + + val fbLoc = fireball.location + val blockHere = fbLoc.block + val blockBelow = fbLoc.clone().subtract(0.0, 1.0, 0.0).block + + // Aufprall: Feuerball berührt soliden Block + if (blockHere.type.isSolid || blockBelow.type.isSolid) { + trailTask.cancel() + monitorTask?.cancel() + fireball.remove() + handleImpact(plugin, fbLoc) + } + }, 0L, 1L) + } + + // ── Impact-Handler ──────────────────────────────────────────────────────── + + private fun handleImpact(plugin: SpeedHG, loc: Location) { + val world = loc.world ?: return + + // Partikel-Burst + world.spawnParticle(Particle.EXPLOSION, loc, 4, 0.4, 0.3, 0.4, 0.0) + world.spawnParticle(Particle.FLAME, loc, 60, 2.5, 1.0, 2.5, 0.18) + world.spawnParticle(Particle.LARGE_SMOKE, loc, 35, 1.8, 0.5, 1.8, 0.06) + + // Sound + world.playSound(loc, Sound.ENTITY_GENERIC_EXPLODE, 2f, 0.55f) + world.playSound(loc, Sound.ENTITY_IRON_GOLEM_HURT, 1f, 0.40f) + + // Explosion: macht Schaden, zerstört aber keine Blöcke (Kartenintegrität) + world.createExplosion( + loc, + EXPLOSION_POWER, + /* setFire = */ false, + /* breakBlocks = */ false + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/ThunderDisaster.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/ThunderDisaster.kt new file mode 100644 index 0000000..9285a57 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/ThunderDisaster.kt @@ -0,0 +1,85 @@ +package club.mcscrims.speedhg.disaster.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.disaster.NaturalDisaster +import club.mcscrims.speedhg.game.GameState +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.entity.Player +import java.util.Random + +/** + * ## Gewitter (Y > 100) + * + * Spieler, die campen oder hochbauen, ziehen Blitze an. + * Feuert [STRIKE_COUNT] Einschläge in [STRIKE_INTERVAL_TICKS]-Abständen. + * + * Jeder Einschlag: + * - Landet zufällig innerhalb von [SCATTER_RADIUS] Blöcken. + * - Hat [FIRE_CHANCE] Wahrscheinlichkeit, den Block darunter zu entzünden. + * - Spawnt [Particle.ELECTRIC_SPARK] als visuelle Verstärkung. + * + * Jede Rakete wird separat re-validiert → kein Schaden nach dem Tod des Spielers. + */ +class ThunderDisaster : NaturalDisaster() { + + override val id = "thunder" + override val probability = 0.60 + override val warningTitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.thunder.warning-main" ) + override val warningSubtitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.thunder.warning-sub" ) + + companion object { + private const val Y_THRESHOLD = 100.0 + private const val STRIKE_COUNT = 5 + private const val STRIKE_INTERVAL_TICKS = 40L // Blitz alle 2 s + private const val SCATTER_RADIUS = 8.0 + private const val FIRE_CHANCE = 0.25 + } + + override fun canTrigger(player: Player): Boolean = + player.location.y > Y_THRESHOLD + + override fun trigger(plugin: SpeedHG, player: Player) { + val rng = Random() + + repeat(STRIKE_COUNT) { index -> + Bukkit.getScheduler().runTaskLater(plugin, Runnable { + // Jeden Blitz einzeln re-validieren + if (!player.isOnline) return@Runnable + if (!plugin.gameManager.alivePlayers.contains(player.uniqueId)) return@Runnable + if (plugin.gameManager.currentState != GameState.INGAME) return@Runnable + + val origin = player.location + val offsetX = (rng.nextDouble() - 0.5) * 2.0 * SCATTER_RADIUS + val offsetZ = (rng.nextDouble() - 0.5) * 2.0 * SCATTER_RADIUS + val strikeLoc = origin.clone().add(offsetX, 0.0, offsetZ) + + // Auf Oberfläche snappen + val surfaceY = strikeLoc.world + ?.getHighestBlockYAt(strikeLoc) + ?.toDouble() + ?.plus(1.0) ?: strikeLoc.y + strikeLoc.y = surfaceY + + // Echten Blitz spawnen (Schaden + Sound inklusive) + strikeLoc.world?.strikeLightning(strikeLoc) + + // Zusätzliche Funken-Partikel + strikeLoc.world?.spawnParticle( + Particle.ELECTRIC_SPARK, + strikeLoc, 25, 0.4, 0.4, 0.4, 0.12 + ) + + // Block unter Blitz entzünden (optional) + if (rng.nextDouble() < FIRE_CHANCE) { + val below = strikeLoc.clone().subtract(0.0, 1.0, 0.0).block + val fireSpot = strikeLoc.block + if (below.type.isSolid && fireSpot.type.isAir) { + fireSpot.type = Material.FIRE + } + } + }, index * STRIKE_INTERVAL_TICKS) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/TornadoDisaster.kt b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/TornadoDisaster.kt new file mode 100644 index 0000000..3d6083d --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/disaster/impl/TornadoDisaster.kt @@ -0,0 +1,140 @@ +package club.mcscrims.speedhg.disaster.impl + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.disaster.NaturalDisaster +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.bukkit.Bukkit +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.Player +import org.bukkit.scheduler.BukkitTask +import org.bukkit.util.Vector +import kotlin.math.cos +import kotlin.math.sin + +/** + * ## Tornado (Badlands-Biome) + * + * Ein spiralförmiger Partikelwirbel entsteht für [DURATION_TICKS] Ticks. + * Alle lebenden Spieler in [PULL_RADIUS] Blöcken werden kontinuierlich + * zum Zentrum gezogen und leicht in die Luft geworfen. + * + * ### Threading-Modell + * Die trigonometrischen Spiralkoordinaten werden auf [Dispatchers.Default] + * (Hintergrund-Thread) vorberechnet. Nur `world.spawnParticle` und + * `player.velocity` werden via `Bukkit.getScheduler().runTask()` auf den + * Main-Thread dispatcht — Paper erlaubt Bukkit-API ausschließlich dort. + * + * Das Dispatching via `runTask` ist thread-safe, weil: + * - Der Main-Thread die Liste `positions` erst liest, wenn die Coroutine + * sie vollständig befüllt und übergeben hat. + * - `ArrayList` hier nie concurrent modifiziert wird. + */ +class TornadoDisaster : NaturalDisaster() { + + override val id = "tornado" + override val probability = 0.45 + override val warningTitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.tornado.warning-main" ) + override val warningSubtitle = SpeedHG.instance.languageManager.getDefaultRawMessage( "disasters.tornado.warning-sub" ) + + companion object { + private const val DURATION_TICKS = 200L // 10 Sekunden + private const val PULL_RADIUS = 10.0 + private const val MAX_HEIGHT = 14 // Spiralschichten + private const val SPOKES = 6 // Partikelstrahlen pro Schicht + private const val ROTATION_SPEED = 0.25 // Rad/Tick (Drehgeschwindigkeit) + private const val BASE_CONE_RADIUS = 3.5 // Max. Radius am Boden + } + + private val eligibleBiomes = setOf( "BADLANDS", "ERODED_BADLANDS", "WOODED_BADLANDS" ) + + override fun canTrigger( + player: Player + ): Boolean + { + val biome = player.location.block.biome.name.uppercase() + return eligibleBiomes.any { biome.contains( it ) } + } + + override fun trigger( + plugin: SpeedHG, + player: Player + ) { + val center = player.location.clone() + val world = center.world ?: return + val coroutineScope = CoroutineScope( Dispatchers.Default + SupervisorJob() ) + + var tick = 0 + var angle = 0.0 + var mainTask: BukkitTask? = null + + mainTask = Bukkit.getScheduler().runTaskTimer( plugin, { -> + tick++ + + if ( tick > DURATION_TICKS ) + { + mainTask?.cancel() + coroutineScope.cancel() + return@runTaskTimer + } + + val currentAngle = angle + angle += ROTATION_SPEED + + coroutineScope.launch { + val positions = ArrayList>( MAX_HEIGHT * SPOKES ) + + for ( layer in 0..MAX_HEIGHT ) + { + val layerRadius = (( MAX_HEIGHT - layer ).toDouble() / MAX_HEIGHT ) + for ( spoke in 0 until SPOKES ) + { + val a = currentAngle + ( spoke * ( Math.PI * 2.0 / SPOKES )) + positions += Triple( + center.x + cos( a ) * layerRadius, + center.y + layer.toDouble(), + center.z + sin( a ) * layerRadius + ) + } + } + + Bukkit.getScheduler().runTask( plugin ) { -> + positions.forEach { (x, y, z) -> + world.spawnParticle( + Particle.CAMPFIRE_COSY_SMOKE, + x, y, z, + 1, 0.0, 0.0, 0.0, 0.0 + ) + } + + if ( tick % 20 == 0 ) + { + world.playSound( center, Sound.ENTITY_PHANTOM_FLAP, 1.5f, 0.4f ) + world.playSound( center, Sound.WEATHER_RAIN, 0.5f, 0.5f ) + } + + plugin.gameManager.alivePlayers + .mapNotNull { Bukkit.getPlayer( it ) } + .forEach { nearby -> + val dist = nearby.location.distance( center ) + if ( dist !in 0.5..PULL_RADIUS ) return@forEach + + val strength = ( 1.0 - dist / PULL_RADIUS ) * 0.35 + val toCenter: Vector = center.toVector() + .subtract( nearby.location.toVector() ) + .normalize() + .multiply( strength ) + + toCenter.y = strength * 0.45 + nearby.velocity = nearby.velocity.add( toCenter ) + } + } + } + }, 0L, 1L ) + } + +} \ 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 15d7367..a06655f 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -67,6 +67,20 @@ pit: title-sub: 'Survive at all costs!' escape-warning: '⚠ Leave the pit and you will die! Return immediately!' +disasters: + tornado: + warning-main: 'BEWARE!' + warning-sub: 'A tornado is forming near you!' + earthquake: + warning-main: 'EARTHQUAKE!' + warning-sub: 'The ground beneath you is starting to shake!' + meteor: + warning-main: 'METEOR!' + warning-sub: 'A glowing boulder is hurtling toward you!' + thunder: + warning-main: 'THUNDERSTORM!' + warning-sub: 'Your height attracts lightning!' + commands: kit: usage: 'Usage: /kit '