Begonnen: Verhalten von Gegnern bei annäherung, Verfolgung etc

This commit is contained in:
2026-09-27 00:12:26 +02:00
parent 94e0cac10d
commit da8e5e99f1
34 changed files with 2220 additions and 887 deletions

View File

@@ -0,0 +1,255 @@
package de.blight.game.control;
import com.jme3.asset.AssetManager;
import com.jme3.math.Matrix4f;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.AbstractControl;
import de.blight.common.model.Item;
import de.blight.common.model.ItemGrip;
import de.blight.game.animation.RetargetingSystem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Hängt Waffe und Schild am NPC – 1:1 dieselbe Logik wie WeaponAttachmentControl (Held).
* - weggesteckt (drawn=false) → Spine2-Knochen (Rücken)
* - gezückt (drawn=true) → RightHand (Waffe) / LeftHand (Schild am Arm)
*/
public class NpcWeaponAttachmentControl extends AbstractControl {
private static final Logger log = LoggerFactory.getLogger(NpcWeaponAttachmentControl.class);
private static final String BONE_SPINE = "mixamorig:Spine2";
private static final String BONE_HAND = "mixamorig:RightHand";
private static final String BONE_PALM = "mixamorig:RightHandMiddle1";
private static final String BONE_LEFT_FOREARM = "mixamorig:LeftHand";
private static final float DEG_TO_RAD = (float) Math.PI / 180f;
private static final float HALF_PI = (float) Math.PI * 0.5f;
private static final Vector3f BACK_GRIP_OFFSET = new Vector3f(0.0f, 0.40f, -0.13f);
private static final Vector3f HAND_GRIP_OFFSET = new Vector3f(0.1f, 0.00f, 0.05f);
private static final Vector3f SHIELD_BACK_OFFSET = new Vector3f(0.12f, -0.10f, -0.10f);
private static final float SHIELD_ARM_ROT_X = -HALF_PI;
private static final float SHIELD_ARM_ROT_Y = 0f;
private static final float SHIELD_ARM_ROT_Z = 0f;
private static final float SHIELD_ARM_ELBOW_OFFSET = 0.25f;
private final AssetManager assetManager;
private final Item weaponItem;
private final Item shieldItem;
private com.jme3.anim.SkinningControl skinCtrl;
private Node backNode;
private Node handNode;
private Node palmNodeRef;
private Node leftForeArmNode;
private Quaternion handBindRotInChar;
private Quaternion leftForeArmBindRotInChar;
private Vector3f palmOffsetLocal;
private int palmOffsetFrameDelay = 0;
private Spatial weaponSpatial;
private Spatial shieldSpatial;
private boolean drawn = false;
public NpcWeaponAttachmentControl(AssetManager assetManager, Item weaponItem, Item shieldItem) {
this.assetManager = assetManager;
this.weaponItem = weaponItem;
this.shieldItem = shieldItem;
}
public void setDrawn(boolean drawn) {
this.drawn = drawn;
}
@Override
protected void controlUpdate(float tpf) {
if (skinCtrl == null) {
skinCtrl = RetargetingSystem.findSkinningControl(spatial);
if (skinCtrl == null) { return; }
try { backNode = skinCtrl.getAttachmentsNode(BONE_SPINE); }
catch (Exception e) { log.warn("[NpcWeaponAttach] Bone '{}' nicht gefunden: {}", BONE_SPINE, e.getMessage()); }
try { handNode = skinCtrl.getAttachmentsNode(BONE_HAND); }
catch (Exception e) { log.warn("[NpcWeaponAttach] Bone '{}' nicht gefunden: {}", BONE_HAND, e.getMessage()); }
try {
palmNodeRef = skinCtrl.getAttachmentsNode(BONE_PALM);
palmOffsetFrameDelay = 2;
} catch (Exception e) { log.warn("[NpcWeaponAttach] Bone '{}' nicht gefunden: {}", BONE_PALM, e.getMessage()); }
try { leftForeArmNode = skinCtrl.getAttachmentsNode(BONE_LEFT_FOREARM); }
catch (Exception e) { log.warn("[NpcWeaponAttach] Bone '{}' nicht gefunden: {}", BONE_LEFT_FOREARM, e.getMessage()); }
handBindRotInChar = extractJointBindRot(skinCtrl, BONE_HAND);
leftForeArmBindRotInChar = extractJointBindRot(skinCtrl, BONE_LEFT_FOREARM);
log.info("[NpcWeaponAttach] Setup: backNode={} handNode={} palmNode={} forearmNode={} weapon={} shield={}",
backNode != null, handNode != null, palmNodeRef != null, leftForeArmNode != null,
weaponItem != null ? weaponItem.getItemId() : "null",
shieldItem != null ? shieldItem.getItemId() : "null");
if (weaponItem != null) { weaponSpatial = loadAndAttach(weaponItem); }
if (shieldItem != null) { shieldSpatial = loadAndAttach(shieldItem); }
log.info("[NpcWeaponAttach] Geladen: weaponSpatial={} shieldSpatial={}",
weaponSpatial != null, shieldSpatial != null);
}
// Palm-Offset nach frühestens 2 Frames berechnen (SkinningControl muss vorher gelaufen sein)
if (palmOffsetFrameDelay > 0 && palmNodeRef != null && handNode != null) {
palmOffsetFrameDelay--;
if (palmOffsetFrameDelay == 0) {
palmOffsetLocal = computePalmOffset(handNode, palmNodeRef);
}
}
Quaternion charWorldRotInv = spatial.getWorldRotation().inverse();
Vector3f charWorldPos = spatial.getWorldTranslation();
// ── Waffe ────────────────────────────────────────────────────────────────
if (weaponSpatial != null) {
Node boneNode = drawn ? handNode : backNode;
if (boneNode != null) {
Vector3f boneInChar = charWorldRotInv.mult(boneNode.getWorldTranslation().subtract(charWorldPos));
Quaternion boneRotInChar = charWorldRotInv.mult(boneNode.getWorldRotation());
if (drawn) {
Vector3f attachInChar = (palmOffsetLocal != null)
? boneInChar.add(boneRotInChar.mult(palmOffsetLocal.mult(0.5f)))
: boneInChar;
applyHandTransform(weaponSpatial, attachInChar, boneRotInChar,
weaponItem != null ? weaponItem.getGrip() : null);
} else {
applyBackTransform(weaponSpatial, boneInChar, boneRotInChar,
weaponItem != null ? weaponItem.getGrip() : null);
}
}
}
// ── Schild ────────────────────────────────────────────────────────────────
if (shieldSpatial != null) {
ItemGrip grip = shieldItem != null ? shieldItem.getGrip() : null;
if (drawn && leftForeArmNode != null) {
Vector3f boneInChar = charWorldRotInv.mult(leftForeArmNode.getWorldTranslation().subtract(charWorldPos));
Quaternion boneRotInChar = charWorldRotInv.mult(leftForeArmNode.getWorldRotation());
applyShieldArmTransform(shieldSpatial, boneInChar, boneRotInChar, grip);
shieldSpatial.setCullHint(Spatial.CullHint.Inherit);
} else if (!drawn && backNode != null) {
Vector3f boneInChar = charWorldRotInv.mult(backNode.getWorldTranslation().subtract(charWorldPos));
Quaternion boneRotInChar = charWorldRotInv.mult(backNode.getWorldRotation());
applyShieldBackTransform(shieldSpatial, boneInChar, boneRotInChar, grip);
shieldSpatial.setCullHint(Spatial.CullHint.Inherit);
} else {
shieldSpatial.setCullHint(Spatial.CullHint.Always);
}
}
}
private Spatial loadAndAttach(Item item) {
if (item.getModelRef() == null || item.getModelRef().getPath() == null) { return null; }
try {
Spatial s = assetManager.loadModel(item.getModelRef().getPath());
((Node) spatial).attachChild(s);
return s;
} catch (Exception e) {
log.warn("[NpcWeaponAttach] Modell nicht ladbar: {} — {}", item.getModelRef().getPath(), e.getMessage());
return null;
}
}
// ── Hand-Transform (1:1 von WeaponAttachmentControl) ─────────────────────────
private void applyHandTransform(Spatial s, Vector3f bonePosInChar,
Quaternion boneRotInChar, ItemGrip grip) {
Quaternion gripRot = (grip != null)
? new Quaternion().fromAngles(DEG_TO_RAD * grip.getRotX(),
DEG_TO_RAD * grip.getRotY(),
DEG_TO_RAD * grip.getRotZ())
: new Quaternion();
Vector3f gripPos = (grip != null)
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ())
: Vector3f.ZERO;
Quaternion animDelta = (handBindRotInChar != null)
? boneRotInChar.mult(handBindRotInChar.inverse())
: boneRotInChar;
Quaternion weaponRot = animDelta.mult(gripRot.inverse());
Vector3f weaponPos = bonePosInChar.subtract(weaponRot.mult(gripPos));
weaponPos = weaponPos.add(weaponRot.mult(HAND_GRIP_OFFSET));
s.setLocalRotation(weaponRot);
s.setLocalTranslation(weaponPos);
}
private void applyBackTransform(Spatial s, Vector3f boneInChar,
Quaternion boneRotInChar, ItemGrip grip) {
Quaternion backRot = new Quaternion().fromAngles(0f, 0f, HALF_PI);
Quaternion weaponRot = boneRotInChar.mult(backRot);
Vector3f target = boneInChar.add(boneRotInChar.mult(BACK_GRIP_OFFSET));
Vector3f gripPos = grip != null
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ()) : Vector3f.ZERO;
s.setLocalRotation(weaponRot);
s.setLocalTranslation(target.subtract(weaponRot.mult(gripPos)));
}
// ── Schild-Transforms (1:1 von WeaponAttachmentControl) ──────────────────────
private void applyShieldArmTransform(Spatial s, Vector3f bonePosInChar,
Quaternion boneRotInChar, ItemGrip grip) {
Quaternion animDelta = (leftForeArmBindRotInChar != null)
? boneRotInChar.mult(leftForeArmBindRotInChar.inverse())
: boneRotInChar;
Quaternion armRot = new Quaternion().fromAngles(SHIELD_ARM_ROT_X, SHIELD_ARM_ROT_Y, SHIELD_ARM_ROT_Z);
Quaternion shieldRot = animDelta.mult(armRot);
Vector3f gripPos = (grip != null)
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ()) : Vector3f.ZERO;
Vector3f anchor = bonePosInChar.add(boneRotInChar.mult(new Vector3f(0f, -SHIELD_ARM_ELBOW_OFFSET, 0f)));
s.setLocalRotation(shieldRot);
s.setLocalTranslation(anchor.subtract(shieldRot.mult(gripPos)));
}
private void applyShieldBackTransform(Spatial s, Vector3f boneInChar,
Quaternion boneRotInChar, ItemGrip grip) {
Quaternion backRot = new Quaternion().fromAngles(0f, (float) Math.PI, 0f);
Quaternion shieldRot = boneRotInChar.mult(backRot);
Vector3f target = boneInChar.add(boneRotInChar.mult(SHIELD_BACK_OFFSET));
Vector3f gripPos = (grip != null)
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ()) : Vector3f.ZERO;
s.setLocalRotation(shieldRot);
s.setLocalTranslation(target.subtract(shieldRot.mult(gripPos)));
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────────
private static Vector3f computePalmOffset(Node handNode, Node palmNode) {
Vector3f deltaWorld = palmNode.getWorldTranslation().subtract(handNode.getWorldTranslation());
return handNode.getWorldRotation().inverse().mult(deltaWorld);
}
private static Quaternion extractJointBindRot(com.jme3.anim.SkinningControl sc, String jointName) {
com.jme3.anim.Armature arm = sc.getArmature();
if (arm == null) { return null; }
com.jme3.anim.Joint joint = arm.getJoint(jointName);
if (joint == null) { return null; }
Matrix4f invBind = joint.getInverseModelBindMatrix();
if (invBind == null) { return null; }
Matrix4f bindMat = invBind.invert();
if (bindMat == null) { return null; }
return bindMat.toRotationQuat().normalizeLocal();
}
@Override
public void setSpatial(Spatial spatial) {
super.setSpatial(spatial);
if (spatial == null) {
if (weaponSpatial != null) { weaponSpatial.removeFromParent(); weaponSpatial = null; }
if (shieldSpatial != null) { shieldSpatial.removeFromParent(); shieldSpatial = null; }
}
}
@Override
protected void controlRender(RenderManager rm, ViewPort vp) {}
}

