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:
2026-06-23 18:18:58 +02:00
parent 79f9cf12a3
commit ba0b80f524
7 changed files with 610 additions and 391 deletions

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}
}