Kampfsystem, hier: weitere Sounds, Interaktion mit NPCs weiter ausgebaut
This commit is contained in:
BIN
blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg
Normal file
BIN
blight-assets/src/main/resources/audio/weapons/impact/stab1.ogg
Normal file
Binary file not shown.
BIN
blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg
Normal file
BIN
blight-assets/src/main/resources/audio/weapons/impact/stab2.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user