From 07c2963e719b035643b65bf163a043f62fb4d62b Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Fri, 27 Mar 2026 02:15:44 +0100 Subject: [PATCH] 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. --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 15 +- .../club/mcscrims/speedhg/game/GameManager.kt | 37 ++- .../speedhg/game/modules/FeastManager.kt | 306 ++++++++++++++++++ .../speedhg/game/modules/PitManager.kt | 287 ++++++++++++++++ .../speedhg/webhook/DiscordWebhookManager.kt | 92 ++++++ src/main/resources/config.yml | 4 + src/main/resources/languages/en_US.yml | 11 + 7 files changed, 741 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/webhook/DiscordWebhookManager.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 1809ef3..7e46fdf 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -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() diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index dd3aed5..08d6882 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -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 --- diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt new file mode 100644 index 0000000..658f417 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt @@ -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 + { + val items = mutableListOf() + 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" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt new file mode 100644 index 0000000..8f26a06 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt @@ -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" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/webhook/DiscordWebhookManager.kt b/src/main/kotlin/club/mcscrims/speedhg/webhook/DiscordWebhookManager.kt new file mode 100644 index 0000000..c9bb572 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/webhook/DiscordWebhookManager.kt @@ -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}" ) + } + } + } + +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0f92005..a984470 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 330e763..db48533 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -45,6 +45,17 @@ craft: no_shield: 'Shields are not allowed in SpeedHG!' iron_nerf: 'Your item has been nerfed as it contains iron!' +feast: + announcement: '⚔ The Feast spawns in Location: X: Z: ' + spawned: 'THE FEAST HAS SPAWNED! Head to X: Z: for the loot!' + +pit: + announcement: '⚠ The Pit spawns in All surviving players will be forced into the final deathmatch!' + spawned: 'THE PIT HAS OPENED! All players have been teleported. There is no escape!' + title-main: 'FINAL DEATHMATCH' + title-sub: 'Survive at all costs!' + escape-warning: '⚠ Leave the pit and you will die! Return immediately!' + commands: kit: usage: 'Usage: /kit '