View File

@@ -38,14 +38,16 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class PlayerInputControl {
private static final Logger log = LoggerFactory.getLogger(PlayerInputControl.class);
private static final float MOVE_SPEED = 0.07f;
private static final float MOVE_SPEED = CharacterNavigator.RUN_SPEED;
private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f;
@@ -153,7 +155,7 @@ public class PlayerInputControl {
private float heavyLungeEndRemaining = 0f;
/** Gesamtdauer der aktuellen Kampfanim — für Kombo-Fenster-Timing (öffnet bei 50%). */
private float combatAnimDuration = 0f;
private MeleeAttackCallback meleeAttackCallback = null;
private final List<MeleeAttackCallback> meleeCallbacks = new ArrayList<>();
private WeaponAttachmentControl weaponAttach = null;
private static final float HIT_WINDOW_START = 0.20f;
private static final float HIT_WINDOW_END = 0.75f;
@@ -430,7 +432,22 @@ public class PlayerInputControl {
public ItemSubCategory getDrawnWeaponType() { return drawnWeaponType; }
public boolean isLastAttackCritical() { return lastAttackCritical; }
public com.jme3.anim.SkinningControl getSkinningControl() { return skinningControl; }
public void setMeleeAttackCallback(MeleeAttackCallback cb) { this.meleeAttackCallback = cb; }
/**
* Geräusch-Radius des Spielers in Welteinheiten (m) – abhängig von der Bewegungsart:
* Sprint → 10 m, normales Laufen → 5 m, Gehen (walk-Taste) → 3 m,
* Schleichen / Stehen → 0 (kein Geräusch).
*/
public float getSoundRadius() {
boolean sneaking = sneakPhase != SneakPhase.NONE;
boolean moving = forward || backward || left || right;
if (!moving || sneaking) { return 0f; }
if (sprint) { return 10f; }
if (walk) { return 3f; }
return 5f;
}
public void addMeleeAttackCallback(MeleeAttackCallback cb) { if (cb != null) meleeCallbacks.add(cb); }
public void setMeleeAttackCallback(MeleeAttackCallback cb) { addMeleeAttackCallback(cb); }
public void setWeaponAttachmentControl(WeaponAttachmentControl w) { this.weaponAttach = w; }
public boolean isPaused() { return paused; }
@@ -1345,6 +1362,13 @@ public class PlayerInputControl {
|| blockPhase == BlockPhase.EXITING;
}
/** True wenn der Spieler gerade läuft oder rennt (nicht steht, schleicht oder geht). */
public boolean isRunningOrSprinting() {
boolean moving = forward || backward || left || right;
boolean sneaking = sneakPhase != SneakPhase.NONE;
return moving && !sneaking && !walk;
}
/** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */
public void triggerBlockImpact() {
if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return;
@@ -1621,7 +1645,7 @@ public class PlayerInputControl {
}
private void fireWeaponCallback(float critMultiplier) {
if (meleeAttackCallback == null || weaponAttach == null) {
if (meleeCallbacks.isEmpty() || weaponAttach == null) {
return;
}
com.jme3.scene.Spatial ws = weaponAttach.getWeaponSpatial();
@@ -1633,7 +1657,9 @@ public class PlayerInputControl {
? mainCharacter.getEquippedMeleeWeapon().getDamage() : 0;
Vector3f grip = hn.getWorldTranslation().clone();
Vector3f bladeDir = ws.getWorldRotation().mult(com.jme3.math.Vector3f.UNIT_Y).normalizeLocal();
meleeAttackCallback.onMeleeAttack(currentAttackId, grip, bladeDir, WEAPON_BLADE_LENGTH, base, critMultiplier);
for (MeleeAttackCallback cb : meleeCallbacks) {
cb.onMeleeAttack(currentAttackId, grip, bladeDir, WEAPON_BLADE_LENGTH, base, critMultiplier);
}
}
private float computeHeavyMultiplier() {

View File

@@ -56,6 +56,10 @@ public class CharacterNavigator {
public static final float WALK_SPEED = 0.035f;
/** Renn-Geschwindigkeit (m/frame bei 60fps). */
public static final float RUN_SPEED = 0.07f;
/** Renn-Geschwindigkeit in m/s (für tpf-basierte Bewegung, z.B. NPCs ohne CharacterControl). */
public static final float RUN_SPEED_MPS = 4.2f;
/** Geh-Geschwindigkeit in m/s. */
public static final float WALK_SPEED_MPS = 2.1f;
/** Minimale Bewegung (m/s) unter der ein Charakter als steckengeblieben gilt. */
private static final float STUCK_MIN_SPEED = 0.2f;
/** Zeit (s) mit zu langsamer Bewegung bis zum Abbruch. */

View File

@@ -0,0 +1,10 @@
package de.blight.game.npc;
public enum AggroEvent {
NONE,
WARN_1, // first warning issued (ENRAGED: first sight)
WARN_2, // second warning issued (ENRAGED: after 10 s)
ATTACK_START, // NPC commits to attacking
PURSUIT_BREAK, // NPC gives up the chase
SOUND_ALERT // player heard but not yet seen — NPC should turn toward sound source
}

View File

@@ -0,0 +1,249 @@
package de.blight.game.npc;
import com.jme3.collision.CollisionResults;
import com.jme3.math.FastMath;
import com.jme3.math.Ray;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import de.blight.common.model.Status;
import de.blight.common.model.TextReference;
import java.util.Random;
/**
* Per-entity aggro state machine — works for NPCs, animals, or any creature.
*
* ENRAGED: warn once → 10 s → warn again → 10 s → attack
* ENEMY: attack immediately on sight
* Player attacks NPC directly → skip warnings, retaliate immediately
*
* Pursuit ends when the player is >25 m away OR the NPC has moved >100 m
* from the schedule anchor that was captured when ATTACKING began.
*/
public class NpcAggroBehavior {
public enum AggroPhase {
CALM,
WARNED_ONCE, // first warning given; timer counting to second warning
WARNED_TWICE, // second warning given; timer counting to attack
ATTACKING
}
// ── Shared phrase pools (same text for every NPC) ─────────────────────────
public static final TextReference[] WARN1_TEXTS = {
new TextReference("npc.aggro.warn1.1"),
new TextReference("npc.aggro.warn1.2"),
new TextReference("npc.aggro.warn1.3"),
new TextReference("npc.aggro.warn1.4"),
new TextReference("npc.aggro.warn1.5"),
};
public static final TextReference[] WARN2_TEXTS = {
new TextReference("npc.aggro.warn2.1"),
new TextReference("npc.aggro.warn2.2"),
new TextReference("npc.aggro.warn2.3"),
new TextReference("npc.aggro.warn2.4"),
new TextReference("npc.aggro.warn2.5"),
};
public static final TextReference[] ATTACK_TEXTS = {
new TextReference("npc.aggro.attack.1"),
new TextReference("npc.aggro.attack.2"),
new TextReference("npc.aggro.attack.3"),
new TextReference("npc.aggro.attack.4"),
new TextReference("npc.aggro.attack.5"),
};
public static final TextReference[] BREAK_TEXTS = {
new TextReference("npc.aggro.break.1"),
new TextReference("npc.aggro.break.2"),
new TextReference("npc.aggro.break.3"),
new TextReference("npc.aggro.break.4"),
new TextReference("npc.aggro.break.5"),
};
/**
* Audio path convention: audio/npc/{npcId}/aggro_{category}_{1-5}.ogg
* category = "warn1" | "warn2" | "attack"
*/
public static String audioPath(String npcId, String category, int phraseIdx) {
return "audio/npc/" + npcId + "/aggro_" + category + "_" + (phraseIdx + 1) + ".ogg";
}
// ── Constants ─────────────────────────────────────────────────────────────
private static final float WARN_INTERVAL = 10f;
private static final float FOV_HALF_ANGLE_COS = FastMath.cos(FastMath.DEG_TO_RAD * 45f);
public static final float PURSUIT_BREAK_DIST = 25f;
public static final float RE_AGGRO_DIST = 15f; // nach PURSUIT_BREAK erst ab hier wieder angreifen
public static final float SCHEDULE_MAX_DIST = 100f;
// ── Per-instance state ────────────────────────────────────────────────────
private AggroPhase phase = AggroPhase.CALM;
private float stateTimer = 0f;
private Vector3f scheduleAnchor = null;
private boolean reAggroGuard = false; // gesetzt nach PURSUIT_BREAK bis Player < RE_AGGRO_DIST
private final Random rng = new Random();
// ── Public API ────────────────────────────────────────────────────────────
public AggroPhase getPhase() { return phase; }
public boolean isAttacking() { return phase == AggroPhase.ATTACKING; }
/**
* Frame update — call every frame for any entity whose status is ENRAGED or ENEMY.
*
* @param tpf time per frame
* @param npcPos NPC feet position (world space)
* @param npcForward NPC forward direction (world space, from spatial rotation)
* @param status current status of this entity
* @param playerPos player feet position (PhysicsLocation)
* @param playerSoundRadius sound detection radius: 10m=sprint, 5m=run, 3m=walk, 0=sneak/still
* @param rootNode scene root for LOS raycasting
* @param schedulePos where this entity should be per its current routine (may be null)
*/
public AggroEvent update(float tpf,
Vector3f npcPos, Vector3f npcForward, Status status,
Vector3f playerPos, float playerSoundRadius,
Node rootNode, Vector3f schedulePos) {
if (status != Status.ENRAGED && status != Status.ENEMY) {
if (phase != AggroPhase.CALM) {
phase = AggroPhase.CALM;
scheduleAnchor = null;
}
return AggroEvent.NONE;
}
switch (phase) {
case CALM: {
if (canSeePlayer(npcPos, npcForward, playerPos, rootNode)) {
// Hysterese: nach PURSUIT_BREAK erst re-aggro wenn Player nah genug herankommt
if (reAggroGuard && dist2d(npcPos, playerPos) > RE_AGGRO_DIST) {
break;
}
reAggroGuard = false;
if (status == Status.ENEMY) {
return transitionToAttacking(npcPos, schedulePos);
}
phase = AggroPhase.WARNED_ONCE;
stateTimer = 0f;
return AggroEvent.WARN_1;
}
// Spieler nicht sichtbar – Geräusch-Check (kein FOV, kein LOS nötig)
if (playerSoundRadius > 0f && dist2d(npcPos, playerPos) <= playerSoundRadius) {
return AggroEvent.SOUND_ALERT;
}
break;
}
case WARNED_ONCE: {
if (!canSeePlayer(npcPos, npcForward, playerPos, rootNode)) {
phase = AggroPhase.CALM;
break;
}
stateTimer += tpf;
if (stateTimer >= WARN_INTERVAL) {
phase = AggroPhase.WARNED_TWICE;
stateTimer = 0f;
return AggroEvent.WARN_2;
}
break;
}
case WARNED_TWICE: {
if (!canSeePlayer(npcPos, npcForward, playerPos, rootNode)) {
// One more chance — back to first warning
phase = AggroPhase.WARNED_ONCE;
stateTimer = 0f;
break;
}
stateTimer += tpf;
if (stateTimer >= WARN_INTERVAL) {
return transitionToAttacking(npcPos, schedulePos);
}
break;
}
case ATTACKING: {
if (dist2d(npcPos, playerPos) > PURSUIT_BREAK_DIST) {
phase = AggroPhase.CALM;
scheduleAnchor = null;
reAggroGuard = true;
return AggroEvent.PURSUIT_BREAK;
}
if (scheduleAnchor != null && dist2d(npcPos, scheduleAnchor) > SCHEDULE_MAX_DIST) {
phase = AggroPhase.CALM;
scheduleAnchor = null;
reAggroGuard = true;
return AggroEvent.PURSUIT_BREAK;
}
break;
}
}
return AggroEvent.NONE;
}
/**
* Call when the player lands a direct hit on this entity.
* Bypasses all warning phases — the entity retaliates immediately.
*/
public AggroEvent onAttackedByPlayer(Vector3f npcPos, Vector3f schedulePos) {
if (phase == AggroPhase.ATTACKING) { return AggroEvent.NONE; }
reAggroGuard = false;
return transitionToAttacking(npcPos, schedulePos);
}
/** Returns a random phrase index in [0, 4]. */
public int randomPhraseIdx() { return rng.nextInt(5); }
// ── Static sight check ────────────────────────────────────────────────────
/**
* True if the player is inside the entity's horizontal FOV (±45°) and
* no scene geometry blocks line of sight.
*
* The check uses a horizontal cone only — vertical angle is ignored so
* NPCs on slightly different elevations still notice the player.
*/
public static boolean canSeePlayer(Vector3f npcPos, Vector3f npcForward,
Vector3f playerPos, Node rootNode) {
Vector3f npcEye = npcPos.add(0f, 1.6f, 0f);
Vector3f playerMid = playerPos.add(0f, 0.9f, 0f);
// Horizontal FOV check
float fx = npcForward.x, fz = npcForward.z;
float px = playerMid.x - npcEye.x, pz = playerMid.z - npcEye.z;
float fLen = (float) Math.sqrt(fx * fx + fz * fz);
float pLen = (float) Math.sqrt(px * px + pz * pz);
if (fLen > 0.001f && pLen > 0.001f) {
float dot = (fx * px + fz * pz) / (fLen * pLen);
if (dot < FOV_HALF_ANGLE_COS) { return false; }
}
// LOS raycast
Vector3f dir = playerMid.subtract(npcEye);
float fullDist = dir.length();
if (fullDist < 0.001f) { return true; }
dir.divideLocal(fullDist);
CollisionResults cr = new CollisionResults();
rootNode.collideWith(new Ray(npcEye, dir), cr);
if (cr.size() == 0) { return true; }
float hitDist = cr.getClosestCollision().getDistance();
if (hitDist < 0.5f) { return true; } // skip NPC's own geometry
return hitDist >= fullDist - 0.3f;
}
// ── Private helpers ───────────────────────────────────────────────────────
private AggroEvent transitionToAttacking(Vector3f npcPos, Vector3f schedulePos) {
scheduleAnchor = (schedulePos != null) ? schedulePos.clone() : npcPos.clone();
phase = AggroPhase.ATTACKING;
return AggroEvent.ATTACK_START;
}
private static float dist2d(Vector3f a, Vector3f b) {
float dx = a.x - b.x, dz = a.z - b.z;
return (float) Math.sqrt(dx * dx + dz * dz);
}
}

View File

@@ -623,6 +623,8 @@ public class WorldScene extends BaseAppState {
animLib.configureLocomotionSnap(AnimationLibrary.findAssetRoot(), setName);
}
animLib.applyAllTo(characterVisual != null ? characterVisual : character);
WorldNpcsState npcsState = app.getStateManager().getState(WorldNpcsState.class);
if (npcsState != null) npcsState.reapplyAnimations();
log.info("[AnimCtx] MainCharacter: {} animSetPath: {} clipCount: {} clips: {}",
mc != null ? mc.getCharacterId() : "null",
setName,

View File

@@ -24,7 +24,8 @@ public class InteractionHudState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(InteractionHudState.class);
private static final float Y_OFFSET = 0.6f;
private static final float Y_OFFSET = 0.6f;
private static final ColorRGBA COL_DEFAULT = new ColorRGBA(1f, 0.95f, 0.6f, 1f);
private Camera cam;
private Node guiNode;
@@ -52,7 +53,7 @@ public class InteractionHudState extends BaseAppState {
labelText = new BitmapText(font);
labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f * 2f);
labelText.setLocalScale(0.5f, 0.5f, 1f);
labelText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f));
labelText.setColor(COL_DEFAULT.clone());
labelText.setCullHint(Spatial.CullHint.Always);
guiNode.attachChild(labelText);
}
@@ -90,15 +91,18 @@ public class InteractionHudState extends BaseAppState {
return;
}
String labelKey = null;
Vector3f worldPos = null;
String labelKey = null;
Vector3f worldPos = null;
com.jme3.math.ColorRGBA labelColor = COL_DEFAULT;
// NPC hat höchste Priorität, dann Interactable, dann Item
if (worldNpcs != null) {
String key = worldNpcs.getHoveredLabelKey();
if (key != null && !key.isBlank()) {
labelKey = key;
worldPos = worldNpcs.getHoveredWorldPos();
labelKey = key;
worldPos = worldNpcs.getHoveredWorldPos();
com.jme3.math.ColorRGBA c = worldNpcs.getHoveredLabelColor();
if (c != null) labelColor = c;
}
}
@@ -143,9 +147,10 @@ public class InteractionHudState extends BaseAppState {
if (keyBindings != null) {
text = text.replace("{interact}", KeyNames.of(keyBindings.interact));
}
labelText.setColor(labelColor);
labelText.setText(text);
float textW = labelText.getLineWidth();
labelText.setLocalTranslation(screenV.x - textW * 0.5f, screenV.y, 1f);
float textW = labelText.getLineWidth() * 0.5f;
labelText.setLocalTranslation(screenV.x - textW, screenV.y, 1f);
labelText.setCullHint(Spatial.CullHint.Inherit);
}
}

View File

@@ -130,7 +130,7 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback
loadDummies();
buildEnemyHud();
playerInput.setMeleeAttackCallback(this);
playerInput.addMeleeAttackCallback(this);
}
@Override