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 '