Kampfsystem, hier: weitere Sounds, Interaktion mit NPCs weiter ausgebaut

This commit is contained in:
2026-09-28 21:56:56 +02:00
parent 555e2293dc
commit b7def4f2e7
16 changed files with 424 additions and 197 deletions

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;
@@ -464,8 +470,10 @@ public class PlayerInputControl {
autopilotDir = null; autopilotDir = null;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
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;
@@ -866,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,7 +1215,9 @@ public class PlayerInputControl {
rightMouseHeld = false; rightMouseHeld = false;
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,
@@ -1290,14 +1300,18 @@ public class PlayerInputControl {
if (isMeleeType(drawnWeaponType)) { if (isMeleeType(drawnWeaponType)) {
if (isPressed) { if (isPressed) {
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 -> {
// Kurzer Tap: Parade-Animation abspielen (auch wenn NPC nicht im Trefferfenster)
if (weaponCombat.onBlockRelease()) {
playCounterAttack();
}
blockPhase = BlockPhase.NONE; blockPhase = BlockPhase.NONE;
playCounterAttack();
} }
case ENTERING, HOLDING -> startBlockExit(); case ENTERING, HOLDING -> startBlockExit();
default -> {} default -> {}
@@ -1311,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 {
@@ -1365,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;
@@ -1376,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
@@ -1392,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

@@ -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; boolean knockedOut = false;
int comboHitsRemaining = 0; float knockAngle = 0f;
int comboStep = 0; float knockAngVel = 0f;
float attackHitWinTimer = 0f; float dodgeDirX = 0f;
float attackClipDuration = 0f; float dodgeDirZ = 0f;
boolean attackHitFired = false; float chargeTimer = 0f;
boolean knockedOut = false; float chaseDelayTimer = 0f;
float knockAngle = 0f;
float knockAngVel = 0f;
float dodgeDirX = 0f;
float dodgeDirZ = 0f;
boolean npcHeavyAttack = false;
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