From bcfe42b1a3197588f4e45cb10e001d63f5f75e11 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Mon, 13 Apr 2026 00:38:32 +0200 Subject: [PATCH] Add 1.8 knockback + various gameplay fixes Introduce a Paper EntityKnockback-based KnockbackListener implementing classic 1.8-style horizontal knockback (configurable via kits.knockback.extras) and register it in the main plugin. Other changes: - Reduce default freeze duration from 200 to 60 ticks in CustomGameSettings and TheWorldKit (3s). Remove the per-freeze hit cap and related logic from TheWorldKit; freezes are now purely timer-based and the FrozenData no longer tracks hitsRemaining. Refactor freeze task/timer logic and cleanup unused imports. - Fix FeastManager chest creation race by reading chest BlockState on the next tick (schedule runTaskLater) before filling/updating the chest. - Refactor GameStateListener block handling: always-block diamond ore, block iron before feast, restrict allowed blocks during INVINCIBILITY to a whitelist, and unify INGAME pickup behavior to directly add drops to player inventory. Use block.getDrops(tool, player) to preserve correct vanilla drop context (fortune/silk/age). Rework pickupBlock to use those drops and ensure sound selection logic remains correct. - Add mushroom harvesting handling: track active mushroom breaker players and handle mushroom chain physics in BlockPhysicsEvent to attribute drops to the correct player (activeMushroomBreaker map + monitor handler). Remove item-despawn cancellation logic. These changes address physics/race bugs, improve combat feel (1.8 KB), simplify freeze behavior for The World kit, and ensure drop attribution and vanilla drop mechanics are preserved. --- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 2 + .../speedhg/combat/KnockbackListener.kt | 242 ++++++++++++++++++ .../speedhg/config/CustomGameSettings.kt | 3 +- .../speedhg/game/modules/FeastManager.kt | 11 +- .../mcscrims/speedhg/kit/impl/TheWorldKit.kt | 65 +---- .../speedhg/listener/GameStateListener.kt | 119 ++++++--- 6 files changed, 351 insertions(+), 91 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/combat/KnockbackListener.kt diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index dd5239c..5569495 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -1,6 +1,7 @@ package club.mcscrims.speedhg import club.mcscrims.speedhg.client.LunarClientManager +import club.mcscrims.speedhg.combat.KnockbackListener import club.mcscrims.speedhg.command.HelpCommand import club.mcscrims.speedhg.command.KitCommand import club.mcscrims.speedhg.command.LeaderboardCommand @@ -312,6 +313,7 @@ class SpeedHG : JavaPlugin() { pm.registerEvents( TeamListener(), this ) pm.registerEvents( lobbyItemManager, this ) pm.registerEvents(ChatListener( this, VolcanoServerRankProvider() ), this ) + pm.registerEvents(KnockbackListener( this ), this ) } private fun registerRecipes() diff --git a/src/main/kotlin/club/mcscrims/speedhg/combat/KnockbackListener.kt b/src/main/kotlin/club/mcscrims/speedhg/combat/KnockbackListener.kt new file mode 100644 index 0000000..b74778b --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/combat/KnockbackListener.kt @@ -0,0 +1,242 @@ +package club.mcscrims.speedhg.combat + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.game.GameState +import io.papermc.paper.event.entity.EntityKnockbackEvent +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.util.Vector +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.cos +import kotlin.math.sin + +/** + * ## KnockbackListener + * + * Ersetzt das Standard-1.9+-Knockback durch klassisches 1.8-Knockback: + * horizontal-fokussiert, kombo-freundlich und W-Tap/Sprint-Reset-sensitiv. + * + * ## Warum 1.9+ sich falsch anfühlt + * Post-1.9-Knockback schleudert Opfer stark nach oben. Das unterbricht Kombos, + * weil das Opfer außer Reichweite fliegt bevor der Angreifer nachhaken kann. + * 1.8 behielt Knockback fast rein horizontal — das Opfer bleibt am Boden + * und der Angreifer kann direkt nachsetzen. + * + * ## Two-Event-Pattern + * `EntityKnockbackEvent` (Paper) kennt **keinen Angreifer** — `event.cause` + * ist ein Enum (`Cause.ENTITY_ATTACK`), keine Entity-Referenz. + * Der Angreifer wird deshalb in einem separaten `EntityDamageByEntityEvent` + * (LOWEST-Priority, läuft **vor** dem Knockback-Event) gecacht und in + * `lastAttacker` gespeichert. `onKnockback` liest ihn daraus. + * + * ``` + * EntityDamageByEntityEvent (LOWEST) + * └─ lastAttacker[victim] = attacker ← Cache befüllen + * + * EntityKnockbackEvent (HIGH) + * └─ lastAttacker[victim] ← Cache auslesen + * └─ isCancelled = true ← Vanilla-KB unterdrücken + * └─ victim.velocity = buildKnockbackVector() ← 1.8-KB anwenden + * ``` + * + * ## Konfiguration (`extras` unter Key `"knockback"` in `SPEEDHG_CUSTOM_SETTINGS`) + * ```json + * { + * "kits": { + * "knockback": { + * "extras": { + * "kb_horizontal": 0.4, + * "kb_vertical": 0.35, + * "kb_sprint_multiplier": 1.5 + * } + * } + * } + * } + * ``` + * Fehlende Keys fallen auf die Defaults im [companion object] zurück. + * + * ## Prioritätenstrategie + * | Handler | Priorität | ignoreCancelled | Grund | + * |---------------|-----------|-----------------|-----------------------------------------------------| + * | [onDamage] | LOWEST | false | Läuft als Erstes — Cache befüllen bevor KB feuert | + * | [onKnockback] | HIGH | true | Überschreibt Vanilla-KB, Kit-Abilities laufen davor | + */ +class KnockbackListener( + private val plugin: SpeedHG +) : Listener { + + companion object { + + /** Einzel-Settings-Key — Entries liegen unter `kits.knockback.extras`. */ + private const val SETTINGS_KEY = "knockback" + + /** Basis-Horizontalkraft — entspricht klassischem 1.8-Feeling. */ + const val DEFAULT_KB_HORIZONTAL: Double = 0.4 + + /** + * Vertikalkraft. + * Bewusst niedrig gehalten — Opfer sollen nicht aufsteigen, + * damit Kombos am Boden bleiben. + */ + const val DEFAULT_KB_VERTICAL: Double = 0.35 + + /** + * Multiplikator wenn der Angreifer sprintet. + * Belohnt W-Tapping und Sprint-Resets mit mehr Schubkraft. + */ + const val DEFAULT_KB_SPRINT_MULTIPLIER: Double = 1.5 + + } + + // ── Angreifer-Cache ─────────────────────────────────────────────────────── + + /** + * Speichert pro Opfer-UUID die UUID des letzten Angreifers. + * + * Wird in [onDamage] (LOWEST) befüllt und in [onKnockback] (HIGH) + * ausgelesen. Beide Events feuern im gleichen Tick synchron auf dem + * Main-Thread — [ConcurrentHashMap] schützt gegen etwaige parallele + * Scheduler-Tasks oder Plugin-Threads die ggf. auf die Map zugreifen. + */ + private val lastAttacker: ConcurrentHashMap = ConcurrentHashMap() + + // ── Live-Config-Accessors ───────────────────────────────────────────────── + + private val extras + get() = plugin.customGameManager.settings.kits.kits[ SETTINGS_KEY ] + + private val kbHorizontal: Double + get() = extras?.getDouble( "kb_horizontal" ) ?: DEFAULT_KB_HORIZONTAL + + private val kbVertical: Double + get() = extras?.getDouble( "kb_vertical" ) ?: DEFAULT_KB_VERTICAL + + private val kbSprintMultiplier: Double + get() = extras?.getDouble( "kb_sprint_multiplier" ) ?: DEFAULT_KB_SPRINT_MULTIPLIER + + // ========================================================================= + // Phase 1 — Angreifer cachen (LOWEST, vor dem Knockback-Event) + // ========================================================================= + + /** + * Läuft mit LOWEST-Priority, also als allererstes aller Damage-Handler. + * + * `ignoreCancelled = false` ist bewusst gesetzt: Auch wenn ein anderer + * Handler das Event später cancelt (z.B. Invincibility), wollen wir den + * Cache trotzdem befüllen — [onKnockback] arbeitet sonst mit einem + * veralteten Eintrag aus dem vorherigen Treffer. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false) + fun onDamage( + event: EntityDamageByEntityEvent + ) { + val attacker = event.damager as? Player ?: return + val victim = event.entity as? Player ?: return + + if ( !isIngame() ) return + + lastAttacker[ victim.uniqueId ] = attacker.uniqueId + } + + // ========================================================================= + // Phase 2 — 1.8-Knockback anwenden (HIGH, nach Kit-Abilities) + // ========================================================================= + + /** + * Feuert wenn Paper kurz davor ist, Knockback auf eine Entity anzuwenden. + * + * Wir canceln das Event (unterdrückt Vanilla-1.9+-KB) und setzen stattdessen + * unsere eigene, horizontal-fokussierte Velocity. + * + * HIGH-Priority stellt sicher, dass Kit-Abilities (z.B. Black Panther Push) + * auf NORMAL bereits gelaufen sind und wir deren Ergebnis nicht überschreiben. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + fun onKnockback( + event: EntityKnockbackEvent + ) { + if ( !isIngame() ) return + if ( event.cause != EntityKnockbackEvent.Cause.ENTITY_ATTACK ) return + + val victim = event.entity as? Player ?: return + + if ( !plugin.gameManager.alivePlayers.contains( victim.uniqueId ) ) return + + // ── Angreifer aus Cache lesen ───────────────────────────────────────── + val attackerUUID = lastAttacker[ victim.uniqueId ] ?: return + val attacker = Bukkit.getPlayer( attackerUUID ) ?: return + + // ── Vanilla-Knockback unterdrücken ──────────────────────────────────── + event.isCancelled = true + + // ── 1.8-Velocity berechnen und setzen ──────────────────────────────── + val kb = buildKnockbackVector( attacker ) + val current = victim.velocity + + // Vertikale Velocity ist additiv wie in 1.8: ein laufender Sprung wird + // nicht abrupt gestoppt, sondern der KB-Anteil addiert sich oben drauf. + // coerceAtMost(0.4) verhindert dass gestapelte Hits ins Skybase-Terrain schießen. + victim.velocity = Vector( + kb.x, + ( current.y + kb.y ).coerceAtMost( 0.4 ), + kb.z + ) + } + + // ========================================================================= + // Knockback-Mathematik + // ========================================================================= + + /** + * Berechnet den 1.8-Knockback-[Vector] anhand des Angreifers. + * + * ## Richtung: Yaw-basiert, nicht positions-basiert + * In 1.8 wurde die Knockback-Richtung vom **Yaw des Angreifers** abgeleitet + * (wohin er schaut), nicht vom Vektor zwischen Angreifer und Opfer. + * Das ist der Kern des W-Tap-Gefühls: Wenn der Angreifer beim Schlag + * genau auf das Opfer zielt, trifft der KB punktgenau nach vorne. + * Sprint-Resets (kurz S drücken, dann wieder W) lohnen sich, weil der + * Sprint-Bonus nur bei aktiv gedrücktem Sprint zählt. + * + * ## Sprint-Bonus + * Wenn [attacker] sprintet, wird [kbHorizontal] mit [kbSprintMultiplier] + * multipliziert. Das belohnt konsequentes W-Tapping gegenüber Spam-Clicking. + * + * @param attacker Der Spieler, der den Treffer ausgeführt hat. + * @return Fertiger Knockback-[Vector] bereit zur Anwendung auf das Opfer. + */ + private fun buildKnockbackVector( + attacker: Player + ): Vector + { + // Minecraft-Koordinatensystem: Yaw 0° = Süden (+Z), 90° = Westen (-X) + val yawRad = Math.toRadians( attacker.location.yaw.toDouble() ) + + val dirX = -sin( yawRad ) + val dirZ = cos( yawRad ) + + val horizontal = kbHorizontal * ( if ( attacker.isSprinting ) kbSprintMultiplier else 1.0 ) + + return Vector( + dirX * horizontal, + kbVertical, + dirZ * horizontal + ) + } + + // ========================================================================= + // Hilfsmethoden + // ========================================================================= + + private fun isIngame(): Boolean = when ( plugin.gameManager.currentState ) + { + GameState.INGAME, GameState.INVINCIBILITY -> true + else -> false + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt index fc98cba..755efbb 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/config/CustomGameSettings.kt @@ -134,8 +134,7 @@ data class CustomGameSettings( @SerialName("shockwave_radius") val shockwaveRadius: Double = 6.0, @SerialName("teleport_range") val teleportRange: Double = 10.0, @SerialName("max_teleport_charges") val maxTeleportCharges: Int = 3, - @SerialName("freeze_duration_ticks") val freezeDurationTicks: Int = 200, - @SerialName("max_hits_on_frozen") val maxHitsOnFrozen: Int = 5, + @SerialName("freeze_duration_ticks") val freezeDurationTicks: Int = 60, /** * Generische Erweiterungs-Map für kit-spezifische Einstellungen, die diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt index 658f417..8d46309 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/modules/FeastManager.kt @@ -121,15 +121,18 @@ class FeastManager( 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 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 -> + + // State erst im nächsten Tick lesen — Block-Commit braucht einen Tick + Bukkit.getScheduler().runTaskLater( plugin, { -> + val chest = chestBlock.state as? Chest ?: return@runTaskLater fillChestWithLoot( chest ) chest.update( true ) - } + }, 1L ) } }, 5L ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt index ee8e018..8d09efa 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/kit/impl/TheWorldKit.kt @@ -13,7 +13,6 @@ import club.mcscrims.speedhg.util.trans import net.kyori.adventure.text.Component import org.bukkit.* import org.bukkit.entity.Player -import org.bukkit.event.entity.EntityDamageByEntityEvent import org.bukkit.inventory.ItemStack import org.bukkit.potion.PotionEffect import org.bukkit.potion.PotionEffectType @@ -82,7 +81,6 @@ import kotlin.math.sin * ### DEFENSIVE active * Radiale Schockwelle + [applyFreeze] auf jeden nahen lebenden Gegner. Ein 1-Tick-Task * setzt die Velocity gefrorener Spieler für [freezeDurationTicks] Ticks auf 0. - * [DefensivePassive] beendet den Freeze nach [maxHitsOnFrozen] Treffern. * * ### Warum hitsRequired = 0? * Beide Aktiv-Fähigkeiten steuern intern wann [execute] feuert. Das eingebaute @@ -110,8 +108,7 @@ class TheWorldKit : Kit() { const val DEFAULT_SHOCKWAVE_RADIUS = 6.0 const val DEFAULT_TELEPORT_RANGE = 10.0 const val DEFAULT_MAX_TELEPORT_CHARGES = 3 - const val DEFAULT_FREEZE_DURATION_TICKS = 200 - const val DEFAULT_MAX_HITS_ON_FROZEN = 5 + const val DEFAULT_FREEZE_DURATION_TICKS = 60 // ← 3 Sekunden statt 200 const val DEFAULT_SHOCKWAVE_KNOCKBACK_SPEED = 2.0 const val DEFAULT_SHOCKWAVE_KNOCKBACK_Y = 0.45 const val DEFAULT_BLINK_STEP_SIZE = 0.4 @@ -161,13 +158,6 @@ class TheWorldKit : Kit() { private val freezeDurationTicks: Int get() = override().freezeDurationTicks - /** - * Maximale Anzahl an Treffern gegen einen gefrorenen Gegner. - * Quelle: typisiertes Feld `tw_max_hits_on_frozen`. - */ - private val maxHitsOnFrozen: Int - get() = override().maxHitsOnFrozen - /** * Horizontaler Velocity-Multiplikator der Schockwelle. * Quelle: `extras["shockwave_knockback_speed"]`. @@ -227,7 +217,6 @@ class TheWorldKit : Kit() { internal val frozenEnemies: MutableMap> = ConcurrentHashMap() data class FrozenData( - var hitsRemaining: Int, val task: BukkitTask ) @@ -278,12 +267,11 @@ class TheWorldKit : Kit() { ) { teleportCharges.remove( player.uniqueId ) - // Alle vom verlassenden Spieler verursachten Freezes auftauen frozenEnemies.entries .filter { ( _, pair ) -> pair.first == player.uniqueId } .map { ( victimUUID, pair ) -> victimUUID to pair.second } .forEach { ( victimUUID, data ) -> - data.task.cancel() + data.task.cancel() // ← identisch, funktioniert weiterhin frozenEnemies.remove( victimUUID ) Bukkit.getPlayer( victimUUID )?.clearFreezeEffects() } @@ -386,7 +374,7 @@ class TheWorldKit : Kit() { } /** - * Immobilisiert [target] und begrenzt Treffer von [attacker] auf [maxHitsOnFrozen]. + * Immobilisiert [target] und beendet den Freeze nach [freezeDurationTicks]. * Ein 1-Tick-Task setzt horizontale + aufwärts gerichtete Velocity auf 0. * * Konfigurationswerte werden zum Aktivierungszeitpunkt gesnapshot, damit @@ -396,13 +384,11 @@ class TheWorldKit : Kit() { attacker: Player, target: Player ) { - // Vorhandenen Freeze überschreiben frozenEnemies.remove( target.uniqueId )?.second?.task?.cancel() - val capturedDurationTicks = freezeDurationTicks - val capturedRefreshTicks = freezeRefreshTicks - val capturedPowderSnowTicks = freezePowderSnowTicks - val capturedMaxHits = maxHitsOnFrozen + val capturedDurationTicks = freezeDurationTicks + val capturedRefreshTicks = freezeRefreshTicks + val capturedPowderSnowTicks = freezePowderSnowTicks target.applyFreezeEffects() @@ -423,16 +409,13 @@ class TheWorldKit : Kit() { return } - // Horizontale + aufwärts Velocity jedes Tick nullen val v = target.velocity target.velocity = v.setX( 0.0 ).setZ( 0.0 ) .let { if ( it.y > 0.0 ) it.setY( 0.0 ) else it } - // Slowness jede Sekunde refreshen damit sie nicht ausläuft if ( ticks % capturedRefreshTicks == 0 ) target.applyFreezeEffects() - // Powder-Snow-Visuals (rein kosmetisch) if ( target.freezeTicks < capturedPowderSnowTicks ) target.freezeTicks = capturedPowderSnowTicks } @@ -440,7 +423,7 @@ class TheWorldKit : Kit() { frozenEnemies[ target.uniqueId ] = Pair( attacker.uniqueId, - FrozenData( hitsRemaining = capturedMaxHits, task = task ) + FrozenData( task = task ) // ← kein hitsRemaining mehr ) target.sendActionBar( target.trans( "kits.theworld.messages.frozen_received" ) ) @@ -630,10 +613,10 @@ class TheWorldKit : Kit() { } // ========================================================================= - // DEFENSIVE passive – 5-Treffer-Cap auf gefrorene Gegner + // DEFENSIVE no-passive // ========================================================================= - private inner class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) { + private class DefensivePassive : PassiveAbility( Playstyle.DEFENSIVE ) { private val plugin get() = SpeedHG.instance @@ -646,35 +629,7 @@ class TheWorldKit : Kit() { "kits.theworld.passive.defensive.description" ) - /** - * Wird nur aufgerufen wenn der TheWorld-Spieler (Angreifer) jemanden trifft. - * Wenn das Opfer vom gleichen Angreifer eingefroren wurde, Treffer-Cap dekrementieren. - */ - override fun onHitEnemy( - attacker: Player, - victim: Player, - event: EntityDamageByEntityEvent - ) { - val ( frozenBy, data ) = frozenEnemies[ victim.uniqueId ] ?: return - - // Nur Treffer vom Spieler zählen, der den Freeze ausgelöst hat - if ( frozenBy != attacker.uniqueId ) return - - data.hitsRemaining-- - - if ( data.hitsRemaining <= 0 ) - { - doUnfreeze( victim ) - attacker.sendActionBar( attacker.trans( "kits.theworld.messages.freeze_broken" ) ) - } - else - { - attacker.sendActionBar(attacker.trans( - "kits.theworld.messages.freeze_hits_left", - mapOf( "hits" to data.hitsRemaining.toString() ) - )) - } - } + // Hit-Cap entfernt — Freeze endet jetzt rein timer-basiert nach 3 Sekunden } // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt b/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt index edce4b2..7c8006e 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/listener/GameStateListener.kt @@ -3,14 +3,17 @@ package club.mcscrims.speedhg.listener import club.mcscrims.speedhg.SpeedHG import club.mcscrims.speedhg.game.GameState import club.mcscrims.speedhg.util.sendMsg +import org.bukkit.Bukkit import org.bukkit.Material import org.bukkit.Sound import org.bukkit.attribute.Attribute import org.bukkit.entity.Player import org.bukkit.event.Event import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority import org.bukkit.event.Listener import org.bukkit.event.block.BlockBreakEvent +import org.bukkit.event.block.BlockPhysicsEvent import org.bukkit.event.block.BlockPlaceEvent import org.bukkit.event.block.LeavesDecayEvent import org.bukkit.event.enchantment.EnchantItemEvent @@ -24,6 +27,7 @@ import org.bukkit.inventory.EnchantingInventory import org.bukkit.inventory.ItemStack import org.bukkit.inventory.meta.Damageable import java.util.* +import java.util.concurrent.ConcurrentHashMap class GameStateListener : Listener { @@ -81,7 +85,7 @@ class GameStateListener : Listener { val player = event.player if ( gameManager.currentState != GameState.INVINCIBILITY && - gameManager.currentState != GameState.INGAME ) + gameManager.currentState != GameState.INGAME ) { event.isCancelled = true player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f ) @@ -90,36 +94,42 @@ class GameStateListener : Listener { val block = event.block - if ( gameManager.currentState == GameState.INVINCIBILITY && - beforeInvisMaterials.containsKey( block.type )) - { - pickupBlock( event, player ) - return - } - - if (alwaysMaterials.containsKey( block.type )) - { - pickupBlock( event, player ) - return - } - + // Diamant-Erz: immer blockieren if ( block.type == Material.DIAMOND_ORE ) { event.isCancelled = true event.block.type = Material.AIR event.block.tick() - player.sendMsg( "build.no_diamonds" ) player.playSound( player.location, Sound.ENTITY_VILLAGER_NO, 1f, 1f ) return } + // Eisen-Erz: vor Feast blockieren if ( block.type == Material.IRON_ORE && !feastStarted ) { event.isCancelled = true - player.sendMsg("build.no_iron_before_feast") - player.playSound(player.location, Sound.ENTITY_VILLAGER_NO, 1f, 1f) + player.sendMsg( "build.no_iron_before_feast" ) + player.playSound( player.location, Sound.ENTITY_VILLAGER_NO, 1f, 1f ) + return } + + // Invincibility: nur Whitelist-Blöcke erlaubt + if ( gameManager.currentState == GameState.INVINCIBILITY ) + { + if ( beforeInvisMaterials.containsKey( block.type ) || + alwaysMaterials.containsKey( block.type ) ) + { + pickupBlock( event, player ) + } else { + event.isCancelled = true + } + return + } + + // INGAME: alle Blöcke direkt in Inventar — nie auf den Boden fallen lassen + // (ItemDespawnEvent cancelt sonst alle Drops sofort) + pickupBlock( event, player ) } private fun pickupBlock( @@ -128,21 +138,34 @@ class GameStateListener : Listener { ) { val block = event.block + // In pickupBlock, direkt vor event.isCancelled = true: + if ( block.type == Material.RED_MUSHROOM || + block.type == Material.BROWN_MUSHROOM ) + { + activeMushroomBreaker[ player.uniqueId ] = player + Bukkit.getScheduler().runTask( plugin ) { -> + activeMushroomBreaker.remove( player.uniqueId ) + } + } + event.isCancelled = true - val sound = if (beforeInvisMaterials.containsKey( block.type )) - beforeInvisMaterials[ block.type ]!! - else alwaysMaterials[ block.type ]!! + val sound = beforeInvisMaterials[ block.type ] + ?: alwaysMaterials[ block.type ]!! - if (!hasInventorySpace( player )) + // block.getDrops( tool, player ) gibt den korrekten Vanilla-Drop-Context + // inkl. Age-State bei Cocoa, Fortune-Enchant, Silk-Touch usw. + val drops = block.getDrops( player.inventory.itemInMainHand, player ) + + if ( !hasInventorySpace( player ) ) { - block.drops.forEach { player.world.dropItem( block.location, it ) } + drops.forEach { player.world.dropItem( block.location, it ) } player.playSound( player.location, sound, 1f, 1f ) block.type = Material.AIR return } - block.drops.forEach { player.inventory.addItem( it ) } + drops.forEach { player.inventory.addItem( it ) } player.playSound( player.location, sound, 1f, 1f ) block.type = Material.AIR } @@ -150,6 +173,49 @@ class GameStateListener : Listener { private fun hasInventorySpace( player: Player ): Boolean = player.inventory.any { it == null || it.type == Material.AIR } + /** + * Tracks welcher Spieler gerade einen Pilz abbaut. + * UUID des Spielers → Location des abgebauten Blocks. + * Wird in onBlockBreak gesetzt und in onBlockPhysics konsumiert. + * + * Notwendig damit der Physics-Handler weiß, wessen Inventory die + * Kettenreaktion-Drops bekommen soll. + */ + private val activeMushroomBreaker: MutableMap = ConcurrentHashMap() + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) + fun onBlockPhysics( + event: BlockPhysicsEvent + ) { + val block = event.block + if ( block.type != Material.RED_MUSHROOM && + block.type != Material.BROWN_MUSHROOM ) return + + if ( gameManager.currentState != GameState.INVINCIBILITY && + gameManager.currentState != GameState.INGAME ) return + + // Einen Spieler in der Nähe finden der gerade einen Pilz abbaut + val harvester = activeMushroomBreaker.values + .firstOrNull { it.location.distanceSquared( block.location ) <= 25.0 } + ?: return // Keine aktive Spieleraktion — normales Physik-Event, ignorieren + + // Block vor dem Pop einsammeln + event.isCancelled = true + + val drops = block.getDrops( harvester.inventory.itemInMainHand, harvester ) + + if ( !hasInventorySpace( harvester ) ) + { + drops.forEach { harvester.world.dropItem( block.location, it ) } + } + else + { + drops.forEach { harvester.inventory.addItem( it ) } + } + + block.type = Material.AIR + } + @EventHandler fun onDropItem( event: PlayerDropItemEvent @@ -190,13 +256,6 @@ class GameStateListener : Listener { player.playSound( player.location, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f ) } - @EventHandler - fun onItemDespawn( - event: ItemDespawnEvent - ) { - event.isCancelled = true - } - private val swordNerf = 0.75 private val otherNerf = 0.4