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:
@@ -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
|
||||
@@ -72,6 +76,7 @@ class SpeedHG : JavaPlugin() {
|
||||
antiRunningManager = AntiRunningManager( this )
|
||||
scoreboardManager = ScoreboardManager( this )
|
||||
kitManager = KitManager( this )
|
||||
discordWebhookManager = DiscordWebhookManager( this )
|
||||
|
||||
registerKits()
|
||||
registerCommands()
|
||||
|
||||
@@ -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
|
||||
@@ -39,9 +41,11 @@ class GameManager(
|
||||
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)
|
||||
|
||||
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 ---
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
287
src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt
Normal file
287
src/main/kotlin/club/mcscrims/speedhg/game/modules/PitManager.kt
Normal 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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}" )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>'
|
||||
|
||||
Reference in New Issue
Block a user