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 ────────────────────────────────────────
/** 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) {
for (var e : nameMap.entrySet()) {
if (!e.getKey().equals(e.getValue())) return false;
Joint srcJ = src.getJoint(e.getKey());
Joint dstJ = dst.getJoint(e.getValue());
if (srcJ == null || dstJ == null) continue;
Quaternion sb = srcJ.getInitialTransform() != null
? srcJ.getInitialTransform().getRotation() : new Quaternion();
Quaternion db = dstJ.getInitialTransform() != null
? dstJ.getInitialTransform().getRotation() : new Quaternion();
if (Math.abs(sb.dot(db)) < 0.999f) {
log.info("[isSameRig] MISMATCH '{}': src=({},{},{},{}) dst=({},{},{},{}) dot={}",
e.getKey(),
String.format("%.4f", sb.getX()), String.format("%.4f", sb.getY()),
String.format("%.4f", sb.getZ()), String.format("%.4f", sb.getW()),
String.format("%.4f", db.getX()), String.format("%.4f", db.getY()),
String.format("%.4f", db.getZ()), String.format("%.4f", db.getW()),
String.format("%.6f", Math.abs(sb.dot(db))));
return false;
}
}
return true;
}

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.config.KeyBindings;
import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.combat.WeaponCombatState;
import de.blight.game.navigation.CharacterNavigator;
import de.blight.game.state.TerrainChunkState;
@@ -56,7 +57,7 @@ public class PlayerInputControl {
/** Zeit in Sekunden nach einem Angriff, in der der nächste Kombo-Schlag gedrückt werden kann. */
private static final float COMBO_WINDOW = 0.6f;
/** Maximale Haltezeit für einen Kurz-Klick (Konterangriff); darüber = Block-Eingabe. */
private static final float BLOCK_TAP_MAX = 0.20f;
private static final float BLOCK_TAP_MAX = WeaponCombatState.BLOCK_TAP_MAX;
/** Maximale Haltezeit, die noch als "schneller Klick" (→ normale Kombo) gilt. */
private static final float QUICK_TAP_THRESHOLD = 0.30f;
/** Animationsanteil (0–1), ab dem die Vorwärtsbewegung beim schweren Angriff beginnt. */
@@ -134,6 +135,8 @@ public class PlayerInputControl {
private boolean rightMouseHeld = false;
private BlockPhase blockPhase = BlockPhase.NONE;
private float blockPhaseTimer = 0f;
/** Gemeinsamer Zustandsautomat (Tap/Hold-Erkennung, parryArmed). */
private final WeaponCombatState weaponCombat = new WeaponCombatState();
private boolean combatAnimActive = false;
private float combatAnimRemaining = 0f;
private Runnable combatAnimCallback = null;
@@ -264,6 +267,9 @@ public class PlayerInputControl {
private float blockingAnimTotal = 0f;
private Runnable blockingAnimCallback = null;
/** Gesetzt wenn Spieler gestorben ist – sperrt alle Eingaben. */
private boolean playerDead = false;
/** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
private boolean turnActive = false;
private float turnRemaining = 0f;
@@ -306,7 +312,7 @@ public class PlayerInputControl {
private final Vector3f scratchTransl = new Vector3f();
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return;
if (paused || playerDead) return;
switch (name) {
case "Forward" -> forward = isPressed;
case "Backward" -> backward = isPressed;
@@ -464,8 +470,10 @@ public class PlayerInputControl {
autopilotDir = null;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
stopWading();
rightMouseHeld = false;
blockPhase = BlockPhase.NONE;
rightMouseHeld = false;
blockPhase = BlockPhase.NONE;
weaponCombat.phase = WeaponCombatState.Phase.IDLE;
weaponCombat.parryArmed = false;
combatAnimActive = false;
combatAnimCallback = null;
comboStep = 0;
@@ -866,7 +874,7 @@ public class PlayerInputControl {
blockingAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (blockingAnimRemaining <= 0f) {
if (blockingAnimRemaining <= 0f && !playerDead) {
blockingAnimActive = false;
Runnable cb = blockingAnimCallback;
blockingAnimCallback = null;
@@ -1207,7 +1215,9 @@ public class PlayerInputControl {
rightMouseHeld = false;
attackHoldPending = false;
heavyLungeVel = null;
blockPhase = BlockPhase.NONE;
blockPhase = BlockPhase.NONE;
weaponCombat.phase = WeaponCombatState.Phase.IDLE;
weaponCombat.parryArmed = false;
if (weaponSoundSystem != null) weaponSoundSystem.play("sheath");
boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield,
@@ -1290,14 +1300,18 @@ public class PlayerInputControl {
if (isMeleeType(drawnWeaponType)) {
if (isPressed) {
if (blockPhase == BlockPhase.NONE) {
blockPhase = BlockPhase.WAITING;
blockPhase = BlockPhase.WAITING;
blockPhaseTimer = 0f;
weaponCombat.onBlockPress();
}
} else {
switch (blockPhase) {
case WAITING -> {
// Kurzer Tap: Parade-Animation abspielen (auch wenn NPC nicht im Trefferfenster)
if (weaponCombat.onBlockRelease()) {
playCounterAttack();
}
blockPhase = BlockPhase.NONE;
playCounterAttack();
}
case ENTERING, HOLDING -> startBlockExit();
default -> {}
@@ -1311,8 +1325,9 @@ public class PlayerInputControl {
private void updateBlockPhase(float tpf) {
switch (blockPhase) {
case WAITING -> {
blockPhaseTimer += tpf;
if (blockPhaseTimer >= BLOCK_TAP_MAX) {
// Tap-Timer läuft in WeaponCombatState; gibt true zurück wenn Fenster abläuft
if (weaponCombat.tickParryWaiting(tpf)) {
// Tap-Fenster abgelaufen ohne Loslassen → Haltephase
if (hasShieldEquipped()) {
startBlockEnter();
} else {
@@ -1365,6 +1380,7 @@ public class PlayerInputControl {
private void playCounterAttack() {
if (combatAnimActive) return;
if (weaponSoundSystem != null) { weaponSoundSystem.play("counterparade"); }
AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType);
float dur = resolveClipLength(counterAct, 0.7f);
nextTransitionLength = COMBAT_BLEND;
@@ -1376,6 +1392,38 @@ public class PlayerInputControl {
currentAnim = counterAct;
}
/** Liefert true (einmalig) wenn der Spieler die Block-Taste als Tap gedrückt hat. */
public boolean consumeParryAttempt() {
return weaponCombat.consumeParryAttempt();
}
/** Spielt die Konter-Animations bei erfolgreicher Parade (nur wenn keine Combat-Animation aktiv). */
public void triggerParrySuccess() {
playCounterAttack();
}
/** Spielt eine zufällige Sterbe-Animation und sperrt alle Eingaben dauerhaft. */
public void playPlayerDeath() {
if (playerDead) { return; }
playerDead = true;
forward = backward = left = right = sprint = walk = false;
if (physicsChar != null) { physicsChar.setWalkDirection(com.jme3.math.Vector3f.ZERO); }
rightMouseHeld = false;
blockPhase = BlockPhase.NONE;
combatAnimActive = false;
comboWindowActive = false;
weaponCombat.phase = WeaponCombatState.Phase.IDLE;
weaponCombat.parryArmed = false;
AnimationAction deathAct = playerRng.nextBoolean()
? AnimationAction.SWORD_DEATH : AnimationAction.SWORD_DEATH_2;
playAction(deathAct);
currentAnim = deathAct;
blockingAnimActive = true;
blockingAnimRemaining = 999f;
blockingAnimTotal = 999f;
blockingAnimCallback = null;
}
public boolean isBlocking() {
return blockPhase == BlockPhase.ENTERING
|| blockPhase == BlockPhase.HOLDING
@@ -1392,6 +1440,7 @@ public class PlayerInputControl {
/** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */
public void triggerBlockImpact() {
if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return;
if (weaponSoundSystem != null) { weaponSoundSystem.play("impactblock"); }
if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) {
if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) {
mainCharacter.setCurrentStamina(0);

View File

@@ -63,6 +63,14 @@ public class NpcAggroBehavior {
new TextReference("npc.aggro.break.5"),
};
public static final TextReference[] DEATH_TEXTS = {
new TextReference("npc.aggro.death.1"),
new TextReference("npc.aggro.death.2"),
new TextReference("npc.aggro.death.3"),
new TextReference("npc.aggro.death.4"),
new TextReference("npc.aggro.death.5"),
};
/**
* Audio path convention: audio/npc/{npcId}/aggro_{category}_{1-5}.ogg
* category = "warn1" | "warn2" | "attack"

View File

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

View File

@@ -49,6 +49,7 @@ import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings;
import de.blight.game.combat.DamageCalculator;
import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.combat.WeaponCombatState;
import de.blight.game.npc.AggroEvent;
import de.blight.game.npc.NpcAggroBehavior;
import de.blight.lang.TextResolver;
@@ -96,6 +97,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private DayNightState dayNight;
private TerrainChunkState terrainChunks;
private AnimationLibrary animLib;
private de.blight.game.audio.WeaponSoundSystem weaponSoundSystem;
// ── NPC-Daten ─────────────────────────────────────────────────────────────
@@ -111,18 +113,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
/** Aktuell in der Welt gespawnte NPCs. */
private final List<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 {
final NPC npc;
final Spatial visual;
@@ -164,20 +154,15 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
RigidBodyControl rbc = null;
// ── Kampf-Zustand ──────────────────────────────────────────────────────
NpcCombatPhase combatPhase = NpcCombatPhase.IDLE_COMBAT;
float combatPhaseTimer = 0f;
float thinkTimer = 0f;
int comboHitsRemaining = 0;
int comboStep = 0;
float attackHitWinTimer = 0f;
float attackClipDuration = 0f;
boolean attackHitFired = false;
boolean knockedOut = false;
float knockAngle = 0f;
float knockAngVel = 0f;
float dodgeDirX = 0f;
float dodgeDirZ = 0f;
boolean npcHeavyAttack = false;
final WeaponCombatState combat = new WeaponCombatState();
float thinkTimer = 0f;
boolean knockedOut = false;
float knockAngle = 0f;
float knockAngVel = 0f;
float dodgeDirX = 0f;
float dodgeDirZ = 0f;
float chargeTimer = 0f;
float chaseDelayTimer = 0f;
de.blight.common.model.ItemSubCategory combatWeaponType = null;
SpawnedNpc(NPC npc, Spatial visual, float x, float z) {
@@ -272,7 +257,8 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private static final float NPC_MOVE_SPEED = CharacterNavigator.RUN_SPEED_MPS;
private static final float DRAW_ANIM_DURATION = 0.9f;
private static final float STEER_RAY_DIST = 1.8f;
private static final float NPC_ATTACK_RANGE = 1.5f;
private static final float NPC_ATTACK_RANGE = 2.0f;
private static final float NPC_STOP_DIST = 2.2f; // Abstand bei dem NPC stoppt und kämpft
private static final float NPC_CLOSE_IN_DIST = 3.0f;
private static final float NPC_CLOSE_IN_SPEED = CharacterNavigator.WALK_SPEED_MPS;
@@ -285,10 +271,12 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private static final float NPC_BLOCK_CHANCE_NORMAL = 0.35f;
private static final float NPC_BLOCK_CHANCE_LOW_HP = 0.15f;
private static final float NPC_DODGE_CHANCE = 0.40f;
private static final float NPC_COMBO_EXTEND_P1 = 0.70f;
private static final float NPC_COMBO_EXTEND_P2 = 0.30f;
private static final float NPC_HIT_WIN_START = 0.35f;
private static final float NPC_HIT_WIN_END = 0.65f;
private static final float NPC_PARRY_CHANCE = 0.25f;
// Kombo/Trefferfenster-Konstanten aus WeaponCombatState (gemeinsam mit Spieler)
private static final float NPC_COMBO_EXTEND_P1 = WeaponCombatState.COMBO_P1;
private static final float NPC_COMBO_EXTEND_P2 = WeaponCombatState.COMBO_P2;
private static final float NPC_HIT_WIN_START = WeaponCombatState.HIT_WIN_START;
private static final float NPC_HIT_WIN_END = WeaponCombatState.HIT_WIN_END;
private static final int NPC_UNARMED_DAMAGE = 8;
private static final int NPC_COMBO3_DAMAGE_MULT = 2;
private static final int NPC_BLOCK_STAMINA_BASE = 10;
@@ -343,6 +331,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
this.thirdPersonCam = cam;
}
public void setWeaponSoundSystem(de.blight.game.audio.WeaponSoundSystem wss) {
this.weaponSoundSystem = wss;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
@@ -509,7 +501,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
vis.setLocalTranslation(pos);
npcsRoot.attachChild(vis);
SpawnedNpc s = new SpawnedNpc(npc, vis, pos.x, pos.z);
attachNpcHpBar(s);
attachNpcCapsule(s);
de.blight.game.scene.WorldScene.collectPbrMaterials(vis, s.pbrMaterials);
if (dayNight != null && !s.pbrMaterials.isEmpty()) {
@@ -587,6 +578,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
for (int i = 0; i < spawned.size(); i++) {
SpawnedNpc s = spawned.get(i);
if (s.aggroBehavior.isAttacking()) continue;
Vector3f pos = s.visual().getLocalTranslation();
float dx = pos.x - charPos.x;
@@ -630,6 +622,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
}
private void startDialog(int idx) {
if (playerInput.isWeaponDrawn()) return;
SpawnedNpc entry = spawned.get(idx);
dialogNpc = entry;
if (entry.currentAction == AnimationAction.SITTING) {
@@ -1500,7 +1493,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
} else {
// Waffe gezückt: Kampf-KI initialisieren
s.combatWeaponType = resolveNpcCombatWeaponType(s);
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
if (s.combatRunAnim != null) { playNpcAction(s, s.combatRunAnim); }
}
@@ -1556,16 +1549,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
Vector3f npcPos = s.visual().getWorldTranslation();
float dist = dist2d(npcPos, playerPos);
if (dist <= NPC_ATTACK_RANGE) {
// Aktive Kampf-Phasen: Bewegungslogik komplett überspringen
if (s.combat.phase != WeaponCombatState.Phase.IDLE
&& s.combat.phase != WeaponCombatState.Phase.CHARGING) {
faceToward(s, playerPos);
if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); }
continue;
}
// Außerhalb Angriffsreichweite → CHARGING-Phase
if (s.combatPhase != NpcCombatPhase.CHARGING
&& s.combatPhase != NpcCombatPhase.DODGING) {
s.combatPhase = NpcCombatPhase.CHARGING;
// Charging-Timer: nach 1.5s Stuck trotzdem angreifen
if (s.combat.phase == WeaponCombatState.Phase.CHARGING) {
s.chargeTimer += tpf;
} else {
s.chargeTimer = 0f;
}
// In Kampf-Stopp-Distanz oder Stuck-Timeout → IDLE + Entscheidung
if (dist <= NPC_STOP_DIST || s.chargeTimer >= 1.5f) {
faceToward(s, playerPos);
s.chargeTimer = 0f;
if (s.combat.phase == WeaponCombatState.Phase.CHARGING) {
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
}
if (s.weaponDrawn) { updateNpcCombatPhase(s, tpf, playerPos); }
continue;
}
// Außerhalb Angriffsreichweite → CHARGING
if (s.combat.phase == WeaponCombatState.Phase.IDLE) {
s.combat.phase = WeaponCombatState.Phase.CHARGING;
s.chargeTimer = 0f;
}
// Close-in: Spieler ≤ 3m und flieht nicht → NPC geht langsam ran
@@ -1651,56 +1665,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
return hitDist >= STEER_RAY_DIST;
}
// ── NPC HP-Balken ─────────────────────────────────────────────────────────
private void attachNpcHpBar(SpawnedNpc s) {
Node hpBarNode = buildWorldHpBar();
hpBarNode.setLocalTranslation(0f, WB_Y_OFFSET, 0f);
hpBarNode.setCullHint(Spatial.CullHint.Always);
((Node) s.visual()).attachChild(hpBarNode);
s.hpBarNode = hpBarNode;
s.hpBarFg = (Geometry) ((Node) hpBarNode.getChild(0)).getChild(1);
}
private Node buildWorldHpBar() {
Node hpBarNode = new Node("npcHpBar");
Node billChild = new Node("npcHpBillboard");
billChild.addControl(new BillboardControl());
Geometry bg = new Geometry("npcHpBg", new Quad(WB_WIDTH, WB_HEIGHT));
bg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f, 0f);
Material bgMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
bgMat.setColor("Color", new ColorRGBA(0.15f, 0.15f, 0.15f, 0.85f));
bgMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
bg.setMaterial(bgMat);
bg.setQueueBucket(RenderQueue.Bucket.Transparent);
billChild.attachChild(bg);
Geometry fg = new Geometry("npcHpFg", new Quad(WB_WIDTH, WB_HEIGHT - 0.01f));
fg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f + 0.005f, 0.001f);
Material fgMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
fgMat.setColor("Color", new ColorRGBA(0.2f, 0.8f, 0.2f, 1f));
fg.setMaterial(fgMat);
fg.setQueueBucket(RenderQueue.Bucket.Transparent);
billChild.attachChild(fg);
hpBarNode.attachChild(billChild);
return hpBarNode;
}
private void refreshNpcHpBar(SpawnedNpc s) {
if (s.hpBarFg == null) { return; }
float frac = (float) s.currentHp / s.maxHp;
frac = Math.max(0f, frac);
ColorRGBA col;
if (frac > 0.5f) col = new ColorRGBA(0.2f, 0.8f, 0.2f, 1f);
else if (frac > 0.2f) col = new ColorRGBA(0.9f, 0.8f, 0.1f, 1f);
else col = new ColorRGBA(0.9f, 0.1f, 0.1f, 1f);
s.hpBarFg.getMaterial().setColor("Color", col);
s.hpBarFg.setLocalScale(Math.max(0.001f, frac), 1f, 1f);
if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Inherit); }
}
// ── NPC Physik-Kapsel ─────────────────────────────────────────────────────
private void attachNpcCapsule(SpawnedNpc s) {
@@ -1752,7 +1716,24 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit, boolean isHeavy, float critMultiplier) {
if (s.knockedOut) { return; }
if (s.combatPhase == NpcCombatPhase.BLOCKING) {
// NPC Konter-Parade: reaktive Parade gegen normalen Angriff (nicht schwer, nicht bereits blockend)
if (!isHeavy
&& (s.combat.phase == WeaponCombatState.Phase.IDLE
|| s.combat.phase == WeaponCombatState.Phase.CHARGING)
&& npcRng.nextFloat() < NPC_PARRY_CHANCE) {
AnimationAction blockImpact = resolveNpcBlockImpactAction(s);
float dur = resolveNpcClipLength(s, blockImpact, NPC_STAGGER_FALLBACK);
s.currentAction = null;
playNpcAction(s, blockImpact);
setNpcCurrentActionSpeed(s, 0.5f);
s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW;
s.combat.timer = dur * 2f;
AnimationAction playerImpact = resolvePlayerImpactAction();
if (playerImpact != null) playerInput.requestAnimation(playerImpact, NPC_STAGGER_FALLBACK, null);
return;
}
if (s.combat.phase == WeaponCombatState.Phase.BLOCKING) {
if (isHeavy) {
// Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus
if (npcRng.nextFloat() < NPC_DODGE_CHANCE) {
@@ -1765,16 +1746,16 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
int cost = Math.round(NPC_BLOCK_STAMINA_BASE * critMultiplier);
s.currentStamina = Math.max(0, s.currentStamina - cost);
if (s.currentStamina <= 0) {
// Stamina leer: Block bricht
staggerNpc(s);
} else {
// Block hält: Impact-Animation + Konter
AnimationAction impact = resolveNpcBlockImpactAction(s);
float dur = resolveNpcClipLength(s, impact, NPC_CLIP_FALLBACK_BLOCK);
s.currentAction = null;
playNpcAction(s, impact);
playerInput.triggerBlockImpact();
s.combatPhase = NpcCombatPhase.COUNTER_WINDOW;
s.combatPhaseTimer = dur;
s.combat.phase = WeaponCombatState.Phase.COUNTER_WINDOW;
s.combat.timer = dur;
}
}
return; // kein HP-Verlust bei geblockt
@@ -1782,7 +1763,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
// Nicht blockend: Schaden anwenden
s.currentHp = Math.max(0, s.currentHp - damage);
refreshNpcHpBar(s);
if (weaponSoundSystem != null) { weaponSoundSystem.play(isCrit ? "crit" : "impact"); }
log.info("[NPC {}] -{} HP → {}/{}{}", s.npc().getCharacterId(), damage,
s.currentHp, s.maxHp, s.currentHp <= 0 ? " K.O.!" : "");
if (s.currentHp <= 0) {
@@ -1893,23 +1874,28 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
// ── NPC Kampf-KI ─────────────────────────────────────────────────────────
private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) {
switch (s.combatPhase) {
switch (s.combat.phase) {
case KNOCKED_OUT -> {
// DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein.
}
case IDLE_COMBAT -> {
case IDLE -> {
playNpcAction(s, resolveNpcIdleAction(s));
Vector3f npcPos = s.visual().getWorldTranslation();
float dist = dist2d(npcPos, playerPos);
if (dist > NPC_STOP_DIST) {
s.chaseDelayTimer += tpf;
if (s.chaseDelayTimer >= 0.5f) {
s.combat.phase = WeaponCombatState.Phase.CHARGING;
s.chaseDelayTimer = 0f;
}
return;
}
s.chaseDelayTimer = 0f;
s.thinkTimer -= tpf;
if (s.thinkTimer > 0f) { return; }
// Think-Timer abgelaufen → Entscheidung
Vector3f npcPos = s.visual().getWorldTranslation();
float dist = dist2d(npcPos, playerPos);
if (dist > NPC_ATTACK_RANGE) {
s.combatPhase = NpcCombatPhase.CHARGING;
return;
}
boolean lowHp = (float) s.currentHp / s.maxHp < NPC_LOW_HP_THRESHOLD;
float blockChance = lowHp ? NPC_BLOCK_CHANCE_LOW_HP : NPC_BLOCK_CHANCE_NORMAL;
float heavyChance = heavyAttackChance();
@@ -1924,63 +1910,65 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
case CHARGING -> {
// Wird durch äußere Bewegungslogik in updateNpcCombat verwaltet.
// Wenn in Reichweite, wechselt updateNpcCombat zu IDLE_COMBAT + resetTimer.
// Wenn in Reichweite, wechselt updateNpcCombat zu IDLE + resetTimer.
}
case ATTACKING, COUNTERING -> {
s.combatPhaseTimer -= tpf;
s.attackHitWinTimer += tpf;
float progress = s.attackClipDuration > 0f
? s.attackHitWinTimer / s.attackClipDuration : 1f;
if (!s.attackHitFired
&& progress >= NPC_HIT_WIN_START
&& progress <= NPC_HIT_WIN_END) {
s.attackHitFired = true;
boolean done = s.combat.tickAttack(tpf);
// Konter-Parade: Spieler tippt Block im Trefferfenster → Stagger (langsam), kein Schaden
if (!s.combat.isHeavy && !s.combat.hitFired
&& s.combat.inHitWindow()
&& playerInput.consumeParryAttempt()) {
staggerNpcSlow(s);
playerInput.triggerParrySuccess();
return;
}
if (!s.combat.hitFired && s.combat.inHitWindow()) {
s.combat.hitFired = true;
fireNpcAttack(s, playerPos);
}
if (s.combatPhaseTimer <= 0f) {
if (s.combatPhase == NpcCombatPhase.ATTACKING
&& s.comboHitsRemaining > 0) {
if (done) {
if (s.combat.phase == WeaponCombatState.Phase.ATTACKING) {
// Nach jedem Treffer: Chance auf nächsten Kombo-Hit (wie Spieler)
int next = s.combat.nextComboStep(npcRng);
Vector3f npcPos = s.visual().getWorldTranslation();
if (dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) {
s.comboHitsRemaining--;
s.comboStep++;
startNpcAttackStep(s);
if (next >= 0 && dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) {
startNpcAttackStep(s, next);
return;
}
}
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
}
}
case BLOCKING -> {
playNpcAction(s, resolveNpcBlockHoldAction(s));
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f || s.currentStamina <= 0) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
s.combat.timer -= tpf;
if (s.combat.timer <= 0f || s.currentStamina <= 0) {
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
}
}
case COUNTER_WINDOW -> {
// Immer sofort kontern
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f) {
s.combat.timer -= tpf;
if (s.combat.timer <= 0f) {
startNpcCounter(s);
}
}
case STAGGERED -> {
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
s.combat.timer -= tpf;
if (s.combat.timer <= 0f) {
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
}
}
case DODGING -> {
s.combatPhaseTimer -= tpf;
s.combat.timer -= tpf;
// Seitliche Ausweich-Bewegung (bereits in startNpcDodge gesetzt)
if (s.dodgeDirX != 0f || s.dodgeDirZ != 0f) {
Vector3f npcPos = s.visual().getWorldTranslation();
@@ -1989,47 +1977,44 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
float ny = terrainChunks != null ? terrainChunks.getHeightAt(nx, nz) : npcPos.y;
s.visual().setLocalTranslation(nx, ny, nz);
}
if (s.combatPhaseTimer <= 0f) {
if (s.combat.timer <= 0f) {
s.dodgeDirX = 0f;
s.dodgeDirZ = 0f;
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
s.combat.phase = WeaponCombatState.Phase.IDLE;
resetNpcThinkTimer(s);
}
}
default -> {}
}
}
/** Steigt nach 2 s dauerhaftem Block: +10 %/s, max. 60 %. */
private float heavyAttackChance() {
float t = Math.max(0f, playerBlockTimer - 2f);
return Math.min(0.60f, t * 0.10f);
return Math.min(0.60f, 0.15f + t * 0.10f);
}
private void startNpcHeavyAttack(SpawnedNpc s) {
s.comboHitsRemaining = 0;
s.comboStep = 2; // ATTACK_3 animation
s.npcHeavyAttack = true;
startNpcAttackStep(s);
// Schwerer Angriff beginnt immer mit Schritt 0 (ATTACK_1 Animation);
// ATTACK_3 ist nur über Kombo (Schritt 0→1→2) erreichbar, wie beim Spieler.
startNpcAttackStep(s, 0, true);
}
private void startNpcAttackSequence(SpawnedNpc s) {
s.comboStep = 0;
int hits = 1;
if (npcRng.nextFloat() < NPC_COMBO_EXTEND_P1) { hits++; }
if (hits == 2 && npcRng.nextFloat() < NPC_COMBO_EXTEND_P2) { hits++; }
s.comboHitsRemaining = hits - 1;
startNpcAttackStep(s);
startNpcAttackStep(s, 0);
}
private void startNpcAttackStep(SpawnedNpc s) {
AnimationAction atk = resolveNpcAttackAction(s, s.comboStep);
private void startNpcAttackStep(SpawnedNpc s, int step) {
startNpcAttackStep(s, step, false);
}
private void startNpcAttackStep(SpawnedNpc s, int step, boolean heavy) {
AnimationAction atk = resolveNpcAttackAction(s, step);
float dur = resolveNpcClipLength(s, atk, NPC_CLIP_FALLBACK_ATK);
s.currentAction = null; // Dedup umgehen damit Combo-Steps sich nicht blockieren
playNpcAction(s, atk);
s.combatPhase = NpcCombatPhase.ATTACKING;
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
s.combat.beginAttack(step, heavy, dur);
}
private void startNpcBlock(SpawnedNpc s) {
@@ -2037,19 +2022,16 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
float entryDur = resolveNpcClipLength(s, blockEntry, NPC_CLIP_FALLBACK_BLOCK);
float holdTime = 0.5f + npcRng.nextFloat() * 1.5f;
playNpcAction(s, blockEntry);
s.combatPhase = NpcCombatPhase.BLOCKING;
s.combatPhaseTimer = entryDur + holdTime;
s.combat.phase = WeaponCombatState.Phase.BLOCKING;
s.combat.timer = entryDur + holdTime;
}
private void startNpcCounter(SpawnedNpc s) {
AnimationAction ctr = resolveNpcCounterAction(s);
float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR);
s.currentAction = null;
playNpcAction(s, ctr);
s.combatPhase = NpcCombatPhase.COUNTERING;
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
s.combat.beginCounter(dur);
}
private void startNpcDodge(SpawnedNpc s) {
@@ -2065,23 +2047,37 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
float sign = npcRng.nextBoolean() ? 1f : -1f;
s.dodgeDirX = (len > 0.001f) ? sign * (dz / len) : 1f;
s.dodgeDirZ = (len > 0.001f) ? sign * (-dx / len) : 0f;
s.combatPhase = NpcCombatPhase.DODGING;
s.combatPhaseTimer = dur;
s.combat.phase = WeaponCombatState.Phase.DODGING;
s.combat.timer = dur;
}
private void staggerNpc(SpawnedNpc s) {
s.attackHitFired = false;
AnimationAction stagger = resolveNpcStaggerAction(s);
float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK);
s.currentAction = null;
playNpcAction(s, stagger);
s.combat.stagger(dur);
}
private void staggerNpcSlow(SpawnedNpc s) {
s.currentAction = null;
AnimationAction stagger = resolveNpcStaggerAction(s);
float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK);
playNpcAction(s, stagger);
s.combatPhase = NpcCombatPhase.STAGGERED;
s.combatPhaseTimer = dur;
setNpcCurrentActionSpeed(s, 0.5f);
s.combat.stagger(dur * 2f);
}
private void setNpcCurrentActionSpeed(SpawnedNpc s, float speed) {
AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual());
if (ac == null) { return; }
com.jme3.anim.tween.action.Action a = ac.getLayer(AnimComposer.DEFAULT_LAYER).getCurrentAction();
if (a != null) { a.setSpeed(speed); }
}
private void knockoutNpc(SpawnedNpc s) {
s.knockedOut = true;
s.combatPhase = NpcCombatPhase.KNOCKED_OUT;
if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Always); }
s.knockedOut = true;
s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT;
s.aggroBehavior.forceCalm();
playNpcDeathAnim(s);
log.info("[NPC {}] K.O.!", s.npc().getCharacterId());
@@ -2112,39 +2108,46 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
if (dist2d(npcPos, playerPos) > NPC_ATTACK_RANGE + 0.3f) { return; }
Item weapon = findItemById(s.npc().getEquippedWeaponId());
int base = (weapon != null && weapon.getDamage() > 0) ? weapon.getDamage() : NPC_UNARMED_DAMAGE;
boolean isHeavy = s.npcHeavyAttack;
s.npcHeavyAttack = false; // verbraucht
boolean isHeavy = s.combat.isHeavy;
if (isHeavy) {
if (playerInput.isBlocking()) {
// Schwerer Angriff bricht Block — kein HP-Verlust, aber Stagger
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
playerInput.triggerBlockImpact();
AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
} else {
applyPlayerDamage(Math.max(1, base * 3));
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
applyPlayerDamage(s, Math.max(1, base * 3));
AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
}
return;
}
int damage = Math.max(1, base * (s.comboStep == 2 ? NPC_COMBO3_DAMAGE_MULT : 1));
int damage = Math.max(1, base * (s.combat.comboStep == 2 ? NPC_COMBO3_DAMAGE_MULT : 1));
if (playerInput.isBlocking()) {
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
playerInput.triggerBlockImpact();
// Konter überspringt Block nicht — normaler Schlag absorbiert
if (s.combatPhase != NpcCombatPhase.COUNTERING) { return; }
if (s.combat.phase != WeaponCombatState.Phase.COUNTERING) { return; }
}
applyPlayerDamage(damage);
if (weaponSoundSystem != null) { weaponSoundSystem.play("impact"); }
applyPlayerDamage(s, damage);
AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
}
private void applyPlayerDamage(int damage) {
private void applyPlayerDamage(SpawnedNpc killer, int damage) {
if (mainCharacter == null) { return; }
int hp = Math.max(0, mainCharacter.getCurrentHp() - damage);
mainCharacter.setCurrentHp(hp);
log.info("[NPC→Player] -{} HP → {}/{}", damage, hp, mainCharacter.getMaxHp());
if (hp <= 0 && playerInput != null) {
playerInput.playPlayerDeath();
if (killer != null) { showNpcSpeech(killer, NpcAggroBehavior.DEATH_TEXTS, "death"); }
}
}
private void resetNpcThinkTimer(SpawnedNpc s) {

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.5=Wir sehen uns wieder – darauf kannst du dich verlassen!
npc.aggro.death.1=Du wolltest es doch so.
npc.aggro.death.2=Hättest du lieber zu Hause geblieben.
npc.aggro.death.3=Deine Klinge hat dich nicht gerettet.
npc.aggro.death.4=Ein tapferer, aber dummer Tod.
npc.aggro.death.5=Das nächste Mal bleib, wo du hingehörst.
npc.bandit1.name=Bandit
npc.bjoernson.name=Bjoernson
npc.silas.name=Silas

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.5=We'll meet again – count on it!
npc.aggro.death.1=You had this coming.
npc.aggro.death.2=Should have stayed home.
npc.aggro.death.3=Your blade couldn't save you.
npc.aggro.death.4=A brave, but foolish end.
npc.aggro.death.5=Next time, know your place.
npc.bandit1.name=Bandit
npc.bjoernson.name=Bjoernson
npc.silas.name=Silas