diff --git a/blight-assets/src/main/resources/animations/sets/human.animset.json b/blight-assets/src/main/resources/animations/sets/human.animset.json index 341baf4..3a1683c 100644 --- a/blight-assets/src/main/resources/animations/sets/human.animset.json +++ b/blight-assets/src/main/resources/animations/sets/human.animset.json @@ -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 diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index bbaa2e3..83b5786 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -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; diff --git a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java index 6c91327..c77274c 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java @@ -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 clips = new ArrayList<>(); diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index ebf1ca7..4e98872 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -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> - motionKeyframesMap = java.util.Map.of(); - private java.util.List - 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 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); diff --git a/blight-game/src/main/java/de/blight/game/navigation/CharacterNavigator.java b/blight-game/src/main/java/de/blight/game/navigation/CharacterNavigator.java new file mode 100644 index 0000000..e305a74 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/navigation/CharacterNavigator.java @@ -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). + * + *

Verwendung: + *

+ *   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);
+ * 
+ * + *

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 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 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: + *

    + *
  1. Y-Koordinaten aller Wegpunkte werden auf Terrain-Höhe gesampled.
  2. + *
  3. 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).
  4. + *
+ */ + private List buildWalkablePath(List raw) { + List 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 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 2e3073b..7325aca 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java index ca3a048..afd8a07 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java @@ -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. * - *

Ablauf Bett

+ *

Ablauf Bank

*
- *   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
  * 
* - * Bank läuft analog mit sit_down / sit_up und einem 0,5m-Pfeil. + *

Ablauf Bett

+ *
+ *   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
+ * 
*/ 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 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 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 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); + } }