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:
2026-06-26 22:51:11 +02:00
parent ba0b80f524
commit b44d583dc3
21 changed files with 287 additions and 43 deletions

View File

@@ -29,23 +29,23 @@
},
"previewModelPath": "Models/Chars/mainchar.j3o",
"motionKeyframes": {
"get_up_sitting": [
{
"time": 0.0,
"tx": 0.0,
"ty": 0.0,
"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,
"tz": -0.5,
"rx": 0.0,
"ry": 0.0,
"rz": 0.0
}
],
"get_up_sitting": [
{
"time": 0.0,
"tx": 0.0,
"ty": 0.0,
"tz": -0.5,
"rx": 0.0,
"ry": 0.0,
"rz": 0.0

View File

@@ -603,6 +603,17 @@ public class EditorApp extends Application {
updateSpawnFields(input.pickedSpawnInfo);
}
// Modell-Editor: gebakte Scale aus j3o erkannt → Spinner aktualisieren
if (input.modelEditorBakedScaleDetected) {
input.modelEditorBakedScaleDetected = false;
double bsX = input.modelEditorScaleX;
double bsY = input.modelEditorScaleY;
double bsZ = input.modelEditorScaleZ;
if (modelEditorSpinX != null) modelEditorSpinX.getValueFactory().setValue(bsX);
if (modelEditorSpinY != null) modelEditorSpinY.getValueFactory().setValue(bsY);
if (modelEditorSpinZ != null) modelEditorSpinZ.getValueFactory().setValue(bsZ);
}
// Modell-Editor: Bounds-Aktualisierung
if (input.modelEditorBoundsReady) {
input.modelEditorBoundsReady = false;
@@ -5999,6 +6010,8 @@ public class EditorApp extends Application {
input.modelInteractableOffsetY = meta.interactableOffsetY();
input.modelInteractableOffsetZ = meta.interactableOffsetZ();
input.modelInteractableRotY = meta.interactableRotY();
input.modelInteractableActive = meta.interactableType() == de.blight.common.model.InteractableType.BED
|| meta.interactableType() == de.blight.common.model.InteractableType.BENCH;
input.modelInteractableOffsetChanged = true;
Label restPosHint = new Label("Klicke auf das Modell um den Ruhepunkt zu setzen:");
@@ -6116,8 +6129,9 @@ public class EditorApp extends Application {
|| nv == de.blight.common.model.InteractableType.BENCH;
restPointBox.setVisible(show);
restPointBox.setManaged(show);
// Pfeil ein-/ausblenden — über SharedInput-Flag signalisieren
input.modelInteractableOffsetChanged = show;
// Pfeil ein-/ausblenden — immer rebuilden, damit Sichtbarkeit aktualisiert wird
input.modelInteractableActive = show;
input.modelInteractableOffsetChanged = true;
});
// ── Buttons ───────────────────────────────────────────────────────────
@@ -6654,8 +6668,9 @@ public class EditorApp extends Application {
de.blight.common.model.InteractableType interactableType,
float interactableOffsetX, float interactableOffsetY,
float interactableOffsetZ, float interactableRotY) {
// Scale wird in j3o eingebrannt → Meta bekommt immer 1.0 (kein doppelter Scale beim Laden)
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
name, category, tags, sx, sy, sz, uniform,
name, category, tags, 1f, 1f, 1f, uniform,
pivotY, placeY, solid, cast, receive, rndMin, rndMax,
lod1Path, lod2Path, 30f, 80f, 120f,
lights, emitters,
@@ -6712,12 +6727,22 @@ public class EditorApp extends Application {
// Asset-Tree aktualisieren
input.refreshAssets = true;
// Thumbnail generieren (JME3-Thread liest das Flag und rendert)
java.nio.file.Path finalJ3o = category.isEmpty() ? absolutePath
: ASSET_ROOT.resolve("Models")
.resolve(java.nio.file.Path.of(category.replace('/', java.io.File.separatorChar)))
.resolve(name.isEmpty() ? absolutePath.getFileName().toString()
: name.replaceAll("[\\\\/:*?\"<>|]", "_") + ".j3o");
// Scale in j3o einbrennen (JME3-Thread) muss vor Thumbnail passieren
if (sx != 1f || sy != 1f || sz != 1f) {
input.modelEditorScaleX = sx;
input.modelEditorScaleY = sy;
input.modelEditorScaleZ = sz;
input.modelEditorBakeScalePath = finalJ3o;
input.modelEditorBakeScaleRequest = true;
}
// Thumbnail generieren (JME3-Thread liest das Flag und rendert)
input.modelEditorThumbnailRequest = finalJ3o;
}

View File

@@ -674,6 +674,13 @@ public class SharedInput {
*/
public volatile java.nio.file.Path modelEditorThumbnailRequest = null;
/** JFX → JME: Scale in j3o einbrennen (für animierte Modelle als Spatial-Transform, sonst Vertex-Bake). */
public volatile boolean modelEditorBakeScaleRequest = false;
public volatile java.nio.file.Path modelEditorBakeScalePath = null;
/** JME → JFX: j3o hatte eine gebakte Scale, die von der Meta abwich Spinner aktualisieren. */
public volatile boolean modelEditorBakedScaleDetected = false;
/** JME → JFX: true wenn das geladene Modell eingebettete LOD-Kinder hat (kein separater Pfad nötig). */
public volatile boolean modelEditorHasEmbeddedLods = false;
@@ -882,6 +889,8 @@ public class SharedInput {
public volatile float modelInteractableOffsetZ = 0f;
public volatile float modelInteractableRotY = 0f;
public volatile boolean modelInteractableOffsetChanged = false;
/** true wenn Interactable-Typ aktiv (BED/BENCH) steuert Pfeil-Sichtbarkeit. */
public volatile boolean modelInteractableActive = false;
/** Gesetzt vom JME-Thread nach Raycast-Klick, damit JFX-Spinner aktualisiert werden. */
public volatile boolean modelInteractablePosSetFromJme = false;

View File

@@ -225,10 +225,9 @@ public class AnimPreviewState extends BaseAppState {
previewTarget.z + FastMath.cos(rotY) * FastMath.cos(rotX) * dist));
c.lookAt(previewTarget, Vector3f.UNIT_Y);
// Achsen: Größe proportional zur Kameradistanz, immer am Weltpunkt (0,0,0)
// Achsen: feste 1m Weltlänge (Schaft = 0.5 lokal → Scale 2.0)
if (axesNode != null) {
float s = previewCamDist * input.animPreviewZoom * 0.18f;
axesNode.setLocalScale(s);
axesNode.setLocalScale(2.0f);
axesNode.setLocalTranslation(Vector3f.ZERO);
}

View File

@@ -20,6 +20,7 @@ import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Cylinder;
import com.jme3.scene.shape.Dome;
import com.jme3.scene.shape.Sphere;
import com.jme3.anim.SkinningControl;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
@@ -219,6 +220,18 @@ public class ModelEditorState extends BaseAppState {
input.modelEditorAttachedEmitters);
}
// Scale in j3o einbrennen (vor Thumbnail, damit Thumbnail die gebackene Datei erhält)
if (input.modelEditorBakeScaleRequest) {
input.modelEditorBakeScaleRequest = false;
Path bakePath = input.modelEditorBakeScalePath;
if (bakePath != null) {
bakeScaleIntoModel(bakePath,
input.modelEditorScaleX,
input.modelEditorScaleY,
input.modelEditorScaleZ);
}
}
// Thumbnail auf Anforderung generieren
Path thumbReq = input.modelEditorThumbnailRequest;
if (thumbReq != null && modelWrapper != null) {
@@ -350,6 +363,18 @@ public class ModelEditorState extends BaseAppState {
modelWrapper.attachChild(box);
}
// Gebakte Spatial-Scale erkennen: j3o hat explizit gesetzte Scale, Meta sagt 1.0
// → Scale aus j3o übernehmen und JavaFX-Spinner aktualisieren lassen
com.jme3.math.Vector3f jScale = modelWrapper.getChildren().isEmpty()
? com.jme3.math.Vector3f.UNIT_XYZ
: modelWrapper.getChild(0).getLocalScale();
if (Math.abs(jScale.y - 1f) > 0.001f && Math.abs(input.modelEditorScaleY - 1f) < 0.001f) {
input.modelEditorScaleX = jScale.x;
input.modelEditorScaleY = jScale.y;
input.modelEditorScaleZ = jScale.z;
input.modelEditorBakedScaleDetected = true;
}
// Skalierung aus SharedInput anwenden
applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ);
applyPivot(input.modelEditorPivotY);
@@ -767,6 +792,48 @@ public class ModelEditorState extends BaseAppState {
}
}
// ── Scale-Bake ────────────────────────────────────────────────────────────
/**
* Brennt den Scale (sx,sy,sz) in die j3o-Datei ein.
* Animierte Modelle (SkinningControl vorhanden): Scale als Spatial-Transform gespeichert.
* Statische Modelle: Scale in Vertex-Positionen gebacken (wie ModelImportState beim Import).
*/
private void bakeScaleIntoModel(Path j3oPath, float sx, float sy, float sz) {
try {
BinaryImporter importer = BinaryImporter.getInstance();
importer.setAssetManager(app.getAssetManager());
Savable savable = importer.load(j3oPath.toFile());
if (!(savable instanceof Spatial root)) {
log.warn("[ModelEditor] Bake: kein Spatial in {}", j3oPath.getFileName());
return;
}
if (hasSkinningControl(root)) {
root.setLocalScale(sx, sy, sz);
log.info("[ModelEditor] Animiert: Scale ({},{},{}) als Spatial-Transform gespeichert", sx, sy, sz);
} else {
root.setLocalScale(sx, sy, sz);
ModelImportState.stripControls(root);
ModelImportState.bakeTransform(root, new Matrix4f());
log.info("[ModelEditor] Statisch: Scale ({},{},{}) in Vertices gebacken", sx, sy, sz);
}
BinaryExporter.getInstance().save(root, j3oPath.toFile());
log.info("[ModelEditor] j3o nach Bake gespeichert: {}", j3oPath.getFileName());
} catch (Exception e) {
log.error("[ModelEditor] Scale-Bake fehlgeschlagen: {}", e.getMessage(), e);
}
}
private static boolean hasSkinningControl(Spatial s) {
if (s.getControl(SkinningControl.class) != null) return true;
if (s instanceof Node n) {
for (Spatial c : n.getChildren()) {
if (hasSkinningControl(c)) return true;
}
}
return false;
}
// ── Thumbnail ─────────────────────────────────────────────────────────────
private void generateThumbnail(Path j3oPath) {
@@ -842,7 +909,8 @@ public class ModelEditorState extends BaseAppState {
group.attachChild(shaft);
group.attachChild(head);
interactableArrowNode.attachChild(group);
interactableArrowNode.setCullHint(Spatial.CullHint.Inherit);
interactableArrowNode.setCullHint(
input.modelInteractableActive ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
/** Setzt den Pfeil sichtbar/unsichtbar. */

View File

@@ -619,7 +619,7 @@ public class ModelImportState extends BaseAppState {
bakeTransform(s, new Matrix4f());
}
private static void bakeTransform(Spatial s, Matrix4f accum) {
static void bakeTransform(Spatial s, Matrix4f accum) {
Matrix4f localMat = new Matrix4f();
s.getLocalTransform().toTransformMatrix(localMat);
Matrix4f combined = accum.mult(localMat);
@@ -637,7 +637,7 @@ public class ModelImportState extends BaseAppState {
}
}
private static void applyMatrixToMesh(Geometry g, Matrix4f mat) {
static void applyMatrixToMesh(Geometry g, Matrix4f mat) {
Mesh newMesh = g.getMesh().deepClone();
FloatBuffer pos = newMesh.getFloatBuffer(VertexBuffer.Type.Position);
@@ -673,7 +673,7 @@ public class ModelImportState extends BaseAppState {
g.setMesh(newMesh);
}
private static Matrix3f buildNormalMatrix(Matrix4f mat) {
static Matrix3f buildNormalMatrix(Matrix4f mat) {
Matrix3f m3 = new Matrix3f(
mat.m00, mat.m01, mat.m02,
mat.m10, mat.m11, mat.m12,
@@ -683,7 +683,7 @@ public class ModelImportState extends BaseAppState {
return m3;
}
private static void stripControls(Spatial s) {
static void stripControls(Spatial s) {
while (s.getNumControls() > 0) s.removeControl(s.getControl(0));
if (s instanceof Node n) n.getChildren().forEach(ModelImportState::stripControls);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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