diff --git a/blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg b/blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg new file mode 100644 index 0000000..2f0fd37 Binary files /dev/null and b/blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg differ diff --git a/blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg b/blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg new file mode 100644 index 0000000..44e7e93 Binary files /dev/null and b/blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg differ diff --git a/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade1.ogg b/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade1.ogg new file mode 100644 index 0000000..5542975 Binary files /dev/null and b/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade1.ogg differ diff --git a/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade2.ogg b/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade2.ogg new file mode 100644 index 0000000..b8c70a2 Binary files /dev/null and b/blight-assets/src/main/resources/audio/weapons/sword/counterparade/parade2.ogg differ diff --git a/blight-assets/src/main/resources/audio/weapons/sword/crit/stab3.ogg b/blight-assets/src/main/resources/audio/weapons/sword/crit/stab3.ogg new file mode 100644 index 0000000..20732ad Binary files /dev/null and b/blight-assets/src/main/resources/audio/weapons/sword/crit/stab3.ogg differ diff --git a/blight-assets/src/main/resources/audio/weapons/sword/impact/Sword Impact Hit 1.ogg b/blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 1.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/weapons/sword/impact/Sword Impact Hit 1.ogg rename to blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 1.ogg diff --git a/blight-assets/src/main/resources/audio/weapons/sword/crit/Sword Impact Hit 2.ogg b/blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 2.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/weapons/sword/crit/Sword Impact Hit 2.ogg rename to blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 2.ogg diff --git a/blight-assets/src/main/resources/audio/weapons/sword/impact/Sword Impact Hit 3.ogg b/blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 3.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/weapons/sword/impact/Sword Impact Hit 3.ogg rename to blight-assets/src/main/resources/audio/weapons/sword/impactblock/Sword Impact Hit 3.ogg diff --git a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java index 9988f71..d6e46f2 100644 --- a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java +++ b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java @@ -405,27 +405,10 @@ public final class RetargetingSystem { // ── Same-rig detection & redirect ──────────────────────────────────────── - /** True nur wenn alle Namen exakt selbst-gemappt UND alle Bind-Posen übereinstimmen. */ + /** True wenn alle Namen exakt selbst-gemappt sind (gleiche Knochennamen auf Quell- und Ziel-Armatur). */ private static boolean isSameRig(Map nameMap, Armature src, Armature dst) { for (var e : nameMap.entrySet()) { if (!e.getKey().equals(e.getValue())) return false; - Joint srcJ = src.getJoint(e.getKey()); - Joint dstJ = dst.getJoint(e.getValue()); - if (srcJ == null || dstJ == null) continue; - Quaternion sb = srcJ.getInitialTransform() != null - ? srcJ.getInitialTransform().getRotation() : new Quaternion(); - Quaternion db = dstJ.getInitialTransform() != null - ? dstJ.getInitialTransform().getRotation() : new Quaternion(); - if (Math.abs(sb.dot(db)) < 0.999f) { - log.info("[isSameRig] MISMATCH '{}': src=({},{},{},{}) dst=({},{},{},{}) dot={}", - e.getKey(), - String.format("%.4f", sb.getX()), String.format("%.4f", sb.getY()), - String.format("%.4f", sb.getZ()), String.format("%.4f", sb.getW()), - String.format("%.4f", db.getX()), String.format("%.4f", db.getY()), - String.format("%.4f", db.getZ()), String.format("%.4f", db.getW()), - String.format("%.6f", Math.abs(sb.dot(db)))); - return false; - } } return true; } 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 new file mode 100644 index 0000000..a99deb8 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/combat/WeaponCombatState.java @@ -0,0 +1,171 @@ +package de.blight.game.combat; + +/** + * Gemeinsamer Zustandsautomat für Waffenkampf. + * + * Wird von PlayerInputControl (Spieler) und WorldNpcsState/SpawnedNpc (NPCs) + * genutzt, damit Blocken, Angriff, Kombo, schwerer Angriff und Konterparade + * auf denselben Zeitkonstanten und Phasen basieren. + */ +public final class WeaponCombatState { + + // ── Phasen ──────────────────────────────────────────────────────────────── + + public enum Phase { + IDLE, // kein aktiver Kampfzustand + PARRY_WAITING, // Blocktaste gedrückt – Tap/Hold-Entscheidung läuft + ATTACKING, // Angriffsanimation + BLOCKING, // Haltephase des Blocks + BLOCK_IMPACT, // Treffer während Block + COUNTER_WINDOW, // nach erfolgreicher Parade → Konter bereit + COUNTERING, // Konterangriff-Animation + STAGGERED, // Trefferstagger + KNOCKED_OUT, // 0 HP + CHARGING, // NPC läuft auf Spieler zu + DODGING // NPC-Ausweichrolle + } + + // ── Gemeinsame Zeitkonstanten ────────────────────────────────────────────── + + /** Maximale Haltezeit für Kurz-Tap (→ Konterparade). Darüber: Block-Haltephase. */ + public static final float BLOCK_TAP_MAX = 0.20f; + /** Animationsfortschritt ab dem das Trefferfenster beginnt. */ + public static final float HIT_WIN_START = 0.35f; + /** Animationsfortschritt bis zu dem das Trefferfenster endet. */ + public static final float HIT_WIN_END = 0.65f; + /** Kombo-Verlängerungschance nach dem 1. Treffer. */ + public static final float COMBO_P1 = 0.70f; + /** Kombo-Verlängerungschance nach dem 2. Treffer. */ + public static final float COMBO_P2 = 0.30f; + + // ── Zustandsfelder ───────────────────────────────────────────────────────── + + public Phase phase = Phase.IDLE; + public int comboStep = 0; // 0=Angriff1, 1=Angriff2, 2=Angriff3/Schwer + public boolean isHeavy = false; + public float timer = 0f; // Phase-Timer (läuft runter bis 0) + public float clipDur = 0f; // Gesamtlänge des laufenden Clips + public float hitTimer = 0f; // Zeit innerhalb des laufenden Clips + public boolean hitFired = false; // Treffer dieser Animation bereits ausgelöst + public boolean parryArmed = false; // Tap erkannt, Parade noch nicht verbraucht + public float tapTime = 0f; // Haltezeit der Blocktaste (Tap-Erkennung) + + // ── Abgeleitete Werte ────────────────────────────────────────────────────── + + /** Fortschritt innerhalb des laufenden Clips (0.0–1.0). */ + public float progress() { + return clipDur > 0f ? Math.min(1f, hitTimer / clipDur) : 1f; + } + + /** True wenn der Angriff im Trefferfenster ist. */ + public boolean inHitWindow() { + float p = progress(); + return p >= HIT_WIN_START && p <= HIT_WIN_END; + } + + // ── Zustandsübergänge ────────────────────────────────────────────────────── + + /** Startet einen Angriffsschritt (Spieler oder NPC). */ + public void beginAttack(int step, boolean heavy, float duration) { + phase = Phase.ATTACKING; + comboStep = step; + isHeavy = heavy; + timer = duration; + clipDur = duration; + hitTimer = 0f; + hitFired = false; + } + + /** Startet einen Konterangriff. */ + public void beginCounter(float duration) { + phase = Phase.COUNTERING; + timer = duration; + clipDur = duration; + hitTimer = 0f; + hitFired = false; + } + + /** Rückt Angriffs- oder Konter-Timer weiter. Gibt true zurück wenn Animation beendet. */ + public boolean tickAttack(float tpf) { + timer -= tpf; + hitTimer += tpf; + return timer <= 0f; + } + + /** + * 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. + */ + public int nextComboStep(java.util.Random rng) { + if (phase != Phase.ATTACKING || isHeavy || comboStep >= 2) { return -1; } + float chance = comboStep == 0 ? COMBO_P1 : COMBO_P2; + return rng.nextFloat() < chance ? comboStep + 1 : -1; + } + + /** + * Blocktaste gedrückt. + * Gibt true zurück wenn Parry-Wartephase gestartet wurde. + */ + public boolean onBlockPress() { + if (phase == Phase.IDLE) { + phase = Phase.PARRY_WAITING; + tapTime = 0f; + parryArmed = false; // wird erst bei Tap-Release gesetzt, nicht schon auf Press + return true; + } + return false; + } + + /** + * Rückt den Tap-Timer weiter. + * Gibt true zurück wenn das Tap-Fenster abgelaufen ist (→ Haltephase starten). + * Löscht parryArmed wenn Fenster ohne Loslassen abläuft. + */ + public boolean tickParryWaiting(float tpf) { + if (phase != Phase.PARRY_WAITING) { return false; } + tapTime += tpf; + if (tapTime >= BLOCK_TAP_MAX) { + parryArmed = false; + phase = Phase.IDLE; // freigeben damit onBlockPress() beim nächsten Druck greift + return true; + } + return false; + } + + /** + * Blocktaste losgelassen. + * Gibt true zurück wenn ein gültiger Tap erkannt wurde → Parade-Animation abspielen. + */ + public boolean onBlockRelease() { + if (phase == Phase.PARRY_WAITING) { + phase = Phase.IDLE; + parryArmed = true; // Tap bestätigt → für consumeParryAttempt() im NPC-Update setzen + return true; + } + return false; + } + + /** + * Verbraucht einen Parade-Versuch (vom Gegner geprüft während dessen Trefferfenster). + * Gibt einmalig true zurück wenn Parade bereit war. + */ + public boolean consumeParryAttempt() { + if (!parryArmed) { return false; } + parryArmed = false; + return true; + } + + /** Versetzt in Stagger-Zustand. */ + public void stagger(float duration) { + phase = Phase.STAGGERED; + timer = duration; + hitFired = false; + } + + /** Rückt Stagger-Timer weiter. Gibt true zurück wenn Stagger beendet. */ + public boolean tickStagger(float tpf) { + if (phase != Phase.STAGGERED) { return false; } + timer -= tpf; + return timer <= 0f; + } +} 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 fd3f828..ff6e59c 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 @@ -31,6 +31,7 @@ import de.blight.game.animation.AnimationLibrary; import de.blight.game.animation.RetargetingSystem; import de.blight.game.config.KeyBindings; import de.blight.game.combat.MeleeAttackCallback; +import de.blight.game.combat.WeaponCombatState; import de.blight.game.navigation.CharacterNavigator; import de.blight.game.state.TerrainChunkState; @@ -56,7 +57,7 @@ public class PlayerInputControl { /** Zeit in Sekunden nach einem Angriff, in der der nächste Kombo-Schlag gedrückt werden kann. */ private static final float COMBO_WINDOW = 0.6f; /** Maximale Haltezeit für einen Kurz-Klick (Konterangriff); darüber = Block-Eingabe. */ - private static final float BLOCK_TAP_MAX = 0.20f; + private static final float BLOCK_TAP_MAX = WeaponCombatState.BLOCK_TAP_MAX; /** Maximale Haltezeit, die noch als "schneller Klick" (→ normale Kombo) gilt. */ private static final float QUICK_TAP_THRESHOLD = 0.30f; /** Animationsanteil (0–1), ab dem die Vorwärtsbewegung beim schweren Angriff beginnt. */ @@ -134,6 +135,8 @@ public class PlayerInputControl { private boolean rightMouseHeld = false; private BlockPhase blockPhase = BlockPhase.NONE; private float blockPhaseTimer = 0f; + /** Gemeinsamer Zustandsautomat (Tap/Hold-Erkennung, parryArmed). */ + private final WeaponCombatState weaponCombat = new WeaponCombatState(); private boolean combatAnimActive = false; private float combatAnimRemaining = 0f; private Runnable combatAnimCallback = null; @@ -264,6 +267,9 @@ public class PlayerInputControl { private float blockingAnimTotal = 0f; private Runnable blockingAnimCallback = null; + /** Gesetzt wenn Spieler gestorben ist – sperrt alle Eingaben. */ + private boolean playerDead = false; + /** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */ private boolean turnActive = false; private float turnRemaining = 0f; @@ -306,7 +312,7 @@ public class PlayerInputControl { private final Vector3f scratchTransl = new Vector3f(); private final ActionListener actionListener = (name, isPressed, tpf) -> { - if (paused) return; + if (paused || playerDead) return; switch (name) { case "Forward" -> forward = isPressed; case "Backward" -> backward = isPressed; @@ -464,8 +470,10 @@ public class PlayerInputControl { autopilotDir = null; if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); stopWading(); - rightMouseHeld = false; - blockPhase = BlockPhase.NONE; + rightMouseHeld = false; + blockPhase = BlockPhase.NONE; + weaponCombat.phase = WeaponCombatState.Phase.IDLE; + weaponCombat.parryArmed = false; combatAnimActive = false; combatAnimCallback = null; comboStep = 0; @@ -866,7 +874,7 @@ public class PlayerInputControl { blockingAnimRemaining -= tpf; physicsChar.setWalkDirection(Vector3f.ZERO); - if (blockingAnimRemaining <= 0f) { + if (blockingAnimRemaining <= 0f && !playerDead) { blockingAnimActive = false; Runnable cb = blockingAnimCallback; blockingAnimCallback = null; @@ -1207,7 +1215,9 @@ public class PlayerInputControl { rightMouseHeld = false; attackHoldPending = false; heavyLungeVel = null; - blockPhase = BlockPhase.NONE; + blockPhase = BlockPhase.NONE; + weaponCombat.phase = WeaponCombatState.Phase.IDLE; + weaponCombat.parryArmed = false; if (weaponSoundSystem != null) weaponSoundSystem.play("sheath"); boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null; startDrawAnimation(hasShield, @@ -1290,14 +1300,18 @@ public class PlayerInputControl { if (isMeleeType(drawnWeaponType)) { if (isPressed) { if (blockPhase == BlockPhase.NONE) { - blockPhase = BlockPhase.WAITING; + blockPhase = BlockPhase.WAITING; blockPhaseTimer = 0f; + weaponCombat.onBlockPress(); } } else { switch (blockPhase) { case WAITING -> { + // Kurzer Tap: Parade-Animation abspielen (auch wenn NPC nicht im Trefferfenster) + if (weaponCombat.onBlockRelease()) { + playCounterAttack(); + } blockPhase = BlockPhase.NONE; - playCounterAttack(); } case ENTERING, HOLDING -> startBlockExit(); default -> {} @@ -1311,8 +1325,9 @@ public class PlayerInputControl { private void updateBlockPhase(float tpf) { switch (blockPhase) { case WAITING -> { - blockPhaseTimer += tpf; - if (blockPhaseTimer >= BLOCK_TAP_MAX) { + // 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 { @@ -1365,6 +1380,7 @@ public class PlayerInputControl { private void playCounterAttack() { if (combatAnimActive) return; + if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); } AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType); float dur = resolveClipLength(counterAct, 0.7f); nextTransitionLength = COMBAT_BLEND; @@ -1376,6 +1392,38 @@ public class PlayerInputControl { currentAnim = counterAct; } + /** Liefert true (einmalig) wenn der Spieler die Block-Taste als Tap gedrückt hat. */ + public boolean consumeParryAttempt() { + return weaponCombat.consumeParryAttempt(); + } + + /** Spielt die Konter-Animations bei erfolgreicher Parade (nur wenn keine Combat-Animation aktiv). */ + public void triggerParrySuccess() { + playCounterAttack(); + } + + /** Spielt eine zufällige Sterbe-Animation und sperrt alle Eingaben dauerhaft. */ + public void playPlayerDeath() { + if (playerDead) { return; } + playerDead = true; + forward = backward = left = right = sprint = walk = false; + if (physicsChar != null) { physicsChar.setWalkDirection(com.jme3.math.Vector3f.ZERO); } + rightMouseHeld = false; + blockPhase = BlockPhase.NONE; + combatAnimActive = false; + comboWindowActive = false; + weaponCombat.phase = WeaponCombatState.Phase.IDLE; + weaponCombat.parryArmed = false; + AnimationAction deathAct = playerRng.nextBoolean() + ? AnimationAction.SWORD_DEATH : AnimationAction.SWORD_DEATH_2; + playAction(deathAct); + currentAnim = deathAct; + blockingAnimActive = true; + blockingAnimRemaining = 999f; + blockingAnimTotal = 999f; + blockingAnimCallback = null; + } + public boolean isBlocking() { return blockPhase == BlockPhase.ENTERING || blockPhase == BlockPhase.HOLDING @@ -1392,6 +1440,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 (weaponSoundSystem != null) { weaponSoundSystem.play("impactblock"); } if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) { if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) { mainCharacter.setCurrentStamina(0); diff --git a/blight-game/src/main/java/de/blight/game/npc/NpcAggroBehavior.java b/blight-game/src/main/java/de/blight/game/npc/NpcAggroBehavior.java index e19bf5f..a1b8dd8 100644 --- a/blight-game/src/main/java/de/blight/game/npc/NpcAggroBehavior.java +++ b/blight-game/src/main/java/de/blight/game/npc/NpcAggroBehavior.java @@ -63,6 +63,14 @@ public class NpcAggroBehavior { new TextReference("npc.aggro.break.5"), }; + public static final TextReference[] DEATH_TEXTS = { + new TextReference("npc.aggro.death.1"), + new TextReference("npc.aggro.death.2"), + new TextReference("npc.aggro.death.3"), + new TextReference("npc.aggro.death.4"), + new TextReference("npc.aggro.death.5"), + }; + /** * Audio path convention: audio/npc/{npcId}/aggro_{category}_{1-5}.ogg * category = "warn1" | "warn2" | "attack" 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 8432b17..0376f17 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 @@ -408,6 +408,7 @@ public class WorldScene extends BaseAppState { app.getStateManager().attach(dialogHud); WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc); npcsState.setThirdPersonCamera(thirdPersonCam); + npcsState.setWeaponSoundSystem(weaponSoundSystem); app.getStateManager().attach(npcsState); app.getStateManager().attach(new InteractionHudState(keyBindings)); inventoryState = new InventoryState(mc, keyBindings); 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 1f0b002..d19b72d 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 @@ -49,6 +49,7 @@ import de.blight.game.animation.AnimationLibrary; import de.blight.game.config.KeyBindings; import de.blight.game.combat.DamageCalculator; import de.blight.game.combat.MeleeAttackCallback; +import de.blight.game.combat.WeaponCombatState; import de.blight.game.npc.AggroEvent; import de.blight.game.npc.NpcAggroBehavior; import de.blight.lang.TextResolver; @@ -96,6 +97,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private DayNightState dayNight; private TerrainChunkState terrainChunks; private AnimationLibrary animLib; + private de.blight.game.audio.WeaponSoundSystem weaponSoundSystem; // ── NPC-Daten ───────────────────────────────────────────────────────────── @@ -111,18 +113,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback /** Aktuell in der Welt gespawnte NPCs. */ private final List spawned = new ArrayList<>(); - private enum NpcCombatPhase { - IDLE_COMBAT, // in Reichweite, Think-Timer läuft - CHARGING, // außer Reichweite, läuft ran - ATTACKING, // Angriffsanimation - BLOCKING, // Block-Pose, wartet auf Spieler-Treffer - COUNTER_WINDOW, // nach erfolgreichem Block → immer Konter - COUNTERING, // Konterangriff-Animation - DODGING, // Ausweichrolle - STAGGERED, // Trefferreaktion - KNOCKED_OUT // 0 HP, Umfall-Physik - } - private static class SpawnedNpc { final NPC npc; final Spatial visual; @@ -164,20 +154,15 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback RigidBodyControl rbc = null; // ── Kampf-Zustand ────────────────────────────────────────────────────── - NpcCombatPhase combatPhase = NpcCombatPhase.IDLE_COMBAT; - float combatPhaseTimer = 0f; - float thinkTimer = 0f; - int comboHitsRemaining = 0; - int comboStep = 0; - float attackHitWinTimer = 0f; - float attackClipDuration = 0f; - boolean attackHitFired = false; - boolean knockedOut = false; - float knockAngle = 0f; - float knockAngVel = 0f; - float dodgeDirX = 0f; - float dodgeDirZ = 0f; - boolean npcHeavyAttack = false; + final WeaponCombatState combat = new WeaponCombatState(); + float thinkTimer = 0f; + boolean knockedOut = false; + float knockAngle = 0f; + float knockAngVel = 0f; + float dodgeDirX = 0f; + float dodgeDirZ = 0f; + float chargeTimer = 0f; + float chaseDelayTimer = 0f; de.blight.common.model.ItemSubCategory combatWeaponType = null; SpawnedNpc(NPC npc, Spatial visual, float x, float z) { @@ -272,7 +257,8 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private static final float NPC_MOVE_SPEED = CharacterNavigator.RUN_SPEED_MPS; private static final float DRAW_ANIM_DURATION = 0.9f; private static final float STEER_RAY_DIST = 1.8f; - private static final float NPC_ATTACK_RANGE = 1.5f; + private static final float NPC_ATTACK_RANGE = 2.0f; + private static final float NPC_STOP_DIST = 2.2f; // Abstand bei dem NPC stoppt und kämpft private static final float NPC_CLOSE_IN_DIST = 3.0f; private static final float NPC_CLOSE_IN_SPEED = CharacterNavigator.WALK_SPEED_MPS; @@ -285,10 +271,12 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback 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_DODGE_CHANCE = 0.40f; - private static final float NPC_COMBO_EXTEND_P1 = 0.70f; - private static final float NPC_COMBO_EXTEND_P2 = 0.30f; - private static final float NPC_HIT_WIN_START = 0.35f; - private static final float NPC_HIT_WIN_END = 0.65f; + private static final float NPC_PARRY_CHANCE = 0.25f; + // 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; + private static final float NPC_HIT_WIN_START = WeaponCombatState.HIT_WIN_START; + private static final float NPC_HIT_WIN_END = WeaponCombatState.HIT_WIN_END; private static final int NPC_UNARMED_DAMAGE = 8; private static final int NPC_COMBO3_DAMAGE_MULT = 2; private static final int NPC_BLOCK_STAMINA_BASE = 10; @@ -343,6 +331,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback this.thirdPersonCam = cam; } + public void setWeaponSoundSystem(de.blight.game.audio.WeaponSoundSystem wss) { + this.weaponSoundSystem = wss; + } + // ── Lifecycle ───────────────────────────────────────────────────────────── @Override @@ -509,7 +501,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback vis.setLocalTranslation(pos); npcsRoot.attachChild(vis); SpawnedNpc s = new SpawnedNpc(npc, vis, pos.x, pos.z); - attachNpcHpBar(s); attachNpcCapsule(s); de.blight.game.scene.WorldScene.collectPbrMaterials(vis, s.pbrMaterials); if (dayNight != null && !s.pbrMaterials.isEmpty()) { @@ -587,6 +578,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback for (int i = 0; i < spawned.size(); i++) { SpawnedNpc s = spawned.get(i); + if (s.aggroBehavior.isAttacking()) continue; Vector3f pos = s.visual().getLocalTranslation(); float dx = pos.x - charPos.x; @@ -630,6 +622,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } private void startDialog(int idx) { + if (playerInput.isWeaponDrawn()) return; SpawnedNpc entry = spawned.get(idx); dialogNpc = entry; if (entry.currentAction == AnimationAction.SITTING) { @@ -1500,7 +1493,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback } else { // Waffe gezückt: Kampf-KI initialisieren s.combatWeaponType = resolveNpcCombatWeaponType(s); - s.combatPhase = NpcCombatPhase.IDLE_COMBAT; + s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); if (s.combatRunAnim != null) { playNpcAction(s, s.combatRunAnim); } } @@ -1556,16 +1549,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback Vector3f npcPos = s.visual().getWorldTranslation(); float dist = dist2d(npcPos, playerPos); - if (dist <= NPC_ATTACK_RANGE) { + // Aktive Kampf-Phasen: Bewegungslogik komplett überspringen + if (s.combat.phase != WeaponCombatState.Phase.IDLE + && s.combat.phase != WeaponCombatState.Phase.CHARGING) { faceToward(s, playerPos); if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); } continue; } - // Außerhalb Angriffsreichweite → CHARGING-Phase - if (s.combatPhase != NpcCombatPhase.CHARGING - && s.combatPhase != NpcCombatPhase.DODGING) { - s.combatPhase = NpcCombatPhase.CHARGING; + // Charging-Timer: nach 1.5s Stuck trotzdem angreifen + if (s.combat.phase == WeaponCombatState.Phase.CHARGING) { + s.chargeTimer += tpf; + } else { + s.chargeTimer = 0f; + } + + // In Kampf-Stopp-Distanz oder Stuck-Timeout → IDLE + Entscheidung + if (dist <= NPC_STOP_DIST || s.chargeTimer >= 1.5f) { + faceToward(s, playerPos); + s.chargeTimer = 0f; + if (s.combat.phase == WeaponCombatState.Phase.CHARGING) { + s.combat.phase = WeaponCombatState.Phase.IDLE; + resetNpcThinkTimer(s); + } + if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); } + continue; + } + + // Außerhalb Angriffsreichweite → CHARGING + if (s.combat.phase == WeaponCombatState.Phase.IDLE) { + s.combat.phase = WeaponCombatState.Phase.CHARGING; + s.chargeTimer = 0f; } // Close-in: Spieler ≤ 3m und flieht nicht → NPC geht langsam ran @@ -1651,56 +1665,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback return hitDist >= STEER_RAY_DIST; } - // ── NPC HP-Balken ───────────────────────────────────────────────────────── - - private void attachNpcHpBar(SpawnedNpc s) { - Node hpBarNode = buildWorldHpBar(); - hpBarNode.setLocalTranslation(0f, WB_Y_OFFSET, 0f); - hpBarNode.setCullHint(Spatial.CullHint.Always); - ((Node) s.visual()).attachChild(hpBarNode); - s.hpBarNode = hpBarNode; - s.hpBarFg = (Geometry) ((Node) hpBarNode.getChild(0)).getChild(1); - } - - private Node buildWorldHpBar() { - Node hpBarNode = new Node("npcHpBar"); - Node billChild = new Node("npcHpBillboard"); - billChild.addControl(new BillboardControl()); - - Geometry bg = new Geometry("npcHpBg", new Quad(WB_WIDTH, WB_HEIGHT)); - bg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f, 0f); - Material bgMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - bgMat.setColor("Color", new ColorRGBA(0.15f, 0.15f, 0.15f, 0.85f)); - bgMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); - bg.setMaterial(bgMat); - bg.setQueueBucket(RenderQueue.Bucket.Transparent); - billChild.attachChild(bg); - - Geometry fg = new Geometry("npcHpFg", new Quad(WB_WIDTH, WB_HEIGHT - 0.01f)); - fg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f + 0.005f, 0.001f); - Material fgMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - fgMat.setColor("Color", new ColorRGBA(0.2f, 0.8f, 0.2f, 1f)); - fg.setMaterial(fgMat); - fg.setQueueBucket(RenderQueue.Bucket.Transparent); - billChild.attachChild(fg); - - hpBarNode.attachChild(billChild); - return hpBarNode; - } - - private void refreshNpcHpBar(SpawnedNpc s) { - if (s.hpBarFg == null) { return; } - float frac = (float) s.currentHp / s.maxHp; - frac = Math.max(0f, frac); - ColorRGBA col; - if (frac > 0.5f) col = new ColorRGBA(0.2f, 0.8f, 0.2f, 1f); - else if (frac > 0.2f) col = new ColorRGBA(0.9f, 0.8f, 0.1f, 1f); - else col = new ColorRGBA(0.9f, 0.1f, 0.1f, 1f); - s.hpBarFg.getMaterial().setColor("Color", col); - s.hpBarFg.setLocalScale(Math.max(0.001f, frac), 1f, 1f); - if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Inherit); } - } - // ── NPC Physik-Kapsel ───────────────────────────────────────────────────── private void attachNpcCapsule(SpawnedNpc s) { @@ -1752,7 +1716,24 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit, boolean isHeavy, float critMultiplier) { if (s.knockedOut) { return; } - if (s.combatPhase == NpcCombatPhase.BLOCKING) { + // NPC Konter-Parade: reaktive Parade gegen normalen Angriff (nicht schwer, nicht bereits blockend) + 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); + return; + } + + if (s.combat.phase == WeaponCombatState.Phase.BLOCKING) { if (isHeavy) { // Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus if (npcRng.nextFloat() < NPC_DODGE_CHANCE) { @@ -1765,16 +1746,16 @@ 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) { - // Stamina leer: Block bricht staggerNpc(s); } else { // Block hält: Impact-Animation + Konter AnimationAction impact = resolveNpcBlockImpactAction(s); float dur = resolveNpcClipLength(s, impact, NPC_CLIP_FALLBACK_BLOCK); + s.currentAction = null; playNpcAction(s, impact); playerInput.triggerBlockImpact(); - s.combatPhase = NpcCombatPhase.COUNTER_WINDOW; - s.combatPhaseTimer = dur; + s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW; + s.combat.timer = dur; } } return; // kein HP-Verlust bei geblockt @@ -1782,7 +1763,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // Nicht blockend: Schaden anwenden s.currentHp = Math.max(0, s.currentHp - damage); - refreshNpcHpBar(s); + 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) { @@ -1893,23 +1874,28 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // ── NPC Kampf-KI ───────────────────────────────────────────────────────── private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) { - switch (s.combatPhase) { + switch (s.combat.phase) { case KNOCKED_OUT -> { // DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein. } - case IDLE_COMBAT -> { + case IDLE -> { playNpcAction(s, resolveNpcIdleAction(s)); + Vector3f npcPos = s.visual().getWorldTranslation(); + float dist = dist2d(npcPos, playerPos); + if (dist > NPC_STOP_DIST) { + s.chaseDelayTimer += tpf; + if (s.chaseDelayTimer >= 0.5f) { + s.combat.phase = WeaponCombatState.Phase.CHARGING; + s.chaseDelayTimer = 0f; + } + return; + } + s.chaseDelayTimer = 0f; s.thinkTimer -= tpf; if (s.thinkTimer > 0f) { return; } // Think-Timer abgelaufen → Entscheidung - Vector3f npcPos = s.visual().getWorldTranslation(); - float dist = dist2d(npcPos, playerPos); - if (dist > NPC_ATTACK_RANGE) { - s.combatPhase = NpcCombatPhase.CHARGING; - return; - } boolean lowHp = (float) s.currentHp / s.maxHp < NPC_LOW_HP_THRESHOLD; float blockChance = lowHp ? NPC_BLOCK_CHANCE_LOW_HP : NPC_BLOCK_CHANCE_NORMAL; float heavyChance = heavyAttackChance(); @@ -1924,63 +1910,65 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback case CHARGING -> { // Wird durch äußere Bewegungslogik in updateNpcCombat verwaltet. - // Wenn in Reichweite, wechselt updateNpcCombat zu IDLE_COMBAT + resetTimer. + // Wenn in Reichweite, wechselt updateNpcCombat zu IDLE + resetTimer. } case ATTACKING, COUNTERING -> { - s.combatPhaseTimer -= tpf; - s.attackHitWinTimer += tpf; - float progress = s.attackClipDuration > 0f - ? s.attackHitWinTimer / s.attackClipDuration : 1f; - if (!s.attackHitFired - && progress >= NPC_HIT_WIN_START - && progress <= NPC_HIT_WIN_END) { - s.attackHitFired = true; + boolean done = s.combat.tickAttack(tpf); + // Konter-Parade: Spieler tippt Block im Trefferfenster → Stagger (langsam), kein Schaden + if (!s.combat.isHeavy && !s.combat.hitFired + && s.combat.inHitWindow() + && playerInput.consumeParryAttempt()) { + staggerNpcSlow(s); + playerInput.triggerParrySuccess(); + return; + } + if (!s.combat.hitFired && s.combat.inHitWindow()) { + s.combat.hitFired = true; fireNpcAttack(s, playerPos); } - if (s.combatPhaseTimer <= 0f) { - if (s.combatPhase == NpcCombatPhase.ATTACKING - && s.comboHitsRemaining > 0) { + if (done) { + if (s.combat.phase == WeaponCombatState.Phase.ATTACKING) { + // Nach jedem Treffer: Chance auf nächsten Kombo-Hit (wie Spieler) + int next = s.combat.nextComboStep(npcRng); Vector3f npcPos = s.visual().getWorldTranslation(); - if (dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) { - s.comboHitsRemaining--; - s.comboStep++; - startNpcAttackStep(s); + if (next >= 0 && dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) { + startNpcAttackStep(s, next); return; } } - s.combatPhase = NpcCombatPhase.IDLE_COMBAT; + s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } } case BLOCKING -> { playNpcAction(s, resolveNpcBlockHoldAction(s)); - s.combatPhaseTimer -= tpf; - if (s.combatPhaseTimer <= 0f || s.currentStamina <= 0) { - s.combatPhase = NpcCombatPhase.IDLE_COMBAT; + s.combat.timer -= tpf; + if (s.combat.timer <= 0f || s.currentStamina <= 0) { + s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } } case COUNTER_WINDOW -> { // Immer sofort kontern - s.combatPhaseTimer -= tpf; - if (s.combatPhaseTimer <= 0f) { + s.combat.timer -= tpf; + if (s.combat.timer <= 0f) { startNpcCounter(s); } } case STAGGERED -> { - s.combatPhaseTimer -= tpf; - if (s.combatPhaseTimer <= 0f) { - s.combatPhase = NpcCombatPhase.IDLE_COMBAT; + s.combat.timer -= tpf; + if (s.combat.timer <= 0f) { + s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } } case DODGING -> { - s.combatPhaseTimer -= tpf; + s.combat.timer -= tpf; // Seitliche Ausweich-Bewegung (bereits in startNpcDodge gesetzt) if (s.dodgeDirX != 0f || s.dodgeDirZ != 0f) { Vector3f npcPos = s.visual().getWorldTranslation(); @@ -1989,47 +1977,44 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float ny = terrainChunks != null ? terrainChunks.getHeightAt(nx, nz) : npcPos.y; s.visual().setLocalTranslation(nx, ny, nz); } - if (s.combatPhaseTimer <= 0f) { + if (s.combat.timer <= 0f) { s.dodgeDirX = 0f; s.dodgeDirZ = 0f; - s.combatPhase = NpcCombatPhase.IDLE_COMBAT; + s.combat.phase = WeaponCombatState.Phase.IDLE; resetNpcThinkTimer(s); } } + + default -> {} } } /** Steigt nach 2 s dauerhaftem Block: +10 %/s, max. 60 %. */ private float heavyAttackChance() { float t = Math.max(0f, playerBlockTimer - 2f); - return Math.min(0.60f, t * 0.10f); + return Math.min(0.60f, 0.15f + t * 0.10f); } private void startNpcHeavyAttack(SpawnedNpc s) { - s.comboHitsRemaining = 0; - s.comboStep = 2; // ATTACK_3 animation - s.npcHeavyAttack = true; - startNpcAttackStep(s); + // Schwerer Angriff beginnt immer mit Schritt 0 (ATTACK_1 Animation); + // ATTACK_3 ist nur über Kombo (Schritt 0→1→2) erreichbar, wie beim Spieler. + startNpcAttackStep(s, 0, true); } private void startNpcAttackSequence(SpawnedNpc s) { - s.comboStep = 0; - int hits = 1; - if (npcRng.nextFloat() < NPC_COMBO_EXTEND_P1) { hits++; } - if (hits == 2 && npcRng.nextFloat() < NPC_COMBO_EXTEND_P2) { hits++; } - s.comboHitsRemaining = hits - 1; - startNpcAttackStep(s); + startNpcAttackStep(s, 0); } - private void startNpcAttackStep(SpawnedNpc s) { - AnimationAction atk = resolveNpcAttackAction(s, s.comboStep); + private void startNpcAttackStep(SpawnedNpc s, int step) { + startNpcAttackStep(s, step, false); + } + + private void startNpcAttackStep(SpawnedNpc s, int step, boolean heavy) { + AnimationAction atk = resolveNpcAttackAction(s, step); float dur = resolveNpcClipLength(s, atk, NPC_CLIP_FALLBACK_ATK); + s.currentAction = null; // Dedup umgehen damit Combo-Steps sich nicht blockieren playNpcAction(s, atk); - s.combatPhase = NpcCombatPhase.ATTACKING; - s.combatPhaseTimer = dur; - s.attackClipDuration = dur; - s.attackHitWinTimer = 0f; - s.attackHitFired = false; + s.combat.beginAttack(step, heavy, dur); } private void startNpcBlock(SpawnedNpc s) { @@ -2037,19 +2022,16 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float entryDur = resolveNpcClipLength(s, blockEntry, NPC_CLIP_FALLBACK_BLOCK); float holdTime = 0.5f + npcRng.nextFloat() * 1.5f; playNpcAction(s, blockEntry); - s.combatPhase = NpcCombatPhase.BLOCKING; - s.combatPhaseTimer = entryDur + holdTime; + s.combat.phase = WeaponCombatState.Phase.BLOCKING; + s.combat.timer = entryDur + holdTime; } private void startNpcCounter(SpawnedNpc s) { AnimationAction ctr = resolveNpcCounterAction(s); float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR); + s.currentAction = null; playNpcAction(s, ctr); - s.combatPhase = NpcCombatPhase.COUNTERING; - s.combatPhaseTimer = dur; - s.attackClipDuration = dur; - s.attackHitWinTimer = 0f; - s.attackHitFired = false; + s.combat.beginCounter(dur); } private void startNpcDodge(SpawnedNpc s) { @@ -2065,23 +2047,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float sign = npcRng.nextBoolean() ? 1f : -1f; s.dodgeDirX = (len > 0.001f) ? sign * (dz / len) : 1f; s.dodgeDirZ = (len > 0.001f) ? sign * (-dx / len) : 0f; - s.combatPhase = NpcCombatPhase.DODGING; - s.combatPhaseTimer = dur; + s.combat.phase = WeaponCombatState.Phase.DODGING; + s.combat.timer = dur; } private void staggerNpc(SpawnedNpc s) { - s.attackHitFired = false; + AnimationAction stagger = resolveNpcStaggerAction(s); + float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK); + s.currentAction = null; + playNpcAction(s, stagger); + s.combat.stagger(dur); + } + + private void staggerNpcSlow(SpawnedNpc s) { + s.currentAction = null; AnimationAction stagger = resolveNpcStaggerAction(s); float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK); playNpcAction(s, stagger); - s.combatPhase = NpcCombatPhase.STAGGERED; - s.combatPhaseTimer = dur; + setNpcCurrentActionSpeed(s, 0.5f); + s.combat.stagger(dur * 2f); + } + + private void setNpcCurrentActionSpeed(SpawnedNpc s, float speed) { + AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual()); + if (ac == null) { return; } + com.jme3.anim.tween.action.Action a = ac.getLayer(AnimComposer.DEFAULT_LAYER).getCurrentAction(); + if (a != null) { a.setSpeed(speed); } } private void knockoutNpc(SpawnedNpc s) { - s.knockedOut = true; - s.combatPhase = NpcCombatPhase.KNOCKED_OUT; - if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Always); } + s.knockedOut = true; + s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT; s.aggroBehavior.forceCalm(); playNpcDeathAnim(s); log.info("[NPC {}] K.O.!", s.npc().getCharacterId()); @@ -2112,39 +2108,46 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE + 0.3f) { return; } Item weapon = findItemById(s.npc().getEquippedWeaponId()); int base = (weapon != null && weapon.getDamage() > 0) ? weapon.getDamage() : NPC_UNARMED_DAMAGE; - boolean isHeavy = s.npcHeavyAttack; - s.npcHeavyAttack = false; // verbraucht + boolean isHeavy = s.combat.isHeavy; 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); } } else { - applyPlayerDamage(Math.max(1, base * 3)); + 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); } } return; } - int damage = Math.max(1, base * (s.comboStep == 2 ? NPC_COMBO3_DAMAGE_MULT : 1)); + int damage = Math.max(1, base * (s.combat.comboStep == 2 ? NPC_COMBO3_DAMAGE_MULT : 1)); if (playerInput.isBlocking()) { + if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } playerInput.triggerBlockImpact(); // Konter überspringt Block nicht — normaler Schlag absorbiert - if (s.combatPhase != NpcCombatPhase.COUNTERING) { return; } + if (s.combat.phase != WeaponCombatState.Phase.COUNTERING) { return; } } - applyPlayerDamage(damage); + if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); } + applyPlayerDamage(s, damage); AnimationAction impact = resolvePlayerImpactAction(); if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } } - private void applyPlayerDamage(int damage) { + private void applyPlayerDamage(SpawnedNpc killer, int damage) { if (mainCharacter == null) { return; } int hp = Math.max(0, mainCharacter.getCurrentHp() - damage); mainCharacter.setCurrentHp(hp); log.info("[NPC→Player] -{} HP → {}/{}", damage, hp, mainCharacter.getMaxHp()); + if (hp <= 0 && playerInput != null) { + playerInput.playPlayerDeath(); + if (killer != null) { showNpcSpeech(killer, NpcAggroBehavior.DEATH_TEXTS, "death"); } + } } private void resetNpcThinkTimer(SpawnedNpc s) { diff --git a/blight-lang/src/main/resources/lang/messages_de.properties b/blight-lang/src/main/resources/lang/messages_de.properties index 10c3aae..46b3c7d 100644 --- a/blight-lang/src/main/resources/lang/messages_de.properties +++ b/blight-lang/src/main/resources/lang/messages_de.properties @@ -276,6 +276,12 @@ npc.aggro.break.3=Lauf nur! Du kannst nicht für immer fliehen! npc.aggro.break.4=Beim nächsten Mal kommst du nicht so leicht davon! npc.aggro.break.5=Wir sehen uns wieder – darauf kannst du dich verlassen! +npc.aggro.death.1=Du wolltest es doch so. +npc.aggro.death.2=Hättest du lieber zu Hause geblieben. +npc.aggro.death.3=Deine Klinge hat dich nicht gerettet. +npc.aggro.death.4=Ein tapferer, aber dummer Tod. +npc.aggro.death.5=Das nächste Mal bleib, wo du hingehörst. + npc.bandit1.name=Bandit npc.bjoernson.name=Bjoernson npc.silas.name=Silas diff --git a/blight-lang/src/main/resources/lang/messages_en.properties b/blight-lang/src/main/resources/lang/messages_en.properties index 39e2c57..5b242ec 100644 --- a/blight-lang/src/main/resources/lang/messages_en.properties +++ b/blight-lang/src/main/resources/lang/messages_en.properties @@ -131,6 +131,12 @@ npc.aggro.break.3=Run! You can't hide forever! npc.aggro.break.4=Next time you won't get off so easily! npc.aggro.break.5=We'll meet again – count on it! +npc.aggro.death.1=You had this coming. +npc.aggro.death.2=Should have stayed home. +npc.aggro.death.3=Your blade couldn't save you. +npc.aggro.death.4=A brave, but foolish end. +npc.aggro.death.5=Next time, know your place. + npc.bandit1.name=Bandit npc.bjoernson.name=Bjoernson npc.silas.name=Silas