Kampfsystem: NPC verhalten weiter angepasst, einige Bugs gefixed

This commit is contained in:
2026-09-27 20:57:04 +02:00
parent da8e5e99f1
commit 0a7c7bc6d1
26 changed files with 678 additions and 38 deletions

View File

@@ -36,13 +36,15 @@ public enum AnimationAction {
SECRET,
TALK_SITTING,
// ── Schwert + Schild ──────────────────────────────────────────────────────
// ── Schwert ───────────────────────────────────────────────────────────────
SWORD_ATTACK_1,
SWORD_DRAW, SWORD_SHEATHE, SWORD_BLOCK,
SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK,
SWORD_IDLE_TO_BLOCK, SWORD_COUNTERATTACK, SWORD_BLOCK_IMPACT, SWORD_IDLE_IMPACT,
SWORD_IDLE, SWORD_WALK, SWORD_RUN,
SWORD_BLOCK_MOVE, SWORD_BLOCK_MOVE_BACKWARD, SWORD_BLOCK_STRAFE_LEFT, SWORD_BLOCK_STRAFE_RIGHT,
SWORD_IDLE_BORED, SWORD_IDLE_BORED_2, SWORD_IDLE_BORED_3, SWORD_DEATH, SWORD_DEATH_2,
SWORD_ATTACK_1, SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK,SWORD_COUNTERATTACK, SWORD_IDLE_IMPACT,
SWORD_IDLE, SWORD_WALK, SWORD_RUN, SWORD_JUMP, SWORD_RUNNING_JUMP,
SWORD_BLOCK, SWORD_IDLE_TO_BLOCK, SWORD_BLOCK_IMPACT, SWORD_BLOCK_MOVE, SWORD_BLOCK_MOVE_BACKWARD, SWORD_BLOCK_STRAFE_LEFT, SWORD_BLOCK_STRAFE_RIGHT,
// ── Axt ───────────────────────────────────────────────────────────────────
AXE_DRAW, AXE_SHEATHE, AXE_BLOCK,
@@ -104,9 +106,14 @@ public enum AnimationAction {
case SECRET -> TextRegistry.resolve(null, key, "Geheimnis");
case TALK_SITTING -> TextRegistry.resolve(null, key, "Reden (Sitzend)");
case SWORD_JUMP -> TextRegistry.resolve(null, key, "Sprung (Schwert)");
case SWORD_RUNNING_JUMP -> TextRegistry.resolve(null, key, "Sprung Laufend (Schwert)");
case SWORD_IDLE_BORED -> TextRegistry.resolve(null, key, "Idle Gelangweilt (Schwert)");
case SWORD_IDLE_BORED_2 -> TextRegistry.resolve(null, key, "Idle Gelangweilt 2 (Schwert)");
case SWORD_IDLE_BORED_3 -> TextRegistry.resolve(null, key, "Idle Gelangweilt 3 (Schwert)");
case SWORD_DEATH -> TextRegistry.resolve(null, key, "Tod (Schwert)");
case SWORD_DEATH_2 -> TextRegistry.resolve(null, key, "Tod 2 (Schwert)");
case SWORD_ATTACK_1 -> TextRegistry.resolve(null, key, "Angriff 1 (Schwert)");
case SWORD_DRAW -> TextRegistry.resolve(null, key, "Ziehen (Schwert)");
case SWORD_SHEATHE -> TextRegistry.resolve(null, key, "Wegstecken (Schwert)");
case SWORD_BLOCK -> TextRegistry.resolve(null, key, "Block (Schwert)");
case SWORD_ATTACK_2 -> TextRegistry.resolve(null, key, "Angriff 2 (Schwert)");
case SWORD_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Schwert)");

View File

@@ -71,9 +71,12 @@ public final class RetargetingSystem {
// Mixamo-Clips, deren Knochen in Blender nur umbenannt (nicht retargeted) wurden,
// haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel.
if (isSameRig(nameMap, sourceArmature, targetArmature)) {
log.debug("[Retarget] '{}' same-rig detected – fast path (redirect only)", sourceClip.getName());
log.info("[Retarget] '{}' same-rig=TRUE → redirectTracks (srcJoints={} dstJoints={})",
sourceClip.getName(), sourceArmature.getJointCount(), targetArmature.getJointCount());
return redirectTracks(sourceClip, targetArmature);
}
log.info("[Retarget] '{}' same-rig=FALSE → full retarget (srcJoints={} dstJoints={})",
sourceClip.getName(), sourceArmature.getJointCount(), targetArmature.getJointCount());
// ── Source track lookup ───────────────────────────────────────────────
Map<String, TransformTrack> srcTrackMap = new HashMap<>();
@@ -402,10 +405,27 @@ public final class RetargetingSystem {
// ── Same-rig detection & redirect ────────────────────────────────────────
/** True wenn alle Namen exakt selbst-gemappt sind (gleiche Knochen-Namen → gleicher Rig-Typ). */
/** True nur wenn alle Namen exakt selbst-gemappt UND alle Bind-Posen übereinstimmen. */
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;
}
@@ -417,13 +437,31 @@ public final class RetargetingSystem {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint srcJoint) {
Joint dstJoint = targetArmature.getJoint(srcJoint.getName());
if (dstJoint == null) continue;
// Scale-Tracks werden nie übernommen: Mixamo/Blender FBX enthält oft
// nicht-triviale Scales (cm-Einheiten, Rig-Skalierung), die den Charakter
// auf Homunkuli-Größe schrumpfen würden. Joint-Scaling passiert über die
// Charakter-Transform, nicht über Animations-Tracks.
newTracks.add(new TransformTrack(dstJoint, tt.getTimes(),
tt.getTranslations(), tt.getRotations(), tt.getScales()));
tt.getTranslations(), tt.getRotations(), null));
}
}
if (newTracks.isEmpty()) return null;
AnimClip result = new AnimClip(sourceClip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
// Log first Hips translation to diagnose shrinking
for (AnimTrack<?> t : newTracks) {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint j
&& j.getName().contains("Hips") && !j.getName().contains("$AssimpFbx$")
&& tt.getTranslations() != null && tt.getTranslations().length > 0) {
var tr = tt.getTranslations()[0];
log.info("[Retarget] '{}' Hips[0]: t=({},{},{}) scales={} tracks={}",
sourceClip.getName(),
String.format("%.3f", tr.x), String.format("%.3f", tr.y), String.format("%.3f", tr.z),
tt.getScales() != null ? "yes" : "null",
newTracks.size());
break;
}
}
log.debug("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}

View File

@@ -285,6 +285,11 @@ public class PlayerInputControl {
// ── Dodge Roll ────────────────────────────────────────────────────────────
private boolean rollActive = false;
private float rollRemaining = 0f;
private static final float BORED_IDLE_DELAY = 30f;
private float boredIdleTimer = 0f;
private float boredAnimRemaining = 0f;
private AnimationAction boredVariant = AnimationAction.SWORD_IDLE_BORED;
private final java.util.Random playerRng = new java.util.Random();
private float rollInitRemaining = 0f;
private boolean rollBaselineCaptured = false;
private float rollHipsBaseY = 0f;
@@ -969,20 +974,8 @@ public class PlayerInputControl {
// Hold-Timer muss jede Frame akkumulieren, unabhängig vom Anim-Status
if (attackHoldPending) {
attackHoldTimer += tpf;
if (attackHoldTimer > QUICK_TAP_THRESHOLD) {
if (combatAnimActive && !combatAnimSkipComboWindow) {
// Normale Kombo unterbrechen und schweren Angriff einleiten
combatAnimActive = false;
combatAnimRemaining = 0f;
comboWindowActive = false;
comboStep = 0;
heavyLungeVel = null;
heavyLungeStartRemaining = 0f;
heavyLungeEndRemaining = 0f;
startHeavyCharge(); // setzt combatAnimSkipComboWindow = true → verhindert Loop
} else if (!combatAnimActive) {
startHeavyCharge();
}
if (attackHoldTimer > QUICK_TAP_THRESHOLD && !combatAnimActive) {
startHeavyCharge();
}
}
@@ -1091,15 +1084,41 @@ public class PlayerInputControl {
&& sneakPhase != SneakPhase.DOWN && sneakPhase != SneakPhase.UP) {
AnimationAction target;
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
boredIdleTimer = 0f; boredAnimRemaining = 0f;
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (sneaking) {
boredIdleTimer = 0f; boredAnimRemaining = 0f;
target = moving ? AnimationAction.SNEAK : AnimationAction.CROUCH_IDLE;
} else if (moving) {
boredIdleTimer = 0f; boredAnimRemaining = 0f;
target = (inDeepWater || walk) ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
} else {
target = AnimationAction.IDLE;
boolean swordShield = drawnWeaponType == de.blight.common.model.ItemSubCategory.SWORD
&& hasShieldEquipped();
if (swordShield) {
if (boredAnimRemaining > 0f) {
boredAnimRemaining -= tpf;
target = boredVariant;
} else {
boredIdleTimer += tpf;
if (boredIdleTimer >= BORED_IDLE_DELAY) {
boredIdleTimer = 0f;
int pick = playerRng.nextInt(3);
boredVariant = pick == 0 ? AnimationAction.SWORD_IDLE_BORED
: pick == 1 ? AnimationAction.SWORD_IDLE_BORED_2
: AnimationAction.SWORD_IDLE_BORED_3;
boredAnimRemaining = resolveClipLength(boredVariant, 3.0f);
target = boredVariant;
} else {
target = AnimationAction.IDLE;
}
}
} else {
boredIdleTimer = 0f; boredAnimRemaining = 0f;
target = AnimationAction.IDLE;
}
}
target = resolveWeaponAction(target);
@@ -1490,6 +1509,12 @@ public class PlayerInputControl {
case IDLE -> weaponIdleAction();
case WALK -> weaponWalkAction();
case RUN, SPRINT -> weaponRunAction();
case JUMP -> (drawnWeaponType == de.blight.common.model.ItemSubCategory.SWORD
&& hasShieldEquipped())
? AnimationAction.SWORD_JUMP : base;
case RUNNING_JUMP -> (drawnWeaponType == de.blight.common.model.ItemSubCategory.SWORD
&& hasShieldEquipped())
? AnimationAction.SWORD_RUNNING_JUMP : base;
default -> base;
};
}
@@ -1558,7 +1583,7 @@ public class PlayerInputControl {
return switch (s) {
case AXE -> AnimationAction.AXE_DRAW;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_DRAW;
default -> AnimationAction.SWORD_DRAW;
default -> AnimationAction.AXE_DRAW;
};
}
@@ -1566,7 +1591,7 @@ public class PlayerInputControl {
return switch (s) {
case AXE -> AnimationAction.AXE_SHEATHE;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_SHEATHE;
default -> AnimationAction.SWORD_SHEATHE;
default -> AnimationAction.AXE_DRAW;
};
}

View File

@@ -196,6 +196,13 @@ public class NpcAggroBehavior {
/** Returns a random phrase index in [0, 4]. */
public int randomPhraseIdx() { return rng.nextInt(5); }
/** Setzt den Aggro-Zustand sofort auf CALM zurück (z. B. bei K.O.). */
public void forceCalm() {
phase = AggroPhase.CALM;
scheduleAnchor = null;
reAggroGuard = false;
}
// ── Static sight check ────────────────────────────────────────────────────
/**

View File

@@ -111,6 +111,18 @@ 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;
@@ -151,6 +163,23 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
// ── Physik-Kapsel (Kollision) ─────────────────────────────────────────
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;
de.blight.common.model.ItemSubCategory combatWeaponType = null;
SpawnedNpc(NPC npc, Spatial visual, float x, float z) {
this.npc = npc;
this.visual = visual;
@@ -247,6 +276,33 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private static final float NPC_CLOSE_IN_DIST = 3.0f;
private static final float NPC_CLOSE_IN_SPEED = CharacterNavigator.WALK_SPEED_MPS;
// ── NPC Kampf-KI ──────────────────────────────────────────────────────────
private static final float NPC_THINK_MIN_NORMAL = 0.8f;
private static final float NPC_THINK_MAX_NORMAL = 2.0f;
private static final float NPC_THINK_MIN_LOW_HP = 0.4f;
private static final float NPC_THINK_MAX_LOW_HP = 1.0f;
private static final float NPC_LOW_HP_THRESHOLD = 0.5f;
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 int NPC_UNARMED_DAMAGE = 8;
private static final int NPC_COMBO3_DAMAGE_MULT = 2;
private static final int NPC_BLOCK_STAMINA_BASE = 10;
private static final float NPC_KNOCK_INITIAL_VEL = 0.4f;
private static final float NPC_KNOCK_GRAVITY = 8f;
private static final float NPC_STAGGER_FALLBACK = 0.6f;
private static final float NPC_CLIP_FALLBACK_ATK = 0.7f;
private static final float NPC_CLIP_FALLBACK_BLOCK = 0.4f;
private static final float NPC_CLIP_FALLBACK_CTR = 0.5f;
private static final float NPC_CLIP_FALLBACK_ROLL = 0.8f;
private final java.util.Random npcRng = new java.util.Random();
private float playerBlockTimer = 0f;
// ── NPC HP / Kampf ────────────────────────────────────────────────────────
private static final int DEFAULT_NPC_MAX_HP = 100;
private static final int DEFAULT_NPC_MAX_MANA = 100;
@@ -1384,7 +1440,6 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
de.blight.common.model.ItemSubCategory sub = w.getSubCategory();
if (sub == null) { return AnimationAction.IDLE; }
return switch (sub) {
case SWORD -> AnimationAction.SWORD_SHEATHE;
case AXE -> AnimationAction.AXE_SHEATHE;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_SHEATHE;
case BOW -> AnimationAction.BOW_SHEATHE;
@@ -1412,6 +1467,8 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
private void updateNpcCombat(float tpf) {
if (dialogActive) { return; }
if (playerInput.isBlocking()) { playerBlockTimer += tpf; }
else { playerBlockTimer = 0f; }
Vector3f playerPos = physicsChar.getPhysicsLocation();
for (SpawnedNpc s : spawned) {
@@ -1441,6 +1498,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
s.combatRunAnim = null;
playNpcAction(s, AnimationAction.IDLE);
} else {
// Waffe gezückt: Kampf-KI initialisieren
s.combatWeaponType = resolveNpcCombatWeaponType(s);
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
resetNpcThinkTimer(s);
if (s.combatRunAnim != null) { playNpcAction(s, s.combatRunAnim); }
}
}
@@ -1486,16 +1547,27 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
if (!s.aggroBehavior.isAttacking()) { continue; }
// K.O.: nur Umfall-Physik, keine sonstige Kampflogik
if (s.knockedOut) {
updateNpcCombatPhase(s, tpf, playerPos);
continue;
}
Vector3f npcPos = s.visual().getWorldTranslation();
float dist = dist2d(npcPos, playerPos);
if (dist <= NPC_ATTACK_RANGE) {
// Kampfdistanz erreicht: stehen, drehen, combat-idle
faceToward(s, playerPos);
if (s.weaponDrawn) { playNpcAction(s, AnimationAction.IDLE); }
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;
}
// Close-in: Spieler ≤ 3m und flieht nicht → NPC geht langsam ran
if (dist <= NPC_CLOSE_IN_DIST && !playerInput.isRunningOrSprinting()) {
Vector3f moveDir = steerTowardPlayer(npcPos, playerPos);
@@ -1662,6 +1734,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
lastAttackId = attackId;
hitThisAttack.clear();
}
boolean isHeavy = critMultiplier >= 3.0f;
for (SpawnedNpc s : spawned) {
if (hitThisAttack.contains(s.npc().getCharacterId())) { continue; }
Vector3f npcPos = s.visual().getWorldTranslation();
@@ -1670,16 +1743,53 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
float dz = npcPos.z - gripPos.z;
if (dx * dx + dy * dy + dz * dz <= bladeLength * bladeLength) {
hitThisAttack.add(s.npc().getCharacterId());
applyNpcDamage(s, DamageCalculator.calculate(baseDamage, critMultiplier), critMultiplier > 1.0f);
applyNpcDamage(s, DamageCalculator.calculate(baseDamage, critMultiplier),
critMultiplier > 1.0f, isHeavy, critMultiplier);
}
}
}
private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit) {
private void applyNpcDamage(SpawnedNpc s, int damage, boolean isCrit, boolean isHeavy, float critMultiplier) {
if (s.knockedOut) { return; }
if (s.combatPhase == NpcCombatPhase.BLOCKING) {
if (isHeavy) {
// Schwerer Angriff: Block bricht, kein Schaden — NPC weicht ggf. aus
if (npcRng.nextFloat() < NPC_DODGE_CHANCE) {
startNpcDodge(s);
} else {
staggerNpc(s);
}
} else {
// Normaler Treffer absorbiert: Stamina-Drain
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);
playNpcAction(s, impact);
playerInput.triggerBlockImpact();
s.combatPhase = NpcCombatPhase.COUNTER_WINDOW;
s.combatPhaseTimer = dur;
}
}
return; // kein HP-Verlust bei geblockt
}
// Nicht blockend: Schaden anwenden
s.currentHp = Math.max(0, s.currentHp - damage);
refreshNpcHpBar(s);
log.info("[NPC {}] -{} HP → {}/{}{}", s.npc().getCharacterId(), damage,
s.currentHp, s.maxHp, s.currentHp <= 0 ? " K.O.!" : "");
if (s.currentHp <= 0) {
knockoutNpc(s);
} else {
staggerNpc(s);
}
}
// ── Feind-HUD ─────────────────────────────────────────────────────────────
@@ -1779,4 +1889,384 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback
mesh.getBuffer(com.jme3.scene.VertexBuffer.Type.TexCoord).setUpdateNeeded();
mesh.updateBound();
}
// ── NPC Kampf-KI ─────────────────────────────────────────────────────────
private void updateNpcCombatPhase(SpawnedNpc s, float tpf, Vector3f playerPos) {
switch (s.combatPhase) {
case KNOCKED_OUT -> {
// DEATH-Animation läuft einmalig (DontLoop) und friert auf dem letzten Frame ein.
}
case IDLE_COMBAT -> {
playNpcAction(s, resolveNpcIdleAction(s));
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();
if (heavyChance > 0f && npcRng.nextFloat() < heavyChance) {
startNpcHeavyAttack(s);
} else if (npcRng.nextFloat() < blockChance && s.currentStamina >= NPC_BLOCK_STAMINA_BASE) {
startNpcBlock(s);
} else {
startNpcAttackSequence(s);
}
}
case CHARGING -> {
// Wird durch äußere Bewegungslogik in updateNpcCombat verwaltet.
// Wenn in Reichweite, wechselt updateNpcCombat zu IDLE_COMBAT + 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;
fireNpcAttack(s, playerPos);
}
if (s.combatPhaseTimer <= 0f) {
if (s.combatPhase == NpcCombatPhase.ATTACKING
&& s.comboHitsRemaining > 0) {
Vector3f npcPos = s.visual().getWorldTranslation();
if (dist2d(npcPos, playerPos) <= NPC_ATTACK_RANGE + 0.5f) {
s.comboHitsRemaining--;
s.comboStep++;
startNpcAttackStep(s);
return;
}
}
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
resetNpcThinkTimer(s);
}
}
case BLOCKING -> {
playNpcAction(s, resolveNpcBlockHoldAction(s));
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f || s.currentStamina <= 0) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
resetNpcThinkTimer(s);
}
}
case COUNTER_WINDOW -> {
// Immer sofort kontern
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f) {
startNpcCounter(s);
}
}
case STAGGERED -> {
s.combatPhaseTimer -= tpf;
if (s.combatPhaseTimer <= 0f) {
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
resetNpcThinkTimer(s);
}
}
case DODGING -> {
s.combatPhaseTimer -= tpf;
// Seitliche Ausweich-Bewegung (bereits in startNpcDodge gesetzt)
if (s.dodgeDirX != 0f || s.dodgeDirZ != 0f) {
Vector3f npcPos = s.visual().getWorldTranslation();
float nx = npcPos.x + s.dodgeDirX * NPC_MOVE_SPEED * tpf;
float nz = npcPos.z + s.dodgeDirZ * NPC_MOVE_SPEED * tpf;
float ny = terrainChunks != null ? terrainChunks.getHeightAt(nx, nz) : npcPos.y;
s.visual().setLocalTranslation(nx, ny, nz);
}
if (s.combatPhaseTimer <= 0f) {
s.dodgeDirX = 0f;
s.dodgeDirZ = 0f;
s.combatPhase = NpcCombatPhase.IDLE_COMBAT;
resetNpcThinkTimer(s);
}
}
}
}
/** 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);
}
private void startNpcHeavyAttack(SpawnedNpc s) {
s.comboHitsRemaining = 0;
s.comboStep = 2; // ATTACK_3 animation
s.npcHeavyAttack = true;
startNpcAttackStep(s);
}
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);
}
private void startNpcAttackStep(SpawnedNpc s) {
AnimationAction atk = resolveNpcAttackAction(s, s.comboStep);
float dur = resolveNpcClipLength(s, atk, NPC_CLIP_FALLBACK_ATK);
playNpcAction(s, atk);
s.combatPhase = NpcCombatPhase.ATTACKING;
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
}
private void startNpcBlock(SpawnedNpc s) {
AnimationAction blockEntry = resolveNpcBlockAction(s);
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;
}
private void startNpcCounter(SpawnedNpc s) {
AnimationAction ctr = resolveNpcCounterAction(s);
float dur = resolveNpcClipLength(s, ctr, NPC_CLIP_FALLBACK_CTR);
playNpcAction(s, ctr);
s.combatPhase = NpcCombatPhase.COUNTERING;
s.combatPhaseTimer = dur;
s.attackClipDuration = dur;
s.attackHitWinTimer = 0f;
s.attackHitFired = false;
}
private void startNpcDodge(SpawnedNpc s) {
playNpcAction(s, AnimationAction.ROLL);
float dur = resolveNpcClipLength(s, AnimationAction.ROLL, NPC_CLIP_FALLBACK_ROLL);
// Seitlich vom Spieler wegbewegen
Vector3f npcPos = s.visual().getWorldTranslation();
Vector3f playerPos = physicsChar.getPhysicsLocation();
float dx = npcPos.x - playerPos.x;
float dz = npcPos.z - playerPos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
// Perpendicular links oder rechts (zufällig)
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;
}
private void staggerNpc(SpawnedNpc s) {
s.attackHitFired = false;
AnimationAction stagger = resolveNpcStaggerAction(s);
float dur = resolveNpcClipLength(s, stagger, NPC_STAGGER_FALLBACK);
playNpcAction(s, stagger);
s.combatPhase = NpcCombatPhase.STAGGERED;
s.combatPhaseTimer = dur;
}
private void knockoutNpc(SpawnedNpc s) {
s.knockedOut = true;
s.combatPhase = NpcCombatPhase.KNOCKED_OUT;
if (s.hpBarNode != null) { s.hpBarNode.setCullHint(Spatial.CullHint.Always); }
s.aggroBehavior.forceCalm();
playNpcDeathAnim(s);
log.info("[NPC {}] K.O.!", s.npc().getCharacterId());
}
private void playNpcDeathAnim(SpawnedNpc s) {
if (animLib == null) { return; }
String setName = s.npc().getAnimSetPath();
if (setName == null || setName.isBlank()) { return; }
AnimationAction deathAction = npcRng.nextBoolean()
? AnimationAction.SWORD_DEATH : AnimationAction.SWORD_DEATH_2;
String clip = AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, deathAction);
if (clip == null) {
clip = AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, AnimationAction.SWORD_DEATH);
}
if (clip == null) { return; }
if (!animLib.applyTo(clip, s.visual())) { return; }
AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual());
if (ac == null) { return; }
ac.setCurrentAction(clip, com.jme3.anim.AnimComposer.DEFAULT_LAYER, false);
s.currentAction = deathAction;
}
private void fireNpcAttack(SpawnedNpc s, Vector3f playerPos) {
Vector3f npcPos = s.visual().getWorldTranslation();
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
if (isHeavy) {
if (playerInput.isBlocking()) {
// Schwerer Angriff bricht Block — kein HP-Verlust, aber Stagger
playerInput.triggerBlockImpact();
AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
} else {
applyPlayerDamage(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));
if (playerInput.isBlocking()) {
playerInput.triggerBlockImpact();
// Konter überspringt Block nicht — normaler Schlag absorbiert
if (s.combatPhase != NpcCombatPhase.COUNTERING) { return; }
}
applyPlayerDamage(damage);
AnimationAction impact = resolvePlayerImpactAction();
if (impact != null) { playerInput.requestAnimation(impact, NPC_STAGGER_FALLBACK, null); }
}
private void applyPlayerDamage(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());
}
private void resetNpcThinkTimer(SpawnedNpc s) {
boolean lowHp = (float) s.currentHp / s.maxHp < NPC_LOW_HP_THRESHOLD;
float min = lowHp ? NPC_THINK_MIN_LOW_HP : NPC_THINK_MIN_NORMAL;
float max = lowHp ? NPC_THINK_MAX_LOW_HP : NPC_THINK_MAX_NORMAL;
s.thinkTimer = min + npcRng.nextFloat() * (max - min);
}
// ── NPC Animations-Resolver ───────────────────────────────────────────────
private de.blight.common.model.ItemSubCategory resolveNpcCombatWeaponType(SpawnedNpc s) {
Item w = findItemById(s.npc().getEquippedWeaponId());
if (w == null || w.getSubCategory() == null) {
return de.blight.common.model.ItemSubCategory.SWORD;
}
return w.getSubCategory();
}
private AnimationAction resolveNpcIdleAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.IDLE; }
return switch (s.combatWeaponType) {
case SWORD -> AnimationAction.SWORD_IDLE;
case AXE -> AnimationAction.AXE_IDLE;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE;
default -> AnimationAction.IDLE;
};
}
private AnimationAction resolveNpcAttackAction(SpawnedNpc s, int step) {
de.blight.common.model.ItemSubCategory wt =
s.combatWeaponType != null ? s.combatWeaponType : de.blight.common.model.ItemSubCategory.SWORD;
return switch (wt) {
case AXE -> switch (step) {
case 1 -> AnimationAction.AXE_ATTACK_2;
case 2 -> AnimationAction.AXE_ATTACK_3;
default -> AnimationAction.AXE_ATTACK_1;
};
case TWO_HANDED_SWORD -> switch (step) {
case 1 -> AnimationAction.TWO_HAND_ATTACK_2;
case 2 -> AnimationAction.TWO_HAND_ATTACK_3;
default -> AnimationAction.TWO_HAND_ATTACK_1;
};
default -> switch (step) {
case 1 -> AnimationAction.SWORD_ATTACK_2;
case 2 -> AnimationAction.SWORD_ATTACK_3;
default -> AnimationAction.SWORD_ATTACK_1;
};
};
}
private AnimationAction resolveNpcBlockAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.SWORD_IDLE_TO_BLOCK; }
return switch (s.combatWeaponType) {
case AXE -> AnimationAction.AXE_IDLE_TO_BLOCK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_TO_BLOCK;
default -> AnimationAction.SWORD_IDLE_TO_BLOCK;
};
}
private AnimationAction resolveNpcBlockHoldAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.SWORD_BLOCK; }
return switch (s.combatWeaponType) {
case AXE -> AnimationAction.AXE_BLOCK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK;
default -> AnimationAction.SWORD_BLOCK;
};
}
private AnimationAction resolveNpcBlockImpactAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.SWORD_BLOCK_IMPACT; }
return switch (s.combatWeaponType) {
case AXE -> AnimationAction.AXE_BLOCK_IMPACT;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK_IMPACT;
default -> AnimationAction.SWORD_BLOCK_IMPACT;
};
}
private AnimationAction resolveNpcCounterAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.SWORD_COUNTERATTACK; }
return switch (s.combatWeaponType) {
case AXE -> AnimationAction.AXE_COUNTERATTACK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_COUNTERATTACK;
default -> AnimationAction.SWORD_COUNTERATTACK;
};
}
private AnimationAction resolveNpcStaggerAction(SpawnedNpc s) {
if (s.combatWeaponType == null) { return AnimationAction.SWORD_IDLE_IMPACT; }
return switch (s.combatWeaponType) {
case AXE -> AnimationAction.AXE_IDLE_IMPACT;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT;
default -> AnimationAction.SWORD_IDLE_IMPACT;
};
}
private AnimationAction resolvePlayerImpactAction() {
de.blight.common.model.ItemSubCategory wt = playerInput.getDrawnWeaponType();
if (wt == null) { return AnimationAction.SWORD_IDLE_IMPACT; }
return switch (wt) {
case AXE -> AnimationAction.AXE_IDLE_IMPACT;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT;
default -> AnimationAction.SWORD_IDLE_IMPACT;
};
}
private float resolveNpcClipLength(SpawnedNpc s, AnimationAction action, float fallback) {
if (animLib == null) { return fallback; }
String setName = s.npc().getAnimSetPath();
if (setName == null || setName.isBlank()) { return fallback; }
try {
String clip = AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, action);
if (clip == null) { return fallback; }
com.jme3.anim.AnimComposer ac =
de.blight.game.animation.RetargetingSystem.findAnimComposer(s.visual());
if (ac == null) { return fallback; }
com.jme3.anim.AnimClip animClip = ac.getAnimClip(clip);
float len = animClip != null ? (float) animClip.getLength() : 0f;
return len > 0f ? len : fallback;
} catch (Exception e) {
return fallback;
}
}
}