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.
This commit is contained in:
TDSTOS
2026-04-04 07:13:39 +02:00
parent 8c2ab684bb
commit 1382de63fc
8 changed files with 712 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import club.mcscrims.speedhg.config.CustomGameSettings
import club.mcscrims.speedhg.config.LanguageManager import club.mcscrims.speedhg.config.LanguageManager
import club.mcscrims.speedhg.database.DatabaseManager import club.mcscrims.speedhg.database.DatabaseManager
import club.mcscrims.speedhg.database.StatsManager import club.mcscrims.speedhg.database.StatsManager
import club.mcscrims.speedhg.disaster.DisasterManager
import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.GameManager
import club.mcscrims.speedhg.game.PodiumManager import club.mcscrims.speedhg.game.PodiumManager
import club.mcscrims.speedhg.game.modules.AntiRunningManager import club.mcscrims.speedhg.game.modules.AntiRunningManager
@@ -83,6 +84,9 @@ class SpeedHG : JavaPlugin() {
lateinit var podiumManager: PodiumManager lateinit var podiumManager: PodiumManager
private set private set
lateinit var disasterManager: DisasterManager
private set
override fun onLoad() override fun onLoad()
{ {
instance = this instance = this
@@ -124,6 +128,9 @@ class SpeedHG : JavaPlugin() {
perkManager = PerkManager( this ) perkManager = PerkManager( this )
perkManager.initialize() perkManager.initialize()
disasterManager = DisasterManager( this )
disasterManager.start()
registerKits() registerKits()
registerPerks() registerPerks()
registerCommands() registerCommands()

View File

@@ -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<NaturalDisaster> = 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 )
}
}

View File

@@ -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.01.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)
)
)
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,20 @@ pit:
title-sub: '<red>Survive at all costs!</red>' title-sub: '<red>Survive at all costs!</red>'
escape-warning: '<red>⚠ Leave the pit and you will die! Return immediately!</red>' escape-warning: '<red>⚠ Leave the pit and you will die! Return immediately!</red>'
disasters:
tornado:
warning-main: '<red><bold>BEWARE!</bold></red>'
warning-sub: '<gray>A tornado is forming near you!</gray>'
earthquake:
warning-main: '<red><bold>EARTHQUAKE!</bold></red>'
warning-sub: '<gray>The ground beneath you is starting to shake!</gray>'
meteor:
warning-main: '<dark_red><bold>METEOR!</bold></dark_red>'
warning-sub: '<gray>A glowing boulder is hurtling toward you!</gray>'
thunder:
warning-main: '<yellow><bold>THUNDERSTORM!</bold></yellow>'
warning-sub: '<gray>Your height attracts lightning!</gray>'
commands: commands:
kit: kit:
usage: '<red>Usage: /kit <kitName> <playstyle></red>' usage: '<red>Usage: /kit <kitName> <playstyle></red>'