diff --git a/blight-assets/src/main/resources/Characters/sets/human.animset.json b/blight-assets/src/main/resources/Characters/sets/human.animset.json index d87a59f..4d7e9f1 100644 --- a/blight-assets/src/main/resources/Characters/sets/human.animset.json +++ b/blight-assets/src/main/resources/Characters/sets/human.animset.json @@ -59,7 +59,7 @@ "SWORD_IDLE_TO_BLOCK": "Sword And Shield Block", "SWORD_BLOCK_IMPACT": "Sword And Shield Block Impact", "SWORD_IDLE_IMPACT": "Sword And Shield Impact", - "SWORD_COUNTERATTACK": "Sword And Shield Attack Counterattack", + "SWORD_PARADE": "Sword And Shield Attack Counterattack", "SWORD_BLOCK_MOVE": "Sword And Shield Block Walk", "SWORD_BLOCK_MOVE_BACKWARD": "Sword And Shield Block Walk Backwards", "SWORD_BLOCK_STRAFE_LEFT": "Sword And Shield Strafe Left", diff --git a/blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg b/blight-assets/src/main/resources/audio/weapons/sword/impact/stab1.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg rename to blight-assets/src/main/resources/audio/weapons/sword/impact/stab1.ogg diff --git a/blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg b/blight-assets/src/main/resources/audio/weapons/sword/impact/stab2.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg rename to blight-assets/src/main/resources/audio/weapons/sword/impact/stab2.ogg diff --git a/blight-editor/src/main/java/de/blight/editor/toolset/game/GameController.java b/blight-editor/src/main/java/de/blight/editor/toolset/game/GameController.java index 8a3468d..70b72dc 100644 --- a/blight-editor/src/main/java/de/blight/editor/toolset/game/GameController.java +++ b/blight-editor/src/main/java/de/blight/editor/toolset/game/GameController.java @@ -264,12 +264,16 @@ public class GameController { } String sessionMapPath = sessionDir.resolve("blight_map.blm").toString(); + String gameLogback = ProjectRoot.PATH + .resolve("blight-game/src/main/resources/logback.xml") + .toString(); java.util.List cmd = new java.util.ArrayList<>(List.of( javaExe, "--add-opens", "java.base/java.lang=ALL-UNNAMED", "--add-opens", "java.desktop/sun.awt=ALL-UNNAMED", "-Xmx2g", "-Djava.library.path=" + libPath, + "-Dlogback.configurationFile=" + gameLogback, "-Dblight.project.root=" + projRoot, "-D" + de.blight.common.MapIO.PROP_SESSION_MAP + "=" + sessionMapPath, "-Dblight.autostart=" + isAutostart)); diff --git a/blight-editor/src/main/resources/logback.xml b/blight-editor/src/main/resources/logback.xml index 032334f..7f9735f 100644 --- a/blight-editor/src/main/resources/logback.xml +++ b/blight-editor/src/main/resources/logback.xml @@ -17,6 +17,9 @@ + + + diff --git a/blight-game/build.gradle b/blight-game/build.gradle index 2b96095..95c86fc 100644 --- a/blight-game/build.gradle +++ b/blight-game/build.gradle @@ -49,6 +49,7 @@ tasks.register('extractNatives', Copy) { tasks.register('run', JavaExec) { group = 'application' description = 'Startet das Spiel' + dependsOn 'classes' // stellt sicher, dass Java vor dem Start kompiliert wird mainClass = mainClassName classpath = sourceSets.main.runtimeClasspath javaLauncher = javaToolchains.launcherFor { diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java b/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java index 63eb8e6..28c8572 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java @@ -87,7 +87,7 @@ public class AnimPlayer { } if (skinning != null && !skinning.isEnabled()) skinning.setEnabled(true); - if ((currentClip != null || pendingClip != null) && transition > 0f) { + if (!pendingReversed && (currentClip != null || pendingClip != null) && transition > 0f) { com.jme3.anim.Armature arm = skinning != null ? skinning.getArmature() : null; AnimClip freeze = buildFreezeClip(arm); if (freeze != null) { @@ -182,6 +182,9 @@ public class AnimPlayer { currentClip = clipName; pendingClip = null; blendSpace = null; + // Speed immer auf 1 zurücksetzen – ClipAction-Instanz wird wiederverwendet + // und könnte noch speed=-1 vom letzten reversed-Play haben. + if (currentAction != null) { currentAction.setSpeed(1f); } double len = currentAction != null ? currentAction.getLength() : -1; if (pendingReversed && currentAction != null && len > 0.01) { currentAction.setSpeed(-1f); @@ -206,24 +209,25 @@ public class AnimPlayer { private void finishBlend() { String clip = pendingClip; - float elapsed = blendElapsed; pendingClip = null; blendSpace = null; if (clip == null) return; try { - if (composer.hasAction("__blend__")) composer.removeAction("__blend__"); - } catch (Exception ignored) {} - try { + // setCurrentAction vor removeAction: kein 1-Frame-Gap ohne aktive Action currentAction = composer.setCurrentAction(clip); currentClip = clip; + try { + if (composer.hasAction("__blend__")) composer.removeAction("__blend__"); + } catch (Exception ignored) {} double len = currentAction != null ? currentAction.getLength() : 0.0; if (pendingReversed && currentAction != null && len > 0.01) { currentAction.setSpeed(-1f); composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime((float) len); pendingReversed = false; - } else if (len > 0.01 && (elapsed > 0 || pendingStartOffset > 0)) { - // Clip startet dort, wo der Übergang endete (kein sichtbarer Sprung). - float t = (pendingStartOffset + elapsed) % (float) len; + } else if (len > 0.01 && (blendDuration > 0 || pendingStartOffset > 0)) { + // blendDuration statt blendElapsed: blendElapsed hat bereits das aktuelle tpf + // eingerechnet, die Sub-Action im AnimComposer aber noch nicht → 1-Frame exakt. + float t = (pendingStartOffset + blendDuration) % (float) len; composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(t); pendingStartOffset = 0f; } diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index c2190ac..fecf66d 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -42,20 +42,20 @@ public enum AnimationAction { // ── Schwert ─────────────────────────────────────────────────────────────── SWORD_IDLE_BORED, SWORD_IDLE_BORED_2, SWORD_IDLE_BORED_3, SWORD_DEATH, SWORD_DEATH_2, - SWORD_ATTACK_1, SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK,SWORD_COUNTERATTACK, SWORD_IDLE_IMPACT, + SWORD_ATTACK_1, SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK, SWORD_PARADE, SWORD_IDLE_IMPACT, SWORD_IDLE, SWORD_WALK, SWORD_RUN, SWORD_JUMP, SWORD_RUNNING_JUMP, SWORD_BLOCK, SWORD_IDLE_TO_BLOCK, SWORD_BLOCK_IMPACT, SWORD_BLOCK_MOVE, SWORD_BLOCK_MOVE_BACKWARD, SWORD_BLOCK_STRAFE_LEFT, SWORD_BLOCK_STRAFE_RIGHT, // ── Axt ─────────────────────────────────────────────────────────────────── AXE_DRAW, AXE_SHEATHE, AXE_BLOCK, AXE_ATTACK_1, AXE_ATTACK_2, AXE_ATTACK_3, AXE_HEAVY_ATTACK, - AXE_IDLE_TO_BLOCK, AXE_COUNTERATTACK, AXE_BLOCK_IMPACT, AXE_IDLE_IMPACT, + AXE_IDLE_TO_BLOCK, AXE_PARADE, AXE_BLOCK_IMPACT, AXE_IDLE_IMPACT, AXE_IDLE, AXE_WALK, AXE_RUN, // ── Zweihänder ──────────────────────────────────────────────────────────── TWO_HAND_DRAW, TWO_HAND_SHEATHE, TWO_HAND_BLOCK, TWO_HAND_ATTACK_1, TWO_HAND_ATTACK_2, TWO_HAND_ATTACK_3, TWO_HAND_HEAVY_ATTACK, - TWO_HAND_IDLE_TO_BLOCK, TWO_HAND_COUNTERATTACK, TWO_HAND_BLOCK_IMPACT, TWO_HAND_IDLE_IMPACT, + TWO_HAND_IDLE_TO_BLOCK, TWO_HAND_PARADE, TWO_HAND_BLOCK_IMPACT, TWO_HAND_IDLE_IMPACT, TWO_HAND_IDLE, TWO_HAND_WALK, TWO_HAND_RUN, // ── Bogen ───────────────────────────────────────────────────────────────── @@ -119,7 +119,7 @@ public enum AnimationAction { case SWORD_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Schwert)"); case SWORD_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Schwert)"); case SWORD_IDLE_TO_BLOCK -> TextRegistry.resolve(null, key, "Idle → Block (Schwert)"); - case SWORD_COUNTERATTACK -> TextRegistry.resolve(null, key, "Konter (Schwert)"); + case SWORD_PARADE -> TextRegistry.resolve(null, key, "Parade (Schwert)"); case SWORD_BLOCK_IMPACT -> TextRegistry.resolve(null, key, "Block-Treffer (Schwert)"); case SWORD_IDLE_IMPACT -> TextRegistry.resolve(null, key, "Treffer (Schwert)"); case SWORD_IDLE -> TextRegistry.resolve(null, key, "Stehen (Schwert)"); @@ -138,7 +138,7 @@ public enum AnimationAction { case AXE_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Axt)"); case AXE_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Axt)"); case AXE_IDLE_TO_BLOCK -> TextRegistry.resolve(null, key, "Idle → Block (Axt)"); - case AXE_COUNTERATTACK -> TextRegistry.resolve(null, key, "Konter (Axt)"); + case AXE_PARADE -> TextRegistry.resolve(null, key, "Parade (Axt)"); case AXE_BLOCK_IMPACT -> TextRegistry.resolve(null, key, "Block-Treffer (Axt)"); case AXE_IDLE_IMPACT -> TextRegistry.resolve(null, key, "Treffer (Axt)"); case AXE_IDLE -> TextRegistry.resolve(null, key, "Stehen (Axt)"); @@ -153,7 +153,7 @@ public enum AnimationAction { case TWO_HAND_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Zweihänder)"); case TWO_HAND_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Zweihänder)"); case TWO_HAND_IDLE_TO_BLOCK -> TextRegistry.resolve(null, key, "Idle → Block (Zweihänder)"); - case TWO_HAND_COUNTERATTACK -> TextRegistry.resolve(null, key, "Konter (Zweihänder)"); + case TWO_HAND_PARADE -> TextRegistry.resolve(null, key, "Parade (Zweihänder)"); case TWO_HAND_BLOCK_IMPACT -> TextRegistry.resolve(null, key, "Block-Treffer (Zweihänder)"); case TWO_HAND_IDLE_IMPACT -> TextRegistry.resolve(null, key, "Treffer (Zweihänder)"); case TWO_HAND_IDLE -> TextRegistry.resolve(null, key, "Stehen (Zweihänder)"); diff --git a/blight-game/src/main/java/de/blight/game/combat/CombatStyleBase.java b/blight-game/src/main/java/de/blight/game/combat/CombatStyleBase.java new file mode 100644 index 0000000..18e7bd4 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/combat/CombatStyleBase.java @@ -0,0 +1,89 @@ +package de.blight.game.combat; + +import com.jme3.anim.AnimComposer; +import com.jme3.math.Vector3f; +import de.blight.game.animation.AnimationAction; + +/** + * Abstrakte Basisklasse für alle Kampfstile. + * + * Enthält: + * – das {@link AnimReceiver}-Interface zum einheitlichen Empfangen von + * Kampf-Animationen (Stagger, Impact) auf Spieler- UND NPC-Seite + * – gemeinsame Hilfsmethoden (Clip-Länge lesen, Klingen-Treffertest) + * – gemeinsame Konstanten (Treffer-Radius, Impact-Speeds) + * + * Konkrete Kampfstile (z. B. {@code swordshield.SwordShieldCombat}) erben + * von dieser Klasse und ergänzen stil-spezifische Animations-Auflösung. + */ +public abstract class CombatStyleBase { + + // ── Gemeinsame Konstanten ───────────────────────────────────────────────── + + /** Abspielgeschwindigkeit für normalen Treffer-Impact. */ + public static final float IMPACT_SPEED_NORMAL = 1.0f; + /** Abspielgeschwindigkeit nach Konterparade (Slow-Motion-Reaktion). */ + public static final float IMPACT_SPEED_PARRIED = 0.5f; + + /** Körperradius eines NPCs für die Klingen-Kapsel-Trefferprüfung. */ + public static final float HIT_RADIUS = 0.5f; + + // ── AnimReceiver-Interface ──────────────────────────────────────────────── + + /** + * Einheitliche Schnittstelle für Entitäten, die eine blockierende + * Kampfanimation empfangen können – implementiert von + * {@code PlayerInputControl} (Held) und NPC-Adaptern in + * {@code WorldNpcsState}. + * + * Eine „blockierende" Animation sperrt die Entität für die Dauer des + * Clips von weiteren eigenen Aktionen (Bewegung, Angriff usw.). + * + * @see CombatStyleBase#readActualClipLength(AnimComposer, float) + */ + public interface AnimReceiver { + /** + * Spielt {@code action} ab und sperrt die Entität für die Clip-Dauer. + * + * @param action die abzuspielende Animation + * @param speed Abspielgeschwindigkeit (1.0 = normal, 0.5 = Konterparade) + * @param onDone optionaler Callback wenn die Animation endet (darf null sein) + */ + void receiveBlockingAnim(AnimationAction action, float speed, Runnable onDone); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + /** + * Liest die tatsächliche Clip-Länge aus dem AnimComposer des DEFAULT_LAYER. + * Muss nach dem Starten der Animation aufgerufen werden, damit + * die korrekte Länge im Composer steht. + * + * @param ac AnimComposer der Entität (darf null sein) + * @param fallback Rückgabewert wenn Länge nicht ermittelbar + * @return tatsächliche Clip-Länge oder {@code fallback} + */ + public static float readActualClipLength(AnimComposer ac, float fallback) { + if (ac == null) { return fallback; } + var layer = ac.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer == null) { return fallback; } + var a = layer.getCurrentAction(); + return (a != null && a.getLength() > 0.01) ? (float) a.getLength() : fallback; + } + + /** + * Quadratischer Abstand von Punkt {@code p} zum Segment {@code a→b}. + * Wird für die Klingen-Kapsel-Trefferprüfung in allen Kampfstilen genutzt. + */ + public static float segmentDistSq(Vector3f p, Vector3f a, Vector3f b) { + float abX = b.x - a.x, abY = b.y - a.y, abZ = b.z - a.z; + float lenSq = abX*abX + abY*abY + abZ*abZ; + float t = lenSq > 0.0001f + ? Math.max(0f, Math.min(1f, ((p.x-a.x)*abX + (p.y-a.y)*abY + (p.z-a.z)*abZ) / lenSq)) + : 0f; + float cx = p.x - (a.x + t*abX); + float cy = p.y - (a.y + t*abY); + float cz = p.z - (a.z + t*abZ); + return cx*cx + cy*cy + cz*cz; + } +} diff --git a/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java b/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java index 5515fba..41666c5 100644 --- a/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java +++ b/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java @@ -18,4 +18,14 @@ public interface MeleeAttackCallback { */ void onMeleeAttack(int attackId, Vector3f gripPos, Vector3f bladeDir, float bladeLength, int baseDamage, float critMultiplier); + + /** + * Wird beim START eines normalen (nicht-schweren) Angriffs aufgerufen, + * damit NPCs die Konterparade-Entscheidung sofort treffen und die + * Parade-Animation frühzeitig starten können. + * + * @param attackId Gleiche ID wie beim nachfolgenden onMeleeAttack + * @param isHeavy true = schwerer Angriff (kein Konterparade-Kandidat) + */ + default void onAttackStart(int attackId, boolean isHeavy) {} } diff --git a/blight-game/src/main/java/de/blight/game/combat/WeaponCombatState.java b/blight-game/src/main/java/de/blight/game/combat/WeaponCombatState.java index a99deb8..5d60a50 100644 --- a/blight-game/src/main/java/de/blight/game/combat/WeaponCombatState.java +++ b/blight-game/src/main/java/de/blight/game/combat/WeaponCombatState.java @@ -94,11 +94,12 @@ public final class WeaponCombatState { /** * Entscheidet nach dem aktuellen Angriffsschritt ob die Kombo verlängert wird. - * Gibt den nächsten Schritt (1 oder 2) zurück, oder -1 wenn keine Verlängerung. + * Gleiche 5-stufige Sequenz wie beim Spieler: 0→1→2→3→4 (gerade=ATK1, ungerade=ATK2, 4=Finisher). + * Gibt den nächsten Schritt zurück, oder -1 wenn keine Verlängerung. */ public int nextComboStep(java.util.Random rng) { - if (phase != Phase.ATTACKING || isHeavy || comboStep >= 2) { return -1; } - float chance = comboStep == 0 ? COMBO_P1 : COMBO_P2; + if (phase != Phase.ATTACKING || isHeavy || comboStep >= 4) { return -1; } + float chance = (comboStep % 2 == 0) ? COMBO_P1 : COMBO_P2; return rng.nextFloat() < chance ? comboStep + 1 : -1; } diff --git a/blight-game/src/main/java/de/blight/game/combat/swordshield/SwordShieldCombat.java b/blight-game/src/main/java/de/blight/game/combat/swordshield/SwordShieldCombat.java new file mode 100644 index 0000000..207ac66 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/combat/swordshield/SwordShieldCombat.java @@ -0,0 +1,84 @@ +package de.blight.game.combat.swordshield; + +import de.blight.common.model.ItemSubCategory; +import de.blight.game.animation.AnimationAction; +import de.blight.game.combat.CombatStyleBase; + +/** + * Gemeinsame Kampflogik für den Schwert-und-Schild-Stil. + * + * Wird von beiden Seiten genutzt: + * – {@code PlayerInputControl} (Held) + * – {@code WorldNpcsState} (Gegner-KI) + * + * Alle Animations-Auflösungen (welche Animation für welches Ereignis?) und + * alle Reaktions-Anwendungen (Impact, Stagger, Konterparade) sind hier + * zentralisiert. Bug-Fixes gelten damit automatisch für Held und NPC. + * + * Neue Kampfstile erben von {@link CombatStyleBase} und folgen demselben + * Muster in einem eigenen Unter-Package. + */ +public final class SwordShieldCombat extends CombatStyleBase { + + private SwordShieldCombat() {} + + // ── Animations-Auflösung ────────────────────────────────────────────────── + + /** + * Impact-Animation in Idle/Kampf-Haltung (Entität wurde getroffen). + * Identisch für Spieler und NPC – weapon type der GETROFFENEN Entität. + */ + public static AnimationAction resolveIdleImpactAction(ItemSubCategory weaponType) { + if (weaponType == null) { return AnimationAction.SWORD_IDLE_IMPACT; } + return switch (weaponType) { + case AXE -> AnimationAction.AXE_IDLE_IMPACT; + case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT; + default -> AnimationAction.SWORD_IDLE_IMPACT; + }; + } + + /** + * Block-Impact-Animation (Entität hat einen Treffer abgeblockt). + * Identisch für Spieler und NPC. + */ + public static AnimationAction resolveBlockImpactAction(ItemSubCategory weaponType) { + if (weaponType == null) { return AnimationAction.SWORD_BLOCK_IMPACT; } + return switch (weaponType) { + case AXE -> AnimationAction.AXE_BLOCK_IMPACT; + case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK_IMPACT; + default -> AnimationAction.SWORD_BLOCK_IMPACT; + }; + } + + /** + * Konterangriff-Animation nach erfolgreicher Parade. + * Identisch für Spieler und NPC. + */ + public static AnimationAction resolveParadeAction(ItemSubCategory weaponType) { + if (weaponType == null) { return AnimationAction.SWORD_PARADE; } + return switch (weaponType) { + case AXE -> AnimationAction.AXE_PARADE; + case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_PARADE; + default -> AnimationAction.SWORD_PARADE; + }; + } + + // ── Reaktionen anwenden ─────────────────────────────────────────────────── + + /** + * Normaler Treffer-Impact (Entität wurde getroffen, kein Block). + * Für Held: {@code PlayerInputControl} implementiert {@link AnimReceiver}. + * Für NPC: {@code WorldNpcsState.npcAnimReceiver(s)} liefert den Adapter. + */ + public static void applyNormalImpact(AnimReceiver receiver, ItemSubCategory weaponType) { + receiver.receiveBlockingAnim(resolveIdleImpactAction(weaponType), IMPACT_SPEED_NORMAL, null); + } + + /** + * Konterparade-Impact (Angreifer wird durch Parade gestoppt – Slow Motion). + * Wird aufgerufen auf dem ANGREIFER, nicht auf dem Parierenden. + */ + public static void applyParriedImpact(AnimReceiver receiver, ItemSubCategory weaponType) { + receiver.receiveBlockingAnim(resolveIdleImpactAction(weaponType), IMPACT_SPEED_PARRIED, null); + } +} diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index ff6e59c..4f67e56 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -30,8 +30,10 @@ import de.blight.game.animation.AnimationAction; import de.blight.game.animation.AnimationLibrary; import de.blight.game.animation.RetargetingSystem; import de.blight.game.config.KeyBindings; +import de.blight.game.combat.CombatStyleBase; import de.blight.game.combat.MeleeAttackCallback; import de.blight.game.combat.WeaponCombatState; +import de.blight.game.combat.swordshield.SwordShieldCombat; import de.blight.game.navigation.CharacterNavigator; import de.blight.game.state.TerrainChunkState; @@ -44,7 +46,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -public class PlayerInputControl { +public class PlayerInputControl implements CombatStyleBase.AnimReceiver { private static final Logger log = LoggerFactory.getLogger(PlayerInputControl.class); @@ -133,8 +135,11 @@ public class PlayerInputControl { private ItemSubCategory drawnWeaponType = null; private boolean isAiming = false; private boolean rightMouseHeld = false; + private boolean attackBuffered = false; // LMB während Block → nach Block-Exit sofort angreifen + private float prevBlockAnimTime = 0f; // Vorzustand für Loop-Erkennung in ENTERING private BlockPhase blockPhase = BlockPhase.NONE; private float blockPhaseTimer = 0f; + private float blockTapTimer = 0f; /** Gemeinsamer Zustandsautomat (Tap/Hold-Erkennung, parryArmed). */ private final WeaponCombatState weaponCombat = new WeaponCombatState(); private boolean combatAnimActive = false; @@ -156,6 +161,12 @@ public class PlayerInputControl { private float heavyLungeStartRemaining = 0f; /** combatAnimRemaining-Wert, ab dem die Lunge endet (Landung). */ private float heavyLungeEndRemaining = 0f; + // ── Spieler-Rückstoß (wenn getroffen oder Block absorbiert) ─────────────── + private float playerKnockbackDirX = 0f; + private float playerKnockbackDirZ = 0f; + private float playerKnockbackTimer = 0f; + private static final float PLAYER_KNOCKBACK_MPS = 1.2f; + private static final float PLAYER_KNOCKBACK_TIME = 0.18f; /** Gesamtdauer der aktuellen Kampfanim — für Kombo-Fenster-Timing (öffnet bei 50%). */ private float combatAnimDuration = 0f; private final List meleeCallbacks = new ArrayList<>(); @@ -167,6 +178,8 @@ public class PlayerInputControl { private int currentAttackId = -1; private boolean hitWindowActive = false; private float hitWindowCritMult = 1.0f; + private float hitWindowStart = HIT_WINDOW_START; + private float hitWindowEnd = HIT_WINDOW_END; // Angriffs-Sound via Waffen-Geschwindigkeit private static final float ATTACK_SOUND_VELOCITY = 3.5f; // Attacken mit Backswing → Sound erst nach Mindest-Fortschritt erlauben @@ -282,6 +295,13 @@ public class PlayerInputControl { /** Autopilot: wenn gesetzt, geht der Charakter automatisch in diese (normalisierte) Richtung. */ private Vector3f autopilotDir = null; + /** Position des nächsten angreifenden NPCs – für Lungen-Richtung bei schwerem Angriff. */ + private Vector3f nearestAttackingNpcPos = null; + + public void setNearestAttackingNpcPos(Vector3f pos) { + this.nearestAttackingNpcPos = pos != null ? pos.clone() : null; + } + // ── Schleichen / Ducken ─────────────────────────────────────────────────── private enum SneakPhase { NONE, DOWN, ACTIVE, UP } private SneakPhase sneakPhase = SneakPhase.NONE; @@ -326,7 +346,8 @@ public class PlayerInputControl { case ACT_SHEATH -> { if (isPressed && weaponDrawn && !blockingAnimActive && !drawAnimActive) sheathCurrentWeapon(); } case ACT_COMBAT_ATK -> { if (isPressed) { - if (!weaponDrawn || blockingAnimActive || drawAnimActive || isBlocking()) break; + if (!weaponDrawn || blockingAnimActive || drawAnimActive) break; + if (isBlocking()) { attackBuffered = true; break; } // Immer Hold-Timer starten – auch mitten in einer Kombo attackHoldPending = true; attackHoldTimer = 0f; @@ -471,6 +492,7 @@ public class PlayerInputControl { if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); stopWading(); rightMouseHeld = false; + attackBuffered = false; blockPhase = BlockPhase.NONE; weaponCombat.phase = WeaponCombatState.Phase.IDLE; weaponCombat.parryArmed = false; @@ -519,19 +541,70 @@ public class PlayerInputControl { * Sekunden und ruft danach {@code onComplete} auf (im Update-Thread). * Bei {@code duration <= 0} wird die echte Clip-Länge aus dem AnimComposer abgefragt. */ + /** Implementierung von {@link de.blight.game.combat.CombatStyleBase.AnimReceiver}. */ + @Override + public void receiveBlockingAnim(AnimationAction action, float speed, Runnable onDone) { + requestAnimation(action, 0f, speed, onDone); + } + public void requestAnimation(AnimationAction action, float duration, Runnable onComplete) { + requestAnimation(action, duration, 1f, onComplete); + } + + public void requestAnimation(AnimationAction action, float duration, float speed, Runnable onComplete) { + if (playerDead) { return; } + // Laufende Kampf-Animation sofort abbrechen – sonst blockiert blockingAnimActive den + // combatAnimActive-Timer nicht (blockingAnimActive-Return kommt früher in controlUpdate). + if (combatAnimActive) { + combatAnimActive = false; + hitWindowActive = false; + hitWindowStart = HIT_WINDOW_START; + hitWindowEnd = HIT_WINDOW_END; + heavyLungeVel = null; + heavyLungeStartRemaining = 0f; + heavyLungeEndRemaining = 0f; + combatAnimCallback = null; + combatAnimSkipComboWindow = false; + comboWindowActive = false; + comboStep = 0; + } + autopilotDir = null; + forward = backward = left = right = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + // Blend-Übergänge für blockierende Animationen (Impact, Stagger) verbieten: + // ein noch aktiver nextTransitionLength aus einem Kombo-Schlag würde eine BlendAction + // erzeugen deren getLength() 0 zurückgibt → Fallback-Timer zu lang → Animation loopt. + nextTransitionLength = -1.0; + playAction(action); + currentAnim = action; + // Clip-Länge zuverlässig aus dem AnimClip lesen (nicht von der live BlendAction) if (duration <= 0f) { duration = resolveClipLength(action, 1.5f); } + if (speed != 1f && animComposer != null) { + var layer = animComposer.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + var a = layer.getCurrentAction(); + if (a != null) { a.setSpeed(speed); } + } + duration = duration / speed; // Timer an Speed anpassen + } + // Rückstoß weg vom angreifenden NPC + if (nearestAttackingNpcPos != null && physicsChar != null) { + Vector3f myPos = physicsChar.getPhysicsLocation(); + float dx = myPos.x - nearestAttackingNpcPos.x; + float dz = myPos.z - nearestAttackingNpcPos.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len > 0.001f) { + playerKnockbackDirX = dx / len; + playerKnockbackDirZ = dz / len; + playerKnockbackTimer = PLAYER_KNOCKBACK_TIME; + } + } blockingAnimActive = true; - blockingAnimRemaining = duration; - blockingAnimTotal = duration; + blockingAnimRemaining = Math.max(0.1f, duration - 0.05f); + blockingAnimTotal = blockingAnimRemaining; blockingAnimCallback = onComplete; - autopilotDir = null; - forward = backward = left = right = false; - if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); - playAction(action); - currentAnim = action; } /** Liefert die Länge des Clips für {@code action} in Sekunden, oder {@code fallback} wenn nicht ermittelbar. */ @@ -872,13 +945,41 @@ public class PlayerInputControl { // Blockierende Einmal-Animation (lie_down, lie_up …) if (blockingAnimActive) { blockingAnimRemaining -= tpf; - physicsChar.setWalkDirection(Vector3f.ZERO); + if (playerKnockbackTimer > 0f) { + playerKnockbackTimer -= tpf; + float ratio = Math.max(0f, playerKnockbackTimer / PLAYER_KNOCKBACK_TIME); + float spd = PLAYER_KNOCKBACK_MPS / CharacterNavigator.RUN_SPEED_MPS * MOVE_SPEED; + physicsChar.setWalkDirection(scratchWalk.set( + playerKnockbackDirX * spd * ratio, 0f, playerKnockbackDirZ * spd * ratio)); + } else { + physicsChar.setWalkDirection(Vector3f.ZERO); + } if (blockingAnimRemaining <= 0f && !playerDead) { blockingAnimActive = false; Runnable cb = blockingAnimCallback; blockingAnimCallback = null; if (cb != null) cb.run(); + // Block-Puffer nach Impact/Stagger: RMB wurde während der Anim gehalten + if (!combatAnimActive && rightMouseHeld && blockPhase == BlockPhase.NONE) { + blockTapTimer = 0f; + weaponCombat.parryArmed = false; + startBlockEnter(); + } + } else if (playerDead && blockingAnimRemaining <= 0f && blockingAnimRemaining > -500f) { + // Todesanim abgelaufen → auf letztem Frame einfrieren + if (animComposer != null) { + var layer = animComposer.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + com.jme3.anim.tween.action.Action a = layer.getCurrentAction(); + if (a != null && a.getSpeed() != 0f) { + a.setSpeed(0f); + layer.setTime(Math.max(0f, (float) a.getLength() - 0.01f)); + } + } + } + blockingAnimRemaining = -1000f; + return; } else { return; } @@ -942,8 +1043,7 @@ public class PlayerInputControl { currentWaterDepth = (waterDepthQuery != null) ? waterDepthQuery.apply(charPos.x, charPos.z) : 0f; boolean inDeepWater = currentWaterDepth > WATER_WADING_DEPTH; - // Schon ab WAITING Gehgeschwindigkeit erzwingen, damit beim Blockstart kein Sprint-Frame entsteht - boolean inBlockStance = isBlocking() || blockPhase == BlockPhase.WAITING; + boolean inBlockStance = isBlocking(); boolean sneaking = sneakPhase != SneakPhase.NONE; @@ -1029,6 +1129,12 @@ public class PlayerInputControl { comboStep = 0; log.info("[Combat] Kombo-Fenster verpasst → Reset"); } + // Gepufferter Block: RMB wurde während des Angriffs gehalten → jetzt Block einleiten + if (!combatAnimActive && rightMouseHeld && blockPhase == BlockPhase.NONE) { + blockTapTimer = 0f; + weaponCombat.parryArmed = false; + startBlockEnter(); + } } else { // Kombo-Fenster öffnet sich in der zweiten Hälfte der Anim if (!comboWindowActive && !combatAnimSkipComboWindow @@ -1060,9 +1166,9 @@ public class PlayerInputControl { } if (hitWindowActive && isMeleeType(drawnWeaponType) && combatAnimDuration > 0f) { float t = (combatAnimDuration - combatAnimRemaining) / combatAnimDuration; - if (t >= HIT_WINDOW_START && t <= HIT_WINDOW_END) { + if (t >= hitWindowStart && t <= hitWindowEnd) { fireWeaponCallback(hitWindowCritMult); - } else if (t > HIT_WINDOW_END) { + } else if (t > hitWindowEnd) { hitWindowActive = false; } } @@ -1241,6 +1347,10 @@ public class PlayerInputControl { currentAttackId = ++attackIdCounter; hitWindowActive = true; hitWindowCritMult = lastAttackCritical ? 2.0f : 1.0f; + // Steps 1+3 = Attack2 (Backswing): Fenster später schließen damit Schwung den NPC erreicht + // Rückschwung (steps 1+3): Trefferfenster beginnt später (Schwung muss erst ankommen) + hitWindowStart = (step == 1 || step == 3) ? 0.45f : HIT_WINDOW_START; + hitWindowEnd = (step == 1 || step == 3) ? 0.92f : HIT_WINDOW_END; currentComboStep = step; comboStep = (step + 1) % 5; comboWindowActive = false; @@ -1258,21 +1368,11 @@ public class PlayerInputControl { combatAnimCallback = null; playAction(atk); currentAnim = atk; - // Finisher (step 4): leichte Vorwärtsbewegung - if (step == 4) { - Vector3f fwd = (visual != null) - ? visual.getWorldRotation().mult(new Vector3f(0f, 0f, 1f)) - : cam.getDirection().clone(); - fwd.y = 0f; - if (fwd.lengthSquared() > 0.001f) fwd.normalizeLocal(); - heavyLungeVel = fwd.mult(MOVE_SPEED * 0.1f); - heavyLungeStartRemaining = dur * (1f - LUNGE_FRAC_START); - heavyLungeEndRemaining = dur * (1f - LUNGE_FRAC_END); - } else { - heavyLungeVel = null; - heavyLungeStartRemaining = 0f; - heavyLungeEndRemaining = 0f; - } + for (MeleeAttackCallback cb : meleeCallbacks) { cb.onAttackStart(currentAttackId, false); } + // Lunge: schließt Lücke zum Gegner auf idealen Treffabstand (1.7m) + heavyLungeVel = computeLungeVelocity(dur); + heavyLungeStartRemaining = dur * (1f - LUNGE_FRAC_START); + heavyLungeEndRemaining = dur * (1f - LUNGE_FRAC_END); } else if (isRangedType(drawnWeaponType) && isAiming) { isAiming = false; AnimationAction fireAnim = getRangedFireAction(drawnWeaponType); @@ -1296,24 +1396,32 @@ public class PlayerInputControl { } private void handleCombatAlt(boolean isPressed) { - if (!weaponDrawn || blockingAnimActive) return; + if (!weaponDrawn) return; if (isMeleeType(drawnWeaponType)) { if (isPressed) { - if (blockPhase == BlockPhase.NONE) { - blockPhase = BlockPhase.WAITING; - blockPhaseTimer = 0f; - weaponCombat.onBlockPress(); + rightMouseHeld = true; + // Sofort blocken nur wenn keine andere Animation läuft + if (blockPhase == BlockPhase.NONE && !combatAnimActive && !blockingAnimActive) { + blockTapTimer = 0f; + weaponCombat.parryArmed = false; + startBlockEnter(); } + // Wenn Angriff oder Impact läuft: Block gepuffert, wird danach ausgelöst } else { + rightMouseHeld = false; + if (blockingAnimActive) return; // Loslassen während Impact → ignorieren switch (blockPhase) { - case WAITING -> { - // Kurzer Tap: Parade-Animation abspielen (auch wenn NPC nicht im Trefferfenster) - if (weaponCombat.onBlockRelease()) { + case ENTERING -> { + if (blockTapTimer <= WeaponCombatState.BLOCK_TAP_MAX) { + // Kurzer Tap: Block abbrechen, Parade auslösen + blockPhase = BlockPhase.NONE; + weaponCombat.parryArmed = true; playCounterAttack(); + } else { + startBlockExit(); } - blockPhase = BlockPhase.NONE; } - case ENTERING, HOLDING -> startBlockExit(); + case HOLDING -> startBlockExit(); default -> {} } } @@ -1324,25 +1432,33 @@ public class PlayerInputControl { private void updateBlockPhase(float tpf) { switch (blockPhase) { - case WAITING -> { - // Tap-Timer läuft in WeaponCombatState; gibt true zurück wenn Fenster abläuft - if (weaponCombat.tickParryWaiting(tpf)) { - // Tap-Fenster abgelaufen ohne Loslassen → Haltephase - if (hasShieldEquipped()) { - startBlockEnter(); - } else { - blockPhase = BlockPhase.NONE; + case ENTERING -> { + blockTapTimer += tpf; + blockPhaseTimer -= tpf; + // Primär: AnimComposer-Fortschritt (>= 90%) ODER Loop-Erkennung (Zeit fällt zurück) + // Sicherung: Timer-Fallback falls AnimComposer-Länge = 0 + boolean switchToHolding = blockPhaseTimer <= 0f; + if (!switchToHolding && animComposer != null) { + var layer = animComposer.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + float t = (float) layer.getTime(); + var a = layer.getCurrentAction(); + double len = (a != null) ? a.getLength() : 0; + if (len > 0.01 && t >= len * 0.90) { + switchToHolding = true; // Animation zu >= 90% abgespielt + } else if (t < prevBlockAnimTime - 0.05f) { + switchToHolding = true; // Loop erkannt: Zeit ist zurückgesprungen + } + prevBlockAnimTime = t; } } - } - case ENTERING -> { - blockPhaseTimer -= tpf; - if (blockPhaseTimer <= 0f) { + if (switchToHolding) { + prevBlockAnimTime = 0f; blockPhase = BlockPhase.HOLDING; - AnimationAction blockAct = getMeleeBlockAction(drawnWeaponType); - nextTransitionLength = COMBAT_BLEND; - playAction(blockAct); - currentAnim = blockAct; + AnimationAction holdAnim = getMeleeBlockAction(drawnWeaponType); + nextTransitionLength = -1.0; + playAction(holdAnim); + currentAnim = holdAnim; } } case EXITING -> { @@ -1350,6 +1466,10 @@ public class PlayerInputControl { if (blockPhaseTimer <= 0f) { blockPhase = BlockPhase.NONE; currentAnim = null; + if (attackBuffered) { + attackBuffered = false; + handleCombatAttack(); + } } } default -> {} @@ -1358,13 +1478,24 @@ public class PlayerInputControl { private void startBlockEnter() { blockPhase = BlockPhase.ENTERING; + prevBlockAnimTime = 0f; // Loop-Detection-Vorzustand zurücksetzen AnimationAction idleToBlock = getMeleeIdleToBlockAction(drawnWeaponType); - float dur = resolveClipLength(idleToBlock, 0.5f); - // Etwas kürzer als Clip-Länge damit die Animation nicht einen Frame lang re-startet bevor HOLDING greift - blockPhaseTimer = Math.max(0.1f, dur - 0.05f); - nextTransitionLength = COMBAT_BLEND; + nextTransitionLength = -1.0; playAction(idleToBlock); currentAnim = idleToBlock; + // Länge nach playAction() aus dem AnimComposer lesen: Clip ist jetzt aktiv, + // daher exakte Länge verfügbar (resolveClipLength kann Fallback liefern → Clip-Loop). + float dur = 0.5f; + if (animComposer != null) { + var layer = animComposer.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + var a = layer.getCurrentAction(); + if (a != null && a.getLength() > 0.01) { + dur = (float) a.getLength(); + } + } + } + blockPhaseTimer = Math.max(0.1f, dur - 0.05f); } private void startBlockExit() { @@ -1380,14 +1511,14 @@ public class PlayerInputControl { private void playCounterAttack() { if (combatAnimActive) return; - if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); } - AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType); + AnimationAction counterAct = getMeleeParadeAction(drawnWeaponType); float dur = resolveClipLength(counterAct, 0.7f); nextTransitionLength = COMBAT_BLEND; combatAnimActive = true; combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND); combatAnimCallback = null; combatAnimSkipComboWindow = true; + attackSoundFired = true; // kein Swoosh-Sound bei Konterparade playAction(counterAct); currentAnim = counterAct; } @@ -1399,6 +1530,7 @@ public class PlayerInputControl { /** Spielt die Konter-Animations bei erfolgreicher Parade (nur wenn keine Combat-Animation aktiv). */ public void triggerParrySuccess() { + if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); } playCounterAttack(); } @@ -1409,21 +1541,36 @@ public class PlayerInputControl { forward = backward = left = right = sprint = walk = false; if (physicsChar != null) { physicsChar.setWalkDirection(com.jme3.math.Vector3f.ZERO); } rightMouseHeld = false; + attackBuffered = false; blockPhase = BlockPhase.NONE; combatAnimActive = false; comboWindowActive = false; weaponCombat.phase = WeaponCombatState.Phase.IDLE; weaponCombat.parryArmed = false; + if (weaponSoundSystem != null) { weaponSoundSystem.play("argh"); } AnimationAction deathAct = playerRng.nextBoolean() ? AnimationAction.SWORD_DEATH : AnimationAction.SWORD_DEATH_2; playAction(deathAct); - currentAnim = deathAct; + currentAnim = deathAct; + // Länge nach playAction() aus AnimComposer lesen (verhindert Loop durch falschen Fallback-Timer) + float deathLen = 2.0f; + if (animComposer != null) { + var layer = animComposer.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + var a = layer.getCurrentAction(); + if (a != null && a.getLength() > 0.01) { + deathLen = (float) a.getLength(); + } + } + } blockingAnimActive = true; - blockingAnimRemaining = 999f; - blockingAnimTotal = 999f; + blockingAnimRemaining = Math.max(0.1f, deathLen - 0.12f); // 120ms vor Clip-Ende: kein Loop + blockingAnimTotal = blockingAnimRemaining; blockingAnimCallback = null; } + public boolean isPlayerDead() { return playerDead; } + public boolean isBlocking() { return blockPhase == BlockPhase.ENTERING || blockPhase == BlockPhase.HOLDING @@ -1439,7 +1586,7 @@ public class PlayerInputControl { /** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */ public void triggerBlockImpact() { - if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return; + if ((blockPhase != BlockPhase.HOLDING && blockPhase != BlockPhase.ENTERING) || drawnWeaponType == null) return; if (weaponSoundSystem != null) { weaponSoundSystem.play("impactblock"); } if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) { if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) { @@ -1695,28 +1842,20 @@ public class PlayerInputControl { }; } - private static AnimationAction getMeleeCounterAttackAction(ItemSubCategory s) { + private static AnimationAction getMeleeParadeAction(ItemSubCategory s) { return switch (s) { - case AXE -> AnimationAction.AXE_COUNTERATTACK; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_COUNTERATTACK; - default -> AnimationAction.SWORD_COUNTERATTACK; + case AXE -> AnimationAction.AXE_PARADE; + case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_PARADE; + default -> AnimationAction.SWORD_PARADE; }; } public static AnimationAction getMeleeBlockImpactAction(ItemSubCategory s) { - return switch (s) { - case AXE -> AnimationAction.AXE_BLOCK_IMPACT; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK_IMPACT; - default -> AnimationAction.SWORD_BLOCK_IMPACT; - }; + return SwordShieldCombat.resolveBlockImpactAction(s); } public static AnimationAction getMeleeIdleImpactAction(ItemSubCategory s) { - return switch (s) { - case AXE -> AnimationAction.AXE_IDLE_IMPACT; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT; - default -> AnimationAction.SWORD_IDLE_IMPACT; - }; + return SwordShieldCombat.resolveIdleImpactAction(s); } private void fireWeaponCallback(float critMultiplier) { @@ -1730,8 +1869,23 @@ public class PlayerInputControl { } int base = (mainCharacter != null && mainCharacter.getEquippedMeleeWeapon() != null) ? mainCharacter.getEquippedMeleeWeapon().getDamage() : 0; - Vector3f grip = hn.getWorldTranslation().clone(); - Vector3f bladeDir = ws.getWorldRotation().mult(com.jme3.math.Vector3f.UNIT_Y).normalizeLocal(); + // Vorwärtsrichtung des Charakters (Spieler schaut immer Richtung Gegner) + Vector3f bladeDir = (visual != null) + ? visual.getWorldRotation().mult(com.jme3.math.Vector3f.UNIT_Z).normalizeLocal() + : cam.getDirection().clone().setY(0).normalizeLocal(); + // physicsChar.getPhysicsLocation() als XZ-Ursprung: immer aktuell, kein staler Cache. + // HandY behalten für korrekte Trefferhöhe; 0.5m forward = gleiche Reichweite wie Attack 1. + Vector3f grip; + if (physicsChar != null) { + com.jme3.math.Vector3f charPos = physicsChar.getPhysicsLocation(); + grip = new Vector3f( + charPos.x + bladeDir.x * 0.5f, + hn.getWorldTranslation().y, + charPos.z + bladeDir.z * 0.5f + ); + } else { + grip = hn.getWorldTranslation().clone(); + } for (MeleeAttackCallback cb : meleeCallbacks) { cb.onMeleeAttack(currentAttackId, grip, bladeDir, WEAPON_BLADE_LENGTH, base, critMultiplier); } @@ -1749,6 +1903,47 @@ public class PlayerInputControl { }; } + /** Normalisierter Vorwärtsvektor Richtung nächstem Gegner, Fallback: Blickrichtung. */ + private Vector3f computeAttackDir() { + Vector3f fwd = null; + if (nearestAttackingNpcPos != null && physicsChar != null) { + Vector3f myPos = physicsChar.getPhysicsLocation(); + fwd = new Vector3f(nearestAttackingNpcPos.x - myPos.x, 0f, nearestAttackingNpcPos.z - myPos.z); + if (fwd.lengthSquared() < 0.001f) { fwd = null; } + } + if (fwd == null) { + fwd = (visual != null) + ? visual.getWorldRotation().mult(new Vector3f(0f, 0f, 1f)) + : cam.getDirection().clone(); + fwd.y = 0f; + } + return fwd.lengthSquared() > 0.001f ? fwd.normalizeLocal() : fwd.set(0f, 0f, 1f); + } + + /** + * Berechnet die Lunge-Geschwindigkeit so, dass der Abstand zum Gegner + * während des Lungesfensters auf ~1.7m (sicherer Treffbereich) schließt. + * Ohne bekannten Gegner: minimaler Schritt nach vorn. + */ + private Vector3f computeLungeVelocity(float animDur) { + Vector3f fwd = computeAttackDir(); + float IDEAL_DIST = 1.7f; + if (nearestAttackingNpcPos != null && physicsChar != null) { + Vector3f myPos2 = physicsChar.getPhysicsLocation(); + float dx2 = nearestAttackingNpcPos.x - myPos2.x; + float dz2 = nearestAttackingNpcPos.z - myPos2.z; + float dist = (float) Math.sqrt(dx2 * dx2 + dz2 * dz2); + float gap = dist - IDEAL_DIST; + if (gap > 0.05f && gap < 2.5f) { + float lungeWindowDur = animDur * (LUNGE_FRAC_END - LUNGE_FRAC_START); + float neededMps = Math.min(gap / lungeWindowDur, CharacterNavigator.RUN_SPEED_MPS * 0.45f); + float speed = neededMps / CharacterNavigator.RUN_SPEED_MPS * MOVE_SPEED; + return fwd.multLocal(speed); + } + } + return fwd.multLocal(MOVE_SPEED * 0.04f); + } + /** Startet sofort die schwere-Angriff-Animation beim Drücken der Taste. */ private void startHeavyCharge() { if (drawnWeaponType == null || !isMeleeType(drawnWeaponType)) { @@ -1768,15 +1963,7 @@ public class PlayerInputControl { playAction(action); currentAnim = action; - // Blickrichtung des Chars in Weltkoordinaten - Vector3f fwd = (visual != null) - ? visual.getWorldRotation().mult(new Vector3f(0f, 0f, 1f)) - : cam.getDirection().clone(); - fwd.y = 0f; - if (fwd.lengthSquared() > 0.001f) fwd.normalizeLocal(); - heavyLungeVel = fwd.mult(MOVE_SPEED * 0.25f); - - // Lunge nur während der Sprungphase: combatAnimRemaining-Fenster vorausberechnen + heavyLungeVel = computeLungeVelocity(dur); heavyLungeStartRemaining = dur * (1f - LUNGE_FRAC_START); heavyLungeEndRemaining = dur * (1f - LUNGE_FRAC_END); log.info("[Combat] Schwerer Angriff Windup gestartet."); @@ -1787,15 +1974,22 @@ public class PlayerInputControl { if (!weaponDrawn || drawnWeaponType == null || !isMeleeType(drawnWeaponType)) { return; } - currentAttackId = ++attackIdCounter; - currentComboStep = -1; - attackSoundFired = false; - if (weaponAttach != null && weaponAttach.getHandNode() != null) + // Ladung kann fehlen wenn Release während Kombo kam (Race: Release vor controlUpdate-Frame) + if (!combatAnimActive) { startHeavyCharge(); } + if (!combatAnimActive) { return; } // startHeavyCharge hat versagt (kein Stamina etc.) + currentAttackId = ++attackIdCounter; + currentComboStep = -1; + attackSoundFired = false; + hitWindowActive = true; + hitWindowCritMult = computeHeavyMultiplier(); + for (MeleeAttackCallback cb : meleeCallbacks) { cb.onAttackStart(currentAttackId, true); } + hitWindowStart = HIT_WINDOW_START; + hitWindowEnd = 1.0f; // bis Animationsende gültig – Release-Timing unkritisch + if (weaponAttach != null && weaponAttach.getHandNode() != null) { lastGripPos.set(weaponAttach.getHandNode().getWorldTranslation()); - float mult = computeHeavyMultiplier(); - fireWeaponCallback(mult); + } log.info("[Combat] Schwerer Angriff ausgelöst | Haltezeit={}s Multiplikator={}x", - String.format("%.2f", attackHoldTimer), String.format("%.2f", mult)); + String.format("%.2f", attackHoldTimer), String.format("%.2f", hitWindowCritMult)); } private static AnimationAction getRangedDrawAction(ItemSubCategory s) { diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 0376f17..0fb0a95 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -225,6 +225,8 @@ public class WorldScene extends BaseAppState { public void setPaused(boolean paused) { if (playerInput != null) playerInput.setPaused(paused); if (thirdPersonCam != null) thirdPersonCam.setPaused(paused); + WorldNpcsState npcsState = app != null ? app.getStateManager().getState(WorldNpcsState.class) : null; + if (npcsState != null) npcsState.setPaused(paused); } // ----------------------------------------------------------------------- diff --git a/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java b/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java index e76d0a9..8994ed4 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java @@ -36,6 +36,7 @@ import de.blight.common.PlacedModelIO; import de.blight.common.model.CombatDummy; import de.blight.common.model.CombatDummyIO; import de.blight.game.animation.AnimationAction; +import de.blight.game.combat.CombatStyleBase; import de.blight.game.combat.DamageCalculator; import de.blight.game.combat.MeleeAttackCallback; import de.blight.game.config.KeyBindings; @@ -367,15 +368,14 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback lastAttackId = attackId; hitThisAttack.clear(); } + float radSq = CombatStyleBase.HIT_RADIUS * CombatStyleBase.HIT_RADIUS; + Vector3f bladeTip = gripPos.add(bladeDir.mult(bladeLength)); for (DummyRuntime dr : dummies) { if (dr.fallen || hitThisAttack.contains(dr.id)) { continue; } - // Distanz vom Griff zum Dummy-Zentrum (Torso-Höhe ~0.9m) - float dx = dr.worldX - gripPos.x; - float dy = (dr.worldY + 0.9f) - gripPos.y; - float dz = dr.worldZ - gripPos.z; - if (dx * dx + dy * dy + dz * dz <= bladeLength * bladeLength) { + Vector3f dummyCenter = new Vector3f(dr.worldX, dr.worldY + 0.9f, dr.worldZ); + if (CombatStyleBase.segmentDistSq(dummyCenter, gripPos, bladeTip) <= radSq) { hitThisAttack.add(dr.id); applyDamage(dr, DamageCalculator.calculate(baseDamage, critMultiplier), critMultiplier > 1.0f); } diff --git a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java index d19b72d..0a585e5 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java @@ -47,9 +47,11 @@ import de.blight.common.model.trigger.NpcStatusTrigger; import de.blight.game.animation.AnimationAction; import de.blight.game.animation.AnimationLibrary; import de.blight.game.config.KeyBindings; +import de.blight.game.combat.CombatStyleBase; import de.blight.game.combat.DamageCalculator; import de.blight.game.combat.MeleeAttackCallback; import de.blight.game.combat.WeaponCombatState; +import de.blight.game.combat.swordshield.SwordShieldCombat; import de.blight.game.npc.AggroEvent; import de.blight.game.npc.NpcAggroBehavior; import de.blight.lang.TextResolver; @@ -161,6 +163,11 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float knockAngVel = 0f; float dodgeDirX = 0f; float dodgeDirZ = 0f; + float knockbackTimer = 0f; + boolean paradeFromParry = false; // true = Konterparade (applyParriedImpact wurde gespielt) + float blockGraceTimer = 0f; // > 0 solange NPC blockt oder gerade aus Block zurückkehrt + int konterParadeAttackId = -1; // attackId für die der NPC bereits die Parade gestartet hat + float konterParadeDelay = 0f; // Countdown bis die Parade-Anim tatsächlich abgespielt wird float chargeTimer = 0f; float chaseDelayTimer = 0f; de.blight.common.model.ItemSubCategory combatWeaponType = null; @@ -191,8 +198,9 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // ── Hover + Dialog ──────────────────────────────────────────────────────── private int hoveredIdx = -1; - private boolean dialogActive = false; - private SpawnedNpc dialogNpc = null; + private boolean dialogActive = false; + private boolean playerDeathHandled = false; + private SpawnedNpc dialogNpc = null; private ThirdPersonCamera thirdPersonCam; // ── NPC Head-Look während Dialog ────────────────────────────────────────── @@ -263,15 +271,15 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private static final float NPC_CLOSE_IN_SPEED = CharacterNavigator.WALK_SPEED_MPS; // ── NPC Kampf-KI ────────────────────────────────────────────────────────── - private static final float NPC_THINK_MIN_NORMAL = 0.8f; - private static final float NPC_THINK_MAX_NORMAL = 2.0f; - private static final float NPC_THINK_MIN_LOW_HP = 0.4f; - private static final float NPC_THINK_MAX_LOW_HP = 1.0f; + private static final float NPC_THINK_MIN_NORMAL = 0.3f; + private static final float NPC_THINK_MAX_NORMAL = 3.5f; + private static final float NPC_THINK_MIN_LOW_HP = 0.3f; + private static final float NPC_THINK_MAX_LOW_HP = 1.2f; private static final float NPC_LOW_HP_THRESHOLD = 0.5f; - private static final float NPC_BLOCK_CHANCE_NORMAL = 0.35f; - private static final float NPC_BLOCK_CHANCE_LOW_HP = 0.15f; + private static final float NPC_BLOCK_CHANCE_NORMAL = 0.45f; + private static final float NPC_BLOCK_CHANCE_LOW_HP = 0.20f; private static final float NPC_DODGE_CHANCE = 0.40f; - private static final float NPC_PARRY_CHANCE = 0.25f; + private static final float NPC_PARRY_CHANCE = 0.40f; // Kombo/Trefferfenster-Konstanten aus WeaponCombatState (gemeinsam mit Spieler) private static final float NPC_COMBO_EXTEND_P1 = WeaponCombatState.COMBO_P1; private static final float NPC_COMBO_EXTEND_P2 = WeaponCombatState.COMBO_P2; @@ -289,7 +297,8 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private static final float NPC_CLIP_FALLBACK_ROLL = 0.8f; private final java.util.Random npcRng = new java.util.Random(); - private float playerBlockTimer = 0f; + private float playerBlockTimer = 0f; + private boolean paused = false; // ── NPC HP / Kampf ──────────────────────────────────────────────────────── private static final int DEFAULT_NPC_MAX_HP = 100; @@ -335,6 +344,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback this.weaponSoundSystem = wss; } + public void setPaused(boolean paused) { + this.paused = paused; + } + // ── Lifecycle ───────────────────────────────────────────────────────────── @Override @@ -418,6 +431,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback @Override public void update(float tpf) { + if (paused) return; checkTimer += tpf; int currentHour = dayNight != null ? dayNight.getDayTime().getHour() : 12; @@ -451,6 +465,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // Bestehende NPCs prüfen: Position aktualisieren oder entladen toRemoveBuf.clear(); for (SpawnedNpc s : spawned) { + if (s.knockedOut) { continue; } // Tote NPCs nicht teleportieren oder überschreiben Vector3f pos = resolveRoutinePosition(s.npc(), currentHour); float dist = pos != null ? dist2d(playerPos, pos) : Float.MAX_VALUE; @@ -966,6 +981,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private void playNpcAction(SpawnedNpc s, AnimationAction action) { if (action == s.currentAction || animLib == null) return; + log.debug("[Combat] NPC={} Anim: {} → {}", s.npc().getCharacterId(), s.currentAction, action); String setName = s.npc().getAnimSetPath(); if (setName == null || setName.isBlank()) return; String clipName = AnimationLibrary.getClipForAction(AnimationLibrary.findAssetRoot(), setName, action); @@ -1151,9 +1167,27 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private void updateAggroBehaviors(float tpf) { if (dialogActive) { return; } + if (playerInput != null && playerInput.isPlayerDead()) { + if (!playerDeathHandled) { + playerDeathHandled = true; + for (SpawnedNpc s : spawned) { + if (!s.knockedOut && s.aggroBehavior.isAttacking()) { + resetNpcCombat(s); + s.aggroBehavior.forceCalm(); + Vector3f returnPos = resolveRoutinePosition(s.npc(), lastHour); + if (returnPos != null) { + s.isReturning = true; + s.returnTarget = returnPos.clone(); + } + } + } + } + return; + } Vector3f playerPos = physicsChar.getPhysicsLocation(); float soundRadius = playerInput.getSoundRadius(); for (SpawnedNpc s : spawned) { + if (s.knockedOut) { continue; } Status status = s.npc().getStatus(); if (status != Status.ENRAGED && status != Status.ENEMY) { continue; } Vector3f npcPos = s.visual().getWorldTranslation(); @@ -1198,6 +1232,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback public void notifyNpcAttackedByPlayer(String npcId) { for (SpawnedNpc s : spawned) { if (!npcId.equals(s.npc().getCharacterId())) { continue; } + if (s.knockedOut) { return; } Status status = s.npc().getStatus(); if (status != Status.ENRAGED && status != Status.ENEMY) { return; } Vector3f npcPos = s.visual().getWorldTranslation(); @@ -1460,12 +1495,31 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private void updateNpcCombat(float tpf) { if (dialogActive) { return; } - if (playerInput.isBlocking()) { playerBlockTimer += tpf; } - else { playerBlockTimer = 0f; } + boolean playerDead = playerInput != null && playerInput.isPlayerDead(); + if (!playerDead) { + if (playerInput.isBlocking()) { playerBlockTimer += tpf; } + else { playerBlockTimer = 0f; } + } Vector3f playerPos = physicsChar.getPhysicsLocation(); + + // Nächsten angreifenden NPC für Lungen-Richtung bei schwerem Angriff bestimmen + if (playerInput != null && !playerDead) { + Vector3f nearestPos = null; + float nearestDistSq = Float.MAX_VALUE; + for (SpawnedNpc s : spawned) { + if (!s.knockedOut && s.aggroBehavior.isAttacking()) { + Vector3f p = s.visual().getWorldTranslation(); + float dx = p.x - playerPos.x, dz = p.z - playerPos.z; + float dSq = dx*dx + dz*dz; + if (dSq < nearestDistSq) { nearestDistSq = dSq; nearestPos = p; } + } + } + playerInput.setNearestAttackingNpcPos(nearestPos); + } + for (SpawnedNpc s : spawned) { - // Zieh/Wegsteck-Animation – läuft unabhängig vom Aggro-Zustand (auch nach PURSUIT_BREAK) + // Zieh/Wegsteck-Animation – läuft auch nach Spielertod (Waffe wird noch korrekt weggesteckt) if (s.drawAnimActive) { s.drawAnimTimer += tpf; de.blight.game.control.NpcWeaponAttachmentControl wac = @@ -1538,14 +1592,18 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback continue; } - if (!s.aggroBehavior.isAttacking()) { continue; } + // Kampf-Logik nur wenn Spieler noch lebt + if (playerDead) { continue; } - // K.O.: nur Umfall-Physik, keine sonstige Kampflogik + // K.O.: Todesanimation einfrieren – vor isAttacking()-Check, da forceCalm() + // isAttacking()=false setzt und der knockedOut-Zweig sonst nie erreicht wird. if (s.knockedOut) { updateNpcCombatPhase(s, tpf, playerPos); continue; } + if (!s.aggroBehavior.isAttacking()) { continue; } + Vector3f npcPos = s.visual().getWorldTranslation(); float dist = dist2d(npcPos, playerPos); @@ -1567,7 +1625,8 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // In Kampf-Stopp-Distanz oder Stuck-Timeout → IDLE + Entscheidung if (dist <= NPC_STOP_DIST || s.chargeTimer >= 1.5f) { faceToward(s, playerPos); - s.chargeTimer = 0f; + s.chargeTimer = 0f; + s.chaseDelayTimer = 0f; if (s.combat.phase == WeaponCombatState.Phase.CHARGING) { s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); @@ -1576,10 +1635,17 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback continue; } - // Außerhalb Angriffsreichweite → CHARGING + // Außerhalb Angriffsreichweite → 0.5s Verzögerung vor Verfolgungsstart if (s.combat.phase == WeaponCombatState.Phase.IDLE) { - s.combat.phase = WeaponCombatState.Phase.CHARGING; - s.chargeTimer = 0f; + s.chaseDelayTimer += tpf; + if (s.chaseDelayTimer < 0.5f) { + faceToward(s, playerPos); + playNpcAction(s, resolveNpcIdleAction(s)); + continue; + } + s.chaseDelayTimer = 0f; + s.combat.phase = WeaponCombatState.Phase.CHARGING; + s.chargeTimer = 0f; } // Close-in: Spieler ≤ 3m und flieht nicht → NPC geht langsam ran @@ -1691,6 +1757,31 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // ── MeleeAttackCallback ─────────────────────────────────────────────────── + @Override + public void onAttackStart(int attackId, boolean isHeavy) { + if (isHeavy || physicsChar == null) { return; } + Vector3f playerPos = physicsChar.getPhysicsLocation(); + for (SpawnedNpc s : spawned) { + if (s.knockedOut) { continue; } + if (s.konterParadeAttackId == attackId) { continue; } // bereits entschieden + boolean npcIsBlocking = (s.blockGraceTimer > 0f || s.combat.phase == WeaponCombatState.Phase.BLOCKING) + && s.combat.phase != WeaponCombatState.Phase.STAGGERED + && s.combat.phase != WeaponCombatState.Phase.KNOCKED_OUT + && s.combat.phase != WeaponCombatState.Phase.DODGING; + if (npcIsBlocking) { continue; } + if (s.combat.phase != WeaponCombatState.Phase.IDLE + && s.combat.phase != WeaponCombatState.Phase.CHARGING) { continue; } + Vector3f npcPos = s.visual().getWorldTranslation(); + if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE + 1.5f) { continue; } + if (npcRng.nextFloat() < NPC_PARRY_CHANCE) { + s.konterParadeAttackId = attackId; + s.konterParadeDelay = 0.1f + npcRng.nextFloat() * 0.1f; // 100–200 ms Verzug + log.debug("[Combat] onAttackStart: NPC={} Parade in {}ms", s.npc().getCharacterId(), + String.format("%.0f", s.konterParadeDelay * 1000f)); + } + } + } + @Override public void onMeleeAttack(int attackId, Vector3f gripPos, Vector3f bladeDir, float bladeLength, int baseDamage, float critMultiplier) { @@ -1699,13 +1790,12 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback hitThisAttack.clear(); } boolean isHeavy = critMultiplier >= 3.0f; + float radSq = CombatStyleBase.HIT_RADIUS * CombatStyleBase.HIT_RADIUS; + Vector3f bladeTip = gripPos.add(bladeDir.mult(bladeLength)); for (SpawnedNpc s : spawned) { if (hitThisAttack.contains(s.npc().getCharacterId())) { continue; } - Vector3f npcPos = s.visual().getWorldTranslation(); - float dx = npcPos.x - gripPos.x; - float dy = (npcPos.y + 0.9f) - gripPos.y; - float dz = npcPos.z - gripPos.z; - if (dx * dx + dy * dy + dz * dz <= bladeLength * bladeLength) { + Vector3f npcCenter = s.visual().getWorldTranslation().add(0f, 0.9f, 0f); + if (CombatStyleBase.segmentDistSq(npcCenter, gripPos, bladeTip) <= radSq) { hitThisAttack.add(s.npc().getCharacterId()); applyNpcDamage(s, DamageCalculator.calculate(baseDamage, critMultiplier), critMultiplier > 1.0f, isHeavy, critMultiplier); @@ -1715,27 +1805,48 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit, boolean isHeavy, float critMultiplier) { if (s.knockedOut) { return; } + log.debug("[Combat] applyNpcDamage NPC={} phase={} grace={} anim={} heavy={}", + s.npc().getCharacterId(), s.combat.phase, + String.format("%.3f", s.blockGraceTimer), s.currentAction, isHeavy); - // NPC Konter-Parade: reaktive Parade gegen normalen Angriff (nicht schwer, nicht bereits blockend) + // blockGraceTimer > 0: NPC blockt gerade oder kehrt gerade aus dem Block zurück. + // Verhindert Konterparade während des gesamten Block-Einstiegs, der Hold-Phase und + // eines kurzen Auslauf-Puffers nach Block-Ende (~0.4 s). + boolean npcIsBlocking = (s.blockGraceTimer > 0f + || s.combat.phase == WeaponCombatState.Phase.BLOCKING) + && s.combat.phase != WeaponCombatState.Phase.STAGGERED + && s.combat.phase != WeaponCombatState.Phase.KNOCKED_OUT + && s.combat.phase != WeaponCombatState.Phase.DODGING; + + // NPC Konter-Parade: nur wenn in onAttackStart bereits entschieden (earlyAnim=true) + boolean konterAlreadyStarted = (s.konterParadeAttackId == lastAttackId); if (!isHeavy && (s.combat.phase == WeaponCombatState.Phase.IDLE || s.combat.phase == WeaponCombatState.Phase.CHARGING) - && npcRng.nextFloat() < NPC_PARRY_CHANCE) { - AnimationAction blockImpact = resolveNpcBlockImpactAction(s); - float dur = resolveNpcClipLength(s, blockImpact, NPC_STAGGER_FALLBACK); - s.currentAction = null; - playNpcAction(s, blockImpact); - setNpcCurrentActionSpeed(s, 0.5f); - s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW; - s.combat.timer = dur * 2f; - AnimationAction playerImpact = resolvePlayerImpactAction(); - if (playerImpact != null) playerInput.requestAnimation(playerImpact, NPC_STAGGER_FALLBACK, null); + && !npcIsBlocking + && konterAlreadyStarted) { + log.debug("[Combat] KONTERPARADE NPC={} phase={} grace={} anim={}", + s.npc().getCharacterId(), s.combat.phase, + String.format("%.3f", s.blockGraceTimer), s.currentAction); + AnimationAction parryAnim = resolveNpcParadeAction(s); + float dur = resolveNpcClipLength(s, parryAnim, NPC_STAGGER_FALLBACK); + s.konterParadeAttackId = -1; + if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); } + s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW; + s.combat.timer = dur * 2f; + s.paradeFromParry = true; + SwordShieldCombat.applyParriedImpact(playerInput, playerInput.getDrawnWeaponType()); return; } + s.konterParadeAttackId = -1; // Entscheidung war kein Konter → zurücksetzen - if (s.combat.phase == WeaponCombatState.Phase.BLOCKING) { + if (npcIsBlocking) { + log.debug("[Combat] BLOCK trifft NPC={} phase={} grace={} heavy={}", + s.npc().getCharacterId(), s.combat.phase, + String.format("%.3f", s.blockGraceTimer), isHeavy); if (isHeavy) { // Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus + setNpcKnockbackDir(s); if (npcRng.nextFloat() < NPC_DODGE_CHANCE) { startNpcDodge(s); } else { @@ -1746,29 +1857,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback int cost = Math.round(NPC_BLOCK_STAMINA_BASE * critMultiplier); s.currentStamina = Math.max(0, s.currentStamina - cost); if (s.currentStamina <= 0) { + if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } + setNpcKnockbackDir(s); staggerNpc(s); } else { - // Block hält: Impact-Animation + Konter + // Block hält: kurzer Rückstoß + Impact-Animation + Konter + setNpcKnockbackDir(s); AnimationAction impact = resolveNpcBlockImpactAction(s); float dur = resolveNpcClipLength(s, impact, NPC_CLIP_FALLBACK_BLOCK); s.currentAction = null; playNpcAction(s, impact); - playerInput.triggerBlockImpact(); - s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW; - s.combat.timer = dur; + if (weaponSoundSystem != null) { weaponSoundSystem.play("impactblock"); } + s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW; + s.combat.timer = dur; + s.paradeFromParry = false; } } return; // kein HP-Verlust bei geblockt } // Nicht blockend: Schaden anwenden + log.debug("[Combat] TREFFER NPC={} -{} HP ({}/{}→{})", + s.npc().getCharacterId(), damage, s.currentHp, s.maxHp, Math.max(0, s.currentHp - damage)); s.currentHp = Math.max(0, s.currentHp - damage); if (weaponSoundSystem != null) { weaponSoundSystem.play(isCrit ? "crit" : "impact"); } log.info("[NPC {}] -{} HP → {}/{}{}", s.npc().getCharacterId(), damage, s.currentHp, s.maxHp, s.currentHp <= 0 ? " K.O.!" : ""); if (s.currentHp <= 0) { + setNpcKnockbackDir(s); knockoutNpc(s); } else { + setNpcKnockbackDir(s); staggerNpc(s); } } @@ -1874,14 +1993,46 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // ── NPC Kampf-KI ───────────────────────────────────────────────────────── private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) { + if (s.blockGraceTimer > 0f) { s.blockGraceTimer -= tpf; } switch (s.combat.phase) { case KNOCKED_OUT -> { - // DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein. + applyNpcKnockback(s, tpf); + // Todesanimation läuft bis kurz vor dem Ende, dann auf letztem Frame einfrieren + if (s.combat.clipDur > 0f) { + s.combat.hitTimer += tpf; + if (s.combat.hitTimer >= s.combat.clipDur - 0.05f) { + AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual()); + if (ac != null) { + var layer = ac.getLayer(AnimComposer.DEFAULT_LAYER); + if (layer != null) { + com.jme3.anim.tween.action.Action a = layer.getCurrentAction(); + if (a != null) { + a.setSpeed(0f); + layer.setTime(Math.max(0f, s.combat.clipDur - 0.01f)); + } + } + } + s.combat.clipDur = 0f; + } + } } case IDLE -> { - playNpcAction(s, resolveNpcIdleAction(s)); + if (s.konterParadeDelay > 0f) { + s.konterParadeDelay -= tpf; + if (s.konterParadeDelay <= 0f) { + s.konterParadeDelay = 0f; + AnimationAction parryAnim = resolveNpcParadeAction(s); + s.currentAction = null; + playNpcAction(s, parryAnim); + setNpcCurrentActionSpeed(s, 0.5f); + } + } + // Parade läuft bereits oder Delay läuft noch → kein Idle-Override + if (s.konterParadeAttackId == -1) { + playNpcAction(s, resolveNpcIdleAction(s)); + } Vector3f npcPos = s.visual().getWorldTranslation(); float dist = dist2d(npcPos, playerPos); if (dist > NPC_STOP_DIST) { @@ -1914,6 +2065,25 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } case ATTACKING, COUNTERING -> { + // Auto-Abstand: NPC schließt während Angriffsanfahrt (erste Hälfte) kleine Lücken + if (s.combat.phase == WeaponCombatState.Phase.ATTACKING && s.combat.progress() < 0.5f) { + Vector3f npcPos2 = s.visual().getWorldTranslation(); + float distToPlayer = dist2d(npcPos2, playerPos); + float idealDist = NPC_ATTACK_RANGE - 0.3f; // 1.7m = sicherer Treffbereich + if (distToPlayer > idealDist && distToPlayer < NPC_ATTACK_RANGE + 2.5f) { + float gap = distToPlayer - idealDist; + float dx2 = playerPos.x - npcPos2.x; + float dz2 = playerPos.z - npcPos2.z; + float len2 = (float) Math.sqrt(dx2 * dx2 + dz2 * dz2); + if (len2 > 0.001f) { + float step2 = Math.min(gap, NPC_MOVE_SPEED * 0.35f * tpf); + float nx2 = npcPos2.x + dx2 / len2 * step2; + float nz2 = npcPos2.z + dz2 / len2 * step2; + float ny2 = terrainChunks != null ? terrainChunks.getHeightAt(nx2, nz2) : npcPos2.y; + s.visual().setLocalTranslation(nx2, ny2, nz2); + } + } + } boolean done = s.combat.tickAttack(tpf); // Konter-Parade: Spieler tippt Block im Trefferfenster → Stagger (langsam), kein Schaden if (!s.combat.isHeavy && !s.combat.hitFired @@ -1943,25 +2113,34 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } case BLOCKING -> { + s.blockGraceTimer = Math.max(s.blockGraceTimer, 0.4f); // erneuern solange aktiv geblockt wird playNpcAction(s, resolveNpcBlockHoldAction(s)); s.combat.timer -= tpf; + applyNpcKnockback(s, tpf); if (s.combat.timer <= 0f || s.currentStamina <= 0) { + s.dodgeDirX = 0f; + s.dodgeDirZ = 0f; s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } } case COUNTER_WINDOW -> { - // Immer sofort kontern + applyNpcKnockback(s, tpf); s.combat.timer -= tpf; if (s.combat.timer <= 0f) { - startNpcCounter(s); + s.paradeFromParry = false; + s.combat.phase = WeaponCombatState.Phase.IDLE; + resetNpcThinkTimer(s); } } case STAGGERED -> { s.combat.timer -= tpf; + applyNpcKnockback(s, tpf); if (s.combat.timer <= 0f) { + s.dodgeDirX = 0f; + s.dodgeDirZ = 0f; s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } @@ -1989,6 +2168,36 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } } + private static final float NPC_KNOCKBACK_SPEED = 1.2f; // m/s + private static final float NPC_KNOCKBACK_TIME = 0.20f; // s + + /** Setzt dodgeDir + knockbackTimer für Rückstoß weg vom Spieler. */ + private void setNpcKnockbackDir(SpawnedNpc s) { + if (physicsChar == null) { return; } + Vector3f playerPos = physicsChar.getPhysicsLocation(); + Vector3f npcPos = s.visual().getWorldTranslation(); + float dx = npcPos.x - playerPos.x; + float dz = npcPos.z - playerPos.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len > 0.001f) { + s.dodgeDirX = dx / len; + s.dodgeDirZ = dz / len; + s.knockbackTimer = NPC_KNOCKBACK_TIME; + } + } + + /** Bewegt NPC für knockbackTimer Sekunden mit linearer Abbremsung rückwärts. */ + private void applyNpcKnockback(SpawnedNpc s, float tpf) { + if (s.knockbackTimer <= 0f) { return; } + s.knockbackTimer -= tpf; + float ratio = Math.max(0f, s.knockbackTimer / NPC_KNOCKBACK_TIME); + Vector3f npcPos = s.visual().getWorldTranslation(); + float nx = npcPos.x + s.dodgeDirX * NPC_KNOCKBACK_SPEED * ratio * tpf; + float nz = npcPos.z + s.dodgeDirZ * NPC_KNOCKBACK_SPEED * ratio * tpf; + float ny = terrainChunks != null ? terrainChunks.getHeightAt(nx, nz) : npcPos.y; + s.visual().setLocalTranslation(nx, ny, nz); + } + /** Steigt nach 2 s dauerhaftem Block: +10 %/s, max. 60 %. */ private float heavyAttackChance() { float t = Math.max(0f, playerBlockTimer - 2f); @@ -2021,13 +2230,17 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback AnimationAction blockEntry = resolveNpcBlockAction(s); float entryDur = resolveNpcClipLength(s, blockEntry, NPC_CLIP_FALLBACK_BLOCK); float holdTime = 0.5f + npcRng.nextFloat() * 1.5f; + log.debug("[Combat] startNpcBlock NPC={} anim={} entryDur={} holdTime={}", + s.npc().getCharacterId(), blockEntry, + String.format("%.2f", entryDur), String.format("%.2f", holdTime)); playNpcAction(s, blockEntry); - s.combat.phase = WeaponCombatState.Phase.BLOCKING; - s.combat.timer = entryDur + holdTime; + s.combat.phase = WeaponCombatState.Phase.BLOCKING; + s.combat.timer = entryDur + holdTime; + s.blockGraceTimer = 0.4f; // Einstieg abdecken; wird in BLOCKING jedes Frame erneuert } private void startNpcCounter(SpawnedNpc s) { - AnimationAction ctr = resolveNpcCounterAction(s); + AnimationAction ctr = resolveNpcParadeAction(s); float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR); s.currentAction = null; playNpcAction(s, ctr); @@ -2051,21 +2264,28 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback s.combat.timer = dur; } + /** + * Erstellt einen {@link CombatStyleBase.AnimReceiver} für einen NPC. + * Die Implementierung spielt die Animation ab, liest die tatsächliche Clip-Länge + * NACH dem Start (wie beim Spieler) und versetzt den NPC in STAGGERED. + */ + private CombatStyleBase.AnimReceiver npcAnimReceiver(SpawnedNpc s) { + return (action, speed, onDone) -> { + s.currentAction = null; + playNpcAction(s, action); + AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual()); + float dur = CombatStyleBase.readActualClipLength(ac, NPC_STAGGER_FALLBACK); + setNpcCurrentActionSpeed(s, speed); + s.combat.stagger(dur / speed); + }; + } + private void staggerNpc(SpawnedNpc s) { - AnimationAction stagger = resolveNpcStaggerAction(s); - float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK); - s.currentAction = null; - playNpcAction(s, stagger); - s.combat.stagger(dur); + SwordShieldCombat.applyNormalImpact(npcAnimReceiver(s), s.combatWeaponType); } private void staggerNpcSlow(SpawnedNpc s) { - s.currentAction = null; - AnimationAction stagger = resolveNpcStaggerAction(s); - float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK); - playNpcAction(s, stagger); - setNpcCurrentActionSpeed(s, 0.5f); - s.combat.stagger(dur * 2f); + SwordShieldCombat.applyParriedImpact(npcAnimReceiver(s), s.combatWeaponType); } private void setNpcCurrentActionSpeed(SpawnedNpc s, float speed) { @@ -2079,6 +2299,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback s.knockedOut = true; s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT; s.aggroBehavior.forceCalm(); + if (weaponSoundSystem != null) { weaponSoundSystem.play("argh"); } playNpcDeathAnim(s); log.info("[NPC {}] K.O.!", s.npc().getCharacterId()); } @@ -2100,33 +2321,38 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual()); if (ac == null) { return; } ac.setCurrentAction(clip, com.jme3.anim.AnimComposer.DEFAULT_LAYER, false); - s.currentAction = deathAction; + com.jme3.anim.AnimClip animClip = ac.getAnimClip(clip); + s.combat.clipDur = (animClip != null && animClip.getLength() > 0.01) + ? (float) animClip.getLength() : 2.0f; + s.combat.hitTimer = 0f; + s.currentAction = deathAction; } private void fireNpcAttack(SpawnedNpc s, Vector3f playerPos) { Vector3f npcPos = s.visual().getWorldTranslation(); - if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE + 0.3f) { return; } + if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE) { return; } Item weapon = findItemById(s.npc().getEquippedWeaponId()); int base = (weapon != null && weapon.getDamage() > 0) ? weapon.getDamage() : NPC_UNARMED_DAMAGE; boolean isHeavy = s.combat.isHeavy; + log.debug("[Combat] fireNpcAttack NPC={} phase={} heavy={} paradeFromParry={} playerBlocking={}", + s.npc().getCharacterId(), s.combat.phase, isHeavy, + s.paradeFromParry, playerInput.isBlocking()); if (isHeavy) { if (playerInput.isBlocking()) { // Schwerer Angriff bricht Block — kein HP-Verlust, aber Stagger if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } playerInput.triggerBlockImpact(); - AnimationAction impact = resolvePlayerImpactAction(); - if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } + SwordShieldCombat.applyNormalImpact(playerInput, playerInput.getDrawnWeaponType()); } else { if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } applyPlayerDamage(s, Math.max(1, base * 3)); - AnimationAction impact = resolvePlayerImpactAction(); - if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } + SwordShieldCombat.applyNormalImpact(playerInput, playerInput.getDrawnWeaponType()); } return; } - int damage = Math.max(1, base * (s.combat.comboStep == 2 ? NPC_COMBO3_DAMAGE_MULT : 1)); + int damage = Math.max(1, base * (s.combat.comboStep == 4 ? NPC_COMBO3_DAMAGE_MULT : 1)); if (playerInput.isBlocking()) { if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } playerInput.triggerBlockImpact(); @@ -2135,15 +2361,21 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } applyPlayerDamage(s, damage); - AnimationAction impact = resolvePlayerImpactAction(); - if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } + // Konterparade: Spieler wurde bereits per applyParriedImpact gestaggert → keine zweite Animation. + // Block+Konter: Spieler war NICHT gestaggert → normale Impact-Animation abspielen. + if (!s.paradeFromParry) { + SwordShieldCombat.applyNormalImpact(playerInput, playerInput.getDrawnWeaponType()); + } + s.paradeFromParry = false; } private void applyPlayerDamage(SpawnedNpc killer, int damage) { if (mainCharacter == null) { return; } - int hp = Math.max(0, mainCharacter.getCurrentHp() - damage); + int before = mainCharacter.getCurrentHp(); + int hp = Math.max(0, before - damage); mainCharacter.setCurrentHp(hp); - log.info("[NPC→Player] -{} HP → {}/{}", damage, hp, mainCharacter.getMaxHp()); + log.debug("[Combat] HELD -{} HP ({}/{} → {}/{})", + damage, before, mainCharacter.getMaxHp(), hp, mainCharacter.getMaxHp()); if (hp <= 0 && playerInput != null) { playerInput.playPlayerDeath(); if (killer != null) { showNpcSpeech(killer, NpcAggroBehavior.DEATH_TEXTS, "death"); } @@ -2182,19 +2414,19 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback s.combatWeaponType != null ? s.combatWeaponType : de.blight.common.model.ItemSubCategory.SWORD; return switch (wt) { case AXE -> switch (step) { - case 1 -> AnimationAction.AXE_ATTACK_2; - case 2 -> AnimationAction.AXE_ATTACK_3; - default -> AnimationAction.AXE_ATTACK_1; + case 1, 3 -> AnimationAction.AXE_ATTACK_2; + case 4 -> AnimationAction.AXE_ATTACK_3; + default -> AnimationAction.AXE_ATTACK_1; }; case TWO_HANDED_SWORD -> switch (step) { - case 1 -> AnimationAction.TWO_HAND_ATTACK_2; - case 2 -> AnimationAction.TWO_HAND_ATTACK_3; - default -> AnimationAction.TWO_HAND_ATTACK_1; + case 1, 3 -> AnimationAction.TWO_HAND_ATTACK_2; + case 4 -> AnimationAction.TWO_HAND_ATTACK_3; + default -> AnimationAction.TWO_HAND_ATTACK_1; }; default -> switch (step) { - case 1 -> AnimationAction.SWORD_ATTACK_2; - case 2 -> AnimationAction.SWORD_ATTACK_3; - default -> AnimationAction.SWORD_ATTACK_1; + case 1, 3 -> AnimationAction.SWORD_ATTACK_2; + case 4 -> AnimationAction.SWORD_ATTACK_3; + default -> AnimationAction.SWORD_ATTACK_1; }; }; } @@ -2218,40 +2450,15 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } private AnimationAction resolveNpcBlockImpactAction(SpawnedNpc s) { - if (s.combatWeaponType == null) { return AnimationAction.SWORD_BLOCK_IMPACT; } - return switch (s.combatWeaponType) { - case AXE -> AnimationAction.AXE_BLOCK_IMPACT; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK_IMPACT; - default -> AnimationAction.SWORD_BLOCK_IMPACT; - }; + return SwordShieldCombat.resolveBlockImpactAction(s.combatWeaponType); } - private AnimationAction resolveNpcCounterAction(SpawnedNpc s) { - if (s.combatWeaponType == null) { return AnimationAction.SWORD_COUNTERATTACK; } - return switch (s.combatWeaponType) { - case AXE -> AnimationAction.AXE_COUNTERATTACK; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_COUNTERATTACK; - default -> AnimationAction.SWORD_COUNTERATTACK; - }; + private AnimationAction resolveNpcParadeAction(SpawnedNpc s) { + return SwordShieldCombat.resolveParadeAction(s.combatWeaponType); } private AnimationAction resolveNpcStaggerAction(SpawnedNpc s) { - if (s.combatWeaponType == null) { return AnimationAction.SWORD_IDLE_IMPACT; } - return switch (s.combatWeaponType) { - case AXE -> AnimationAction.AXE_IDLE_IMPACT; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT; - default -> AnimationAction.SWORD_IDLE_IMPACT; - }; - } - - private AnimationAction resolvePlayerImpactAction() { - de.blight.common.model.ItemSubCategory wt = playerInput.getDrawnWeaponType(); - if (wt == null) { return AnimationAction.SWORD_IDLE_IMPACT; } - return switch (wt) { - case AXE -> AnimationAction.AXE_IDLE_IMPACT; - case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT; - default -> AnimationAction.SWORD_IDLE_IMPACT; - }; + return SwordShieldCombat.resolveIdleImpactAction(s.combatWeaponType); } private float resolveNpcClipLength(SpawnedNpc s, AnimationAction action, float fallback) { diff --git a/blight-game/src/main/resources/logback.xml b/blight-game/src/main/resources/logback.xml index f329bfa..6506081 100644 --- a/blight-game/src/main/resources/logback.xml +++ b/blight-game/src/main/resources/logback.xml @@ -17,6 +17,9 @@ + + + diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 29a6300..0f7e2df 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ diff --git a/blight-map/src/main/map/chunks/chunk_08_07.blc b/blight-map/src/main/map/chunks/chunk_08_07.blc index 6e79df4..f2ca47d 100644 Binary files a/blight-map/src/main/map/chunks/chunk_08_07.blc and b/blight-map/src/main/map/chunks/chunk_08_07.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_08_08.blc b/blight-map/src/main/map/chunks/chunk_08_08.blc index 63a1628..6dc3312 100644 Binary files a/blight-map/src/main/map/chunks/chunk_08_08.blc and b/blight-map/src/main/map/chunks/chunk_08_08.blc differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_15.blvc b/blight-map/src/main/map/chunks/voxel_16_0_15.blvc new file mode 100644 index 0000000..65668c1 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_16_0_15.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_16.blvc b/blight-map/src/main/map/chunks/voxel_16_0_16.blvc new file mode 100644 index 0000000..e18870e Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_16_0_16.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc new file mode 100644 index 0000000..b6ed2f8 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc new file mode 100644 index 0000000..72cdb65 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_16.blvc b/blight-map/src/main/map/chunks/voxel_17_0_16.blvc new file mode 100644 index 0000000..f7b4550 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_17_0_16.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc new file mode 100644 index 0000000..c436a1d Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc differ