Navigation, Bank-Setzen und AnimSet-Editor Keyframes
- CharacterNavigator: universelle Pfad-Navigation für Spieler und NPCs (PathFinder + Terrain-Slope-Check + Stuck-Erkennung, Walk/Run) - PlayerInputControl: navigateTo/stopNavigation-API, Navigator hat Vorrang vor WASD; setNavigationSources für PathFinder + TerrainChunkState - WorldInteractableState: Bank-Setzen komplett neu (< 5m, E-Taste), Navigator läuft zum Sitzpunkt, dreht Rücken zur Bank, spielt sit_down_bench / sitting / get_up_sitting; Bett weiterhin mit Rücklauf - AnimSet-Editor: Kamera startet mit -45° Pitch; AnimKeyframe-Offset-Editor - WorldScene: PathFinder + ObstacleRoot an PlayerInputControl übergeben Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,18 +23,29 @@
|
||||
"RUNNING_JUMP": "running_jump",
|
||||
"JUMP": "idle_jump",
|
||||
"PICK_UP": "pickup",
|
||||
"SIT_UP": "stand_up",
|
||||
"SITTING": "sitting",
|
||||
"SIT_DOWN": "sit_down_bench"
|
||||
"SIT_DOWN": "sit_down_bench",
|
||||
"SIT_UP": "get_up_sitting"
|
||||
},
|
||||
"previewModelPath": "Models/Chars/mainchar.j3o",
|
||||
"motionKeyframes": {
|
||||
"sit_down_bench": [
|
||||
"get_up_sitting": [
|
||||
{
|
||||
"time": 0.0,
|
||||
"tx": 0.0,
|
||||
"ty": 0.0,
|
||||
"tz": 0.25,
|
||||
"tz": -0.25,
|
||||
"rx": 0.0,
|
||||
"ry": 0.0,
|
||||
"rz": 0.0
|
||||
}
|
||||
],
|
||||
"sitting": [
|
||||
{
|
||||
"time": 0.0,
|
||||
"tx": 0.0,
|
||||
"ty": 0.0,
|
||||
"tz": -0.25,
|
||||
"rx": 0.0,
|
||||
"ry": 0.0,
|
||||
"rz": 0.0
|
||||
|
||||
@@ -538,7 +538,7 @@ public class SharedInput {
|
||||
|
||||
// ── Animations-Vorschau ──────────────────────────────────────────────────
|
||||
public volatile float animPreviewRotY = 0f;
|
||||
public volatile float animPreviewRotX = 25f;
|
||||
public volatile float animPreviewRotX = -45f;
|
||||
public volatile float animPreviewZoom = 1.0f;
|
||||
public volatile float animPreviewSpeed = 1.0f;
|
||||
public volatile int animPreviewW = 512;
|
||||
|
||||
@@ -319,6 +319,8 @@ public class AnimPreviewState extends BaseAppState {
|
||||
}
|
||||
previewTarget.set(0, 1, 0);
|
||||
input.animPreviewZoom = 1.0f;
|
||||
input.animPreviewRotX = -45f;
|
||||
input.animPreviewRotY = 0f;
|
||||
|
||||
// Clips sammeln und melden
|
||||
List<String> clips = new ArrayList<>();
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.input.InputManager;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.math.FastMath;
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
@@ -14,6 +13,8 @@ import de.blight.game.animation.AnimationAction;
|
||||
import de.blight.game.animation.AnimationLibrary;
|
||||
import de.blight.game.animation.RetargetingSystem;
|
||||
import de.blight.game.config.KeyBindings;
|
||||
import de.blight.game.navigation.CharacterNavigator;
|
||||
import de.blight.game.state.TerrainChunkState;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -51,6 +52,11 @@ public class PlayerInputControl {
|
||||
private String runningClip;
|
||||
|
||||
private com.jme3.anim.SkinningControl skinningControl = null;
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
private CharacterNavigator navigator = null;
|
||||
private de.blight.game.navigation.PathFinder navPathFinder = null;
|
||||
private TerrainChunkState navTerrain = null;
|
||||
private int jumpFrames = 0;
|
||||
private boolean pickupActive = false;
|
||||
private float pickupRemaining = 0f;
|
||||
@@ -61,14 +67,6 @@ public class PlayerInputControl {
|
||||
private float blockingAnimTotal = 0f;
|
||||
private Runnable blockingAnimCallback = null;
|
||||
|
||||
/** Manueller Motion-Override: pro Aktion konfigurierbare Keyframe-Liste. */
|
||||
private java.util.Map<String, java.util.List<de.blight.game.animation.AnimKeyframe>>
|
||||
motionKeyframesMap = java.util.Map.of();
|
||||
private java.util.List<de.blight.game.animation.AnimKeyframe>
|
||||
currentMotionKfs = null;
|
||||
private Vector3f preMotionTranslation = null;
|
||||
private Quaternion preMotionRotation = null;
|
||||
|
||||
/** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
|
||||
private boolean turnActive = false;
|
||||
private float turnRemaining = 0f;
|
||||
@@ -108,6 +106,17 @@ public class PlayerInputControl {
|
||||
this.visual = visual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Navigationsquellen (PathFinder + Terrain). Muss vor oder beim
|
||||
* ersten setAnimationContext gesetzt sein, damit der CharacterNavigator
|
||||
* korrekt initialisiert wird.
|
||||
*/
|
||||
public void setNavigationSources(de.blight.game.navigation.PathFinder pathFinder,
|
||||
TerrainChunkState terrain) {
|
||||
this.navPathFinder = pathFinder;
|
||||
this.navTerrain = terrain;
|
||||
}
|
||||
|
||||
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
|
||||
this.animLib = animLib;
|
||||
this.animSetName = animSetName;
|
||||
@@ -118,16 +127,11 @@ public class PlayerInputControl {
|
||||
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
|
||||
skinningControl = findSkinningControl(visual);
|
||||
log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null);
|
||||
if (animSetName != null && assetRoot != null) {
|
||||
try {
|
||||
java.nio.file.Path setDir = assetRoot.resolve("animations").resolve("sets");
|
||||
de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, animSetName);
|
||||
motionKeyframesMap = set.getMotionKeyframes();
|
||||
log.info("[AnimCtx] MotionKeyframes geladen: {} Aktionen: {}",
|
||||
motionKeyframesMap.size(), motionKeyframesMap.keySet());
|
||||
} catch (Exception e) {
|
||||
motionKeyframesMap = java.util.Map.of();
|
||||
}
|
||||
// Navigator (neu) aufbauen sobald alle Abhängigkeiten bereit sind
|
||||
if (navPathFinder != null && navTerrain != null && physicsChar != null && visual != null) {
|
||||
navigator = new CharacterNavigator(physicsChar, visual, navPathFinder, navTerrain);
|
||||
navigator.setAnimationContext(animLib, animSetName, assetRoot);
|
||||
log.info("[AnimCtx] CharacterNavigator initialisiert.");
|
||||
}
|
||||
if (animSetName != null) {
|
||||
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
|
||||
@@ -189,21 +193,6 @@ public class PlayerInputControl {
|
||||
if (duration <= 0f) {
|
||||
duration = resolveClipLength(action, 1.5f);
|
||||
}
|
||||
String kfClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
|
||||
currentMotionKfs = kfClip != null ? motionKeyframesMap.get(kfClip) : null;
|
||||
if (currentMotionKfs != null && !currentMotionKfs.isEmpty() && visual != null) {
|
||||
// KF-Werte sind absolute Positionen im Charakter-Lokalraum (nicht additiv)
|
||||
preMotionTranslation = Vector3f.ZERO;
|
||||
preMotionRotation = visual.getLocalRotation().clone();
|
||||
} else {
|
||||
// Keine Keyframes: visuelle Verschiebung aus vorheriger Keyframe-Aktion zurücksetzen
|
||||
if (visual != null) {
|
||||
visual.setLocalTranslation(Vector3f.ZERO);
|
||||
}
|
||||
currentMotionKfs = null;
|
||||
preMotionTranslation = null;
|
||||
preMotionRotation = null;
|
||||
}
|
||||
blockingAnimActive = true;
|
||||
blockingAnimRemaining = duration;
|
||||
blockingAnimTotal = duration;
|
||||
@@ -267,6 +256,38 @@ public class PlayerInputControl {
|
||||
|
||||
public boolean isLockedInPlace() { return lockedInPlace; }
|
||||
|
||||
/**
|
||||
* Startet die Navigation zum angegebenen Welt-Punkt.
|
||||
* Während der Navigation werden WASD-Eingaben ignoriert.
|
||||
* Der CharacterNavigator übernimmt Bewegung und Animation.
|
||||
*
|
||||
* @param target Zielposition (Y wird auf Terrain gesampled)
|
||||
* @param speed {@link CharacterNavigator.Speed#WALK} oder {@link CharacterNavigator.Speed#RUN}
|
||||
* @param onArrival Callback nach Ankunft (null erlaubt)
|
||||
* @param onFailed Callback bei Abbruch (null erlaubt)
|
||||
*/
|
||||
public void navigateTo(Vector3f target, CharacterNavigator.Speed speed,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
if (navigator == null) {
|
||||
log.warn("[Nav] navigateTo: kein CharacterNavigator – setNavigationSources() vorher aufrufen.");
|
||||
if (onFailed != null) onFailed.run();
|
||||
return;
|
||||
}
|
||||
forward = backward = left = right = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
autopilotDir = null;
|
||||
navigator.navigateTo(target, speed, onArrival, onFailed);
|
||||
}
|
||||
|
||||
/** Bricht eine laufende Navigation ab (kein Callback). */
|
||||
public void stopNavigation() {
|
||||
if (navigator != null) navigator.stop();
|
||||
}
|
||||
|
||||
public boolean isNavigating() {
|
||||
return navigator != null && navigator.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands.
|
||||
* Nur sinnvoll nach {@link #lockInPlace()}.
|
||||
@@ -276,21 +297,6 @@ public class PlayerInputControl {
|
||||
public void playLockedAnimation(AnimationAction action) {
|
||||
playAction(action);
|
||||
currentAnim = action;
|
||||
String lockedClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
|
||||
java.util.List<de.blight.game.animation.AnimKeyframe> kfs =
|
||||
lockedClip != null ? motionKeyframesMap.get(lockedClip) : null;
|
||||
log.info("[AnimKF] playLockedAnimation({}) clip='{}' → KFs gefunden: {}",
|
||||
action, lockedClip, kfs != null ? kfs.size() : "null");
|
||||
if (kfs != null && !kfs.isEmpty() && visual != null) {
|
||||
currentMotionKfs = kfs;
|
||||
preMotionTranslation = Vector3f.ZERO;
|
||||
preMotionRotation = visual.getLocalRotation().clone();
|
||||
applyMotionKeyframes(0f);
|
||||
log.info("[AnimKF] Offset angewendet: visual.localTranslation={}", visual.getLocalTranslation());
|
||||
currentMotionKfs = null;
|
||||
preMotionTranslation = null;
|
||||
preMotionRotation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void registerMappings(KeyBindings kb) {
|
||||
@@ -308,7 +314,6 @@ public class PlayerInputControl {
|
||||
if (physicsChar == null) return;
|
||||
|
||||
if (paused) {
|
||||
// Autopilot bei Pause sofort beenden
|
||||
if (autopilotDir != null) {
|
||||
autopilotDir = null;
|
||||
physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
@@ -316,6 +321,12 @@ public class PlayerInputControl {
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigator: hat Vorrang vor allem außer Pause
|
||||
if (navigator != null && navigator.isActive()) {
|
||||
navigator.update(tpf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pickup-Animation hat höchste Priorität
|
||||
if (pickupActive) {
|
||||
pickupRemaining -= tpf;
|
||||
@@ -354,26 +365,16 @@ public class PlayerInputControl {
|
||||
return;
|
||||
}
|
||||
|
||||
// Blockierende Einmal-Animation (lie_down, sit_down, lie_up, sit_up …)
|
||||
// Blockierende Einmal-Animation (lie_down, lie_up …)
|
||||
if (blockingAnimActive) {
|
||||
blockingAnimRemaining -= tpf;
|
||||
physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
float elapsed = blockingAnimTotal - blockingAnimRemaining;
|
||||
applyMotionKeyframes(elapsed);
|
||||
|
||||
if (blockingAnimRemaining <= 0f) {
|
||||
blockingAnimActive = false;
|
||||
applyMotionKeyframes(blockingAnimTotal); // Endwert einrasten
|
||||
currentMotionKfs = null;
|
||||
preMotionTranslation = null;
|
||||
preMotionRotation = null;
|
||||
Runnable cb = blockingAnimCallback;
|
||||
blockingAnimCallback = null;
|
||||
if (cb != null) cb.run();
|
||||
// Kein Ruhezustand nach der Animation → visuellen Versatz zurücksetzen
|
||||
if (!lockedInPlace && visual != null) {
|
||||
visual.setLocalTranslation(Vector3f.ZERO);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -489,59 +490,6 @@ public class PlayerInputControl {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpoliert die Motion-Keyframes der laufenden Aktion für den Zeitpunkt {@code time}
|
||||
* und setzt Translation + Rotation des Visual-Nodes.
|
||||
* TX/TZ werden im Charakter-lokalen Raum (Rotation zu Animations-Start) angewendet.
|
||||
* Dabei gilt: positive TX = rechts vom Charakter, positives TZ = vor dem Charakter
|
||||
* (= weg von der Bank), negatives TZ = hinter den Charakter (= zur Bank hin).
|
||||
* TY ist Welt-Y. RX/RY/RZ sind additiv zur Startrotation.
|
||||
*/
|
||||
private void applyMotionKeyframes(float time) {
|
||||
if (currentMotionKfs == null || currentMotionKfs.isEmpty()) return;
|
||||
if (preMotionRotation == null || visual == null) return;
|
||||
|
||||
de.blight.game.animation.AnimKeyframe before = null, after = null;
|
||||
for (de.blight.game.animation.AnimKeyframe kf : currentMotionKfs) {
|
||||
if (kf.time <= time) { before = kf; }
|
||||
else if (after == null) { after = kf; break; }
|
||||
}
|
||||
|
||||
float tx, ty, tz, rx, ry, rz;
|
||||
if (before == null && after == null) { return; }
|
||||
else if (before == null) {
|
||||
tx = after.tx; ty = after.ty; tz = after.tz;
|
||||
rx = after.rx; ry = after.ry; rz = after.rz;
|
||||
} else if (after == null) {
|
||||
tx = before.tx; ty = before.ty; tz = before.tz;
|
||||
rx = before.rx; ry = before.ry; rz = before.rz;
|
||||
} else {
|
||||
float t = Math.max(0f, Math.min(1f,
|
||||
(time - before.time) / (after.time - before.time)));
|
||||
tx = lerp(before.tx, after.tx, t);
|
||||
ty = lerp(before.ty, after.ty, t);
|
||||
tz = lerp(before.tz, after.tz, t);
|
||||
rx = lerp(before.rx, after.rx, t);
|
||||
ry = lerp(before.ry, after.ry, t);
|
||||
rz = lerp(before.rz, after.rz, t);
|
||||
}
|
||||
|
||||
// TX/TZ im Charakter-lokalen Raum: preMotionRotation dreht den Offset in Welt-Raum.
|
||||
// Konvention: local -Z = forward (lookAt-Konvention), also tz negativ = hinter Charakter.
|
||||
Vector3f localXZ = preMotionRotation.mult(new Vector3f(tx, 0f, tz));
|
||||
visual.setLocalTranslation(localXZ.x, ty, localXZ.z);
|
||||
|
||||
// Rotation: additiv zur Startrotation via SLERP der Euler-Offsets
|
||||
Quaternion rotOffset = new Quaternion();
|
||||
rotOffset.fromAngles(
|
||||
rx * FastMath.DEG_TO_RAD,
|
||||
ry * FastMath.DEG_TO_RAD,
|
||||
rz * FastMath.DEG_TO_RAD);
|
||||
visual.setLocalRotation(preMotionRotation.mult(rotOffset));
|
||||
}
|
||||
|
||||
private static float lerp(float a, float b, float t) { return a + (b - a) * t; }
|
||||
|
||||
private boolean tryPlay(String clip) {
|
||||
if (animComposer == null || !animLib.applyTo(clip, visual)) {
|
||||
log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip);
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package de.blight.game.navigation;
|
||||
|
||||
import com.jme3.anim.AnimComposer;
|
||||
import com.jme3.anim.SkinningControl;
|
||||
import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.scene.Spatial;
|
||||
import de.blight.common.model.WorldPoint;
|
||||
import de.blight.game.animation.AnimationAction;
|
||||
import de.blight.game.animation.AnimationLibrary;
|
||||
import de.blight.game.animation.RetargetingSystem;
|
||||
import de.blight.game.state.TerrainChunkState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Universelle Punkt-zu-Punkt-Navigation für beliebige Charaktere mit dem animset "human"
|
||||
* (Spieler und NPCs gleichermaßen).
|
||||
*
|
||||
* <p>Verwendung:
|
||||
* <pre>
|
||||
* CharacterNavigator nav = new CharacterNavigator(physicsChar, visual, pathFinder, terrain);
|
||||
* nav.setAnimationContext(animLib, "human", assetRoot);
|
||||
*
|
||||
* nav.navigateTo(ziel, Speed.RUN, this::onAngekommen, this::onAbbruch);
|
||||
*
|
||||
* // jeden Frame:
|
||||
* nav.update(tpf);
|
||||
* </pre>
|
||||
*
|
||||
* <p>Hindernisse werden vom PathFinder (Wegnetz + Raycasting) und dem SteeringHelper
|
||||
* (Bug-Algorithmus) umgangen. Steile Terrain-Segmente werden durch seitliche Umwege
|
||||
* ersetzt; ist kein Umweg begehbar, wird das Segment direkt passiert (Physik sorgt
|
||||
* dann für Abbremsung).
|
||||
*/
|
||||
public class CharacterNavigator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CharacterNavigator.class);
|
||||
|
||||
// ── Tuneable ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ankunftsradius zum finalen Ziel (m). */
|
||||
private static final float ARRIVE_RADIUS = 0.45f;
|
||||
/** Radius zum Weiterspringen auf den nächsten Wegpunkt (m). */
|
||||
private static final float WAYPOINT_RADIUS = 0.9f;
|
||||
/** Rotationsgeschwindigkeit (rad/s). */
|
||||
private static final float ROTATE_SPEED = 8f;
|
||||
/** Geh-Geschwindigkeit (m/frame bei 60fps). */
|
||||
public static final float WALK_SPEED = 0.035f;
|
||||
/** Renn-Geschwindigkeit (m/frame bei 60fps). */
|
||||
public static final float RUN_SPEED = 0.07f;
|
||||
/** 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. */
|
||||
private static final float STUCK_TIMEOUT = 3.5f;
|
||||
/** Maximale Steigung als tan(α) — 36° entspricht ≈ 0.73. */
|
||||
private static final float MAX_SLOPE_TAN = (float) Math.tan(Math.toRadians(36));
|
||||
/** Abtastabstand für Steigungsprüfung entlang eines Pfadsegments (m). */
|
||||
private static final float SLOPE_STEP = 1.5f;
|
||||
|
||||
// ── Abhängigkeiten ────────────────────────────────────────────────────────
|
||||
|
||||
public enum Speed { WALK, RUN }
|
||||
|
||||
private final CharacterControl physicsChar;
|
||||
private final Spatial visual;
|
||||
private final PathFinder pathFinder;
|
||||
private final TerrainChunkState terrain;
|
||||
|
||||
// Animation (optional — ohne Context wird nur bewegt, keine Animation gespielt)
|
||||
private AnimationLibrary animLib;
|
||||
private String animSetName;
|
||||
private Path assetRoot;
|
||||
private AnimComposer animComposer;
|
||||
private SkinningControl skinningControl;
|
||||
private AnimationAction currentAnim = null;
|
||||
|
||||
// ── Laufzustand ───────────────────────────────────────────────────────────
|
||||
|
||||
private boolean active = false;
|
||||
private List<WorldPoint> path = new ArrayList<>();
|
||||
private int pathStep = 0;
|
||||
private Speed speed = Speed.WALK;
|
||||
private Runnable onArrival = null;
|
||||
private Runnable onFailed = null;
|
||||
|
||||
/** Für Stuck-Erkennung: Position beim letzten Frame. */
|
||||
private Vector3f lastPos = null;
|
||||
private float stuckTimer = 0f;
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
public CharacterNavigator(CharacterControl physicsChar,
|
||||
Spatial visual,
|
||||
PathFinder pathFinder,
|
||||
TerrainChunkState terrain) {
|
||||
this.physicsChar = physicsChar;
|
||||
this.visual = visual;
|
||||
this.pathFinder = pathFinder;
|
||||
this.terrain = terrain;
|
||||
}
|
||||
|
||||
// ── Animations-Kontext ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Setzt den Animations-Kontext. Muss vor dem ersten {@link #navigateTo} aufgerufen
|
||||
* werden wenn Animationen abgespielt werden sollen; optional für reine Bewegung.
|
||||
*/
|
||||
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
|
||||
this.animLib = animLib;
|
||||
this.animSetName = animSetName;
|
||||
this.assetRoot = assetRoot;
|
||||
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
|
||||
this.skinningControl = (visual != null) ? RetargetingSystem.findSkinningControl(visual) : null;
|
||||
this.currentAnim = null;
|
||||
}
|
||||
|
||||
// ── Öffentliche API ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Startet die Navigation zu {@code target}. Der Y-Wert des Ziels wird ignoriert –
|
||||
* alle Wegpunkte werden auf die Terrain-Höhe gesampled.
|
||||
*
|
||||
* @param target Zielposition (Y wird durch Terrain ersetzt)
|
||||
* @param speed {@link Speed#WALK} oder {@link Speed#RUN}
|
||||
* @param onArrival Callback nach Ankunft (darf null sein)
|
||||
* @param onFailed Callback bei Abbruch durch Steckenbleiben (darf null sein)
|
||||
*/
|
||||
public void navigateTo(Vector3f target, Speed speed,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
this.speed = speed;
|
||||
this.onArrival = onArrival;
|
||||
this.onFailed = onFailed;
|
||||
this.active = true;
|
||||
this.stuckTimer = 0f;
|
||||
this.lastPos = physicsChar.getPhysicsLocation().clone();
|
||||
this.currentAnim = null;
|
||||
|
||||
Vector3f from3 = physicsChar.getPhysicsLocation();
|
||||
WorldPoint from = new WorldPoint(from3.x, from3.y, from3.z);
|
||||
WorldPoint to = new WorldPoint(target.x, target.y, target.z);
|
||||
|
||||
List<WorldPoint> raw = (pathFinder != null)
|
||||
? pathFinder.findPath(from, to)
|
||||
: List.of(to);
|
||||
|
||||
path = buildWalkablePath(raw);
|
||||
pathStep = 0;
|
||||
|
||||
log.info("[Navigator] navigateTo ({:.1f},{:.1f}) speed={} waypoints={}",
|
||||
target.x, target.z, speed, path.size());
|
||||
}
|
||||
|
||||
/** Wie {@link #navigateTo} ohne Fehler-Callback. */
|
||||
public void navigateTo(Vector3f target, Speed speed, Runnable onArrival) {
|
||||
navigateTo(target, speed, onArrival, null);
|
||||
}
|
||||
|
||||
/** Bricht die Navigation sofort ab, kein Callback wird ausgeführt. */
|
||||
public void stop() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
path.clear();
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
playAnim(AnimationAction.IDLE);
|
||||
onArrival = null;
|
||||
onFailed = null;
|
||||
}
|
||||
|
||||
public boolean isActive() { return active; }
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Muss jeden Frame aufgerufen werden, solange Navigation aktiv ist.
|
||||
* Bewegt den Charakter, dreht ihn zum nächsten Wegpunkt und spielt
|
||||
* die passende Animation.
|
||||
*/
|
||||
public void update(float tpf) {
|
||||
if (!active || physicsChar == null) return;
|
||||
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
|
||||
// ── Finales Ziel erreicht? ────────────────────────────────────────────
|
||||
if (path.isEmpty()) { arrive(); return; }
|
||||
WorldPoint goal = path.get(path.size() - 1);
|
||||
float gdx = goal.x - pos.x, gdz = goal.z - pos.z;
|
||||
if (gdx * gdx + gdz * gdz <= ARRIVE_RADIUS * ARRIVE_RADIUS) { arrive(); return; }
|
||||
|
||||
// ── Wegpunkte überspringen die bereits passiert sind ──────────────────
|
||||
while (pathStep < path.size() - 1) {
|
||||
WorldPoint wp = path.get(pathStep);
|
||||
float dx = wp.x - pos.x, dz = wp.z - pos.z;
|
||||
if (dx * dx + dz * dz > WAYPOINT_RADIUS * WAYPOINT_RADIUS) break;
|
||||
pathStep++;
|
||||
}
|
||||
|
||||
// ── Zum aktuellen Wegpunkt bewegen ────────────────────────────────────
|
||||
WorldPoint target = path.get(pathStep);
|
||||
float dx = target.x - pos.x, dz = target.z - pos.z;
|
||||
float len = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (len < 0.001f) { pathStep++; return; }
|
||||
|
||||
Vector3f dir = new Vector3f(dx / len, 0f, dz / len);
|
||||
float moveSpeed = (speed == Speed.RUN) ? RUN_SPEED : WALK_SPEED;
|
||||
physicsChar.setWalkDirection(dir.mult(moveSpeed));
|
||||
rotateVisual(dir, tpf);
|
||||
playAnim(speed == Speed.RUN ? AnimationAction.RUN : AnimationAction.WALK);
|
||||
|
||||
// ── Stuck-Erkennung ───────────────────────────────────────────────────
|
||||
float movedPerSec = pos.distance(lastPos) / Math.max(tpf, 0.001f);
|
||||
if (movedPerSec < STUCK_MIN_SPEED) {
|
||||
stuckTimer += tpf;
|
||||
if (stuckTimer > STUCK_TIMEOUT) {
|
||||
log.warn("[Navigator] Stecken erkannt – Navigation abgebrochen.");
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stuckTimer = 0f;
|
||||
}
|
||||
lastPos = pos.clone();
|
||||
}
|
||||
|
||||
// ── Interna ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void arrive() {
|
||||
active = false;
|
||||
physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
playAnim(AnimationAction.IDLE);
|
||||
Runnable cb = onArrival;
|
||||
onArrival = null;
|
||||
if (cb != null) cb.run();
|
||||
}
|
||||
|
||||
private void fail() {
|
||||
active = false;
|
||||
physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
playAnim(AnimationAction.IDLE);
|
||||
Runnable cb = onFailed;
|
||||
onFailed = null;
|
||||
if (cb != null) cb.run();
|
||||
}
|
||||
|
||||
private void rotateVisual(Vector3f dir, float tpf) {
|
||||
if (visual == null) return;
|
||||
Quaternion target = new Quaternion();
|
||||
target.lookAt(dir, Vector3f.UNIT_Y);
|
||||
Quaternion cur = visual.getLocalRotation().clone();
|
||||
cur.slerp(target, ROTATE_SPEED * tpf);
|
||||
visual.setLocalRotation(cur);
|
||||
}
|
||||
|
||||
private void playAnim(AnimationAction action) {
|
||||
if (action == currentAnim) return;
|
||||
if (animLib == null || animSetName == null || visual == null || animComposer == null) return;
|
||||
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
|
||||
if (clip == null) {
|
||||
clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
|
||||
}
|
||||
if (clip == null) return;
|
||||
if (!animLib.applyTo(clip, visual)) return;
|
||||
com.jme3.anim.tween.action.Action act = animComposer.setCurrentAction(clip);
|
||||
if (act == null) return;
|
||||
if (skinningControl != null && !skinningControl.isEnabled()) {
|
||||
skinningControl.setEnabled(true);
|
||||
}
|
||||
currentAnim = action;
|
||||
}
|
||||
|
||||
// ── Pfad-Aufbereitung ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bereitet den rohen PathFinder-Pfad auf:
|
||||
* <ol>
|
||||
* <li>Y-Koordinaten aller Wegpunkte werden auf Terrain-Höhe gesampled.</li>
|
||||
* <li>Zu steile Segmente (> {@value #MAX_SLOPE_TAN} tan) werden durch
|
||||
* seitliche Umwegpunkte ersetzt; ist kein begehbarer Umweg vorhanden,
|
||||
* bleibt das Segment im Pfad (Physik bremst den Charakter natürlich ab).</li>
|
||||
* </ol>
|
||||
*/
|
||||
private List<WorldPoint> buildWalkablePath(List<WorldPoint> raw) {
|
||||
List<WorldPoint> snapped = new ArrayList<>(raw.size());
|
||||
for (WorldPoint wp : raw) {
|
||||
float y = (terrain != null) ? terrain.getHeightAt(wp.x, wp.z) : wp.y;
|
||||
snapped.add(new WorldPoint(wp.x, y, wp.z));
|
||||
}
|
||||
|
||||
if (terrain == null || snapped.size() < 2) return snapped;
|
||||
|
||||
List<WorldPoint> result = new ArrayList<>();
|
||||
result.add(snapped.get(0));
|
||||
for (int i = 0; i < snapped.size() - 1; i++) {
|
||||
WorldPoint a = snapped.get(i);
|
||||
WorldPoint b = snapped.get(i + 1);
|
||||
if (!isSegmentWalkable(a.x, a.z, b.x, b.z)) {
|
||||
WorldPoint detour = findFlatDetour(a, b);
|
||||
if (detour != null) {
|
||||
result.add(detour);
|
||||
log.debug("[Navigator] Steiles Segment, Umweg: ({:.1f},{:.1f})",
|
||||
detour.x, detour.z);
|
||||
}
|
||||
}
|
||||
result.add(b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Pfadsegment von (x1,z1) nach (x2,z2) begehbar ist,
|
||||
* indem die Terrain-Höhe alle {@value #SLOPE_STEP}m abgetastet wird.
|
||||
*/
|
||||
private boolean isSegmentWalkable(float x1, float z1, float x2, float z2) {
|
||||
if (terrain == null) return true;
|
||||
float dx = x2 - x1, dz = z2 - z1;
|
||||
float dist = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (dist < 0.1f) return true;
|
||||
float nx = dx / dist, nz = dz / dist;
|
||||
float prevH = terrain.getHeightAt(x1, z1);
|
||||
for (float t = SLOPE_STEP; t < dist; t += SLOPE_STEP) {
|
||||
float h = terrain.getHeightAt(x1 + nx * t, z1 + nz * t);
|
||||
float dh = Math.abs(h - prevH);
|
||||
if (dh / SLOPE_STEP > MAX_SLOPE_TAN) return false;
|
||||
prevH = h;
|
||||
}
|
||||
// letztes Teilstück bis b
|
||||
float h = terrain.getHeightAt(x2, z2);
|
||||
float remaining = dist - (float) Math.floor(dist / SLOPE_STEP) * SLOPE_STEP;
|
||||
if (remaining > 0.01f && Math.abs(h - prevH) / remaining > MAX_SLOPE_TAN) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen begehbaren Umweg um ein zu steiles Segment von {@code a} nach {@code b}.
|
||||
* Versucht senkrechte Versätze (links und rechts) in 3m-Schritten bis 12m.
|
||||
*
|
||||
* @return Umweg-Wegpunkt oder {@code null} wenn keiner gefunden.
|
||||
*/
|
||||
private WorldPoint findFlatDetour(WorldPoint a, WorldPoint b) {
|
||||
float dx = b.x - a.x, dz = b.z - a.z;
|
||||
float len = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (len < 0.001f) return null;
|
||||
float mx = (a.x + b.x) * 0.5f, mz = (a.z + b.z) * 0.5f;
|
||||
// Senkrechter Einheitsvektor
|
||||
float perpX = -dz / len, perpZ = dx / len;
|
||||
for (float side : new float[]{1f, -1f}) {
|
||||
for (float offset = 3f; offset <= 12f; offset += 3f) {
|
||||
float cx = mx + perpX * side * offset;
|
||||
float cz = mz + perpZ * side * offset;
|
||||
if (isSegmentWalkable(a.x, a.z, cx, cz)
|
||||
&& isSegmentWalkable(cx, cz, b.x, b.z)) {
|
||||
float cy = terrain.getHeightAt(cx, cz);
|
||||
return new WorldPoint(cx, cy, cz);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,15 @@ public class WorldScene extends BaseAppState {
|
||||
playerInput.setPhysicsCharacter(physicsChar);
|
||||
playerInput.setVisual(characterVisual != null ? characterVisual : character);
|
||||
|
||||
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
|
||||
try {
|
||||
de.blight.game.navigation.PathFinder pf = de.blight.game.navigation.PathFinder.load();
|
||||
pf.setObstacleRoot(app.getRootNode());
|
||||
playerInput.setNavigationSources(pf, terrainChunkState);
|
||||
} catch (java.io.IOException e) {
|
||||
log.warn("[WorldScene] PathFinder nicht ladbar – Navigation deaktiviert: {}", e.getMessage());
|
||||
}
|
||||
|
||||
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
|
||||
thirdPersonCam.setTarget(character);
|
||||
|
||||
|
||||
@@ -16,11 +16,10 @@ import de.blight.common.model.BedIO;
|
||||
import de.blight.common.model.Bench;
|
||||
import de.blight.common.model.BenchIO;
|
||||
import de.blight.common.model.InteractableType;
|
||||
import de.blight.common.model.WorldPoint;
|
||||
import de.blight.game.animation.AnimationAction;
|
||||
import de.blight.game.config.KeyBindings;
|
||||
import de.blight.game.control.PlayerInputControl;
|
||||
import de.blight.game.navigation.PathFinder;
|
||||
import de.blight.game.navigation.CharacterNavigator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -31,25 +30,32 @@ import java.util.List;
|
||||
/**
|
||||
* Steuert die Interaktion des Hauptcharakters mit Betten und Bänken.
|
||||
*
|
||||
* <h2>Ablauf Bett</h2>
|
||||
* <h2>Ablauf Bank</h2>
|
||||
* <pre>
|
||||
* Interact-Taste (E)
|
||||
* → WALKING : walk zu Punkt neben dem Bett (via PathFinder)
|
||||
* → LIE_ANIM : lie_down Animation (Charakter gleitet in Liegeposition)
|
||||
* → RESTING : Charakter liegt; alle Eingaben gesperrt
|
||||
* → GET_UP : Rechtsklick startet lie_up Animation
|
||||
* → WALKING_BACK : Charakter kehrt zur Ausgangsposition zurück
|
||||
* E (< 5m)
|
||||
* → WALKING : CharacterNavigator → Sitzpunkt (umlaufen wenn nötig)
|
||||
* → PLAY_ANIM : Drehen (Rücken zur Bank) → sit_down_bench
|
||||
* → RESTING : sitting-Loop; Eingaben gesperrt
|
||||
* → GET_UP : Rechtsklick → get_up_sitting → IDLE
|
||||
* </pre>
|
||||
*
|
||||
* Bank läuft analog mit sit_down / sit_up und einem 0,5m-Pfeil.
|
||||
* <h2>Ablauf Bett</h2>
|
||||
* <pre>
|
||||
* E (< 6m)
|
||||
* → WALKING : CharacterNavigator → Anfahrtspunkt
|
||||
* → PLAY_ANIM : Drehen → lie_down
|
||||
* → RESTING : lying-Loop; Eingaben gesperrt
|
||||
* → GET_UP : Rechtsklick → lie_up
|
||||
* → WALKING_BACK : Rückkehr zur Ausgangsposition
|
||||
* </pre>
|
||||
*/
|
||||
public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WorldInteractableState.class);
|
||||
|
||||
private static final float INTERACT_RANGE = 6f;
|
||||
private static final float REACH_DIST = 0.35f;
|
||||
private static final float WALK_TIMEOUT = 12f;
|
||||
private static final float BENCH_RANGE = 5f;
|
||||
private static final float BED_RANGE = 6f;
|
||||
private static final float WALK_TIMEOUT = 12f;
|
||||
|
||||
// ── Abhängigkeiten ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,9 +65,8 @@ public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
private InputManager inputManager;
|
||||
|
||||
// ── Interactable-Daten aus der Karte ─────────────────────────────────────
|
||||
// ── Interactable-Einträge ────────────────────────────────────────────────
|
||||
|
||||
/** Beschreibt ein platziertes Interactable-Objekt. */
|
||||
private record InteractableEntry(
|
||||
float worldX, float worldY, float worldZ,
|
||||
InteractableType type,
|
||||
@@ -70,33 +75,22 @@ public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
private final List<InteractableEntry> entries = new ArrayList<>();
|
||||
|
||||
// ── Zustandsmaschine ──────────────────────────────────────────────────────
|
||||
// ── Zustandsmaschine ─────────────────────────────────────────────────────
|
||||
|
||||
private enum Phase {
|
||||
IDLE,
|
||||
WALKING, // Annäherung an Interactable
|
||||
PLAY_ANIM, // Einlege-/Sitz-Animation läuft
|
||||
RESTING, // Charakter liegt/sitzt; nur Rechtsklick erlaubt
|
||||
GET_UP_ANIM, // Aufsteh-Animation läuft
|
||||
WALKING_BACK // Rückkehr zur Ausgangsposition
|
||||
WALKING,
|
||||
PLAY_ANIM,
|
||||
RESTING,
|
||||
GET_UP_ANIM,
|
||||
WALKING_BACK
|
||||
}
|
||||
|
||||
private Phase phase = Phase.IDLE;
|
||||
private int targetIdx = -1;
|
||||
private float walkTimer = 0f;
|
||||
private Phase phase = Phase.IDLE;
|
||||
private int targetIdx = -1;
|
||||
private float walkTimer = 0f;
|
||||
|
||||
/** Ziel-Weltpunkt, zu dem der Charakter laufen soll (neben dem Objekt). */
|
||||
private Vector3f approachTarget = null;
|
||||
/** Position des Charakters vor der Interaktion (für Rückkehr). */
|
||||
private Vector3f originPos = null;
|
||||
/** Aktive Pfadliste für Annäherung / Rückkehr. */
|
||||
private List<WorldPoint> currentPath = new ArrayList<>();
|
||||
private int pathStep = 0;
|
||||
|
||||
private PathFinder pathFinder = null;
|
||||
|
||||
/** Sitz-/Liegeposition des aktuell angesteuerten Interactables (für Bypass-Berechnung). */
|
||||
private Vector3f interactableSitPt = null;
|
||||
private Vector3f originPos = null;
|
||||
|
||||
// ── Eingabe-Mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,10 +110,6 @@ public class WorldInteractableState extends BaseAppState {
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.inputManager = app.getInputManager();
|
||||
|
||||
try { pathFinder = PathFinder.load(); }
|
||||
catch (IOException e) { log.warn("[WorldInteractable] Wegnetz nicht ladbar: {}", e.getMessage()); }
|
||||
|
||||
try {
|
||||
List<PlacedModel> models = PlacedModelIO.load();
|
||||
for (PlacedModel m : models) {
|
||||
@@ -154,95 +144,14 @@ public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
@Override protected void cleanup(Application app) {}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
switch (phase) {
|
||||
case WALKING -> updateWalking(tpf);
|
||||
case WALKING_BACK -> updateWalkingBack(tpf);
|
||||
default -> {}
|
||||
}
|
||||
// Navigation läuft im CharacterNavigator (PlayerInputControl.update).
|
||||
// Nur walkTimer für Bett-Rückkehr brauchen wir noch.
|
||||
if (phase == Phase.WALKING_BACK) walkTimer += tpf;
|
||||
}
|
||||
|
||||
private void updateWalking(float tpf) {
|
||||
if (playerInput.isPaused()) { cancelInteraction(); return; }
|
||||
|
||||
walkTimer += tpf;
|
||||
if (walkTimer > WALK_TIMEOUT) {
|
||||
log.info("[WorldInteractable] Annäherung abgebrochen (Timeout).");
|
||||
cancelInteraction();
|
||||
return;
|
||||
}
|
||||
if (targetIdx < 0 || targetIdx >= entries.size()) { cancelInteraction(); return; }
|
||||
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
Vector3f dest = approachTarget;
|
||||
|
||||
float dx = dest.x - pos.x;
|
||||
float dz = dest.z - pos.z;
|
||||
float distSq = dx * dx + dz * dz;
|
||||
|
||||
if (distSq <= REACH_DIST * REACH_DIST) {
|
||||
startRestAnim();
|
||||
} else {
|
||||
// Nächsten Wegpunkt aus dem Pfad verwenden
|
||||
advancePath(pos);
|
||||
}
|
||||
}
|
||||
|
||||
private void advancePath(Vector3f pos) {
|
||||
if (currentPath.isEmpty()) {
|
||||
// Direkt zum Ziel
|
||||
float dx = approachTarget.x - pos.x;
|
||||
float dz = approachTarget.z - pos.z;
|
||||
float len = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
|
||||
return;
|
||||
}
|
||||
// Zum aktuellen Wegpunkt steuern; wenn nah genug → weiter
|
||||
while (pathStep < currentPath.size()) {
|
||||
WorldPoint wp = currentPath.get(pathStep);
|
||||
float dx = wp.x - pos.x;
|
||||
float dz = wp.z - pos.z;
|
||||
float d2 = dx * dx + dz * dz;
|
||||
if (d2 < 0.8f * 0.8f) { pathStep++; continue; }
|
||||
float len = (float) Math.sqrt(d2);
|
||||
playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
|
||||
return;
|
||||
}
|
||||
// Pfad fertig → direkt zum Annäherungs-Ziel
|
||||
float dx = approachTarget.x - pos.x;
|
||||
float dz = approachTarget.z - pos.z;
|
||||
float len = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
|
||||
}
|
||||
|
||||
private void updateWalkingBack(float tpf) {
|
||||
if (originPos == null) { phase = Phase.IDLE; return; }
|
||||
|
||||
walkTimer += tpf;
|
||||
if (walkTimer > WALK_TIMEOUT) {
|
||||
playerInput.setAutopilotDirection(null);
|
||||
phase = Phase.IDLE;
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
float dx = originPos.x - pos.x;
|
||||
float dz = originPos.z - pos.z;
|
||||
float distSq = dx * dx + dz * dz;
|
||||
|
||||
if (distSq <= REACH_DIST * REACH_DIST) {
|
||||
playerInput.setAutopilotDirection(null);
|
||||
phase = Phase.IDLE;
|
||||
} else {
|
||||
float len = (float) Math.sqrt(distSq);
|
||||
playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listener ──────────────────────────────────────────────────────────────
|
||||
// ── Listener ─────────────────────────────────────────────────────────────
|
||||
|
||||
private final ActionListener interactListener = (name, isPressed, tpf) -> {
|
||||
if (!isPressed || phase != Phase.IDLE) return;
|
||||
@@ -255,215 +164,191 @@ public class WorldInteractableState extends BaseAppState {
|
||||
startGetUp();
|
||||
};
|
||||
|
||||
// ── Logik ─────────────────────────────────────────────────────────────────
|
||||
// ── Suche nächstes Interactable ───────────────────────────────────────────
|
||||
|
||||
private int findNearestInRange() {
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
int bestIdx = -1;
|
||||
float bestDist = INTERACT_RANGE;
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
int bestIdx = -1;
|
||||
float bestDist = Float.MAX_VALUE;
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
InteractableEntry e = entries.get(i);
|
||||
float dx = e.worldX() - pos.x;
|
||||
float dz = e.worldZ() - pos.z;
|
||||
float d = (float) Math.sqrt(dx * dx + dz * dz);
|
||||
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
float range = (e.type() == InteractableType.BENCH) ? BENCH_RANGE : BED_RANGE;
|
||||
if (d < range && d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
// ── Annäherung ────────────────────────────────────────────────────────────
|
||||
|
||||
private void startApproach(int idx) {
|
||||
targetIdx = idx;
|
||||
walkTimer = 0f;
|
||||
originPos = physicsChar.getPhysicsLocation().clone();
|
||||
targetIdx = idx;
|
||||
walkTimer = 0f;
|
||||
originPos = physicsChar.getPhysicsLocation().clone();
|
||||
|
||||
InteractableEntry entry = entries.get(idx);
|
||||
|
||||
// Kollision des Zielobjekts deaktivieren, damit der Charakter hindurchgehen kann
|
||||
setTargetPhysicsEnabled(entry, false);
|
||||
|
||||
approachTarget = computeApproachTarget(entry);
|
||||
|
||||
// Pfad berechnen (PathFinder falls vorhanden)
|
||||
WorldPoint from = new WorldPoint(originPos.x, originPos.y, originPos.z);
|
||||
WorldPoint to = new WorldPoint(approachTarget.x, approachTarget.y, approachTarget.z);
|
||||
if (pathFinder != null) {
|
||||
currentPath = new ArrayList<>(pathFinder.findPath(from, to));
|
||||
} else {
|
||||
currentPath = new ArrayList<>(List.of(to));
|
||||
Vector3f target = computeApproachTarget(entry);
|
||||
if (target == null) {
|
||||
setTargetPhysicsEnabled(entry, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass-Punkt einfügen wenn Sitzpunkt auf dem Weg liegt
|
||||
insertBypassIfNeeded(from);
|
||||
pathStep = 0;
|
||||
|
||||
phase = Phase.WALKING;
|
||||
log.info("[WorldInteractable] Annäherung an {} [{}]", entry.type(), entry.interactableId());
|
||||
log.info("[WorldInteractable] Annäherung {} [{}]", entry.type(), entry.interactableId());
|
||||
|
||||
playerInput.navigateTo(target, CharacterNavigator.Speed.WALK,
|
||||
this::onApproachArrived,
|
||||
this::cancelInteraction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den Punkt, zu dem der Charakter läuft.
|
||||
* Bank: direkt zum Sitzpunkt (Pfeilspitze).
|
||||
* Bett: 1m in Pfeilrichtung vor dem Liegepunkt (Anfahrt von vorne).
|
||||
* Berechnet das Ziel für den Navigator:
|
||||
* – Bank: exakter Sitzpunkt (sitzX/sitzZ) — Charakter steht danach direkt am Sitzpunkt
|
||||
* – Bett: 1m vor dem Liegepunkt in Blickrichtung (Anfahrt von vorne)
|
||||
*/
|
||||
private Vector3f computeApproachTarget(InteractableEntry entry) {
|
||||
if (entry.type() == InteractableType.BED) {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed != null && bed.isLiegeSet()) {
|
||||
float rotY = bed.getLiegeRotY();
|
||||
interactableSitPt = new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ());
|
||||
// Bett: 1m in Pfeilrichtung vor dem Liegepunkt anfahren
|
||||
return new Vector3f(
|
||||
bed.getLiegeX() + (float) Math.cos(rotY),
|
||||
bed.getLiegeY(),
|
||||
bed.getLiegeZ() + (float) Math.sin(rotY));
|
||||
}
|
||||
} else if (entry.type() == InteractableType.BENCH) {
|
||||
if (entry.type() == InteractableType.BENCH) {
|
||||
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
|
||||
if (bench != null && bench.isSitzSet()) {
|
||||
interactableSitPt = new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
|
||||
// Bank: direkt zum Sitzpunkt (Pfeilspitze) laufen
|
||||
return new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
|
||||
if (bench == null || !bench.isSitzSet()) {
|
||||
log.warn("[WorldInteractable] Bank {} hat keinen Sitzpunkt.", entry.interactableId());
|
||||
return null;
|
||||
}
|
||||
return new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
|
||||
} else {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed == null || !bed.isLiegeSet()) {
|
||||
log.warn("[WorldInteractable] Bett {} hat keinen Liegepunkt.", entry.interactableId());
|
||||
return null;
|
||||
}
|
||||
float rotY = bed.getLiegeRotY();
|
||||
return new Vector3f(
|
||||
bed.getLiegeX() + (float) Math.cos(rotY),
|
||||
bed.getLiegeY(),
|
||||
bed.getLiegeZ() + (float) Math.sin(rotY));
|
||||
}
|
||||
// Fallback: 1m östlich des Objekts
|
||||
interactableSitPt = null;
|
||||
return new Vector3f(entry.worldX() + 1f, entry.worldY(), entry.worldZ());
|
||||
}
|
||||
|
||||
// ── Am Ziel angekommen → drehen ────────────────────────────────────────────
|
||||
|
||||
private void onApproachArrived() {
|
||||
phase = Phase.PLAY_ANIM;
|
||||
InteractableEntry entry = entries.get(targetIdx);
|
||||
float rotY = getSitFacingRotY(entry);
|
||||
// Charakter dreht sich so dass der Rücken zur Bank / zum Bett zeigt
|
||||
Vector3f sitDir = new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY));
|
||||
playerInput.requestTurn(sitDir, 0.35f, () -> startSitAnim(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der direkte Weg von {@code from} zum approachTarget durch den
|
||||
* Sitzpunkt führt (nur relevant wenn Annährungspunkt ≠ Sitzpunkt, d.h. Bett).
|
||||
* Falls ja, wird ein Bypass-Punkt senkrecht eingefügt.
|
||||
* Gibt die Blickrichtung (radiant) des Charakters während des Sitzens / Liegens zurück.
|
||||
* Bank: sitzRotY (Pfeilspitze = Charakter schaut in diese Richtung → Rücken zur Bank).
|
||||
* Bett: liegeRotY.
|
||||
*/
|
||||
private void insertBypassIfNeeded(WorldPoint from) {
|
||||
if (interactableSitPt == null || approachTarget == null) return;
|
||||
|
||||
float sx = interactableSitPt.x, sz = interactableSitPt.z;
|
||||
float tx = approachTarget.x, tz = approachTarget.z;
|
||||
|
||||
// Wenn Annährungsziel = Sitzpunkt, ist kein Bypass nötig
|
||||
float aDx = tx - sx, aDz = tz - sz;
|
||||
if (aDx * aDx + aDz * aDz < 0.1f) return;
|
||||
|
||||
float fx = from.x, fz = from.z;
|
||||
|
||||
// Projektion des Sitzpunkts auf die direkte Linie from→approachTarget
|
||||
float dx = tx - fx, dz = tz - fz;
|
||||
float lenSq = dx * dx + dz * dz;
|
||||
if (lenSq < 0.001f) return;
|
||||
|
||||
float t = ((sx - fx) * dx + (sz - fz) * dz) / lenSq;
|
||||
if (t < 0.05f || t > 0.95f) return;
|
||||
|
||||
float closestX = fx + t * dx;
|
||||
float closestZ = fz + t * dz;
|
||||
float distToPath = (float) Math.sqrt((sx - closestX) * (sx - closestX)
|
||||
+ (sz - closestZ) * (sz - closestZ));
|
||||
if (distToPath > 1.2f) return;
|
||||
|
||||
float faceX = sx - tx;
|
||||
float faceZ = sz - tz;
|
||||
float faceLen = (float) Math.sqrt(faceX * faceX + faceZ * faceZ);
|
||||
if (faceLen > 0.001f) { faceX /= faceLen; faceZ /= faceLen; }
|
||||
|
||||
float perpX = -faceZ;
|
||||
float perpZ = faceX;
|
||||
|
||||
float dot = (fx - sx) * perpX + (fz - sz) * perpZ;
|
||||
float sign = dot >= 0f ? 1f : -1f;
|
||||
|
||||
WorldPoint bypass = new WorldPoint(
|
||||
sx + perpX * sign * 2.5f,
|
||||
from.y,
|
||||
sz + perpZ * sign * 2.5f);
|
||||
|
||||
currentPath.add(currentPath.size() - 1, bypass);
|
||||
log.info("[WorldInteractable] Bypass-Punkt eingefügt: ({}, {})", bypass.x, bypass.z);
|
||||
private float getSitFacingRotY(InteractableEntry entry) {
|
||||
if (entry.type() == InteractableType.BENCH) {
|
||||
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
|
||||
return bench != null ? bench.getSitzRotY() : 0f;
|
||||
} else {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
return bed != null ? bed.getLiegeRotY() : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private void startRestAnim() {
|
||||
playerInput.setAutopilotDirection(null);
|
||||
phase = Phase.PLAY_ANIM;
|
||||
|
||||
InteractableEntry entry = entries.get(targetIdx);
|
||||
float rotY = getRestRotY(entry);
|
||||
|
||||
// Zuerst Rücken zur Bank drehen, dann Sitz-/Liegeanimation
|
||||
Vector3f sitDir = new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY));
|
||||
playerInput.requestTurn(sitDir, 0.4f, () -> startSitAnim(entry));
|
||||
}
|
||||
// ── Sitz-/Liegeanimation ──────────────────────────────────────────────────
|
||||
|
||||
private void startSitAnim(InteractableEntry entry) {
|
||||
boolean isBed = entry.type() == InteractableType.BED;
|
||||
AnimationAction action = isBed ? AnimationAction.LIE_DOWN : AnimationAction.SIT_DOWN;
|
||||
AnimationAction idleAction = isBed ? AnimationAction.LYING : AnimationAction.SITTING;
|
||||
AnimationAction downAction = isBench(entry) ? AnimationAction.SIT_DOWN : AnimationAction.LIE_DOWN;
|
||||
AnimationAction idleAction = isBench(entry) ? AnimationAction.SITTING : AnimationAction.LYING;
|
||||
|
||||
// duration=0 → PlayerInputControl ermittelt die echte Clip-Länge automatisch
|
||||
playerInput.requestAnimation(action, 0f, () -> {
|
||||
if (isBed) teleportToRestPos(entry);
|
||||
playerInput.requestAnimation(downAction, 0f, () -> {
|
||||
snapToSitPos(entry);
|
||||
playerInput.lockInPlace();
|
||||
playerInput.playLockedAnimation(idleAction);
|
||||
phase = Phase.RESTING;
|
||||
log.info("[WorldInteractable] Ruhezustand aktiv: {}", entry.type());
|
||||
log.info("[WorldInteractable] Ruhezustand: {}", entry.type());
|
||||
});
|
||||
}
|
||||
|
||||
/** Snapped die Physik-Kapsel auf den exakten Sitz-/Liegepunkt. */
|
||||
private void snapToSitPos(InteractableEntry entry) {
|
||||
if (physicsChar == null) return;
|
||||
if (isBench(entry)) {
|
||||
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
|
||||
if (bench != null && bench.isSitzSet()) {
|
||||
physicsChar.setPhysicsLocation(
|
||||
new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ()));
|
||||
}
|
||||
} else {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed != null && bed.isLiegeSet()) {
|
||||
physicsChar.setPhysicsLocation(
|
||||
new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aufstehen ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void startGetUp() {
|
||||
if (targetIdx < 0 || targetIdx >= entries.size()) { phase = Phase.IDLE; return; }
|
||||
|
||||
InteractableEntry entry = entries.get(targetIdx);
|
||||
boolean isBed = entry.type() == InteractableType.BED;
|
||||
AnimationAction action = isBed ? AnimationAction.LIE_UP : AnimationAction.SIT_UP;
|
||||
|
||||
playerInput.unlockFromPlace();
|
||||
phase = Phase.GET_UP_ANIM;
|
||||
|
||||
// Sink-Wert für SIT_UP/LIE_UP kommt ebenfalls aus AnimSet-Konfiguration
|
||||
playerInput.requestAnimation(action, 0f, () -> {
|
||||
// Kollision des Objekts nach dem Aufstehen wieder aktivieren
|
||||
if (targetIdx >= 0 && targetIdx < entries.size()) {
|
||||
setTargetPhysicsEnabled(entries.get(targetIdx), true);
|
||||
AnimationAction upAction = isBench(entry) ? AnimationAction.SIT_UP : AnimationAction.LIE_UP;
|
||||
|
||||
playerInput.requestAnimation(upAction, 0f, () -> {
|
||||
setTargetPhysicsEnabled(entry, true);
|
||||
|
||||
if (isBench(entry)) {
|
||||
// Bank: Charakter bleibt stehen, Spieler übernimmt wieder
|
||||
phase = Phase.IDLE;
|
||||
targetIdx = -1;
|
||||
log.info("[WorldInteractable] Bank verlassen.");
|
||||
} else {
|
||||
// Bett: zur Ausgangsposition zurücklaufen
|
||||
phase = Phase.WALKING_BACK;
|
||||
walkTimer = 0f;
|
||||
log.info("[WorldInteractable] Rückkehr zur Ausgangsposition.");
|
||||
if (originPos != null) {
|
||||
playerInput.navigateTo(originPos, CharacterNavigator.Speed.WALK,
|
||||
() -> { phase = Phase.IDLE; targetIdx = -1; },
|
||||
() -> { phase = Phase.IDLE; targetIdx = -1; });
|
||||
} else {
|
||||
phase = Phase.IDLE;
|
||||
targetIdx = -1;
|
||||
}
|
||||
}
|
||||
phase = Phase.WALKING_BACK;
|
||||
walkTimer = 0f;
|
||||
log.info("[WorldInteractable] Rückkehr zur Ausgangsposition.");
|
||||
});
|
||||
}
|
||||
|
||||
private void teleportToRestPos(InteractableEntry entry) {
|
||||
if (physicsChar == null || entry.type() != InteractableType.BED) return;
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed != null && bed.isLiegeSet()) {
|
||||
physicsChar.setPhysicsLocation(new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ()));
|
||||
}
|
||||
}
|
||||
|
||||
private float getRestRotY(InteractableEntry entry) {
|
||||
if (entry.type() == InteractableType.BED) {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed != null) return bed.getLiegeRotY();
|
||||
} else if (entry.type() == InteractableType.BENCH) {
|
||||
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
|
||||
if (bench != null) return bench.getSitzRotY();
|
||||
}
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private void setTargetPhysicsEnabled(InteractableEntry entry, boolean enabled) {
|
||||
WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class);
|
||||
if (wos != null) wos.setInteractablePhysicsEnabled(entry.interactableId(), enabled);
|
||||
}
|
||||
// ── Abbruch ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void cancelInteraction() {
|
||||
playerInput.setAutopilotDirection(null);
|
||||
playerInput.stopNavigation();
|
||||
if (phase == Phase.RESTING || phase == Phase.GET_UP_ANIM) {
|
||||
playerInput.unlockFromPlace();
|
||||
}
|
||||
// Kollision bei Abbruch immer wieder aktivieren
|
||||
if (targetIdx >= 0 && targetIdx < entries.size()) {
|
||||
setTargetPhysicsEnabled(entries.get(targetIdx), true);
|
||||
}
|
||||
phase = Phase.IDLE;
|
||||
targetIdx = -1;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private static boolean isBench(InteractableEntry e) {
|
||||
return e.type() == InteractableType.BENCH;
|
||||
}
|
||||
|
||||
private void setTargetPhysicsEnabled(InteractableEntry entry, boolean enabled) {
|
||||
WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class);
|
||||
if (wos != null) wos.setInteractablePhysicsEnabled(entry.interactableId(), enabled);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user