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:
@@ -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()
|
||||
|
||||
@@ -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 )
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 )
|
||||
}
|
||||
|
||||
}
|
||||
@@ -67,6 +67,20 @@ pit:
|
||||
title-sub: '<red>Survive at all costs!</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:
|
||||
kit:
|
||||
usage: '<red>Usage: /kit <kitName> <playstyle></red>'
|
||||
|
||||
Reference in New Issue
Block a user