Begonnen: Verhalten von Gegnern bei annäherung, Verfolgung etc
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
10
blight-game/src/main/java/de/blight/game/npc/AggroEvent.java
Normal file
10
blight-game/src/main/java/de/blight/game/npc/AggroEvent.java
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ public class WorldDummyState extends BaseAppState implements MeleeAttackCallback
|
||||
|
||||
loadDummies();
|
||||
buildEnemyHud();
|
||||
playerInput.setMeleeAttackCallback(this);
|
||||
playerInput.addMeleeAttackCallback(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user