Compare commits

...

2 Commits

19 changed files with 462 additions and 211 deletions

View File

@@ -43,6 +43,8 @@ public class AnimPlayer {
private float pendingStartOffset; private float pendingStartOffset;
/** Wenn true: Clip rückwärts abspielen (speed=-1, Start am Ende). */ /** Wenn true: Clip rückwärts abspielen (speed=-1, Start am Ende). */
private boolean pendingReversed; private boolean pendingReversed;
/** Abspielgeschwindigkeit die beim nächsten direkten Clip-Start gesetzt wird (1f = normal). */
private float pendingSpeed = 1f;
public AnimPlayer(AnimComposer composer, SkinningControl skinning) { public AnimPlayer(AnimComposer composer, SkinningControl skinning) {
this.composer = composer; this.composer = composer;
@@ -150,6 +152,9 @@ public class AnimPlayer {
/** Aktuelle JME3-Action (BlendAction während Blend, ClipAction danach). */ /** Aktuelle JME3-Action (BlendAction während Blend, ClipAction danach). */
public Action getCurrentAction() { return currentAction; } public Action getCurrentAction() { return currentAction; }
/** Setzt die Abspielgeschwindigkeit für den nächsten Clip (wird nach Blend-Ende angewendet). */
public void setNextSpeed(float speed) { pendingSpeed = speed; }
// ── intern ─────────────────────────────────────────────────────────────── // ── intern ───────────────────────────────────────────────────────────────
private boolean directPlay(String clipName) { private boolean directPlay(String clipName) {
@@ -187,6 +192,10 @@ public class AnimPlayer {
} }
pendingStartOffset = 0f; pendingStartOffset = 0f;
pendingReversed = false; pendingReversed = false;
if (pendingSpeed != 1f && currentAction != null) {
currentAction.setSpeed(pendingSpeed);
pendingSpeed = 1f;
}
log.info("[AnimPlayer] direktPlay → '{}' length={}s", clipName, len); log.info("[AnimPlayer] direktPlay → '{}' length={}s", clipName, len);
return currentAction != null; return currentAction != null;
} catch (Exception e) { } catch (Exception e) {
@@ -219,6 +228,10 @@ public class AnimPlayer {
pendingStartOffset = 0f; pendingStartOffset = 0f;
} }
pendingReversed = false; pendingReversed = false;
if (pendingSpeed != 1f && currentAction != null) {
currentAction.setSpeed(pendingSpeed);
pendingSpeed = 1f;
}
log.debug("[AnimPlayer] finishBlend → '{}'", clip); log.debug("[AnimPlayer] finishBlend → '{}'", clip);
} catch (Exception e) { } catch (Exception e) {
log.warn("[AnimPlayer] finishBlend('{}') fehlgeschlagen: {}", clip, e.getMessage()); log.warn("[AnimPlayer] finishBlend('{}') fehlgeschlagen: {}", clip, e.getMessage());

View File

@@ -405,27 +405,10 @@ public final class RetargetingSystem {
// ── Same-rig detection & redirect ──────────────────────────────────────── // ── 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<String, String> nameMap, Armature src, Armature dst) { private static boolean isSameRig(Map<String, String> nameMap, Armature src, Armature dst) {
for (var e : nameMap.entrySet()) { for (var e : nameMap.entrySet()) {
if (!e.getKey().equals(e.getValue())) return false; 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; return true;
} }

View File

@@ -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;
}
}

View File

@@ -31,6 +31,7 @@ import de.blight.game.animation.AnimationLibrary;
import de.blight.game.animation.RetargetingSystem; import de.blight.game.animation.RetargetingSystem;
import de.blight.game.config.KeyBindings; import de.blight.game.config.KeyBindings;
import de.blight.game.combat.MeleeAttackCallback; import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.combat.WeaponCombatState;
import de.blight.game.navigation.CharacterNavigator; import de.blight.game.navigation.CharacterNavigator;
import de.blight.game.state.TerrainChunkState; 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. */ /** 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; private static final float COMBO_WINDOW = 0.6f;
/** Maximale Haltezeit für einen Kurz-Klick (Konterangriff); darüber = Block-Eingabe. */ /** 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. */ /** Maximale Haltezeit, die noch als "schneller Klick" (→ normale Kombo) gilt. */
private static final float QUICK_TAP_THRESHOLD = 0.30f; private static final float QUICK_TAP_THRESHOLD = 0.30f;
/** Animationsanteil (0–1), ab dem die Vorwärtsbewegung beim schweren Angriff beginnt. */ /** Animationsanteil (0–1), ab dem die Vorwärtsbewegung beim schweren Angriff beginnt. */
@@ -134,6 +135,8 @@ public class PlayerInputControl {
private boolean rightMouseHeld = false; private boolean rightMouseHeld = false;
private BlockPhase blockPhase = BlockPhase.NONE; private BlockPhase blockPhase = BlockPhase.NONE;
private float blockPhaseTimer = 0f; private float blockPhaseTimer = 0f;
/** Gemeinsamer Zustandsautomat (Tap/Hold-Erkennung, parryArmed). */
private final WeaponCombatState weaponCombat = new WeaponCombatState();
private boolean combatAnimActive = false; private boolean combatAnimActive = false;
private float combatAnimRemaining = 0f; private float combatAnimRemaining = 0f;
private Runnable combatAnimCallback = null; private Runnable combatAnimCallback = null;
@@ -264,6 +267,9 @@ public class PlayerInputControl {
private float blockingAnimTotal = 0f; private float blockingAnimTotal = 0f;
private Runnable blockingAnimCallback = null; 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). */ /** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
private boolean turnActive = false; private boolean turnActive = false;
private float turnRemaining = 0f; private float turnRemaining = 0f;
@@ -306,7 +312,7 @@ public class PlayerInputControl {
private final Vector3f scratchTransl = new Vector3f(); private final Vector3f scratchTransl = new Vector3f();
private final ActionListener actionListener = (name, isPressed, tpf) -> { private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return; if (paused || playerDead) return;
switch (name) { switch (name) {
case "Forward" -> forward = isPressed; case "Forward" -> forward = isPressed;
case "Backward" -> backward = isPressed; case "Backward" -> backward = isPressed;
@@ -466,6 +472,8 @@ public class PlayerInputControl {
stopWading(); stopWading();
rightMouseHeld = false; rightMouseHeld = false;
blockPhase = BlockPhase.NONE; blockPhase = BlockPhase.NONE;
weaponCombat.phase = WeaponCombatState.Phase.IDLE;
weaponCombat.parryArmed = false;
combatAnimActive = false; combatAnimActive = false;
combatAnimCallback = null; combatAnimCallback = null;
comboStep = 0; comboStep = 0;
@@ -501,6 +509,7 @@ public class PlayerInputControl {
autopilotDir = null; autopilotDir = null;
forward = backward = left = right = false; forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
if (animPlayer != null) animPlayer.setNextSpeed(4f);
playAction(AnimationAction.PICK_UP); playAction(AnimationAction.PICK_UP);
currentAnim = AnimationAction.PICK_UP; currentAnim = AnimationAction.PICK_UP;
} }
@@ -865,7 +874,7 @@ public class PlayerInputControl {
blockingAnimRemaining -= tpf; blockingAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO); physicsChar.setWalkDirection(Vector3f.ZERO);
if (blockingAnimRemaining <= 0f) { if (blockingAnimRemaining <= 0f && !playerDead) {
blockingAnimActive = false; blockingAnimActive = false;
Runnable cb = blockingAnimCallback; Runnable cb = blockingAnimCallback;
blockingAnimCallback = null; blockingAnimCallback = null;
@@ -1207,6 +1216,8 @@ public class PlayerInputControl {
attackHoldPending = false; attackHoldPending = false;
heavyLungeVel = null; heavyLungeVel = null;
blockPhase = BlockPhase.NONE; blockPhase = BlockPhase.NONE;
weaponCombat.phase = WeaponCombatState.Phase.IDLE;
weaponCombat.parryArmed = false;
if (weaponSoundSystem != null) weaponSoundSystem.play("sheath"); if (weaponSoundSystem != null) weaponSoundSystem.play("sheath");
boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null; boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield, startDrawAnimation(hasShield,
@@ -1291,13 +1302,17 @@ public class PlayerInputControl {
if (blockPhase == BlockPhase.NONE) { if (blockPhase == BlockPhase.NONE) {
blockPhase = BlockPhase.WAITING; blockPhase = BlockPhase.WAITING;
blockPhaseTimer = 0f; blockPhaseTimer = 0f;
weaponCombat.onBlockPress();
} }
} else { } else {
switch (blockPhase) { switch (blockPhase) {
case WAITING -> { case WAITING -> {
blockPhase = BlockPhase.NONE; // Kurzer Tap: Parade-Animation abspielen (auch wenn NPC nicht im Trefferfenster)
if (weaponCombat.onBlockRelease()) {
playCounterAttack(); playCounterAttack();
} }
blockPhase = BlockPhase.NONE;
}
case ENTERING, HOLDING -> startBlockExit(); case ENTERING, HOLDING -> startBlockExit();
default -> {} default -> {}
} }
@@ -1310,8 +1325,9 @@ public class PlayerInputControl {
private void updateBlockPhase(float tpf) { private void updateBlockPhase(float tpf) {
switch (blockPhase) { switch (blockPhase) {
case WAITING -> { case WAITING -> {
blockPhaseTimer += tpf; // Tap-Timer läuft in WeaponCombatState; gibt true zurück wenn Fenster abläuft
if (blockPhaseTimer >= BLOCK_TAP_MAX) { if (weaponCombat.tickParryWaiting(tpf)) {
// Tap-Fenster abgelaufen ohne Loslassen → Haltephase
if (hasShieldEquipped()) { if (hasShieldEquipped()) {
startBlockEnter(); startBlockEnter();
} else { } else {
@@ -1364,6 +1380,7 @@ public class PlayerInputControl {
private void playCounterAttack() { private void playCounterAttack() {
if (combatAnimActive) return; if (combatAnimActive) return;
if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); }
AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType); AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType);
float dur = resolveClipLength(counterAct, 0.7f); float dur = resolveClipLength(counterAct, 0.7f);
nextTransitionLength = COMBAT_BLEND; nextTransitionLength = COMBAT_BLEND;
@@ -1375,6 +1392,38 @@ public class PlayerInputControl {
currentAnim = counterAct; 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() { public boolean isBlocking() {
return blockPhase == BlockPhase.ENTERING return blockPhase == BlockPhase.ENTERING
|| blockPhase == BlockPhase.HOLDING || blockPhase == BlockPhase.HOLDING
@@ -1391,6 +1440,7 @@ public class PlayerInputControl {
/** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */ /** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */
public void triggerBlockImpact() { public void triggerBlockImpact() {
if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return; if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return;
if (weaponSoundSystem != null) { weaponSoundSystem.play("impactblock"); }
if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) { if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) {
if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) { if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) {
mainCharacter.setCurrentStamina(0); mainCharacter.setCurrentStamina(0);

View File

@@ -63,6 +63,14 @@ public class NpcAggroBehavior {
new TextReference("npc.aggro.break.5"), 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 * Audio path convention: audio/npc/{npcId}/aggro_{category}_{1-5}.ogg
* category = "warn1" | "warn2" | "attack" * category = "warn1" | "warn2" | "attack"

View File

@@ -408,6 +408,7 @@ public class WorldScene extends BaseAppState {
app.getStateManager().attach(dialogHud); app.getStateManager().attach(dialogHud);
WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc); WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc);
npcsState.setThirdPersonCamera(thirdPersonCam); npcsState.setThirdPersonCamera(thirdPersonCam);
npcsState.setWeaponSoundSystem(weaponSoundSystem);
app.getStateManager().attach(npcsState); app.getStateManager().attach(npcsState);
app.getStateManager().attach(new InteractionHudState(keyBindings)); app.getStateManager().attach(new InteractionHudState(keyBindings));
inventoryState = new InventoryState(mc, keyBindings); inventoryState = new InventoryState(mc, keyBindings);

View File

@@ -409,17 +409,8 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback
} }
private void standUp(DummyRuntime dr) { private void standUp(DummyRuntime dr) {
playerInput.requestAnimation(AnimationAction.PICK_UP, 0f, null); playerInput.requestPickup(1.0f);
dr.fallen = false; dr.standUpTimer = 0f;
dr.fallenLerp = 0f;
dr.knockAngle = 0f;
dr.knockAngVel = 0f;
dr.currentHp = dr.maxHp;
dr.restoreTimer = 0f;
dr.dummyNode.setLocalRotation(new Quaternion());
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
refreshWorldHpBar(dr);
log.info("[Dummy {}] Aufgestellt.", dr.id);
} }
private void refreshWorldHpBar(DummyRuntime dr) { private void refreshWorldHpBar(DummyRuntime dr) {
@@ -445,6 +436,24 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback
for (int i = 0; i < dummies.size(); i++) { for (int i = 0; i < dummies.size(); i++) {
DummyRuntime dr = dummies.get(i); DummyRuntime dr = dummies.get(i);
// Aufstell-Timer: Puppe steht nach 0,5s auf (wenn Bücke-Animation halb durch ist)
if (dr.standUpTimer >= 0f) {
dr.standUpTimer += tpf;
if (dr.standUpTimer >= 0.5f) {
dr.standUpTimer = -1f;
dr.fallen = false;
dr.fallenLerp = 0f;
dr.knockAngle = 0f;
dr.knockAngVel = 0f;
dr.currentHp = dr.maxHp;
dr.restoreTimer = 0f;
dr.dummyNode.setLocalRotation(new Quaternion());
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
refreshWorldHpBar(dr);
log.info("[Dummy {}] Aufgestellt.", dr.id);
}
}
// Umfall-Animation (physikalisch: Schwerkraft-Drehmoment nimmt mit cos(Winkel) ab) // Umfall-Animation (physikalisch: Schwerkraft-Drehmoment nimmt mit cos(Winkel) ab)
if (dr.fallen && dr.fallenLerp < 1f) { if (dr.fallen && dr.fallenLerp < 1f) {
float gravAccel = KNOCK_GRAVITY * FastMath.cos(dr.knockAngle); float gravAccel = KNOCK_GRAVITY * FastMath.cos(dr.knockAngle);
@@ -564,6 +573,7 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback
float fallenLerp = 0f; float fallenLerp = 0f;
float knockAngle = 0f; // aktueller Kippwinkel (0..PI/2) float knockAngle = 0f; // aktueller Kippwinkel (0..PI/2)
float knockAngVel = 0f; // Winkelgeschwindigkeit (rad/s) float knockAngVel = 0f; // Winkelgeschwindigkeit (rad/s)
float standUpTimer = -1f; // >= 0 während Aufstell-Animation läuft
DummyRuntime(String id, float wx, float wy, float wz, DummyRuntime(String id, float wx, float wy, float wz,
Node dummyNode, Node hpBarNode, Geometry hpBarFg, int maxHp, Node dummyNode, Node hpBarNode, Geometry hpBarFg, int maxHp,

View File

@@ -50,7 +50,7 @@ public class WorldItemsState extends BaseAppState {
private static final float INTERACT_RANGE = 5f; private static final float INTERACT_RANGE = 5f;
private static final float REACH_DIST = 0.5f; private static final float REACH_DIST = 0.5f;
private static final float PICKUP_ANIM_DURATION = 0.8f; private static final float PICKUP_ANIM_DURATION = 1.0f;
private static final float WALK_TIMEOUT = 5.0f; private static final float WALK_TIMEOUT = 5.0f;
private static final String INTERACT_ACTION = "Interact"; private static final String INTERACT_ACTION = "Interact";

View File

@@ -49,6 +49,7 @@ import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings; import de.blight.game.config.KeyBindings;
import de.blight.game.combat.DamageCalculator; import de.blight.game.combat.DamageCalculator;
import de.blight.game.combat.MeleeAttackCallback; import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.combat.WeaponCombatState;
import de.blight.game.npc.AggroEvent; import de.blight.game.npc.AggroEvent;
import de.blight.game.npc.NpcAggroBehavior; import de.blight.game.npc.NpcAggroBehavior;
import de.blight.lang.TextResolver; import de.blight.lang.TextResolver;
@@ -96,6 +97,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private DayNightState dayNight; private DayNightState dayNight;
private TerrainChunkState terrainChunks; private TerrainChunkState terrainChunks;
private AnimationLibrary animLib; private AnimationLibrary animLib;
private de.blight.game.audio.WeaponSoundSystem weaponSoundSystem;
// ── NPC-Daten ───────────────────────────────────────────────────────────── // ── NPC-Daten ─────────────────────────────────────────────────────────────
@@ -111,18 +113,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
/** Aktuell in der Welt gespawnte NPCs. */ /** Aktuell in der Welt gespawnte NPCs. */
private final List<SpawnedNpc> spawned = new ArrayList<>(); private final List<SpawnedNpc> 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 { private static class SpawnedNpc {
final NPC npc; final NPC npc;
final Spatial visual; final Spatial visual;
@@ -164,20 +154,15 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
RigidBodyControl rbc = null; RigidBodyControl rbc = null;
// ── Kampf-Zustand ────────────────────────────────────────────────────── // ── Kampf-Zustand ──────────────────────────────────────────────────────
NpcCombatPhase combatPhase = NpcCombatPhase.IDLE_COMBAT; final WeaponCombatState combat = new WeaponCombatState();
float combatPhaseTimer = 0f;
float thinkTimer = 0f; float thinkTimer = 0f;
int comboHitsRemaining = 0;
int comboStep = 0;
float attackHitWinTimer = 0f;
float attackClipDuration = 0f;
boolean attackHitFired = false;
boolean knockedOut = false; boolean knockedOut = false;
float knockAngle = 0f; float knockAngle = 0f;
float knockAngVel = 0f; float knockAngVel = 0f;
float dodgeDirX = 0f; float dodgeDirX = 0f;
float dodgeDirZ = 0f; float dodgeDirZ = 0f;
boolean npcHeavyAttack = false; float chargeTimer = 0f;
float chaseDelayTimer = 0f;
de.blight.common.model.ItemSubCategory combatWeaponType = null; de.blight.common.model.ItemSubCategory combatWeaponType = null;
SpawnedNpc(NPC npc, Spatial visual, float x, float z) { 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 NPC_MOVE_SPEED = CharacterNavigator.RUN_SPEED_MPS;
private static final float DRAW_ANIM_DURATION = 0.9f; private static final float DRAW_ANIM_DURATION = 0.9f;
private static final float STEER_RAY_DIST = 1.8f; 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_DIST = 3.0f;
private static final float NPC_CLOSE_IN_SPEED = CharacterNavigator.WALK_SPEED_MPS; 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_NORMAL = 0.35f;
private static final float NPC_BLOCK_CHANCE_LOW_HP = 0.15f; 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_DODGE_CHANCE = 0.40f;
private static final float NPC_COMBO_EXTEND_P1 = 0.70f; private static final float NPC_PARRY_CHANCE = 0.25f;
private static final float NPC_COMBO_EXTEND_P2 = 0.30f; // Kombo/Trefferfenster-Konstanten aus WeaponCombatState (gemeinsam mit Spieler)
private static final float NPC_HIT_WIN_START = 0.35f; private static final float NPC_COMBO_EXTEND_P1 = WeaponCombatState.COMBO_P1;
private static final float NPC_HIT_WIN_END = 0.65f; 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_UNARMED_DAMAGE = 8;
private static final int NPC_COMBO3_DAMAGE_MULT = 2; private static final int NPC_COMBO3_DAMAGE_MULT = 2;
private static final int NPC_BLOCK_STAMINA_BASE = 10; private static final int NPC_BLOCK_STAMINA_BASE = 10;
@@ -343,6 +331,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
this.thirdPersonCam = cam; this.thirdPersonCam = cam;
} }
public void setWeaponSoundSystem(de.blight.game.audio.WeaponSoundSystem wss) {
this.weaponSoundSystem = wss;
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
@@ -509,7 +501,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
vis.setLocalTranslation(pos); vis.setLocalTranslation(pos);
npcsRoot.attachChild(vis); npcsRoot.attachChild(vis);
SpawnedNpc s = new SpawnedNpc(npc, vis, pos.x, pos.z); SpawnedNpc s = new SpawnedNpc(npc, vis, pos.x, pos.z);
attachNpcHpBar(s);
attachNpcCapsule(s); attachNpcCapsule(s);
de.blight.game.scene.WorldScene.collectPbrMaterials(vis, s.pbrMaterials); de.blight.game.scene.WorldScene.collectPbrMaterials(vis, s.pbrMaterials);
if (dayNight != null && !s.pbrMaterials.isEmpty()) { 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++) { for (int i = 0; i < spawned.size(); i++) {
SpawnedNpc s = spawned.get(i); SpawnedNpc s = spawned.get(i);
if (s.aggroBehavior.isAttacking()) continue;
Vector3f pos = s.visual().getLocalTranslation(); Vector3f pos = s.visual().getLocalTranslation();
float dx = pos.x - charPos.x; float dx = pos.x - charPos.x;
@@ -630,6 +622,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
} }
private void startDialog(int idx) { private void startDialog(int idx) {
if (playerInput.isWeaponDrawn()) return;
SpawnedNpc entry = spawned.get(idx); SpawnedNpc entry = spawned.get(idx);
dialogNpc = entry; dialogNpc = entry;
if (entry.currentAction == AnimationAction.SITTING) { if (entry.currentAction == AnimationAction.SITTING) {
@@ -1500,7 +1493,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
} else { } else {
// Waffe gezückt: Kampf-KI initialisieren // Waffe gezückt: Kampf-KI initialisieren
s.combatWeaponType = resolveNpcCombatWeaponType(s); s.combatWeaponType = resolveNpcCombatWeaponType(s);
s.combatPhase = NpcCombatPhase.IDLE_COMBAT; s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s); resetNpcThinkTimer(s);
if (s.combatRunAnim != null) { playNpcAction(s, s.combatRunAnim); } if (s.combatRunAnim != null) { playNpcAction(s, s.combatRunAnim); }
} }
@@ -1556,16 +1549,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
Vector3f npcPos = s.visual().getWorldTranslation(); Vector3f npcPos = s.visual().getWorldTranslation();
float dist = dist2d(npcPos, playerPos); 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); faceToward(s, playerPos);
if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); } if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); }
continue; continue;
} }
// Außerhalb Angriffsreichweite → CHARGING-Phase // Charging-Timer: nach 1.5s Stuck trotzdem angreifen
if (s.combatPhase != NpcCombatPhase.CHARGING if (s.combat.phase == WeaponCombatState.Phase.CHARGING) {
&& s.combatPhase != NpcCombatPhase.DODGING) { s.chargeTimer += tpf;
s.combatPhase = NpcCombatPhase.CHARGING; } 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 // 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; 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 ───────────────────────────────────────────────────── // ── NPC Physik-Kapsel ─────────────────────────────────────────────────────
private void attachNpcCapsule(SpawnedNpc s) { 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) { private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit, boolean isHeavy, float critMultiplier) {
if (s.knockedOut) { return; } 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) { if (isHeavy) {
// Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus // Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus
if (npcRng.nextFloat() < NPC_DODGE_CHANCE) { 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); int cost = Math.round(NPC_BLOCK_STAMINA_BASE * critMultiplier);
s.currentStamina = Math.max(0, s.currentStamina - cost); s.currentStamina = Math.max(0, s.currentStamina - cost);
if (s.currentStamina <= 0) { if (s.currentStamina <= 0) {
// Stamina leer: Block bricht
staggerNpc(s); staggerNpc(s);
} else { } else {
// Block hält: Impact-Animation + Konter // Block hält: Impact-Animation + Konter
AnimationAction impact = resolveNpcBlockImpactAction(s); AnimationAction impact = resolveNpcBlockImpactAction(s);
float dur = resolveNpcClipLength(s, impact, NPC_CLIP_FALLBACK_BLOCK); float dur = resolveNpcClipLength(s, impact, NPC_CLIP_FALLBACK_BLOCK);
s.currentAction = null;
playNpcAction(s, impact); playNpcAction(s, impact);
playerInput.triggerBlockImpact(); playerInput.triggerBlockImpact();
s.combatPhase = NpcCombatPhase.COUNTER_WINDOW; s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW;
s.combatPhaseTimer = dur; s.combat.timer = dur;
} }
} }
return; // kein HP-Verlust bei geblockt return; // kein HP-Verlust bei geblockt
@@ -1782,7 +1763,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
// Nicht blockend: Schaden anwenden // Nicht blockend: Schaden anwenden
s.currentHp = Math.max(0, s.currentHp - damage); 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, log.info("[NPC {}] -{} HP → {}/{}{}", s.npc().getCharacterId(), damage,
s.currentHp, s.maxHp, s.currentHp <= 0 ? " K.O.!" : ""); s.currentHp, s.maxHp, s.currentHp <= 0 ? " K.O.!" : "");
if (s.currentHp <= 0) { if (s.currentHp <= 0) {
@@ -1893,23 +1874,28 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
// ── NPC Kampf-KI ───────────────────────────────────────────────────────── // ── NPC Kampf-KI ─────────────────────────────────────────────────────────
private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) { private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) {
switch (s.combatPhase) { switch (s.combat.phase) {
case KNOCKED_OUT -> { case KNOCKED_OUT -> {
// DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein. // DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein.
} }
case IDLE_COMBAT -> { case IDLE -> {
playNpcAction(s, resolveNpcIdleAction(s)); 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; s.thinkTimer -= tpf;
if (s.thinkTimer > 0f) { return; } if (s.thinkTimer > 0f) { return; }
// Think-Timer abgelaufen → Entscheidung // 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; boolean lowHp = (float) s.currentHp / s.maxHp < NPC_LOW_HP_THRESHOLD;
float blockChance = lowHp ? NPC_BLOCK_CHANCE_LOW_HP : NPC_BLOCK_CHANCE_NORMAL; float blockChance = lowHp ? NPC_BLOCK_CHANCE_LOW_HP : NPC_BLOCK_CHANCE_NORMAL;
float heavyChance = heavyAttackChance(); float heavyChance = heavyAttackChance();
@@ -1924,63 +1910,65 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
case CHARGING -> { case CHARGING -> {
// Wird durch äußere Bewegungslogik in updateNpcCombat verwaltet. // 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 -> { case ATTACKING, COUNTERING -> {
s.combatPhaseTimer -= tpf; boolean done = s.combat.tickAttack(tpf);
s.attackHitWinTimer += tpf; // Konter-Parade: Spieler tippt Block im Trefferfenster → Stagger (langsam), kein Schaden
float progress = s.attackClipDuration > 0f if (!s.combat.isHeavy && !s.combat.hitFired
? s.attackHitWinTimer / s.attackClipDuration : 1f; && s.combat.inHitWindow()
if (!s.attackHitFired && playerInput.consumeParryAttempt()) {
&& progress >= NPC_HIT_WIN_START staggerNpcSlow(s);
&& progress <= NPC_HIT_WIN_END) { playerInput.triggerParrySuccess();
s.attackHitFired = true; return;
}
if (!s.combat.hitFired && s.combat.inHitWindow()) {
s.combat.hitFired = true;
fireNpcAttack(s, playerPos); fireNpcAttack(s, playerPos);
} }
if (s.combatPhaseTimer <= 0f) { if (done) {
if (s.combatPhase == NpcCombatPhase.ATTACKING if (s.combat.phase == WeaponCombatState.Phase.ATTACKING) {
&& s.comboHitsRemaining > 0) { // Nach jedem Treffer: Chance auf nächsten Kombo-Hit (wie Spieler)
int next = s.combat.nextComboStep(npcRng);
Vector3f npcPos = s.visual().getWorldTranslation(); Vector3f npcPos = s.visual().getWorldTranslation();
if (dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) { if (next >= 0 && dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) {
s.comboHitsRemaining--; startNpcAttackStep(s, next);
s.comboStep++;
startNpcAttackStep(s);
return; return;
} }
} }
s.combatPhase = NpcCombatPhase.IDLE_COMBAT; s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s); resetNpcThinkTimer(s);
} }
} }
case BLOCKING -> { case BLOCKING -> {
playNpcAction(s, resolveNpcBlockHoldAction(s)); playNpcAction(s, resolveNpcBlockHoldAction(s));
s.combatPhaseTimer -= tpf; s.combat.timer -= tpf;
if (s.combatPhaseTimer <= 0f || s.currentStamina <= 0) { if (s.combat.timer <= 0f || s.currentStamina <= 0) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT; s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s); resetNpcThinkTimer(s);
} }
} }
case COUNTER_WINDOW -> { case COUNTER_WINDOW -> {
// Immer sofort kontern // Immer sofort kontern
s.combatPhaseTimer -= tpf; s.combat.timer -= tpf;
if (s.combatPhaseTimer <= 0f) { if (s.combat.timer <= 0f) {
startNpcCounter(s); startNpcCounter(s);
} }
} }
case STAGGERED -> { case STAGGERED -> {
s.combatPhaseTimer -= tpf; s.combat.timer -= tpf;
if (s.combatPhaseTimer <= 0f) { if (s.combat.timer <= 0f) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT; s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s); resetNpcThinkTimer(s);
} }
} }
case DODGING -> { case DODGING -> {
s.combatPhaseTimer -= tpf; s.combat.timer -= tpf;
// Seitliche Ausweich-Bewegung (bereits in startNpcDodge gesetzt) // Seitliche Ausweich-Bewegung (bereits in startNpcDodge gesetzt)
if (s.dodgeDirX != 0f || s.dodgeDirZ != 0f) { if (s.dodgeDirX != 0f || s.dodgeDirZ != 0f) {
Vector3f npcPos = s.visual().getWorldTranslation(); 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; float ny = terrainChunks != null ? terrainChunks.getHeightAt(nx, nz) : npcPos.y;
s.visual().setLocalTranslation(nx, ny, nz); s.visual().setLocalTranslation(nx, ny, nz);
} }
if (s.combatPhaseTimer <= 0f) { if (s.combat.timer <= 0f) {
s.dodgeDirX = 0f; s.dodgeDirX = 0f;
s.dodgeDirZ = 0f; s.dodgeDirZ = 0f;
s.combatPhase = NpcCombatPhase.IDLE_COMBAT; s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s); resetNpcThinkTimer(s);
} }
} }
default -> {}
} }
} }
/** Steigt nach 2 s dauerhaftem Block: +10 %/s, max. 60 %. */ /** Steigt nach 2 s dauerhaftem Block: +10 %/s, max. 60 %. */
private float heavyAttackChance() { private float heavyAttackChance() {
float t = Math.max(0f, playerBlockTimer - 2f); 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) { private void startNpcHeavyAttack(SpawnedNpc s) {
s.comboHitsRemaining = 0; // Schwerer Angriff beginnt immer mit Schritt 0 (ATTACK_1 Animation);
s.comboStep = 2; // ATTACK_3 animation // ATTACK_3 ist nur über Kombo (Schritt 0→1→2) erreichbar, wie beim Spieler.
s.npcHeavyAttack = true; startNpcAttackStep(s, 0, true);
startNpcAttackStep(s);
} }
private void startNpcAttackSequence(SpawnedNpc s) { private void startNpcAttackSequence(SpawnedNpc s) {
s.comboStep = 0; startNpcAttackStep(s, 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);
} }
private void startNpcAttackStep(SpawnedNpc s) { private void startNpcAttackStep(SpawnedNpc s, int step) {
AnimationAction atk = resolveNpcAttackAction(s, s.comboStep); 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); float dur = resolveNpcClipLength(s, atk, NPC_CLIP_FALLBACK_ATK);
s.currentAction = null; // Dedup umgehen damit Combo-Steps sich nicht blockieren
playNpcAction(s, atk); playNpcAction(s, atk);
s.combatPhase = NpcCombatPhase.ATTACKING; s.combat.beginAttack(step, heavy, dur);
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
} }
private void startNpcBlock(SpawnedNpc s) { 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 entryDur = resolveNpcClipLength(s, blockEntry, NPC_CLIP_FALLBACK_BLOCK);
float holdTime = 0.5f + npcRng.nextFloat() * 1.5f; float holdTime = 0.5f + npcRng.nextFloat() * 1.5f;
playNpcAction(s, blockEntry); playNpcAction(s, blockEntry);
s.combatPhase = NpcCombatPhase.BLOCKING; s.combat.phase = WeaponCombatState.Phase.BLOCKING;
s.combatPhaseTimer = entryDur + holdTime; s.combat.timer = entryDur + holdTime;
} }
private void startNpcCounter(SpawnedNpc s) { private void startNpcCounter(SpawnedNpc s) {
AnimationAction ctr = resolveNpcCounterAction(s); AnimationAction ctr = resolveNpcCounterAction(s);
float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR); float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR);
s.currentAction = null;
playNpcAction(s, ctr); playNpcAction(s, ctr);
s.combatPhase = NpcCombatPhase.COUNTERING; s.combat.beginCounter(dur);
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
} }
private void startNpcDodge(SpawnedNpc s) { private void startNpcDodge(SpawnedNpc s) {
@@ -2065,23 +2047,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
float sign = npcRng.nextBoolean() ? 1f : -1f; float sign = npcRng.nextBoolean() ? 1f : -1f;
s.dodgeDirX = (len > 0.001f) ? sign * (dz / len) : 1f; s.dodgeDirX = (len > 0.001f) ? sign * (dz / len) : 1f;
s.dodgeDirZ = (len > 0.001f) ? sign * (-dx / len) : 0f; s.dodgeDirZ = (len > 0.001f) ? sign * (-dx / len) : 0f;
s.combatPhase = NpcCombatPhase.DODGING; s.combat.phase = WeaponCombatState.Phase.DODGING;
s.combatPhaseTimer = dur; s.combat.timer = dur;
} }
private void staggerNpc(SpawnedNpc s) { 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); AnimationAction stagger = resolveNpcStaggerAction(s);
float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK); float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK);
playNpcAction(s, stagger); playNpcAction(s, stagger);
s.combatPhase = NpcCombatPhase.STAGGERED; setNpcCurrentActionSpeed(s, 0.5f);
s.combatPhaseTimer = dur; 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) { private void knockoutNpc(SpawnedNpc s) {
s.knockedOut = true; s.knockedOut = true;
s.combatPhase = NpcCombatPhase.KNOCKED_OUT; s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT;
if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Always); }
s.aggroBehavior.forceCalm(); s.aggroBehavior.forceCalm();
playNpcDeathAnim(s); playNpcDeathAnim(s);
log.info("[NPC {}] K.O.!", s.npc().getCharacterId()); 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; } if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE + 0.3f) { return; }
Item weapon = findItemById(s.npc().getEquippedWeaponId()); Item weapon = findItemById(s.npc().getEquippedWeaponId());
int base = (weapon != null && weapon.getDamage() > 0) ? weapon.getDamage() : NPC_UNARMED_DAMAGE; int base = (weapon != null && weapon.getDamage() > 0) ? weapon.getDamage() : NPC_UNARMED_DAMAGE;
boolean isHeavy = s.npcHeavyAttack; boolean isHeavy = s.combat.isHeavy;
s.npcHeavyAttack = false; // verbraucht
if (isHeavy) { if (isHeavy) {
if (playerInput.isBlocking()) { if (playerInput.isBlocking()) {
// Schwerer Angriff bricht Block — kein HP-Verlust, aber Stagger // Schwerer Angriff bricht Block — kein HP-Verlust, aber Stagger
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
playerInput.triggerBlockImpact(); playerInput.triggerBlockImpact();
AnimationAction impact = resolvePlayerImpactAction(); AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
} else { } else {
applyPlayerDamage(Math.max(1, base * 3)); if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
applyPlayerDamage(s, Math.max(1, base * 3));
AnimationAction impact = resolvePlayerImpactAction(); AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
} }
return; 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 (playerInput.isBlocking()) {
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
playerInput.triggerBlockImpact(); playerInput.triggerBlockImpact();
// Konter überspringt Block nicht — normaler Schlag absorbiert // 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(); AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); } 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; } if (mainCharacter == null) { return; }
int hp = Math.max(0, mainCharacter.getCurrentHp() - damage); int hp = Math.max(0, mainCharacter.getCurrentHp() - damage);
mainCharacter.setCurrentHp(hp); mainCharacter.setCurrentHp(hp);
log.info("[NPC→Player] -{} HP → {}/{}", damage, hp, mainCharacter.getMaxHp()); 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) { private void resetNpcThinkTimer(SpawnedNpc s) {

View File

@@ -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.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.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.bandit1.name=Bandit
npc.bjoernson.name=Bjoernson npc.bjoernson.name=Bjoernson
npc.silas.name=Silas npc.silas.name=Silas

View File

@@ -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.4=Next time you won't get off so easily!
npc.aggro.break.5=We'll meet again – count on it! 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.bandit1.name=Bandit
npc.bjoernson.name=Bjoernson npc.bjoernson.name=Bjoernson
npc.silas.name=Silas npc.silas.name=Silas