Add Feast, Pit modules and Discord webhook

Introduce two new game modules (FeastManager and PitManager) to handle timed endgame events: announcements, world edits, loot generation, teleportation and escape-prevention logic. Add DiscordWebhookManager to send asynchronous webhook messages (embeds/text) and wire it into SpeedHG and GameManager to broadcast game start/end events. Integrate managers into the game loop and reset lifecycle (startGame), add config entries for Discord, and add corresponding language strings. Also include small tweaks (killer XP reward, minor formatting) and updated resource files.
This commit is contained in:
TDSTOS
2026-03-27 02:15:44 +01:00
parent 72a58fdd9c
commit 07c2963e71
7 changed files with 741 additions and 11 deletions

View File

@@ -16,6 +16,7 @@ import club.mcscrims.speedhg.listener.GameStateListener
import club.mcscrims.speedhg.listener.SoupListener
import club.mcscrims.speedhg.listener.StatsListener
import club.mcscrims.speedhg.scoreboard.ScoreboardManager
import club.mcscrims.speedhg.webhook.DiscordWebhookManager
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
@@ -49,6 +50,9 @@ class SpeedHG : JavaPlugin() {
lateinit var statsManager: StatsManager
private set
lateinit var discordWebhookManager: DiscordWebhookManager
private set
override fun onEnable()
{
instance = this
@@ -67,11 +71,12 @@ class SpeedHG : JavaPlugin() {
statsManager = StatsManager( this )
statsManager.initialize()
languageManager = LanguageManager( this )
gameManager = GameManager( this )
antiRunningManager = AntiRunningManager( this )
scoreboardManager = ScoreboardManager( this )
kitManager = KitManager( this )
languageManager = LanguageManager( this )
gameManager = GameManager( this )
antiRunningManager = AntiRunningManager( this )
scoreboardManager = ScoreboardManager( this )
kitManager = KitManager( this )
discordWebhookManager = DiscordWebhookManager( this )
registerKits()
registerCommands()

View File

@@ -1,6 +1,8 @@
package club.mcscrims.speedhg.game
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.game.modules.FeastManager
import club.mcscrims.speedhg.game.modules.PitManager
import club.mcscrims.speedhg.util.sendMsg
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.title.Title
@@ -34,13 +36,15 @@ class GameManager(
private var gameTask: BukkitTask? = null
// Einstellungen aus Config (gecached für Performance)
private val minPlayers = plugin.config.getInt("game.min-players", 2)
private val lobbyTime = plugin.config.getInt("game.lobby-time", 60)
private val minPlayers = plugin.config.getInt("game.min-players", 2)
private val lobbyTime = plugin.config.getInt("game.lobby-time", 60)
private val invincibilityTime = plugin.config.getInt("game.invincibility-time", 60)
private val startBorder = plugin.config.getDouble("game.border-start", 300.0)
private val endBorder = plugin.config.getDouble("game.border-end", 20.0)
// Zeit in Sekunden, bis Border komplett klein ist (z.B. 10 Min)
private val borderShrinkTime = plugin.config.getLong("game.border-shrink-time", 600)
private val startBorder = plugin.config.getDouble("game.border-start", 300.0)
private val endBorder = plugin.config.getDouble("game.border-end", 20.0)
private val borderShrinkTime = plugin.config.getLong("game.border-shrink-time", 600)
val feastManager = FeastManager( plugin )
val pitManager = PitManager( plugin )
init {
plugin.server.pluginManager.registerEvents( this, plugin )
@@ -120,6 +124,9 @@ class GameManager(
timer++
updateCompass()
checkWin()
feastManager.onTick( timer )
pitManager.onTick( timer )
}
GameState.ENDING ->
@@ -140,6 +147,9 @@ class GameManager(
private fun startGame()
{
feastManager.reset()
pitManager.reset()
setGameState( GameState.INVINCIBILITY )
timer = invincibilityTime
@@ -194,6 +204,12 @@ class GameManager(
Bukkit.getOnlinePlayers().forEach { player ->
player.sendMsg( "game.invincibility-start", "time" to invincibilityTime.toString() )
}
plugin.discordWebhookManager.broadcastEmbed(
title = "🎮 Spiel gestartet!",
description = "Eine neue Runde SpeedHG mit **${Bukkit.getOnlinePlayers().size}** Spielern hat begonnen!",
colorHex = 0x55FF55 // Grün
)
}
private fun startFighting()
@@ -231,6 +247,7 @@ class GameManager(
if ( killer != null )
{
killer.exp += 0.5f
plugin.statsManager.addKill( killer.uniqueId )
plugin.statsManager.adjustScrimScore( killer.uniqueId, +15 ) // Elo-Gewinn
}
@@ -267,6 +284,8 @@ class GameManager(
setGameState( GameState.ENDING )
timer = 15
pitManager.reset()
val winnerUUID = alivePlayers.firstOrNull()
Bukkit.getOnlinePlayers().forEach { p ->
@@ -286,6 +305,12 @@ class GameManager(
))
p.sendMsg( "game.win-chat", "winner" to winnerName )
}
plugin.discordWebhookManager.broadcastEmbed(
title = "🏆 Spiel beendet!",
description = "**$winnerName** hat das Spiel gewonnen! GG!",
colorHex = 0xFFAA00 // Gold
)
}
// --- Helfer Methoden ---

View File

@@ -0,0 +1,306 @@
package club.mcscrims.speedhg.game.modules
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.util.WorldEditUtils
import club.mcscrims.speedhg.util.sendMsg
import org.bukkit.Bukkit
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.Sound
import org.bukkit.block.Chest
import org.bukkit.enchantments.Enchantment
import org.bukkit.inventory.ItemStack
import org.bukkit.inventory.meta.PotionMeta
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import kotlin.math.cos
import kotlin.math.round
import kotlin.math.sin
import kotlin.random.Random
/**
* Verwaltet den Feast-Event (Loot-Drop) in SpeedHG.
*
* ## Ablauf
* 1. Beim ersten Ankündigungs-Tick (300s vor dem Feast) wird eine sichere
* Zufalls-Location generiert und für alle weiteren Ankündigungen gecacht.
* 2. Bei timer == [FEAST_TIME] wird die Plattform gebaut, Kisten befüllt
* und alle Spieler benachrichtigt.
*
* ## Integration
* Rufe [onTick] in `GameManager.gameLoop()` auf, während `currentState == INGAME`.
* Rufe [reset] in `GameManager.startGame()` auf (vor dem Spielstart).
*/
class FeastManager(
private val plugin: SpeedHG
) {
companion object {
/** Timer-Wert (Sekunden im INGAME-State), bei dem der Feast spawnt. */
const val FEAST_TIME = 600 // Minute 10
/**
* Sekunden VOR dem Feast, zu denen eine Ankündigung ausgesendet wird.
* Wird gegen `(FEAST_TIME - timer)` geprüft.
*/
private val ANNOUNCEMENT_OFFSETS = setOf(300, 60, 30, 10)
private const val SPAWN_RADIUS = 100 // Zufalls-Radius um 0,0
private const val PLATFORM_RADIUS = 11 // Plattform-Radius in Blöcken
private const val CHEST_COUNT = 8 // Kisten rund um den Enchanting Table
private const val CHEST_ORBIT = 2 // Abstand der Kisten vom Mittelpunkt
}
/** Gecachte Spawn-Location; wird beim ersten Ankündigungs-Tick gesetzt */
private var feastLocation: Location? = null
var hasSpawned: Boolean = false
private set
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Muss einmal pro Sekunde aus `GameManager.gameLoop()` aufgerufen werden.
* @param timer Aktueller INGAME-Timer (wird jede Sekunde hochgezählt).
*/
fun onTick(
timer: Int
) {
if ( hasSpawned ) return
val timeLeft = FEAST_TIME - timer
when {
timeLeft == 0 -> spawnFeast()
timeLeft < 0 -> return // Sicherheitsnetz
timeLeft in ANNOUNCEMENT_OFFSETS -> {
if ( feastLocation == null ) feastLocation = generateSafeLocation()
broadcastAnnouncement( timeLeft )
}
}
}
/** Setzt den Manager auf den Initialzustand zurück. In `startGame()` aufrufen. */
fun reset()
{
feastLocation = null
hasSpawned = false
}
// -------------------------------------------------------------------------
// Spawn-Logik
// -------------------------------------------------------------------------
private fun spawnFeast()
{
val world = Bukkit.getWorld( "world" ) ?: Bukkit.getWorlds().first()
val location = feastLocation ?: generateSafeLocation().also { feastLocation = it }
hasSpawned = true
val platformY = world.getHighestBlockYAt( location.blockX, location.blockZ )
val centerLoc = Location( world, location.x, platformY.toDouble(), location.z )
// ── 1. Plattform bauen (Gras oben, Erde darunter) ─────────────────────
WorldEditUtils.createCylinder(
world, centerLoc.clone().subtract( 0.0, 1.0, 0.0 ),
PLATFORM_RADIUS, true, 1, Material.DIRT
)
WorldEditUtils.createCylinder(
world, centerLoc,
PLATFORM_RADIUS, true, 1, Material.GRASS_BLOCK
)
// ── 2. Enchanting Table + Kisten platzieren (nach WorldEdit-Commit) ────
Bukkit.getScheduler().runTaskLater( plugin, { ->
// Enchanting Table genau in der Mitte
world.getBlockAt( centerLoc.blockX, platformY + 1, centerLoc.blockZ )
.type = Material.ENCHANTING_TABLE
// 8 Kisten im Kreis rund um den Table
for ( i in 0 until CHEST_COUNT )
{
val angle = i * ( 2.0 * Math.PI / CHEST_COUNT )
val cx = centerLoc.blockX + round(cos( angle ) * CHEST_ORBIT ).toInt()
val cz = centerLoc.blockZ + round(sin( angle ) * CHEST_ORBIT ).toInt()
val chestBlock = world.getBlockAt( cx, platformY + 1, cz )
chestBlock.type = Material.CHEST
( chestBlock.state as? Chest )?.let { chest ->
fillChestWithLoot( chest )
chest.update( true )
}
}
}, 5L )
// ── 3. Broadcast ───────────────────────────────────────────────────────
Bukkit.getOnlinePlayers().forEach { p ->
p.sendMsg(
"feast.spawned",
"x" to centerLoc.blockX.toString(),
"z" to centerLoc.blockZ.toString()
)
p.playSound( p.location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 1f, 0.8f )
}
plugin.discordWebhookManager.broadcastEmbed(
title = "🎉 Feast ist gespawned",
description = "Das Feast ist bei **X: ${centerLoc.blockX} & Z: ${centerLoc.blockZ}** gespawnt!"
)
}
private fun broadcastAnnouncement(
secondsLeft: Int
) {
val loc = feastLocation ?: return
Bukkit.getOnlinePlayers().forEach { p ->
p.sendMsg(
"feast.announcement",
"time" to formatTime( secondsLeft ),
"x" to loc.blockX.toString(),
"z" to loc.blockZ.toString()
)
}
}
// -------------------------------------------------------------------------
// Location-Generierung
// -------------------------------------------------------------------------
/**
* Generiert eine sichere Spawn-Location innerhalb von [SPAWN_RADIUS] Blöcken um 0,0.
* Vermeidet Lava und Wasser an der Oberfläche; fallback auf 0,0.
*/
private fun generateSafeLocation(): Location
{
val world = Bukkit.getWorld( "world" ) ?: Bukkit.getWorlds().first()
repeat( 30 ) {
val x = Random.nextDouble( -SPAWN_RADIUS.toDouble(), SPAWN_RADIUS.toDouble() )
val z = Random.nextDouble( -SPAWN_RADIUS.toDouble(), SPAWN_RADIUS.toDouble() )
val y = world.getHighestBlockYAt( x.toInt(), z.toInt() )
val surface = world.getBlockAt( x.toInt(), y, z.toInt() ).type
if ( surface != Material.LAVA && surface != Material.WATER &&
surface != Material.LAVA_CAULDRON )
return Location( world, x, y.toDouble(), z )
}
// Fallback
val y = world.getHighestBlockYAt( 0, 0 )
return Location( world, 0.0, y.toDouble(), 0.0 )
}
// -------------------------------------------------------------------------
// Loot-Tabelle
// -------------------------------------------------------------------------
private fun fillChestWithLoot(
chest: Chest
) {
val loot = buildLootTable()
val inventory = chest.blockInventory
val slots = ( 0 until inventory.size ).shuffled()
loot.forEachIndexed { idx, item ->
if ( idx < slots.size ) inventory.setItem(slots[ idx ], item )
}
}
/**
* Erzeugt eine randomisierte HG-Feast-Loot-Liste.
* Alle Items werden vor dem Zurückgeben nochmal durchgemischt, damit
* Kisten untereinander unterschiedliche Inhalte haben.
*/
private fun buildLootTable(): List<ItemStack>
{
val items = mutableListOf<ItemStack>()
val rng = java.util.Random()
// ── Diamant-Rüstung ──────────────────────────────────────────────────
data class ArmorEntry(
val material : Material,
val chance : Double,
val enchant : Enchantment,
val maxLevel : Int
)
listOf(
ArmorEntry( Material.DIAMOND_HELMET, 0.65, Enchantment.PROTECTION, 3 ),
ArmorEntry( Material.DIAMOND_CHESTPLATE, 0.75, Enchantment.PROTECTION, 3 ),
ArmorEntry( Material.DIAMOND_LEGGINGS, 0.70, Enchantment.PROTECTION, 3 ),
ArmorEntry( Material.DIAMOND_BOOTS, 0.65, Enchantment.FEATHER_FALLING, 3 ),
).forEach { ( material, chance, enchant, maxLevel ) ->
if ( rng.nextDouble() < chance )
{
items.add(ItemStack( material ).also { item ->
if ( rng.nextDouble() < 0.55 )
{
item.editMeta { meta ->
meta.addEnchant( enchant, rng.nextInt( maxLevel ) + 1, true )
}
}
})
}
}
// ── Diamantschwert ────────────────────────────────────────────────────
if ( rng.nextDouble() < 0.85 )
{
items.add(ItemStack( Material.DIAMOND_SWORD ).also { sword ->
sword.editMeta { meta ->
meta.addEnchant( Enchantment.SHARPNESS, rng.nextInt( 3 ) + 1, true )
}
})
}
// ── Suppen (immer vorhanden, 6-10 Stück) ─────────────────────────────
repeat(rng.nextInt( 5 ) + 6) { items.add(ItemStack( Material.MUSHROOM_STEW )) }
// ── Splash-Tränke (2-4 Stück) ─────────────────────────────────────────
data class PotionEntry( val type: PotionEffectType, val duration: Int, val amplifier: Int )
val potionPool = listOf(
PotionEntry( PotionEffectType.STRENGTH, 200, 0 ),
PotionEntry( PotionEffectType.SPEED, 400, 0 ),
PotionEntry( PotionEffectType.REGENERATION, 160, 1 ),
PotionEntry( PotionEffectType.INSTANT_HEALTH, 1, 0 ),
)
repeat(rng.nextInt( 3 ) + 2 ) {
val entry = potionPool.random()
items.add(ItemStack( Material.SPLASH_POTION ).also { potion ->
potion.editMeta { meta ->
if ( meta is PotionMeta )
meta.addCustomEffect(
PotionEffect( entry.type, entry.duration, entry.amplifier ), true
)
}
})
}
// ── Exp-Flaschen (4-8 Stück) ──────────────────────────────────────────
repeat(rng.nextInt( 5 ) + 4) { items.add(ItemStack( Material.EXPERIENCE_BOTTLE )) }
// ── Goldener Apfel (50 % Chance) ──────────────────────────────────────
if ( rng.nextDouble() < 0.50 ) items.add(ItemStack( Material.GOLDEN_APPLE ))
// ── Verzauberter Goldener Apfel (10 % — sehr selten) ──────────────────
if ( rng.nextDouble() < 0.10 ) items.add(ItemStack( Material.ENCHANTED_GOLDEN_APPLE ))
return items.shuffled()
}
// -------------------------------------------------------------------------
// Hilfsmittel
// -------------------------------------------------------------------------
private fun formatTime(
seconds: Int
): String = when {
seconds >= 120 -> "${seconds / 60} minutes"
seconds >= 60 -> "1 minute"
else -> "$seconds seconds"
}
}

View File

@@ -0,0 +1,287 @@
package club.mcscrims.speedhg.game.modules
import club.mcscrims.speedhg.SpeedHG
import club.mcscrims.speedhg.game.GameState
import club.mcscrims.speedhg.util.WorldEditUtils
import club.mcscrims.speedhg.util.sendMsg
import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.title.Title
import org.bukkit.*
import org.bukkit.entity.Player
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.scheduler.BukkitRunnable
import org.bukkit.scheduler.BukkitTask
import kotlin.math.cos
import kotlin.math.sin
/**
* Verwaltet das Pit-Deathmatch-Event in SpeedHG.
*
* ## Ablauf
* 1. Ankündigungen werden 10 Min, 5 Min, 1 Min und 10 Sekunden vorher ausgesendet.
* 2. Bei [PIT_TIME] Sekunden im INGAME-State wird ein tiefer Zylinder bei X=0, Z=0 gegraben,
* eine Obsidian-Plattform unten gelegt und alle lebenden Spieler teleportiert.
* 3. Ein periodischer Check (1× pro Sekunde) bestraft Spieler, die den Pit verlassen,
* mit Wither IV — so lange, bis sie zurückkehren.
*
* ## Integration
* Rufe [onTick] in `GameManager.gameLoop()` auf, während `currentState == INGAME`.
* Rufe [reset] in `GameManager.startGame()` auf.
*/
class PitManager(private val plugin: SpeedHG) {
companion object {
/** Timer-Wert (Sekunden im INGAME-State), bei dem das Pit spawnt. */
const val PIT_TIME = 1800 // Minute 30
/**
* Sekunden VOR dem Pit, zu denen eine Ankündigung ausgesendet wird.
* Wird gegen `(PIT_TIME - timer)` geprüft.
*/
private val ANNOUNCEMENT_OFFSETS = setOf( 600, 300, 60, 10 )
private const val PIT_X = 0.0
private const val PIT_Z = 0.0
private const val PIT_RADIUS = 20 // Radius des Zylinders in Blöcken
/**
* Toleranz-Puffer: Spieler sind erst *außerhalb*, wenn sie weiter als
* `PIT_RADIUS + ESCAPE_BUFFER` Blöcke vom Mittelpunkt entfernt sind.
* Verhindert false-positives am Wandrand.
*/
private const val ESCAPE_BUFFER = 1.5
private const val ESCAPE_BUFFER_SQ = ( PIT_RADIUS + ESCAPE_BUFFER ) * ( PIT_RADIUS + ESCAPE_BUFFER )
/** Y-Höhe oberhalb des Pit-Bodens, ab der der "Hochbauer"-Check greift. */
private const val MAX_HEIGHT_ABOVE_FLOOR = 35
}
var hasSpawned: Boolean = false
private set
/** Y-Koordinate der Obsidian-Plattform (wird bei spawn gesetzt). */
private var pitFloorY: Int = Int.MIN_VALUE
private var escapeCheckTask: BukkitTask? = null
// -------------------------------------------------------------------------
// Öffentliche API
// -------------------------------------------------------------------------
/**
* Muss einmal pro Sekunde aus `GameManager.gameLoop()` aufgerufen werden.
* @param timer Aktueller INGAME-Timer.
*/
fun onTick(
timer: Int
) {
if ( hasSpawned ) return
val timeLeft = PIT_TIME - timer
when {
timeLeft == 0 -> spawnPit()
timeLeft < 0 -> return
timeLeft in ANNOUNCEMENT_OFFSETS -> broadcastAnnouncement( timeLeft )
}
}
/** Setzt den Manager zurück und stoppt den Escape-Check. In `startGame()` aufrufen. */
fun reset()
{
hasSpawned = false
pitFloorY = Int.MIN_VALUE
stopEscapeCheck()
}
// -------------------------------------------------------------------------
// Spawn-Logik
// -------------------------------------------------------------------------
private fun spawnPit()
{
val world = Bukkit.getWorld( "world" ) ?: Bukkit.getWorlds().first()
hasSpawned = true
val highestY = world.getHighestBlockYAt( PIT_X.toInt(), PIT_Z.toInt() )
pitFloorY = world.minHeight + 1 // Direkt über dem absoluten Minimum der Welt
val floorLoc = Location( world, PIT_X, pitFloorY.toDouble(), PIT_Z )
val airStartLoc = Location( world, PIT_X, ( pitFloorY + 1 ).toDouble(), PIT_Z )
val airHeight = ( highestY - pitFloorY + 10 ).coerceAtLeast( 1 ) // +10: auch oberirdisch frei
// ── 1. Obsidian-Plattform am Boden ────────────────────────────────────
WorldEditUtils.createCylinder( world, floorLoc, PIT_RADIUS, true, 1, Material.OBSIDIAN )
// ── 2. Luftschacht von Boden+1 bis über die Oberfläche ────────────────
WorldEditUtils.createCylinder( world, airStartLoc, PIT_RADIUS, true, airHeight, Material.AIR )
// ── 3. Broadcast sofort ───────────────────────────────────────────────
Bukkit.getOnlinePlayers().forEach { p ->
p.sendMsg( "pit.spawned" )
p.playSound( p.location, Sound.ENTITY_WITHER_SPAWN, 1f, 0.5f )
}
// ── 4. Spieler teleportieren (kurz nach WorldEdit-Commit) ─────────────
Bukkit.getScheduler().runTaskLater( plugin, { ->
teleportPlayersToPit( world )
startEscapeCheck( world )
}, 25L ) // ~1.25 s Puffer für WorldEdit
plugin.logger.info(
"[PitManager] Pit gespawnt. Boden bei Y=$pitFloorY, " +
"Schachthöhe=$airHeight Blöcke, Radius=$PIT_RADIUS."
)
plugin.discordWebhookManager.broadcastEmbed(
title = "🪦 Pit ist gespawned",
description = "Das Pit ist gespawned und Spieler kämpfen nun ums überleben!"
)
}
private fun teleportPlayersToPit(
world: World
) {
// Spieler landen auf der Plattform, leicht versetzt damit sie nicht übereinandergestapelt werden
val spawnCenter = Location( world, PIT_X, ( pitFloorY + 1 ).toDouble(), PIT_Z )
val alive = plugin.gameManager.alivePlayers
.mapNotNull { Bukkit.getPlayer( it ) }
alive.forEachIndexed { idx, player ->
// Fächerförmige Teleportation: Spieler stehen im Kreis um die Mitte
val angle = idx * ( 2.0 * Math.PI / alive.size.coerceAtLeast( 1 ))
val r = if ( alive.size <= 1 ) 0.0 else 3.0
val dest = spawnCenter.clone().add(
cos( angle ) * r, 0.0, sin( angle ) * r
)
player.teleport( dest )
player.showTitle(Title.title(
player.trans("pit.title-main"),
player.trans("pit.title-sub")
))
}
}
// -------------------------------------------------------------------------
// Escape-Prevention
// -------------------------------------------------------------------------
/**
* Startet den 1-Sekunden-Takt-Check für den Pit-Escape.
*
* **Warum BukkitRunnable statt Coroutine?**
* Der Check erfordert Zugriff auf Bukkit-API (Spieler-Location, PotionEffects)
* und muss auf dem Main-Thread laufen. Ein einfacher BukkitRunnable ist hier
* die sauberste Lösung — Coroutines würden wegen `withContext(Dispatchers.Main)`
* keinen wirklichen Mehrwert bieten.
*/
private fun startEscapeCheck(
world: World
) {
stopEscapeCheck()
escapeCheckTask = object : BukkitRunnable() {
override fun run() {
// Automatisch stoppen wenn das Spiel nicht mehr läuft
if ( plugin.gameManager.currentState != GameState.INGAME ) {
cancel()
return
}
plugin.gameManager.alivePlayers
.mapNotNull { Bukkit.getPlayer( it ) }
.forEach { player -> checkPlayerBounds( player, world ) }
}
}.runTaskTimer( plugin, 0L, 20L ) // alle 20 Ticks = 1 Sekunde
}
/**
* Prüft einen Spieler auf horizontale Flucht **und** vertikales Hochbauen.
* Beide Verstöße führen sofort zu Wither IV (Dauer: 2 s, wird jede Sekunde erneuert).
*/
private fun checkPlayerBounds(
player: Player,
pitWorld: World
) {
val loc = player.location
// Anderes World → sofort bestrafen
if ( loc.world != pitWorld )
{
applyEscapePunishment( player )
return
}
// Horizontaler Radius-Check (quadratische Distanz, kein sqrt nötig)
val dx = loc.x - PIT_X
val dz = loc.z - PIT_Z
val distSq = dx * dx + dz * dz
// Vertikaler Höhen-Check: Spieler baut sich aus dem Schacht raus
val tooHigh = pitFloorY != Int.MIN_VALUE &&
loc.y > ( pitFloorY + MAX_HEIGHT_ABOVE_FLOOR )
if ( distSq > ESCAPE_BUFFER_SQ || tooHigh ) {
applyEscapePunishment( player )
} else {
removeEscapePunishment( player )
}
}
/**
* Wither IV für 2 Sekunden (wird jede Sekunde erneut gesetzt → dauerhafter Effekt
* solange der Spieler außerhalb bleibt, ohne einen endlosen Potion-Stack aufzubauen).
*/
private fun applyEscapePunishment(
player: Player
) {
player.addPotionEffect(
PotionEffect(
PotionEffectType.WITHER,
/* duration */ 40, // 2 Sekunden; wird jede Sekunde überschrieben
/* amplifier */ 3, // Wither IV (0-basierter Index)
/* ambient */ false,
/* particles */ true,
/* icon */ true
)
)
player.sendActionBar(player.trans( "pit.escape-warning" ))
}
private fun removeEscapePunishment(
player: Player
) {
if (player.hasPotionEffect( PotionEffectType.WITHER ))
player.removePotionEffect( PotionEffectType.WITHER )
}
private fun stopEscapeCheck()
{
escapeCheckTask?.cancel()
escapeCheckTask = null
}
// -------------------------------------------------------------------------
// Ankündigungen
// -------------------------------------------------------------------------
private fun broadcastAnnouncement(
secondsLeft: Int
) {
Bukkit.getOnlinePlayers().forEach { p ->
p.sendMsg("pit.announcement", "time" to formatTime( secondsLeft ))
}
}
private fun formatTime(
seconds: Int
): String = when {
seconds >= 120 -> "${seconds / 60} minutes"
seconds >= 60 -> "1 minute"
else -> "$seconds seconds"
}
}

View File

@@ -0,0 +1,92 @@
package club.mcscrims.speedhg.webhook
import club.mcscrims.speedhg.SpeedHG
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
class DiscordWebhookManager(
private val plugin: SpeedHG
) {
private val enabled: Boolean = plugin.config.getBoolean( "discord.enabled", false )
private val webhookUrl: String? = plugin.config.getString( "discord.webhook-url" )
private val httpClient = HttpClient.newBuilder()
.connectTimeout( 5.seconds.toJavaDuration() )
.build()
private val gson = Gson()
private val scope = CoroutineScope( Dispatchers.IO + SupervisorJob() )
/**
* Sendet eine einfache Textnachricht an den Discord Channel
*/
fun broadcastMessage(
content: String
) {
if ( !enabled || webhookUrl.isNullOrEmpty() )
return
val payload = JsonObject().apply {
addProperty( "content", content )
}
sendPayload( payload )
}
/**
* Sendet ein hübsches Discord-Embed (ideal für Game Start / Game End)
*/
fun broadcastEmbed(
title: String,
description: String,
colorHex: Int = 0x5865F2
) {
if ( !enabled || webhookUrl.isNullOrEmpty() )
return
val embed = JsonObject().apply {
addProperty( "title", title )
addProperty( "description", description )
addProperty( "color", colorHex )
}
val payload = JsonObject().apply {
val embedsArray = JsonArray()
embedsArray.add( embed )
add( "embeds", embedsArray )
}
sendPayload( payload )
}
private fun sendPayload(
payload: JsonObject
) {
scope.launch {
try {
val request = HttpRequest.newBuilder()
.uri(URI.create( webhookUrl!! ))
.header( "Content-Type", "application/json" )
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson( payload )))
.build()
httpClient.send( request, HttpResponse.BodyHandlers.discarding() )
} catch ( e: Exception ) {
plugin.logger.warning( "[Discord] Fehler beim Senden des Webhooks: ${e.message}" )
}
}
}
}

View File

@@ -20,6 +20,10 @@ anti-runner:
ignore-vertical-distance: 15.0 # Wenn Höhenunterschied > 15, Timer ignorieren
ignore-cave-surface-mix: true # Ignorieren, wenn einer Sonne hat und der andere nicht
discord:
enabled: false
webhook-url: "DEINE_WEBHOOK_URL_HIER"
database:
host: "localhost"
port: 3306

View File

@@ -45,6 +45,17 @@ craft:
no_shield: '<prefix><red>Shields are not allowed in SpeedHG!</red>'
iron_nerf: '<prefix><red>Your item has been nerfed as it contains iron!</red>'
feast:
announcement: '<prefix><gold>⚔ The Feast spawns in <time>!</gold> <gray>Location: <yellow>X: <x> Z: <z></yellow></gray>'
spawned: '<prefix><red><bold>THE FEAST HAS SPAWNED!</bold></red> <gray>Head to <yellow>X: <x> Z: <z></yellow> for the loot!</gray>'
pit:
announcement: '<prefix><dark_red>⚠ The Pit spawns in <time>!</dark_red> <gray>All surviving players will be forced into the final deathmatch!</gray>'
spawned: '<prefix><dark_red><bold>THE PIT HAS OPENED!</bold></dark_red> <gray>All players have been teleported. There is no escape!</gray>'
title-main: '<dark_red><bold>FINAL DEATHMATCH</bold></dark_red>'
title-sub: '<red>Survive at all costs!</red>'
escape-warning: '<red>⚠ Leave the pit and you will die! Return immediately!</red>'
commands:
kit:
usage: '<red>Usage: /kit <kitName> <playstyle></red>'