Bank-Sitz-Fix: clearKfOffset-Timing, Approach-Distanz, Animationsübergänge
- clearKfOffset() erst nach get_up-Animation (Callback) statt sofort beim Start → kein Slide des Visuals während der Aufsteh-Animation - Approach-Distanz zur Bank um 17.5cm verkürzt (läuft näher ran, sitzt tiefer) - blockingAnimRemaining um 1 Frame (1/60s) gekürzt → verhindert Extra-Keyframe-Hold am Animationsende (noch zu beobachten) - Diverses aus vorheriger Session: AnimSet-Editor, Navigation, Assets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.scene.Spatial;
|
||||
import de.blight.game.animation.AnimKeyframe;
|
||||
import de.blight.game.animation.AnimSet;
|
||||
import de.blight.game.animation.AnimationAction;
|
||||
import de.blight.game.animation.AnimationLibrary;
|
||||
import de.blight.game.animation.RetargetingSystem;
|
||||
@@ -20,6 +22,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class PlayerInputControl {
|
||||
|
||||
@@ -53,6 +58,15 @@ public class PlayerInputControl {
|
||||
|
||||
private com.jme3.anim.SkinningControl skinningControl = null;
|
||||
|
||||
// ── Motion-Keyframe-Offsets ───────────────────────────────────────────────
|
||||
private Map<String, List<AnimKeyframe>> motionKeyframes = new LinkedHashMap<>();
|
||||
/** Basis-Local-Translation des Visual-Nodes; wird beim Laden des AnimSet einmalig gespeichert. */
|
||||
private Vector3f visualBaseTranslation = new Vector3f();
|
||||
private final Vector3f kfOffsetCurrent = new Vector3f();
|
||||
private final Vector3f kfOffsetTarget = new Vector3f();
|
||||
// Lineare Geschwindigkeit des KF-Offsets in m/s; 0 = kein aktiver Lerp
|
||||
private float kfOffsetSpeed = 0f;
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
private CharacterNavigator navigator = null;
|
||||
private de.blight.game.navigation.PathFinder navPathFinder = null;
|
||||
@@ -127,6 +141,17 @@ public class PlayerInputControl {
|
||||
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
|
||||
skinningControl = findSkinningControl(visual);
|
||||
log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null);
|
||||
if (visual != null) {
|
||||
visualBaseTranslation = visual.getLocalTranslation().clone();
|
||||
}
|
||||
try {
|
||||
AnimSet as = AnimSet.load(assetRoot.resolve("animations/sets"), animSetName);
|
||||
motionKeyframes = as.getMotionKeyframes();
|
||||
log.info("[AnimCtx] {} Motion-KF-Einträge geladen.", motionKeyframes.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[AnimCtx] AnimSet-KF nicht ladbar: {}", e.getMessage());
|
||||
motionKeyframes = new LinkedHashMap<>();
|
||||
}
|
||||
// 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);
|
||||
@@ -194,7 +219,7 @@ public class PlayerInputControl {
|
||||
duration = resolveClipLength(action, 1.5f);
|
||||
}
|
||||
blockingAnimActive = true;
|
||||
blockingAnimRemaining = duration;
|
||||
blockingAnimRemaining = Math.max(duration - (1f / 60f), 0f);
|
||||
blockingAnimTotal = duration;
|
||||
blockingAnimCallback = onComplete;
|
||||
autopilotDir = null;
|
||||
@@ -268,6 +293,15 @@ public class PlayerInputControl {
|
||||
*/
|
||||
public void navigateTo(Vector3f target, CharacterNavigator.Speed speed,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
navigateTo(target, speed, -1f, onArrival, onFailed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie {@link #navigateTo} mit explizitem Ankunftsradius (Meter).
|
||||
* Werte <= 0 verwenden den Navigator-Standard (0.45m).
|
||||
*/
|
||||
public void navigateTo(Vector3f target, CharacterNavigator.Speed speed, float arriveRadius,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
if (navigator == null) {
|
||||
log.warn("[Nav] navigateTo: kein CharacterNavigator – setNavigationSources() vorher aufrufen.");
|
||||
if (onFailed != null) onFailed.run();
|
||||
@@ -276,7 +310,12 @@ public class PlayerInputControl {
|
||||
forward = backward = left = right = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
autopilotDir = null;
|
||||
navigator.navigateTo(target, speed, onArrival, onFailed);
|
||||
clearMotionKfOffset();
|
||||
if (arriveRadius > 0f) {
|
||||
navigator.navigateTo(target, speed, arriveRadius, onArrival, onFailed);
|
||||
} else {
|
||||
navigator.navigateTo(target, speed, onArrival, onFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bricht eine laufende Navigation ab (kein Callback). */
|
||||
@@ -291,8 +330,6 @@ public class PlayerInputControl {
|
||||
/**
|
||||
* Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands.
|
||||
* Nur sinnvoll nach {@link #lockInPlace()}.
|
||||
* Hat die Aktion Motion Keyframes, wird der erste Keyframe (time=0) als statischer
|
||||
* Versatz auf den Visual-Node angewendet.
|
||||
*/
|
||||
public void playLockedAnimation(AnimationAction action) {
|
||||
playAction(action);
|
||||
@@ -313,6 +350,19 @@ public class PlayerInputControl {
|
||||
public void update(float tpf) {
|
||||
if (physicsChar == null) return;
|
||||
|
||||
if (visual != null && kfOffsetSpeed > 0f) {
|
||||
Vector3f delta = kfOffsetTarget.subtract(kfOffsetCurrent);
|
||||
float dist = delta.length();
|
||||
float step = kfOffsetSpeed * tpf;
|
||||
if (dist <= step) {
|
||||
kfOffsetCurrent.set(kfOffsetTarget);
|
||||
kfOffsetSpeed = 0f;
|
||||
} else {
|
||||
kfOffsetCurrent.addLocal(delta.normalizeLocal().multLocal(step));
|
||||
}
|
||||
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(kfOffsetCurrent));
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
if (autopilotDir != null) {
|
||||
autopilotDir = null;
|
||||
@@ -490,6 +540,38 @@ public class PlayerInputControl {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyMotionKfOffset(String clip) {
|
||||
if (visual == null) return;
|
||||
List<AnimKeyframe> kfs = (clip != null) ? motionKeyframes.get(clip) : null;
|
||||
if (kfs == null || kfs.isEmpty()) { clearMotionKfOffset(); return; }
|
||||
AnimKeyframe kf = kfs.get(0);
|
||||
Quaternion facing = visual.getLocalRotation().clone();
|
||||
Vector3f worldOffset = facing.mult(new Vector3f(kf.tx, kf.ty, kf.tz));
|
||||
kfOffsetTarget.set(worldOffset);
|
||||
kfOffsetCurrent.set(worldOffset);
|
||||
kfOffsetSpeed = 0f;
|
||||
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(kfOffsetCurrent));
|
||||
log.info("[KF] Clip '{}' → Offset ({},{},{}) sofort", clip, worldOffset.x, worldOffset.y, worldOffset.z);
|
||||
}
|
||||
|
||||
public void clearKfOffset() {
|
||||
log.info("[KF] clearKfOffset() → Lerp zu 0, current=({},{},{})", kfOffsetCurrent.x, kfOffsetCurrent.y, kfOffsetCurrent.z);
|
||||
kfOffsetTarget.set(0, 0, 0);
|
||||
kfOffsetSpeed = 5.0f; // 0.25m in ~3 Frames bei 60fps
|
||||
}
|
||||
|
||||
private void clearMotionKfOffset() {
|
||||
clearKfOffset();
|
||||
}
|
||||
|
||||
/** Gibt die konfigurierte Bank-Approach-Distanz (|sitting.tz|) zurück; 0.25m als Fallback. */
|
||||
public float getBenchApproachDist() {
|
||||
List<AnimKeyframe> kfs = motionKeyframes.get("sitting");
|
||||
if (kfs == null || kfs.isEmpty()) return 0.25f;
|
||||
float tz = kfs.get(0).tz;
|
||||
return Math.abs(tz) > 0.01f ? Math.abs(tz) : 0.25f;
|
||||
}
|
||||
|
||||
private boolean tryPlay(String clip) {
|
||||
if (animComposer == null || !animLib.applyTo(clip, visual)) {
|
||||
log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip);
|
||||
@@ -507,6 +589,7 @@ public class PlayerInputControl {
|
||||
log.info("[Anim] SkinningControl aktiviert nach Action '{}'", clip);
|
||||
}
|
||||
runningClip = clip;
|
||||
applyMotionKfOffset(clip);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,10 @@ public class CharacterNavigator {
|
||||
|
||||
// ── Tuneable ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ankunftsradius zum finalen Ziel (m). */
|
||||
private static final float ARRIVE_RADIUS = 0.45f;
|
||||
/** Standard-Ankunftsradius zum finalen Ziel (m). */
|
||||
private static final float DEFAULT_ARRIVE_RADIUS = 0.45f;
|
||||
/** Aktueller Ankunftsradius; kann pro navigateTo-Aufruf überschrieben werden. */
|
||||
private float arriveRadius = DEFAULT_ARRIVE_RADIUS;
|
||||
/** Radius zum Weiterspringen auf den nächsten Wegpunkt (m). */
|
||||
private static final float WAYPOINT_RADIUS = 0.9f;
|
||||
/** Rotationsgeschwindigkeit (rad/s). */
|
||||
@@ -133,6 +135,16 @@ public class CharacterNavigator {
|
||||
*/
|
||||
public void navigateTo(Vector3f target, Speed speed,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
navigateTo(target, speed, DEFAULT_ARRIVE_RADIUS, onArrival, onFailed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie {@link #navigateTo} mit explizitem Ankunftsradius.
|
||||
* Kleinere Werte (z. B. 0.05m für Sitzpunkte) reduzieren den Versatz beim End-Snap.
|
||||
*/
|
||||
public void navigateTo(Vector3f target, Speed speed, float customArriveRadius,
|
||||
Runnable onArrival, Runnable onFailed) {
|
||||
this.arriveRadius = customArriveRadius;
|
||||
this.speed = speed;
|
||||
this.onArrival = onArrival;
|
||||
this.onFailed = onFailed;
|
||||
@@ -158,7 +170,7 @@ public class CharacterNavigator {
|
||||
|
||||
/** Wie {@link #navigateTo} ohne Fehler-Callback. */
|
||||
public void navigateTo(Vector3f target, Speed speed, Runnable onArrival) {
|
||||
navigateTo(target, speed, onArrival, null);
|
||||
navigateTo(target, speed, DEFAULT_ARRIVE_RADIUS, onArrival, null);
|
||||
}
|
||||
|
||||
/** Bricht die Navigation sofort ab, kein Callback wird ausgeführt. */
|
||||
@@ -190,7 +202,7 @@ public class CharacterNavigator {
|
||||
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; }
|
||||
if (gdx * gdx + gdz * gdz <= arriveRadius * arriveRadius) { arrive(); return; }
|
||||
|
||||
// ── Wegpunkte überspringen die bereits passiert sind ──────────────────
|
||||
while (pathStep < path.size() - 1) {
|
||||
|
||||
@@ -340,7 +340,13 @@ public class WorldScene extends BaseAppState {
|
||||
float modelHeight = yRange[1] - yRange[0];
|
||||
log.info("[WorldScene] Vertex-Y-Range: min={} max={} height={}", yRange[0], yRange[1], modelHeight);
|
||||
float offsetY;
|
||||
if (modelHeight > 0.1f) {
|
||||
com.jme3.math.Vector3f savedScale = loaded.getLocalScale();
|
||||
if (Math.abs(savedScale.y - 1f) > 0.001f) {
|
||||
// Im j3o eingebrannte Skalierung verwenden (nicht auto-berechnen)
|
||||
float scale = savedScale.y;
|
||||
offsetY = -(0.9f + scale * yRange[0]);
|
||||
log.info("[WorldScene] Charakter: gespeicherte Skalierung {}x offsetY={}", scale, offsetY);
|
||||
} else if (modelHeight > 0.1f) {
|
||||
float scale = 1.8f / modelHeight;
|
||||
loaded.setLocalScale(scale);
|
||||
// Füße des Modells (scale * minY in loaded-Local) auf Kapsel-Unterkante legen
|
||||
|
||||
@@ -208,7 +208,8 @@ public class GrassState extends BaseAppState
|
||||
node.attachChild(geo);
|
||||
}
|
||||
if (node.getChildren().isEmpty()) return;
|
||||
node.setCullHint(Spatial.CullHint.Always); // sichtbar erst wenn LOD0
|
||||
boolean visibleNow = terrainChunkState.getChunkLod(cx, cz) == 0;
|
||||
node.setCullHint(visibleNow ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
|
||||
chunkNodes[idx] = node;
|
||||
grassNode.attachChild(node);
|
||||
}
|
||||
|
||||
@@ -257,6 +257,13 @@ public class TerrainChunkState extends BaseAppState {
|
||||
public void addChunkListener(ChunkListener l) { listeners.add(l); }
|
||||
public void removeChunkListener(ChunkListener l) { listeners.remove(l); }
|
||||
|
||||
/** Aktueller LOD eines Chunks (cx/cz in Chunk-Koordinaten), -1 = nicht geladen/versteckt. */
|
||||
public int getChunkLod(int cx, int cz) {
|
||||
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
|
||||
if (ci < 0 || ci >= TOTAL) return -1;
|
||||
return chunkLod[ci];
|
||||
}
|
||||
|
||||
// ── Chunk-Mesh-Aufbau ─────────────────────────────────────────────────────
|
||||
|
||||
private void rebuildChunkMesh(int cx, int cz, int lod, int[] targetLod) {
|
||||
|
||||
@@ -57,6 +57,14 @@ public class WorldInteractableState extends BaseAppState {
|
||||
private static final float BED_RANGE = 6f;
|
||||
private static final float WALK_TIMEOUT = 12f;
|
||||
|
||||
// Nach dem Aufstehen: Bank-Physik erst re-enablen wenn Charakter weit genug weg
|
||||
private static final float BENCH_REENABLE_DIST_SQ = 0.36f; // 0.6m Radius
|
||||
private static final float BENCH_REENABLE_TIMEOUT = 8f;
|
||||
private String benchPendingId = null;
|
||||
private float benchPendingX = 0f;
|
||||
private float benchPendingZ = 0f;
|
||||
private float benchPendingTimer = 0f;
|
||||
|
||||
// ── Abhängigkeiten ────────────────────────────────────────────────────────
|
||||
|
||||
private final KeyBindings keyBindings;
|
||||
@@ -146,9 +154,16 @@ public class WorldInteractableState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
// Navigation läuft im CharacterNavigator (PlayerInputControl.update).
|
||||
// Nur walkTimer für Bett-Rückkehr brauchen wir noch.
|
||||
if (phase == Phase.WALKING_BACK) walkTimer += tpf;
|
||||
if (benchPendingId != null) {
|
||||
benchPendingTimer += tpf;
|
||||
Vector3f pos = physicsChar.getPhysicsLocation();
|
||||
float dx = pos.x - benchPendingX;
|
||||
float dz = pos.z - benchPendingZ;
|
||||
if (dx * dx + dz * dz >= BENCH_REENABLE_DIST_SQ || benchPendingTimer >= BENCH_REENABLE_TIMEOUT) {
|
||||
flushBenchReEnable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────────
|
||||
@@ -197,10 +212,14 @@ public class WorldInteractableState extends BaseAppState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (benchPendingId != null && benchPendingId.equals(entry.interactableId())) {
|
||||
benchPendingId = null; // gleiche Bank – wird gleich wieder disabled
|
||||
}
|
||||
phase = Phase.WALKING;
|
||||
log.info("[WorldInteractable] Annäherung {} [{}]", entry.type(), entry.interactableId());
|
||||
|
||||
playerInput.navigateTo(target, CharacterNavigator.Speed.WALK,
|
||||
float radius = isBench(entry) ? 0.05f : -1f;
|
||||
playerInput.navigateTo(target, CharacterNavigator.Speed.WALK, radius,
|
||||
this::onApproachArrived,
|
||||
this::cancelInteraction);
|
||||
}
|
||||
@@ -217,7 +236,13 @@ public class WorldInteractableState extends BaseAppState {
|
||||
log.warn("[WorldInteractable] Bank {} hat keinen Sitzpunkt.", entry.interactableId());
|
||||
return null;
|
||||
}
|
||||
return new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
|
||||
// Approach 17.5cm kürzer als KF-Offset → Charakter läuft näher ran, Visual landet tiefer zur Bank.
|
||||
float approachDist = Math.max(playerInput.getBenchApproachDist() - 0.175f, 0.05f);
|
||||
float rotY = bench.getSitzRotY();
|
||||
float dx = (float) Math.cos(rotY) * approachDist;
|
||||
float dz = (float) Math.sin(rotY) * approachDist;
|
||||
log.info("[WorldInteractable] Bank approach: {}m von Sitzpunkt", approachDist);
|
||||
return new Vector3f(bench.getSitzX() + dx, bench.getSitzY(), bench.getSitzZ() + dz);
|
||||
} else {
|
||||
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
|
||||
if (bed == null || !bed.isLiegeSet()) {
|
||||
@@ -238,7 +263,6 @@ public class WorldInteractableState extends BaseAppState {
|
||||
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));
|
||||
}
|
||||
@@ -265,7 +289,7 @@ public class WorldInteractableState extends BaseAppState {
|
||||
AnimationAction idleAction = isBench(entry) ? AnimationAction.SITTING : AnimationAction.LYING;
|
||||
|
||||
playerInput.requestAnimation(downAction, 0f, () -> {
|
||||
snapToSitPos(entry);
|
||||
if (!isBench(entry)) snapToSitPos(entry); // Bank: Physik bleibt am Approach-Punkt
|
||||
playerInput.lockInPlace();
|
||||
playerInput.playLockedAnimation(idleAction);
|
||||
phase = Phase.RESTING;
|
||||
@@ -303,10 +327,12 @@ public class WorldInteractableState extends BaseAppState {
|
||||
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
|
||||
playerInput.clearKfOffset();
|
||||
benchPendingId = entry.interactableId();
|
||||
benchPendingX = entry.worldX();
|
||||
benchPendingZ = entry.worldZ();
|
||||
benchPendingTimer = 0f;
|
||||
phase = Phase.IDLE;
|
||||
targetIdx = -1;
|
||||
log.info("[WorldInteractable] Bank verlassen.");
|
||||
@@ -337,10 +363,18 @@ public class WorldInteractableState extends BaseAppState {
|
||||
if (targetIdx >= 0 && targetIdx < entries.size()) {
|
||||
setTargetPhysicsEnabled(entries.get(targetIdx), true);
|
||||
}
|
||||
flushBenchReEnable();
|
||||
phase = Phase.IDLE;
|
||||
targetIdx = -1;
|
||||
}
|
||||
|
||||
private void flushBenchReEnable() {
|
||||
if (benchPendingId == null) return;
|
||||
WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class);
|
||||
if (wos != null) wos.setInteractablePhysicsEnabled(benchPendingId, true);
|
||||
benchPendingId = null;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private static boolean isBench(InteractableEntry e) {
|
||||
|
||||
Reference in New Issue
Block a user