Compare commits

...

10 Commits

Author SHA1 Message Date
63aa7aa104 Commit vor Änderung Umstellung auf eine einzelne Animation 2026-06-28 22:59:36 +02:00
6d061cd621 Sicherstellen des Zustands der passt 2026-06-28 21:31:00 +02:00
cd350a92fa Bank-Sitz: blockingAnimRemaining +1Frame, applyMotionKfOffset als Lerp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 15:11:44 +02:00
b44d583dc3 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>
2026-06-26 22:51:11 +02:00
ba0b80f524 Navigation, Bank-Setzen und AnimSet-Editor Keyframes
- CharacterNavigator: universelle Pfad-Navigation für Spieler und NPCs
  (PathFinder + Terrain-Slope-Check + Stuck-Erkennung, Walk/Run)
- PlayerInputControl: navigateTo/stopNavigation-API, Navigator hat Vorrang
  vor WASD; setNavigationSources für PathFinder + TerrainChunkState
- WorldInteractableState: Bank-Setzen komplett neu (< 5m, E-Taste),
  Navigator läuft zum Sitzpunkt, dreht Rücken zur Bank, spielt
  sit_down_bench / sitting / get_up_sitting; Bett weiterhin mit Rücklauf
- AnimSet-Editor: Kamera startet mit -45° Pitch; AnimKeyframe-Offset-Editor
- WorldScene: PathFinder + ObstacleRoot an PlayerInputControl übergeben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:18:58 +02:00
79f9cf12a3 Animationen jetzt heil und Keyframes eingebaut 2026-06-23 17:44:22 +02:00
914bf6e673 Zwischenstand - animationen passen weitesgehend 2026-06-22 22:36:44 +02:00
b3b943e588 Charakter-Liegend-Fix: T-Pose im Editor, CullHint im Spiel, Strip in src+bin
Editor (AnimPreviewState):
- __tpose__ AnimClip mit Einzel-Frame-Track für Root-Joints (behebt NPE in
  ClipAction.doInterpolate; leerer Clip hatte null-tracks-Array)
- Beim Laden: eingebettete Clips automatisch strippt und in src+bin speichern;
  danach __tpose__ abspielen → Charakter steht in Bind-Pose
- stopAll(): zurück zur T-Pose statt SkinningControl deaktivieren
- Achsen-Indikator immer fest auf (0,0,0) statt bounding-box-Mitte

Spiel (WorldScene):
- CullHint.Always beim Laden, Inherit nach Animation-Setup (kein liegender Charakter)
- stripEmbeddedClips: speichert jetzt in src + bin + build (alle bekannten Pfade);
  besseres Logging bei fehlendem Pfad

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:25:23 +02:00
d3c6e8ed77 Zwischenstand: CullHint-Workaround gegen liegenden Charakter + stripEmbeddedClips-Fix
- WorldScene: Charakter wird beim Laden mit CullHint.Always versteckt, erst nach
  setupAnimationContext (idle läuft) wieder sichtbar
- WorldScene: stripEmbeddedClips nutzt jetzt AnimationLibrary.findAssetRoot() statt
  hartkodierter Pfad-Liste; besseres Logging wenn Datei nicht gefunden
- AnimPreviewState: Modell beim Laden versteckt (CullHint.Always), erst bei playClip
  sichtbar; stopAll versteckt Modell wieder
- PlayerInputControl: tryPlay setzt Action VOR SkinningControl-Aktivierung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:03:48 +02:00
7a3b2b8733 snapRootBoneXZ: Achsen-Fix für Mixamo-Hips (X=0, Y=0/frei, Z=frei)
In diesen Mixamo-Exporten ist Local-Y die Vorwärts-Richtung (nicht Höhe),
Local-Z die Höhe. Bisheriger Code fror Z=0 ein → Charakter 1m zu tief.
Und Y war frei → Lauf-Drift blieb.

Neue Logik:
  X → 0 (kein Seiten-Drift)
  Y → 0 für Lauf-Clips (running/walking/sprinting/running_jump), normalisiert sonst
  Z → vollständig frei (Höhe und Setz/Aufsteh-Bewegung erhalten)

Nur der flachste Bone (Hips) wird modifiziert, alle anderen unberührt.
Clips vollständig neu importiert mit korrektem Snap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:28:51 +02:00
38 changed files with 1414 additions and 746 deletions

View File

@@ -1,16 +1,17 @@
{
"clips": [
"get_up_sitting",
"alive_again",
"idle",
"idle_jump",
"pickup",
"running",
"running_jump",
"sit_down",
"sitting",
"sit_down_bench",
"sitting_bench",
"sitting_floor",
"sprinting",
"stand_up",
"stand_up_bench",
"tpose",
"walking"
],
@@ -20,14 +21,17 @@
"WALK": "walking",
"RUN": "running",
"SPRINT": "sprinting",
"RUNNING_JUMP": "running_jump",
"JUMP": "idle_jump",
"RUNNING_JUMP": "running_jump",
"PICK_UP": "pickup",
"SIT_DOWN": "sit_down",
"SIT_UP": "stand_up",
"SITTING": "sitting"
"SIT_DOWN": "sit_down_bench",
"SIT_UP": "stand_up_bench",
"SITTING": "sitting_bench",
"REVIVE": "alive_again"
},
"previewModelPath": "Models/Chars/mainchar.j3o",
"sinkMap": {},
"anchorBoneMap": {}
"animOffsets": {
"sitting": {"tx": 0.0, "ty": 0.0, "tz": -0.5, "rx": 0.0, "ry": 0.0, "rz": 0.0},
"get_up_sitting": {"tx": 0.0, "ty": 0.0, "tz": -0.5, "rx": 0.0, "ry": 0.0, "rz": 0.0}
}
}

View File

@@ -152,15 +152,17 @@ public class EditorApp extends Application {
// AnimSet-Editor
private ListView<String> animSetClipListView;
private ListView<String> animSetActionListView;
private ListView<String> animSetSinkListView;
private ListView<String> animSetAnchorBoneListView;
private String animSetPendingPlayClip = null;
private ComboBox<String> animSetModelCombo;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
private java.util.List<String> animJointNames = new java.util.ArrayList<>();
private Label animSetBonesLabel;
// Anim-Offset-Editor (innerhalb AnimSet-Editor)
private javafx.scene.control.ListView<de.blight.game.animation.AnimOffset> animSetOffsetListView;
private javafx.collections.ObservableList<de.blight.game.animation.AnimOffset> animSetOffsetList =
javafx.collections.FXCollections.observableArrayList();
private java.util.Map<String, de.blight.game.animation.AnimOffset>
animSetOffsets = new java.util.LinkedHashMap<>();
// Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView;
@@ -453,19 +455,6 @@ public class EditorApp extends Application {
animClipListView.getItems().setAll(newClips);
if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst();
}
java.util.List<String> newJoints = input.animPreviewJointNames.getAndSet(null);
if (newJoints != null) {
animJointNames = new java.util.ArrayList<>(newJoints);
if (animSetBonesLabel != null) {
if (animJointNames.isEmpty()) {
animSetBonesLabel.setText("Kein Armature gefunden");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #c66;");
} else {
animSetBonesLabel.setText(animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #6a6;");
}
}
}
// AnimSet-Editor: nach Clip-Load automatisch abspielen
if (newClips != null && animSetPendingPlayClip != null) {
input.animPreviewPlayClip = animSetPendingPlayClip;
@@ -614,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;
@@ -6010,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:");
@@ -6127,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 ───────────────────────────────────────────────────────────
@@ -6665,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,
@@ -6723,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;
}
@@ -8276,7 +8290,27 @@ public class EditorApp extends Application {
removeClipBtn.setDisable(true);
animSetClipListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeClipBtn.setDisable(nv == null));
.addListener((obs, ov, nv) -> {
removeClipBtn.setDisable(nv == null);
// Offset des alten Clips sichern
if (ov != null && animSetOffsetList != null && !animSetOffsetList.isEmpty()) {
animSetOffsets.put(ov, animSetOffsetList.get(0));
} else if (ov != null) {
animSetOffsets.remove(ov);
}
// Offset des neuen Clips laden
if (animSetOffsetList != null) {
animSetOffsetList.clear();
if (nv != null) {
de.blight.game.animation.AnimOffset off = animSetOffsets.get(nv);
if (off != null) animSetOffsetList.setAll(off);
// Clip direkt in Vorschau abspielen
input.animPreviewPlayClip = nv;
}
}
// Offset-Bereich aktivieren/deaktivieren
if (animSetOffsetListView != null) animSetOffsetListView.setDisable(nv == null);
});
addClipBtn.setOnAction(e -> {
org.slf4j.Logger _log = org.slf4j.LoggerFactory.getLogger("AnimSetClipScan");
@@ -8395,196 +8429,69 @@ public class EditorApp extends Application {
HBox.setHgrow(removeActionBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetActionListView, actionBtns);
// ── Bone-Anchoring ────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Bone-Anchoring"), new Separator());
// ── Anim-Offsets ──────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Anim-Offsets"), new Separator());
Label anchorHint = new Label("Pro Aktion: Knochen angeben, der auf seiner Welt-Y fixiert bleibt (z. B. SIT_DOWN → foot.l). Hat Vorrang vor manuellem Sink.");
anchorHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
anchorHint.setWrapText(true);
animSetBonesLabel = new Label(animJointNames.isEmpty() ? "Kein Modell geladen" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#888;" : "#6a6;"));
Label kfHint = new Label("TX/TZ = charakter-lokal (seitlich/vorwärts), TY = Welt-Y (hoch/runter). RX/RY/RZ in Grad, additiv zur Startrotation.");
kfHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
kfHint.setWrapText(true);
animSetAnchorBoneListView = new ListView<>();
animSetAnchorBoneListView.setPrefHeight(110);
if (animSet.getAnchorBoneMap() != null) {
for (var e2 : animSet.getAnchorBoneMap().entrySet()) {
animSetAnchorBoneListView.getItems().add(e2.getKey() + "" + e2.getValue());
// Offsets aus AnimSet laden
animSetOffsets = new java.util.LinkedHashMap<>(animSet.getAnimOffsets());
animSetOffsetList = javafx.collections.FXCollections.observableArrayList();
animSetOffsetList.addListener((javafx.collections.ListChangeListener<de.blight.game.animation.AnimOffset>)
change -> updateAnimPreviewOffset());
// ListView zeigt den einen Offset des gewählten Clips; Doppelklick öffnet Edit-Dialog
animSetOffsetListView = new javafx.scene.control.ListView<>(animSetOffsetList);
animSetOffsetListView.setPrefHeight(60);
animSetOffsetListView.setPlaceholder(new Label("Kein Offset [+ Offset] zum Setzen"));
animSetOffsetListView.setCellFactory(lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimOffset off, boolean empty) {
super.updateItem(off, empty);
if (empty || off == null) { setText(null); return; }
setText(String.format("TX%+.3f TY%+.3f TZ%+.3f | RX%+.1f° RY%+.1f° RZ%+.1f°",
off.tx, off.ty, off.tz, off.rx, off.ry, off.rz));
}
}
Button addAnchorBtn = new Button("+ Hinzufügen…");
Button removeAnchorBtn = new Button("- Entfernen");
addAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setDisable(true);
animSetAnchorBoneListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeAnchorBtn.setDisable(nv == null));
addAnchorBtn.setOnAction(e -> {
// Pending joint names aus JME3-Thread abholen (falls Timer sie noch nicht konsumiert hat)
java.util.List<String> fresh = input.animPreviewJointNames.getAndSet(null);
if (fresh != null) {
animJointNames = new java.util.ArrayList<>(fresh);
if (animSetBonesLabel != null) {
animSetBonesLabel.setText(animJointNames.isEmpty() ? "Kein Armature gefunden" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#c66;" : "#6a6;"));
}
});
animSetOffsetListView.setOnMouseClicked(ev -> {
if (ev.getClickCount() == 2) {
de.blight.game.animation.AnimOffset sel =
animSetOffsetListView.getSelectionModel().getSelectedItem();
if (sel != null) showAnimOffsetDialog(sel);
}
ComboBox<de.blight.game.animation.AnimationAction> anchorActionCombo = new ComboBox<>();
anchorActionCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> acf =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
anchorActionCombo.setCellFactory(acf);
anchorActionCombo.setButtonCell(acf.call(null));
anchorActionCombo.setMaxWidth(Double.MAX_VALUE);
anchorActionCombo.getSelectionModel().selectFirst();
// Joint-Auswahl: ComboBox mit geladenen Namen, editierbar als Fallback
ComboBox<String> boneCombo = new ComboBox<>();
boneCombo.setEditable(true);
boneCombo.setMaxWidth(Double.MAX_VALUE);
if (animJointNames.isEmpty()) {
boneCombo.setPromptText("Joint-Name (erst Modell laden)");
} else {
boneCombo.getItems().addAll(animJointNames);
boneCombo.setPromptText("Joint auswählen…");
}
javafx.scene.layout.GridPane anchorGrid = new javafx.scene.layout.GridPane();
anchorGrid.setHgap(8); anchorGrid.setVgap(6);
anchorGrid.add(new Label("Aktion:"), 0, 0); anchorGrid.add(anchorActionCombo, 1, 0);
anchorGrid.add(new Label("Joint-Name:"), 0, 1); anchorGrid.add(boneCombo, 1, 1);
javafx.scene.layout.ColumnConstraints anchorCc = new javafx.scene.layout.ColumnConstraints();
anchorCc.setHgrow(Priority.ALWAYS);
anchorGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), anchorCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> anchorDlg = new javafx.scene.control.Dialog<>();
anchorDlg.setTitle("Bone-Anchoring konfigurieren");
javafx.scene.control.ButtonType okAnchor = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
anchorDlg.getDialogPane().getButtonTypes().addAll(okAnchor, javafx.scene.control.ButtonType.CANCEL);
anchorDlg.getDialogPane().setContent(anchorGrid);
anchorDlg.showAndWait().ifPresent(bt -> {
if (bt != okAnchor) {
return;
}
var selAction = anchorActionCombo.getValue();
String bone = boneCombo.getEditor().getText();
if (selAction == null || bone == null || bone.isBlank()) {
return;
}
String newEntry = selAction.name() + "" + bone.trim();
animSetAnchorBoneListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetAnchorBoneListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeAnchorBtn.setOnAction(e -> {
String sel = animSetAnchorBoneListView.getSelectionModel().getSelectedItem();
// initial deaktiviert wird durch Clip-Selektion gesteuert
animSetOffsetListView.setDisable(true);
Button addKfBtn = new Button("+ Offset");
Button removeKfBtn = new Button("- Entfernen");
addKfBtn.setMaxWidth(Double.MAX_VALUE);
removeKfBtn.setMaxWidth(Double.MAX_VALUE);
addKfBtn.setDisable(true);
removeKfBtn.setDisable(true);
animSetOffsetListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeKfBtn.setDisable(nv == null));
// addKfBtn folgt Clip-Selektion (nur wenn noch kein Offset gesetzt)
animSetClipListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> addKfBtn.setDisable(nv == null));
addKfBtn.setOnAction(e -> showAnimOffsetDialog(null));
removeKfBtn.setOnAction(e -> {
de.blight.game.animation.AnimOffset sel =
animSetOffsetListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetAnchorBoneListView.getItems().remove(sel);
animSetOffsetList.remove(sel);
animSetDirty = true;
}
});
HBox anchorBtns = new HBox(6, addAnchorBtn, removeAnchorBtn);
HBox.setHgrow(addAnchorBtn, Priority.ALWAYS);
HBox.setHgrow(removeAnchorBtn, Priority.ALWAYS);
inner.getChildren().addAll(anchorHint, animSetBonesLabel, animSetAnchorBoneListView, anchorBtns);
// ── Sink-Konfiguration (Fallback) ─────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Manueller Sink-Fallback"), new Separator());
Label sinkHint = new Label("Root-Motion-Ersatz: Körper senkt/hebt sich während der Animation.\nNegativ = nach unten (Setzen), Positiv = nach oben.");
sinkHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
sinkHint.setWrapText(true);
animSetSinkListView = new ListView<>();
animSetSinkListView.setPrefHeight(120);
if (animSet.getSinkMap() != null) {
for (var e2 : animSet.getSinkMap().entrySet()) {
animSetSinkListView.getItems().add(e2.getKey() + "" + e2.getValue());
}
}
Button addSinkBtn = new Button("+ Setzen…");
Button removeSinkBtn = new Button("- Entfernen");
addSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setDisable(true);
animSetSinkListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeSinkBtn.setDisable(nv == null));
addSinkBtn.setOnAction(e -> {
ComboBox<de.blight.game.animation.AnimationAction> actionSinkCombo = new ComboBox<>();
actionSinkCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> cf2 =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
actionSinkCombo.setCellFactory(cf2);
actionSinkCombo.setButtonCell(cf2.call(null));
actionSinkCombo.setMaxWidth(Double.MAX_VALUE);
actionSinkCombo.getSelectionModel().selectFirst();
Spinner<Double> sinkSpinner = new Spinner<>(-3.0, 3.0, 0.0, 0.05);
sinkSpinner.setEditable(true);
sinkSpinner.setMaxWidth(Double.MAX_VALUE);
javafx.scene.layout.GridPane sinkGrid = new javafx.scene.layout.GridPane();
sinkGrid.setHgap(8); sinkGrid.setVgap(6);
sinkGrid.add(new Label("Aktion:"), 0, 0); sinkGrid.add(actionSinkCombo, 1, 0);
sinkGrid.add(new Label("Versatz (m):"), 0, 1); sinkGrid.add(sinkSpinner, 1, 1);
javafx.scene.layout.ColumnConstraints sinkCc = new javafx.scene.layout.ColumnConstraints();
sinkCc.setHgrow(Priority.ALWAYS);
sinkGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), sinkCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> sinkDlg = new javafx.scene.control.Dialog<>();
sinkDlg.setTitle("Sink-Wert setzen");
javafx.scene.control.ButtonType okSink = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
sinkDlg.getDialogPane().getButtonTypes().addAll(okSink, javafx.scene.control.ButtonType.CANCEL);
sinkDlg.getDialogPane().setContent(sinkGrid);
sinkDlg.showAndWait().ifPresent(bt -> {
if (bt != okSink) {
return;
}
var selAction = actionSinkCombo.getValue();
if (selAction == null) {
return;
}
double val = sinkSpinner.getValue();
String newEntry = selAction.name() + "" + val;
// Bestehenden Eintrag für diese Aktion ersetzen
animSetSinkListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetSinkListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeSinkBtn.setOnAction(e -> {
String sel = animSetSinkListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetSinkListView.getItems().remove(sel);
animSetDirty = true;
}
});
HBox sinkBtns = new HBox(6, addSinkBtn, removeSinkBtn);
HBox.setHgrow(addSinkBtn, Priority.ALWAYS);
HBox.setHgrow(removeSinkBtn, Priority.ALWAYS);
inner.getChildren().addAll(sinkHint, animSetSinkListView, sinkBtns);
HBox kfBtns = new HBox(6, addKfBtn, removeKfBtn);
HBox.setHgrow(addKfBtn, Priority.ALWAYS);
HBox.setHgrow(removeKfBtn, Priority.ALWAYS);
inner.getChildren().addAll(kfHint, animSetOffsetListView, kfBtns);
// ── Vorschau ─────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
@@ -8644,6 +8551,98 @@ public class EditorApp extends Application {
return panel;
}
/** Öffnet den Offset-Dialog (null = Hinzufügen, non-null = Bearbeiten). */
private void showAnimOffsetDialog(de.blight.game.animation.AnimOffset existing) {
boolean isAdd = (existing == null);
float initTX = isAdd ? 0f : existing.tx;
float initTY = isAdd ? 0f : existing.ty;
float initTZ = isAdd ? 0f : existing.tz;
float initRX = isAdd ? 0f : existing.rx;
float initRY = isAdd ? 0f : existing.ry;
float initRZ = isAdd ? 0f : existing.rz;
Spinner<Double> spTX = new Spinner<>(-10.0, 10.0, initTX, 0.05);
Spinner<Double> spTY = new Spinner<>(-10.0, 10.0, initTY, 0.05);
Spinner<Double> spTZ = new Spinner<>(-10.0, 10.0, initTZ, 0.05);
Spinner<Double> spRX = new Spinner<>(-360.0, 360.0, initRX, 1.0);
Spinner<Double> spRY = new Spinner<>(-360.0, 360.0, initRY, 1.0);
Spinner<Double> spRZ = new Spinner<>(-360.0, 360.0, initRZ, 1.0);
for (Spinner<Double> sp : new Spinner[]{spTX, spTY, spTZ, spRX, spRY, spRZ}) {
sp.setEditable(true);
sp.setMaxWidth(Double.MAX_VALUE);
}
String[][] rows = {{"TX (m):"}, {"TY (m):"}, {"TZ (m):"}, {"RX (°):"}, {"RY (°):"}, {"RZ (°):"}};
Spinner<?>[] sps = {spTX, spTY, spTZ, spRX, spRY, spRZ};
javafx.scene.layout.GridPane kfGrid = new javafx.scene.layout.GridPane();
kfGrid.setHgap(8); kfGrid.setVgap(5);
for (int i = 0; i < sps.length; i++) {
kfGrid.add(new Label(rows[i][0]), 0, i);
kfGrid.add(sps[i], 1, i);
}
javafx.scene.layout.ColumnConstraints kfCC = new javafx.scene.layout.ColumnConstraints();
kfCC.setHgrow(Priority.ALWAYS);
kfGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), kfCC);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> kfDlg =
new javafx.scene.control.Dialog<>();
kfDlg.setTitle(isAdd ? "Offset hinzufügen" : "Offset bearbeiten");
javafx.scene.control.ButtonType okKf = new javafx.scene.control.ButtonType(
isAdd ? "Hinzufügen" : "Übernehmen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
kfDlg.getDialogPane().getButtonTypes().addAll(okKf, javafx.scene.control.ButtonType.CANCEL);
kfDlg.getDialogPane().setContent(kfGrid);
kfDlg.showAndWait().ifPresent(bt -> {
if (bt != okKf) return;
if (isAdd) {
de.blight.game.animation.AnimOffset off = new de.blight.game.animation.AnimOffset(
spTX.getValue().floatValue(), spTY.getValue().floatValue(), spTZ.getValue().floatValue(),
spRX.getValue().floatValue(), spRY.getValue().floatValue(), spRZ.getValue().floatValue());
animSetOffsetList.setAll(off);
} else {
existing.tx = spTX.getValue().floatValue();
existing.ty = spTY.getValue().floatValue();
existing.tz = spTZ.getValue().floatValue();
existing.rx = spRX.getValue().floatValue();
existing.ry = spRY.getValue().floatValue();
existing.rz = spRZ.getValue().floatValue();
}
if (animSetOffsetListView != null) animSetOffsetListView.refresh();
animSetDirty = true;
});
}
/** Spielt den Clip der gegebenen Aktion in der Vorschau ab, um animPreviewCurrentClipDuration zu setzen. */
private void autoPreviewClipForAction(de.blight.game.animation.AnimationAction action) {
if (animSetActionListView == null || action == null) return;
String prefix = action.name() + "";
for (String entry : animSetActionListView.getItems()) {
if (entry.startsWith(prefix)) {
String clip = entry.substring(prefix.length()).trim();
if (!clip.isEmpty()) {
input.animPreviewPlayClip = clip;
}
break;
}
}
}
private void updateAnimPreviewOffset() {
if (animSetOffsetList == null || animSetOffsetList.isEmpty()) {
input.animPreviewOffsetTx = 0f;
input.animPreviewOffsetTy = 0f;
input.animPreviewOffsetTz = 0f;
input.animPreviewOffsetActive = false;
} else {
de.blight.game.animation.AnimOffset off = animSetOffsetList.get(0);
input.animPreviewOffsetTx = off.tx;
input.animPreviewOffsetTy = off.ty;
input.animPreviewOffsetTz = off.tz;
input.animPreviewOffsetActive = true;
}
}
private void previewSelectedClip() {
if (animSetClipListView == null) return;
String clip = animSetClipListView.getSelectionModel().getSelectedItem();
@@ -8749,28 +8748,25 @@ public class EditorApp extends Application {
}
}
animSet.setActionMap(actionMap);
java.util.Map<String, Float> sinkMap = new java.util.LinkedHashMap<>();
if (animSetSinkListView != null) {
for (String it : animSetSinkListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
try {
sinkMap.put(parts[0], Float.parseFloat(parts[1]));
} catch (NumberFormatException ignored) {}
// Anim-Offsets: aktuell sichtbaren Offset (des selektierten Clips) sichern, dann schreiben
if (animSetClipListView != null) {
String selClip = animSetClipListView.getSelectionModel().getSelectedItem();
if (selClip != null) {
if (!animSetOffsetList.isEmpty()) {
animSetOffsets.put(selClip, animSetOffsetList.get(0));
} else {
animSetOffsets.remove(selClip);
}
}
}
animSet.setSinkMap(sinkMap);
java.util.Map<String, String> anchorBoneMap = new java.util.LinkedHashMap<>();
if (animSetAnchorBoneListView != null) {
for (String it : animSetAnchorBoneListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
anchorBoneMap.put(parts[0], parts[1]);
}
java.util.Map<String, de.blight.game.animation.AnimOffset> offsetFinal =
new java.util.LinkedHashMap<>();
for (var entry : animSetOffsets.entrySet()) {
if (entry.getValue() != null) {
offsetFinal.put(entry.getKey(), entry.getValue());
}
}
animSet.setAnchorBoneMap(anchorBoneMap);
animSet.setAnimOffsets(offsetFinal);
// Vorschau-Modell-Pfad beibehalten
if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue());
@@ -9439,25 +9435,6 @@ public class EditorApp extends Application {
refreshCharAnimSetCombo();
charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet());
Button embedAnimBtn = new Button("Animationen einbetten");
embedAnimBtn.setMaxWidth(Double.MAX_VALUE);
embedAnimBtn.setDisable(true);
javafx.beans.value.ChangeListener<String> embedEnableListener = (obs, ov, nv) -> {
boolean ready = charModelCombo.getValue() != null && !charModelCombo.getValue().isBlank()
&& charAnimSetCombo.getValue() != null && !charAnimSetCombo.getValue().isBlank();
embedAnimBtn.setDisable(!ready);
};
charModelCombo.valueProperty().addListener(embedEnableListener);
charAnimSetCombo.valueProperty().addListener(embedEnableListener);
embedAnimBtn.setOnAction(e -> {
String modelPath = charModelCombo.getValue();
String setName = charAnimSetCombo.getValue();
if (modelPath == null || setName == null) return;
if (charEditorStatusLabel != null)
charEditorStatusLabel.setText("Bette Animationen ein…");
input.animEmbedRequest.set(new SharedInput.AnimEmbedRequest(modelPath, setName));
});
Button stripClipsBtn = new Button("Eingebettete Clips löschen");
stripClipsBtn.setMaxWidth(Double.MAX_VALUE);
stripClipsBtn.setDisable(true);
@@ -9581,7 +9558,6 @@ public class EditorApp extends Application {
charEditContainer.getChildren().addAll(
new Label("Modell:"), charModelCombo,
new Label("Anim-Set:"), charAnimSetCombo,
embedAnimBtn,
stripClipsBtn
);

View File

@@ -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;
@@ -567,9 +567,16 @@ public class SharedInput {
/** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewClips = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Joint-Namen des geladenen Armatures (für Bone-Anchoring-Auswahl). */
/** JME3 → JavaFX: Joint-Namen des geladenen Armatures. */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewJointNames = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Länge des zuletzt gestarteten Clips in Sekunden (0 = unbekannt). */
public volatile float animPreviewCurrentClipDuration = 0f;
/** JavaFX → JME3: Anim-Offset-Vorschau (tx/ty/tz in Metern). */
public volatile float animPreviewOffsetTx = 0f;
public volatile float animPreviewOffsetTy = 0f;
public volatile float animPreviewOffsetTz = 0f;
public volatile boolean animPreviewOffsetActive = false;
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
@@ -667,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;
@@ -875,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

@@ -200,9 +200,7 @@ public class AnimPreviewState extends BaseAppState {
currentAction = ac.setCurrentAction(currentClipName);
if (currentAction != null) currentAction.setSpeed(input.animPreviewSpeed);
} else {
currentAction = null;
currentClipName = null;
setSkinningEnabled(currentModel, false);
stopAll();
}
}
}
@@ -227,11 +225,20 @@ 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 Ursprung
// 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.setLocalTranslation(previewTarget);
axesNode.setLocalScale(2.0f);
axesNode.setLocalTranslation(Vector3f.ZERO);
}
// Anim-Offset-Vorschau: Versatz auf Modell anwenden (TX/TY/TZ in Welt-Koordinaten)
if (currentModel != null) {
if (input.animPreviewOffsetActive) {
currentModel.setLocalTranslation(
input.animPreviewOffsetTx, input.animPreviewOffsetTy, input.animPreviewOffsetTz);
} else {
currentModel.setLocalTranslation(Vector3f.ZERO);
}
}
previewScene.updateLogicalState(tpf);
@@ -249,10 +256,6 @@ public class AnimPreviewState extends BaseAppState {
try {
Spatial model = loadFresh(assetPath);
// SkinningControl nur aktiv lassen wenn eine Animation läuft,
// sonst kollabiert das Mesh durch uninitalisierte Skin-Matrizen.
setSkinningEnabled(model, false);
// Im Animations-Editor soll der Charakter immer am Ursprung stehen.
// Eventuelle Translation die beim GLB-Export eingebacken wurde entfernen.
model.setLocalTranslation(Vector3f.ZERO);
@@ -264,7 +267,12 @@ public class AnimPreviewState extends BaseAppState {
// Alle Clips in-place snappen (verhindert Drift im Preview)
AnimComposer previewAC = findControl(model, AnimComposer.class);
SkinningControl previewSC = findControl(model, SkinningControl.class);
LOG.info("[AnimPreview] Modell-Controls: AnimComposer={}, SkinningControl={}, EmbeddedClips={}",
previewAC != null ? "gefunden" : "NULL",
previewSC != null ? "gefunden" : "NULL",
previewAC != null ? previewAC.getAnimClips().size() : "n/a");
if (previewAC != null && previewSC != null) {
// Eingebettete Clips snappen
for (AnimClip c : new java.util.ArrayList<>(previewAC.getAnimClips())) {
AnimClip snapped = de.blight.game.animation.AnimationLibrary.snapRootBoneXZ(c, previewSC.getArmature());
if (snapped != c) {
@@ -272,19 +280,46 @@ public class AnimPreviewState extends BaseAppState {
previewAC.addAnimClip(snapped);
}
}
// Eingebettete Clips aus der Datei entfernen und speichern (Datei-Größe reduzieren)
if (!previewAC.getAnimClips().isEmpty()) {
int embedCount = previewAC.getAnimClips().size();
for (AnimClip c : new java.util.ArrayList<>(previewAC.getAnimClips())) {
previewAC.removeAnimClip(c);
}
saveModelStripped(model, assetPath, embedCount);
}
// T-Pose: AnimClip für alle Joints in Bind-Pose.
// Leerer Clip → NPE in ClipAction.doInterpolate (JME3 erwartet tracks != null).
// getInitialTransform() liefert die Bind-Pose → SC-Matrix = Bind × Bind⁻¹ = I
// → Vertices in Y-up = stehender Charakter.
AnimClip tpose = buildTPoseClip(previewSC.getArmature());
LOG.info("[AnimPreview] T-Pose Clip: {}", tpose != null ? "erstellt" : "NULL (keine Root-Joints?)");
if (tpose != null) {
previewAC.addAnimClip(tpose);
setSkinningEnabled(model, true);
previewAC.setCurrentAction("__tpose__");
LOG.info("[AnimPreview] T-Pose aktiviert");
}
} else {
LOG.warn("[AnimPreview] T-Pose NICHT möglich: previewAC={}, previewSC={}",
previewAC != null ? "ok" : "NULL",
previewSC != null ? "ok" : "NULL");
}
// Kamera auf Bounding Box ausrichten
// Kamera: immer auf Hüfthöhe (0, 1, 0) zielen; Distanz aus BoundingBox.
// WICHTIG: BoundingBox wird VOR dem ersten SC-Update berechnet (T-Pose noch nicht sichtbar).
// Deshalb Minimum von 3f + 2m Puffer, damit die Kamera nicht im Körper startet.
model.updateGeometricState();
if (model.getWorldBound() instanceof BoundingBox bb) {
float ext = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
previewCamDist = ext * 2.8f;
previewTarget.set(bb.getCenter());
previewCamDist = Math.max(ext * 2.8f, 3f) + 2f;
} else {
previewCamDist = 3f;
previewTarget.set(0, 1, 0);
previewCamDist = 5f;
}
previewTarget.set(0, 1, 0);
input.animPreviewZoom = 1.0f;
input.animPreviewRotX = -45f;
input.animPreviewRotY = 0f;
// Clips sammeln und melden
List<String> clips = new ArrayList<>();
@@ -359,6 +394,7 @@ public class AnimPreviewState extends BaseAppState {
currentClipName = clipName;
if (currentAction != null) {
currentAction.setSpeed(input.animPreviewSpeed);
input.animPreviewCurrentClipDuration = (float) currentAction.getLength();
LOG.info("[AnimPreview] Play '{}' length={}", clipName, currentAction.getLength());
}
} catch (Exception e) {
@@ -379,7 +415,12 @@ public class AnimPreviewState extends BaseAppState {
currentClipName = null;
if (currentModel != null) {
stopOnSpatial(currentModel);
setSkinningEnabled(currentModel, false);
// Zurück zur T-Pose: __tpose__ wiedergeben (leerer Clip = Bind-Pose)
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac != null && ac.getAnimClipsNames().contains("__tpose__")) {
setSkinningEnabled(currentModel, true);
ac.setCurrentAction("__tpose__");
}
}
}
@@ -668,21 +709,31 @@ public class AnimPreviewState extends BaseAppState {
for (Spatial child : n.getChildren()) collectControlTypes(child, out);
}
/** Speichert das aktuelle Modell (inkl. aller AnimClips) zurück auf Disk. */
/** Speichert das aktuelle Modell zurück auf Disk ohne eingebettete AnimClips. */
private void saveModel() {
if (currentModelPath == null || currentModel == null) return;
if (!currentModelPath.endsWith(".j3o")) {
LOG.warn("[AnimPreview] Speichern übersprungen kein .j3o: {}", currentModelPath);
return;
}
AnimComposer ac = findControl(currentModel, AnimComposer.class);
List<AnimClip> tempClips = new java.util.ArrayList<>();
if (ac != null) {
tempClips.addAll(ac.getAnimClips());
for (AnimClip c : tempClips) ac.removeAnimClip(c);
}
Path file = ASSET_ROOT.resolve(currentModelPath.replace('/', java.io.File.separatorChar));
try {
BinaryExporter.getInstance().save(currentModel, file.toFile());
LOG.info("[AnimPreview] Modell gespeichert: {}", currentModelPath);
LOG.info("[AnimPreview] Modell gespeichert (ohne Clips): {}", currentModelPath);
assets.deleteFromCache(new ModelKey(currentModelPath));
} catch (Exception e) {
input.animPreviewStatus += " | Speicherfehler: " + e.getMessage();
LOG.error("[AnimPreview] Speicherfehler: {}", e.toString());
} finally {
if (ac != null) {
for (AnimClip c : tempClips) ac.addAnimClip(c);
}
}
}
@@ -967,6 +1018,70 @@ public class AnimPreviewState extends BaseAppState {
}
}
/**
* Erzeugt einen AnimClip "__tpose__" mit einem Einzel-Frame-Track für alle Root-Joints.
* Hält jeden Root-Joint an seiner lokalen Bind-Pose-Transform → SC-Matrix = I → T-Pose.
* Gibt null zurück wenn das Armature keine Root-Joints hat.
*/
private static AnimClip buildTPoseClip(com.jme3.anim.Armature armature) {
int jointCount = armature.getJointCount();
if (jointCount == 0) return null;
AnimClip clip = new AnimClip("__tpose__");
com.jme3.anim.AnimTrack[] tracks = new com.jme3.anim.AnimTrack[jointCount];
for (int i = 0; i < jointCount; i++) {
com.jme3.anim.Joint joint = armature.getJoint(i);
// getInitialTransform() liefert die echte Bind-Pose (nicht den aktuellen Zustand)
com.jme3.math.Transform bt = joint.getInitialTransform();
tracks[i] = new com.jme3.anim.TransformTrack(
joint,
new float[]{0f},
new com.jme3.math.Vector3f[]{bt.getTranslation().clone()},
new com.jme3.math.Quaternion[]{bt.getRotation().clone()},
new com.jme3.math.Vector3f[]{bt.getScale().clone()});
}
clip.setTracks(tracks);
return clip;
}
private static final String[] MODEL_SAVE_ROOTS = {
null, // ASSET_ROOT (src) wird durch ASSET_ROOT ersetzt
"blight-assets/bin/main",
"blight-assets/build/resources/main",
};
/** Speichert das (bereits strip-bereinigte) Modell in allen bekannten Asset-Verzeichnissen. */
private void saveModelStripped(Spatial model, String modelPath, int removedCount) {
String rel = modelPath.replace('/', java.io.File.separatorChar);
int saved = 0;
// Zuerst in ASSET_ROOT (= blight-assets/src/main/resources)
java.nio.file.Path srcFile = ASSET_ROOT.resolve(rel);
if (java.nio.file.Files.exists(srcFile)) {
try {
BinaryExporter.getInstance().save(model, srcFile.toFile());
assets.deleteFromCache(new com.jme3.asset.ModelKey(modelPath));
LOG.info("[AnimPreview] {} Clips entfernt, gespeichert: {}", removedCount, srcFile.toAbsolutePath());
saved++;
} catch (Exception e) {
LOG.warn("[AnimPreview] Speichern fehlgeschlagen (src): {}", e.getMessage());
}
}
// Auch in Gradle-Output-Verzeichnisse (damit der Laufzeit-Loader die bereinigte Datei sieht)
for (String root : new String[]{"blight-assets/bin/main", "blight-assets/build/resources/main"}) {
java.nio.file.Path f = java.nio.file.Paths.get(root).resolve(rel);
if (!java.nio.file.Files.exists(f)) continue;
try {
BinaryExporter.getInstance().save(model, f.toFile());
LOG.info("[AnimPreview] Auch gespeichert ({}): {}", root, f.toAbsolutePath());
saved++;
} catch (Exception e) {
LOG.warn("[AnimPreview] Speichern fehlgeschlagen ({}): {}", root, e.getMessage());
}
}
if (saved == 0) {
LOG.warn("[AnimPreview] Modell nicht gespeichert kein Pfad gefunden für '{}'", modelPath);
}
}
private void saveClipToFile(AnimClip clip, com.jme3.anim.Armature armature,
java.nio.file.Path outFile) throws Exception {
Node holder = new Node("clip_" + clip.getName());

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

@@ -25,6 +25,7 @@
<logger name="com.jme3.util.TangentBinormalGenerator" level="ERROR"/>
<!-- Material warnt bei linear-color-space Texturen ohne passenden Parameter bekannt, kein Fehler -->
<logger name="com.jme3.material.Material" level="ERROR"/>
<logger name="de.blight.game.animation.FootIKControl" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>

View File

@@ -0,0 +1,22 @@
package de.blight.game.animation;
/**
* Positions-/Rotations-Versatz für eine blockierende Animation.
*
* TX/TZ: Versatz in Charakter-lokalem Raum (TX=seitlich, TZ=vorwärts relativ zur Blickrichtung).
* TY: Versatz in Welt-Y (hoch/runter).
* RX/RY/RZ: Additiver Rotations-Versatz in Grad (Euler XYZ, relativ zur Startrotation).
*/
public class AnimOffset {
public float tx, ty, tz; // Positions-Versatz (Meter)
public float rx, ry, rz; // Rotations-Versatz (Grad)
public AnimOffset() {}
public AnimOffset(float tx, float ty, float tz,
float rx, float ry, float rz) {
this.tx = tx; this.ty = ty; this.tz = tz;
this.rx = rx; this.ry = ry; this.rz = rz;
}
}

View File

@@ -26,15 +26,8 @@ public class AnimSet {
private Map<String, String> actionMap = new LinkedHashMap<>();
/** Zuletzt im Editor verwendeter Modell-Pfad (relativ zu Assets-Root). Wird beim Öffnen auto-geladen. */
private String previewModelPath = null;
/** Vertikaler Versatz des Visual-Nodes während der jeweiligen Animation (Root-Motion-Ersatz, Fallback). */
private Map<String, Float> sinkMap = new LinkedHashMap<>();
/**
* Pro Aktion konfigurierbarer Anchor-Knochen (z. B. SIT_DOWN → "foot.l", PICK_UP → "hand.r").
* Wenn für eine Aktion ein Eintrag vorhanden ist, wird Bone-Anchoring verwendet:
* der Knochen bleibt auf seiner Welt-Y vor der Animation fixiert.
* Überschreibt sinkMap für diese Aktion.
*/
private Map<String, String> anchorBoneMap = new LinkedHashMap<>();
/** Manueller Positions-/Rotations-Versatz pro Clip-Name. */
private Map<String, AnimOffset> animOffsets = new LinkedHashMap<>();
public List<String> getClips() { return clips; }
public void setClips(List<String> clips) { this.clips = clips; }
@@ -42,10 +35,12 @@ public class AnimSet {
public void setActionMap(Map<String, String> actionMap) { this.actionMap = actionMap; }
public String getPreviewModelPath() { return previewModelPath; }
public void setPreviewModelPath(String previewModelPath) { this.previewModelPath = previewModelPath; }
public Map<String, Float> getSinkMap() { return sinkMap != null ? sinkMap : new LinkedHashMap<>(); }
public void setSinkMap(Map<String, Float> sinkMap) { this.sinkMap = sinkMap; }
public Map<String, String> getAnchorBoneMap() { return anchorBoneMap != null ? anchorBoneMap : new LinkedHashMap<>(); }
public void setAnchorBoneMap(Map<String, String> anchorBoneMap) { this.anchorBoneMap = anchorBoneMap; }
public Map<String, AnimOffset> getAnimOffsets() {
return animOffsets != null ? animOffsets : new LinkedHashMap<>();
}
public void setAnimOffsets(Map<String, AnimOffset> animOffsets) {
this.animOffsets = animOffsets;
}
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
public void save(Path setDir, String setName) throws IOException {

View File

@@ -117,7 +117,6 @@ public class AnimationLibrary extends BaseAppState {
return false;
}
target = snapRootBoneXZ(target, sc.getArmature());
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
@@ -129,6 +128,12 @@ public class AnimationLibrary extends BaseAppState {
/** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
public void applyAllTo(Spatial model) {
if (RetargetingSystem.findSkinningControl(model) == null) return;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
if (ac != null) {
for (AnimClip c : new java.util.ArrayList<>(ac.getAnimClips())) {
ac.removeAnimClip(c);
}
}
int applied = 0;
for (String key : clips.keySet()) {
if (applyTo(key, model)) applied++;
@@ -234,6 +239,9 @@ public class AnimationLibrary extends BaseAppState {
for (String name : ac.getAnimClipsNames()) {
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
if (armature != null) {
animClip = snapRootBoneXZ(animClip, armature);
}
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
@@ -260,15 +268,21 @@ public class AnimationLibrary extends BaseAppState {
return null;
}
// Clips mit Vorwärts-Root-Motion in Local-Y (rennen, gehen, springen):
// Y wird auf 0 eingefroren → kein Drift. Alle anderen Clips: Y bleibt frei (Hinsetzen usw.)
private static final java.util.Set<String> LOCOMOTION_CLIPS = java.util.Set.of(
"running", "walking", "sprinting", "running_jump"
);
/**
* Friert X und Z des "Hüft-Knochens" auf den Wert von Frame 0 ein.
* Y (Höhenachse, JME3 Y-Up) bleibt vollständig erhalten — sit_down / Jump / Bounce laufen korrekt.
* Entfernt Root-Motion aus dem flachsten Bone mit Translation-Track (typischerweise Hips).
* Nur dieser eine Bone wird modifiziert — alle anderen Bones bleiben unverändert.
*
* Strategie: findet die kleinste Tiefe unter allen Joints die einen Translation-Track haben.
* Bei Rigs wo Root (Tiefe 0) selbst Translations hat, wird Root gesnappt.
* Bei Rigs wo Hips (Tiefe 1) die erste Ebene mit Translations ist (Root hat nur Rotation),
* wird Hips gesnappt. So passt der Snap zu beiden Rig-Strukturen.
* Erstellt einen neuen in-memory-Clip; J3O-Dateien bleiben unverändert.
* In diesen Mixamo-Exporten ist die Achsenbelegung des Hips-Bones:
* Local X → seitlich → wird auf 0 eingefroren (kein Seiten-Drift)
* Local Y → vorwärts → wird auf 0 eingefroren für Lauf-Clips (kein Vorwärts-Drift)
* bleibt frei für Sitz/Stand-Clips (leichte Neigungsbewegung)
* Local Z → Höhe → IMMER frei lassen (Charakter-Höhe und Setz-Bewegung erhalten)
*/
public static AnimClip snapRootBoneXZ(AnimClip clip, Armature armature) {
if (clip == null || armature == null) return clip;
@@ -282,7 +296,9 @@ public class AnimationLibrary extends BaseAppState {
minDepth = Math.min(minDepth, jointDepth(j));
}
}
if (minDepth == Integer.MAX_VALUE) return clip; // keine Translation-Tracks vorhanden
if (minDepth == Integer.MAX_VALUE) return clip;
boolean isLocomotion = LOCOMOTION_CLIPS.contains(clip.getName());
List<AnimTrack<?>> newTracks = new ArrayList<>();
boolean modified = false;
@@ -292,20 +308,45 @@ public class AnimationLibrary extends BaseAppState {
continue;
}
Vector3f[] translations = tt.getTranslations();
// Nur den flachsten Bone anfassen alle anderen unverändert lassen
if (translations == null || translations.length == 0 || jointDepth(j) != minDepth) {
newTracks.add(track);
continue;
}
float f0x = translations[0].x;
float f0z = translations[0].z;
// Y-Normalisierung für Nicht-Lauf-Clips: Frame-0 auf Bind-Pose-Y
float bindY = j.getInitialTransform().getTranslation().y;
float frame0Y = translations[0].y;
float yOffset = bindY - frame0Y;
// Diagnostik
float yMin = Float.MAX_VALUE, yMax = -Float.MAX_VALUE;
float xRange = 0, zRange = 0;
for (Vector3f t : translations) {
if (t.y < yMin) yMin = t.y;
if (t.y > yMax) yMax = t.y;
xRange = Math.max(xRange, Math.abs(t.x - translations[0].x));
zRange = Math.max(zRange, Math.abs(t.z - translations[0].z));
}
log.info("[AnimLib] snap '{}' root='{}' locomotion={} bindY={} frame0Y={} yOffset={} yRange={} xRange={} zRange={}",
clip.getName(), j.getName(), isLocomotion,
String.format("%.3f", bindY), String.format("%.3f", frame0Y),
String.format("%.3f", yOffset), String.format("%.3f", yMax - yMin),
String.format("%.3f", xRange), String.format("%.3f", zRange));
Vector3f[] snapped = new Vector3f[translations.length];
for (int i = 0; i < translations.length; i++) {
snapped[i] = new Vector3f(f0x, translations[i].y, f0z);
float newY = isLocomotion
? 0f // Lauf-Clips: Y einfrieren (Vorwärts-Drift weg)
: (translations[i].y + yOffset); // Rest: Y normalisiert (Neigung erhalten)
snapped[i] = new Vector3f(
0f, // X immer 0 (Seiten-Drift weg)
newY,
translations[i].z // Z immer frei (Höhe und Setz-Bewegung erhalten)
);
}
newTracks.add(new TransformTrack(j, tt.getTimes(), snapped, tt.getRotations(), tt.getScales()));
modified = true;
log.info("[AnimLib] '{}': Tiefe-{}-Joint '{}' XZ={},{} eingefroren, Y frei",
clip.getName(), minDepth, j.getName(), f0x, f0z);
}
if (!modified) return clip;
AnimClip result = new AnimClip(clip.getName());

View File

@@ -5,20 +5,25 @@ 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;
import com.jme3.scene.Spatial;
import de.blight.game.animation.AnimOffset;
import de.blight.game.animation.AnimSet;
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;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
public class PlayerInputControl {
@@ -49,17 +54,24 @@ public class PlayerInputControl {
private AnimComposer animComposer;
private String runningClip;
private java.util.Map<String, Float> animSinkMap = java.util.Map.of();
private java.util.Map<String, String> animAnchorBoneMap = java.util.Map.of();
/** Bone-Anchoring: SkinningControl + Referenz-Position vor der Animation (Model-Space, alle Achsen). */
private com.jme3.anim.SkinningControl skinningControl = null;
private Vector3f preAnimAnchorBoneModel = null;
private Vector3f preAnimVisualTranslation = null;
private String currentAnchorBone = null;
private boolean boneAnchorWarnLogged = false;
private int boneAnchorLogFrames = 0;
private int jumpFrames = 0;
private com.jme3.anim.SkinningControl skinningControl = null;
// ── Anim-Offsets ─────────────────────────────────────────────────────────
private Map<String, AnimOffset> animOffsets = new LinkedHashMap<>();
/** Basis-Local-Translation des Visual-Nodes; wird beim Laden des AnimSet einmalig gespeichert. */
private Vector3f visualBaseTranslation = new Vector3f();
private final Vector3f animOffsetCurrent = new Vector3f();
private final Vector3f animOffsetTarget = new Vector3f();
private float animOffsetSpeed = 0f;
// ── Navigation ────────────────────────────────────────────────────────────
private CharacterNavigator navigator = null;
private de.blight.game.navigation.PathFinder navPathFinder = null;
private TerrainChunkState navTerrain = null;
private int jumpFrames = 0;
private int groundGraceFrames = 0;
private double nextTransitionLength = 0.0;
private boolean pickupActive = false;
private float pickupRemaining = 0f;
@@ -69,15 +81,6 @@ public class PlayerInputControl {
private float blockingAnimTotal = 0f;
private Runnable blockingAnimCallback = null;
/**
* Vertikaler Versatz des Visual-Nodes während einer blockierenden Animation
* (Root-Motion-Ersatz: Körper senkt sich beim Setzen, hebt sich beim Aufstehen).
* visualSinkCurrent wird pro Frame interpoliert.
*/
private float visualSinkStart = 0f;
private float visualSinkTarget = 0f;
private float visualSinkCurrent = 0f;
/** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
private boolean turnActive = false;
private float turnRemaining = 0f;
@@ -117,6 +120,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;
@@ -125,21 +139,25 @@ public class PlayerInputControl {
this.runningClip = null;
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
// SinkMap + AnchorBoneMap aus AnimSet laden
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);
animSinkMap = set.getSinkMap();
animAnchorBoneMap = set.getAnchorBoneMap();
} catch (Exception e) {
animSinkMap = java.util.Map.of();
animAnchorBoneMap = java.util.Map.of();
}
}
skinningControl = findSkinningControl(visual);
log.info("[AnimCtx] SkinningControl gefunden: {}, AnchorBoneMap: {}",
skinningControl != null, animAnchorBoneMap);
log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null);
if (visual != null) {
visualBaseTranslation = visual.getLocalTranslation().clone();
}
try {
AnimSet as = AnimSet.load(assetRoot.resolve("animations/sets"), animSetName);
animOffsets = as.getAnimOffsets();
log.info("[AnimCtx] {} Anim-Offset-Einträge geladen.", animOffsets.size());
} catch (Exception e) {
log.warn("[AnimCtx] AnimSet-KF nicht ladbar: {}", e.getMessage());
animOffsets = 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);
navigator.setAnimationContext(animLib, animSetName, assetRoot);
log.info("[AnimCtx] CharacterNavigator initialisiert.");
}
if (animSetName != null) {
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
if (clip != null && tryPlay(clip)) {
@@ -200,31 +218,10 @@ public class PlayerInputControl {
if (duration <= 0f) {
duration = resolveClipLength(action, 1.5f);
}
// Bone-Anchoring: pro Aktion konfigurierten Knochen laden und Referenz-Y einfrieren
currentAnchorBone = animAnchorBoneMap.get(action.name());
if (currentAnchorBone != null && !currentAnchorBone.isBlank()) {
preAnimAnchorBoneModel = getBoneModelPos(currentAnchorBone);
preAnimVisualTranslation = visual != null ? visual.getLocalTranslation().clone() : new Vector3f();
boneAnchorWarnLogged = false;
boneAnchorLogFrames = 0;
log.info("[BoneAnchor] Aktion={} Knochen='{}' preModelY={} (null={})",
action.name(), currentAnchorBone,
preAnimAnchorBoneModel != null ? preAnimAnchorBoneModel.y : Float.NaN,
preAnimAnchorBoneModel == null);
} else {
currentAnchorBone = null;
preAnimAnchorBoneModel = null;
preAnimVisualTranslation = null;
// Fallback: manuellen Sink aus AnimSet-Konfiguration laden
if (animSinkMap.containsKey(action.name())) {
visualSinkTarget = animSinkMap.get(action.name());
}
}
blockingAnimActive = true;
blockingAnimRemaining = duration;
blockingAnimTotal = duration;
blockingAnimCallback = onComplete;
visualSinkStart = visualSinkCurrent;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
@@ -232,14 +229,6 @@ public class PlayerInputControl {
currentAnim = action;
}
/**
* Überschreibt das Sink-Ziel für die nächste {@link #requestAnimation}-Animation manuell.
* Hat Vorrang vor der AnimSet-Konfiguration wenn VOR requestAnimation aufgerufen.
*/
public void setNextAnimationSink(float targetY) {
this.visualSinkTarget = targetY;
}
/** Liefert die Länge des Clips für {@code action} in Sekunden, oder {@code fallback} wenn nicht ermittelbar. */
private float resolveClipLength(AnimationAction action, float fallback) {
if (animComposer == null || animLib == null || animSetName == null) {
@@ -292,6 +281,52 @@ 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) {
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();
return;
}
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
autopilotDir = null;
clearAnimOffset();
if (arriveRadius > 0f) {
navigator.navigateTo(target, speed, arriveRadius, onArrival, onFailed);
} else {
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()}.
@@ -315,8 +350,20 @@ public class PlayerInputControl {
public void update(float tpf) {
if (physicsChar == null) return;
if (visual != null && animOffsetSpeed > 0f) {
Vector3f delta = animOffsetTarget.subtract(animOffsetCurrent);
float dist = delta.length();
float step = animOffsetSpeed * tpf;
if (dist <= step) {
animOffsetCurrent.set(animOffsetTarget);
animOffsetSpeed = 0f;
} else {
animOffsetCurrent.addLocal(delta.normalizeLocal().multLocal(step));
}
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(animOffsetCurrent));
}
if (paused) {
// Autopilot bei Pause sofort beenden
if (autopilotDir != null) {
autopilotDir = null;
physicsChar.setWalkDirection(Vector3f.ZERO);
@@ -324,6 +371,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;
@@ -362,35 +415,13 @@ 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);
// Visuellen Versatz anpassen: Foot-Anchoring hat Vorrang vor manuellem Sink
if (visual != null) {
if (currentAnchorBone != null && preAnimAnchorBoneModel != null) {
// Bone-Anchoring: 3D-Delta im Model-Space messen und als Visual-Offset anwenden.
// Model-Space ist unabhängig vom Visual-Shift → keine Rückkopplung.
applyBoneAnchorOffset(currentAnchorBone);
} else if (blockingAnimTotal > 0f) {
// Fallback: manueller Sink interpoliert
float t = Math.max(0f, Math.min(1f, 1f - blockingAnimRemaining / blockingAnimTotal));
visualSinkCurrent = visualSinkStart + (visualSinkTarget - visualSinkStart) * t;
applyVisualSink();
}
}
if (blockingAnimRemaining <= 0f) {
blockingAnimActive = false;
if (currentAnchorBone != null && preAnimAnchorBoneModel != null) {
// Bone-Anchoring: letzten Kompensationswert einrasten
applyBoneAnchorOffset(currentAnchorBone);
} else {
// Fallback: Zielwert einrasten
visualSinkCurrent = visualSinkTarget;
applyVisualSink();
}
Runnable cb = blockingAnimCallback;
blockingAnimCallback = null;
if (cb != null) cb.run();
@@ -454,9 +485,10 @@ public class PlayerInputControl {
// Animations-Auswahl
if (jumpFrames > 0) jumpFrames--;
if (groundGraceFrames > 0) groundGraceFrames--;
AnimationAction target;
if (jumpFrames > 0 || !physicsChar.onGround()) {
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) {
target = walk ? AnimationAction.WALK
@@ -489,67 +521,6 @@ public class PlayerInputControl {
}
}
/**
* Liefert die aktuelle Welt-Y des angegebenen Joints, oder NaN wenn nicht ermittelbar.
* Liest den Joint aus dem SkinningControl (nach AnimComposer-Update = aktueller Frame).
*/
/**
* Gibt die Position des Joints im Model-Space des Armatures zurück.
* Bewusst KEIN Welt-Transform: sonst entsteht eine Rückkopplung mit dem Visual-Offset,
* weil der Visual-Node-Shift den Welt-Transform des Knochens beeinflusst.
*/
private Vector3f getBoneModelPos(String boneName) {
if (skinningControl == null || boneName == null || boneName.isBlank()) {
return null;
}
com.jme3.anim.Armature armature = skinningControl.getArmature();
if (armature == null) {
return null;
}
com.jme3.anim.Joint joint = armature.getJoint(boneName);
if (joint == null) {
return null;
}
return joint.getModelTransform().getTranslation().clone();
}
/**
* Berechnet den Y-Offset des Anchor-Knochens gegenüber seiner Startposition
* (in Model-Space, keine Rückkopplung mit dem Visual-Shift) und setzt die
* Local-Y des Visual-Nodes so, dass der Knochen vertikal fixiert bleibt.
*
* Nur Y wird kompensiert. X/Z-Drift im Model-Space liegt in einem anderen
* Koordinatensystem als der Visual-Node (Blender-Export-Rotation) und würde
* den Charakter horizontal verschieben — das ist falsch.
*
* Formel: visual.localY = preAnimVisualY + (preAnimBone.y - currentBone.y) * scale
*/
private void applyBoneAnchorOffset(String boneName) {
if (visual == null || preAnimAnchorBoneModel == null || preAnimVisualTranslation == null) {
if (!boneAnchorWarnLogged) {
log.warn("[BoneAnchor] applyBoneAnchorOffset abgebrochen: visual={} preModel={} preVis={}",
visual != null, preAnimAnchorBoneModel, preAnimVisualTranslation);
boneAnchorWarnLogged = true;
}
return;
}
Vector3f current = getBoneModelPos(boneName);
if (current == null) {
if (!boneAnchorWarnLogged) {
log.warn("[BoneAnchor] Knochen '{}' nicht im Armature gefunden (skinningControl={})",
boneName, skinningControl != null);
boneAnchorWarnLogged = true;
}
return;
}
float scale = skinningControl != null && skinningControl.getSpatial() != null
? skinningControl.getSpatial().getWorldScale().y : 1f;
float newY = preAnimVisualTranslation.y + (preAnimAnchorBoneModel.y - current.y) * scale;
visualSinkCurrent = newY;
com.jme3.math.Vector3f t = visual.getLocalTranslation();
visual.setLocalTranslation(t.x, newY, t.z);
}
/** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */
private com.jme3.anim.SkinningControl findSkinningControl(Spatial s) {
if (s == null) {
@@ -570,25 +541,96 @@ public class PlayerInputControl {
return null;
}
private void applyVisualSink() {
if (visual == null) {
return;
private void applyAnimOffset(String clip) {
if (visual == null) return;
AnimOffset off = (clip != null) ? animOffsets.get(clip) : null;
if (off == null) { clearAnimOffset(); return; }
Quaternion facing = visual.getLocalRotation().clone();
Vector3f worldOffset = facing.mult(new Vector3f(off.tx, off.ty, off.tz));
animOffsetTarget.set(worldOffset);
if (animOffsetCurrent.distanceSquared(worldOffset) > 1e-4f) {
animOffsetSpeed = 5.0f;
}
com.jme3.math.Vector3f t = visual.getLocalTranslation();
visual.setLocalTranslation(t.x, visualSinkCurrent, t.z);
log.info("[AnimOffset] Clip '{}' → Ziel ({},{},{})", clip, worldOffset.x, worldOffset.y, worldOffset.z);
}
public void clearAnimOffset() {
log.info("[AnimOffset] clearAnimOffset() → Lerp zu 0, current=({},{},{})", animOffsetCurrent.x, animOffsetCurrent.y, animOffsetCurrent.z);
animOffsetTarget.set(0, 0, 0);
animOffsetSpeed = 5.0f;
}
/**
* Setzt den Visual-Versatz sofort (kein Lerp), ohne den Physik-Körper zu bewegen.
* tx/ty/tz in charakter-lokalem Raum (tz = vorwärts/rückwärts in Blickrichtung).
*/
public void setAnimOffsetInstant(float tx, float ty, float tz) {
if (visual == null) return;
Quaternion facing = visual.getLocalRotation().clone();
Vector3f worldOffset = facing.mult(new Vector3f(tx, ty, tz));
animOffsetTarget.set(worldOffset);
animOffsetCurrent.set(worldOffset);
animOffsetSpeed = 0f;
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(animOffsetCurrent));
log.info("[AnimOffset] Instant: ({},{},{}) → world ({},{},{})", tx, ty, tz, worldOffset.x, worldOffset.y, worldOffset.z);
}
/** Setzt den Visual-Versatz sofort auf 0 zurück (kein Lerp). */
public void clearAnimOffsetInstant() {
animOffsetTarget.set(0, 0, 0);
animOffsetCurrent.set(0, 0, 0);
animOffsetSpeed = 0f;
if (visual != null) {
visual.setLocalTranslation(visualBaseTranslation.clone());
}
log.info("[AnimOffset] Instant-Clear");
}
/** Verhindert für {@code frames} Frames, dass die JUMP-Animation durch kurzes onGround=false nach Teleport ausgelöst wird. */
public void setGroundGrace(int frames) {
groundGraceFrames = frames;
}
/** Setzt eine einmalige Überblend-Zeit für den nächsten Animations-Wechsel (sanfter Skelett-Übergang). */
public void setNextAnimTransition(double seconds) {
nextTransitionLength = seconds;
}
/** Gibt die konfigurierte Bank-Approach-Distanz (|sitting.tz|) zurück; 0.25m als Fallback. */
public float getBenchApproachDist() {
AnimOffset off = animOffsets.get("sitting");
if (off == null) return 0.25f;
return Math.abs(off.tz) > 0.01f ? Math.abs(off.tz) : 0.25f;
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.ensureApplied(clip, visual)) {
log.info("[Anim] tryPlay('{}') → ensureApplied FAILED", clip);
if (animComposer == null || !animLib.applyTo(clip, visual)) {
log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip);
return false;
}
// Optionale Überblend-Zeit (einmalig, wird nach Verwendung auf 0 zurückgesetzt)
double transLen = nextTransitionLength;
nextTransitionLength = 0.0;
if (transLen > 0.0) {
com.jme3.anim.tween.action.Action nextAction = animComposer.action(clip);
if (nextAction instanceof com.jme3.anim.tween.action.BlendableAction) {
((com.jme3.anim.tween.action.BlendableAction) nextAction).setTransitionLength(transLen);
log.info("[Anim] Transition {} → '{}' über {:.2f}s", runningClip, clip, transLen);
}
}
// Erst Action setzen, DANN SkinningControl aktivieren
// vermeidet 1 Frame in Bind-Pose × Armature-Rx90° = liegender Charakter.
com.jme3.anim.tween.action.Action action = animComposer.setCurrentAction(clip);
log.info("[Anim] setCurrentAction('{}') → {}", clip, action != null ? "OK" : "FAILED");
if (action != null) {
runningClip = clip;
return true;
if (action == null) {
return false;
}
return false;
if (skinningControl != null && !skinningControl.isEnabled()) {
skinningControl.setEnabled(true);
log.info("[Anim] SkinningControl aktiviert nach Action '{}'", clip);
}
runningClip = clip;
applyAnimOffset(clip);
return true;
}
}

View File

@@ -0,0 +1,376 @@
package de.blight.game.navigation;
import com.jme3.anim.AnimComposer;
import com.jme3.anim.SkinningControl;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import de.blight.common.model.WorldPoint;
import de.blight.game.animation.AnimationAction;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.animation.RetargetingSystem;
import de.blight.game.state.TerrainChunkState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Universelle Punkt-zu-Punkt-Navigation für beliebige Charaktere mit dem animset "human"
* (Spieler und NPCs gleichermaßen).
*
* <p>Verwendung:
* <pre>
* CharacterNavigator nav = new CharacterNavigator(physicsChar, visual, pathFinder, terrain);
* nav.setAnimationContext(animLib, "human", assetRoot);
*
* nav.navigateTo(ziel, Speed.RUN, this::onAngekommen, this::onAbbruch);
*
* // jeden Frame:
* nav.update(tpf);
* </pre>
*
* <p>Hindernisse werden vom PathFinder (Wegnetz + Raycasting) und dem SteeringHelper
* (Bug-Algorithmus) umgangen. Steile Terrain-Segmente werden durch seitliche Umwege
* ersetzt; ist kein Umweg begehbar, wird das Segment direkt passiert (Physik sorgt
* dann für Abbremsung).
*/
public class CharacterNavigator {
private static final Logger log = LoggerFactory.getLogger(CharacterNavigator.class);
// ── Tuneable ─────────────────────────────────────────────────────────────
/** 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). */
private static final float ROTATE_SPEED = 8f;
/** Geh-Geschwindigkeit (m/frame bei 60fps). */
public static final float WALK_SPEED = 0.035f;
/** Renn-Geschwindigkeit (m/frame bei 60fps). */
public static final float RUN_SPEED = 0.07f;
/** Minimale Bewegung (m/s) unter der ein Charakter als steckengeblieben gilt. */
private static final float STUCK_MIN_SPEED = 0.2f;
/** Zeit (s) mit zu langsamer Bewegung bis zum Abbruch. */
private static final float STUCK_TIMEOUT = 3.5f;
/** Maximale Steigung als tan(α) — 36° entspricht ≈ 0.73. */
private static final float MAX_SLOPE_TAN = (float) Math.tan(Math.toRadians(36));
/** Abtastabstand für Steigungsprüfung entlang eines Pfadsegments (m). */
private static final float SLOPE_STEP = 1.5f;
// ── Abhängigkeiten ────────────────────────────────────────────────────────
public enum Speed { WALK, RUN }
private final CharacterControl physicsChar;
private final Spatial visual;
private final PathFinder pathFinder;
private final TerrainChunkState terrain;
// Animation (optional — ohne Context wird nur bewegt, keine Animation gespielt)
private AnimationLibrary animLib;
private String animSetName;
private Path assetRoot;
private AnimComposer animComposer;
private SkinningControl skinningControl;
private AnimationAction currentAnim = null;
// ── Laufzustand ───────────────────────────────────────────────────────────
private boolean active = false;
private List<WorldPoint> path = new ArrayList<>();
private int pathStep = 0;
private Speed speed = Speed.WALK;
private Runnable onArrival = null;
private Runnable onFailed = null;
/** Für Stuck-Erkennung: Position beim letzten Frame. */
private Vector3f lastPos = null;
private float stuckTimer = 0f;
// ── Konstruktor ───────────────────────────────────────────────────────────
public CharacterNavigator(CharacterControl physicsChar,
Spatial visual,
PathFinder pathFinder,
TerrainChunkState terrain) {
this.physicsChar = physicsChar;
this.visual = visual;
this.pathFinder = pathFinder;
this.terrain = terrain;
}
// ── Animations-Kontext ────────────────────────────────────────────────────
/**
* Setzt den Animations-Kontext. Muss vor dem ersten {@link #navigateTo} aufgerufen
* werden wenn Animationen abgespielt werden sollen; optional für reine Bewegung.
*/
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
this.animLib = animLib;
this.animSetName = animSetName;
this.assetRoot = assetRoot;
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
this.skinningControl = (visual != null) ? RetargetingSystem.findSkinningControl(visual) : null;
this.currentAnim = null;
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Startet die Navigation zu {@code target}. Der Y-Wert des Ziels wird ignoriert
* alle Wegpunkte werden auf die Terrain-Höhe gesampled.
*
* @param target Zielposition (Y wird durch Terrain ersetzt)
* @param speed {@link Speed#WALK} oder {@link Speed#RUN}
* @param onArrival Callback nach Ankunft (darf null sein)
* @param onFailed Callback bei Abbruch durch Steckenbleiben (darf null sein)
*/
public void navigateTo(Vector3f target, Speed speed,
Runnable onArrival, Runnable onFailed) {
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;
this.active = true;
this.stuckTimer = 0f;
this.lastPos = physicsChar.getPhysicsLocation().clone();
this.currentAnim = null;
Vector3f from3 = physicsChar.getPhysicsLocation();
WorldPoint from = new WorldPoint(from3.x, from3.y, from3.z);
WorldPoint to = new WorldPoint(target.x, target.y, target.z);
List<WorldPoint> raw = (pathFinder != null)
? pathFinder.findPath(from, to)
: List.of(to);
path = buildWalkablePath(raw);
pathStep = 0;
log.info("[Navigator] navigateTo ({:.1f},{:.1f}) speed={} waypoints={}",
target.x, target.z, speed, path.size());
}
/** Wie {@link #navigateTo} ohne Fehler-Callback. */
public void navigateTo(Vector3f target, Speed speed, Runnable onArrival) {
navigateTo(target, speed, DEFAULT_ARRIVE_RADIUS, 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 <= arriveRadius * arriveRadius) { arrive(); return; }
// ── Wegpunkte überspringen die bereits passiert sind ──────────────────
while (pathStep < path.size() - 1) {
WorldPoint wp = path.get(pathStep);
float dx = wp.x - pos.x, dz = wp.z - pos.z;
if (dx * dx + dz * dz > WAYPOINT_RADIUS * WAYPOINT_RADIUS) break;
pathStep++;
}
// ── Zum aktuellen Wegpunkt bewegen ────────────────────────────────────
WorldPoint target = path.get(pathStep);
float dx = target.x - pos.x, dz = target.z - pos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.001f) { pathStep++; return; }
Vector3f dir = new Vector3f(dx / len, 0f, dz / len);
float moveSpeed = (speed == Speed.RUN) ? RUN_SPEED : WALK_SPEED;
physicsChar.setWalkDirection(dir.mult(moveSpeed));
rotateVisual(dir, tpf);
playAnim(speed == Speed.RUN ? AnimationAction.RUN : AnimationAction.WALK);
// ── Stuck-Erkennung ───────────────────────────────────────────────────
float movedPerSec = pos.distance(lastPos) / Math.max(tpf, 0.001f);
if (movedPerSec < STUCK_MIN_SPEED) {
stuckTimer += tpf;
if (stuckTimer > STUCK_TIMEOUT) {
log.warn("[Navigator] Stecken erkannt Navigation abgebrochen.");
fail();
return;
}
} else {
stuckTimer = 0f;
}
lastPos = pos.clone();
}
// ── Interna ───────────────────────────────────────────────────────────────
private void arrive() {
active = false;
physicsChar.setWalkDirection(Vector3f.ZERO);
playAnim(AnimationAction.IDLE);
Runnable cb = onArrival;
onArrival = null;
if (cb != null) cb.run();
}
private void fail() {
active = false;
physicsChar.setWalkDirection(Vector3f.ZERO);
playAnim(AnimationAction.IDLE);
Runnable cb = onFailed;
onFailed = null;
if (cb != null) cb.run();
}
private void rotateVisual(Vector3f dir, float tpf) {
if (visual == null) return;
Quaternion target = new Quaternion();
target.lookAt(dir, Vector3f.UNIT_Y);
Quaternion cur = visual.getLocalRotation().clone();
cur.slerp(target, ROTATE_SPEED * tpf);
visual.setLocalRotation(cur);
}
private void playAnim(AnimationAction action) {
if (action == currentAnim) return;
if (animLib == null || animSetName == null || visual == null || animComposer == null) return;
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
if (clip == null) {
clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
}
if (clip == null) return;
if (!animLib.applyTo(clip, visual)) return;
com.jme3.anim.tween.action.Action act = animComposer.setCurrentAction(clip);
if (act == null) return;
if (skinningControl != null && !skinningControl.isEnabled()) {
skinningControl.setEnabled(true);
}
currentAnim = action;
}
// ── Pfad-Aufbereitung ─────────────────────────────────────────────────────
/**
* Bereitet den rohen PathFinder-Pfad auf:
* <ol>
* <li>Y-Koordinaten aller Wegpunkte werden auf Terrain-Höhe gesampled.</li>
* <li>Zu steile Segmente (> {@value #MAX_SLOPE_TAN} tan) werden durch
* seitliche Umwegpunkte ersetzt; ist kein begehbarer Umweg vorhanden,
* bleibt das Segment im Pfad (Physik bremst den Charakter natürlich ab).</li>
* </ol>
*/
private List<WorldPoint> buildWalkablePath(List<WorldPoint> raw) {
List<WorldPoint> snapped = new ArrayList<>(raw.size());
for (WorldPoint wp : raw) {
float y = (terrain != null) ? terrain.getHeightAt(wp.x, wp.z) : wp.y;
snapped.add(new WorldPoint(wp.x, y, wp.z));
}
if (terrain == null || snapped.size() < 2) return snapped;
List<WorldPoint> result = new ArrayList<>();
result.add(snapped.get(0));
for (int i = 0; i < snapped.size() - 1; i++) {
WorldPoint a = snapped.get(i);
WorldPoint b = snapped.get(i + 1);
if (!isSegmentWalkable(a.x, a.z, b.x, b.z)) {
WorldPoint detour = findFlatDetour(a, b);
if (detour != null) {
result.add(detour);
log.debug("[Navigator] Steiles Segment, Umweg: ({:.1f},{:.1f})",
detour.x, detour.z);
}
}
result.add(b);
}
return result;
}
/**
* Prüft ob ein Pfadsegment von (x1,z1) nach (x2,z2) begehbar ist,
* indem die Terrain-Höhe alle {@value #SLOPE_STEP}m abgetastet wird.
*/
private boolean isSegmentWalkable(float x1, float z1, float x2, float z2) {
if (terrain == null) return true;
float dx = x2 - x1, dz = z2 - z1;
float dist = (float) Math.sqrt(dx * dx + dz * dz);
if (dist < 0.1f) return true;
float nx = dx / dist, nz = dz / dist;
float prevH = terrain.getHeightAt(x1, z1);
for (float t = SLOPE_STEP; t < dist; t += SLOPE_STEP) {
float h = terrain.getHeightAt(x1 + nx * t, z1 + nz * t);
float dh = Math.abs(h - prevH);
if (dh / SLOPE_STEP > MAX_SLOPE_TAN) return false;
prevH = h;
}
// letztes Teilstück bis b
float h = terrain.getHeightAt(x2, z2);
float remaining = dist - (float) Math.floor(dist / SLOPE_STEP) * SLOPE_STEP;
if (remaining > 0.01f && Math.abs(h - prevH) / remaining > MAX_SLOPE_TAN) return false;
return true;
}
/**
* Sucht einen begehbaren Umweg um ein zu steiles Segment von {@code a} nach {@code b}.
* Versucht senkrechte Versätze (links und rechts) in 3m-Schritten bis 12m.
*
* @return Umweg-Wegpunkt oder {@code null} wenn keiner gefunden.
*/
private WorldPoint findFlatDetour(WorldPoint a, WorldPoint b) {
float dx = b.x - a.x, dz = b.z - a.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.001f) return null;
float mx = (a.x + b.x) * 0.5f, mz = (a.z + b.z) * 0.5f;
// Senkrechter Einheitsvektor
float perpX = -dz / len, perpZ = dx / len;
for (float side : new float[]{1f, -1f}) {
for (float offset = 3f; offset <= 12f; offset += 3f) {
float cx = mx + perpX * side * offset;
float cz = mz + perpZ * side * offset;
if (isSegmentWalkable(a.x, a.z, cx, cz)
&& isSegmentWalkable(cx, cz, b.x, b.z)) {
float cy = terrain.getHeightAt(cx, cz);
return new WorldPoint(cx, cy, cz);
}
}
}
return null;
}
}

View File

@@ -173,6 +173,15 @@ public class WorldScene extends BaseAppState {
playerInput.setPhysicsCharacter(physicsChar);
playerInput.setVisual(characterVisual != null ? characterVisual : character);
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
try {
de.blight.game.navigation.PathFinder pf = de.blight.game.navigation.PathFinder.load();
pf.setObstacleRoot(app.getRootNode());
playerInput.setNavigationSources(pf, terrainChunkState);
} catch (java.io.IOException e) {
log.warn("[WorldScene] PathFinder nicht ladbar Navigation deaktiviert: {}", e.getMessage());
}
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
thirdPersonCam.setTarget(character);
@@ -307,6 +316,9 @@ public class WorldScene extends BaseAppState {
}
}
playerInput.setAnimationContext(animLib, setName, AnimationLibrary.findAssetRoot());
if (characterVisual != null) {
characterVisual.setCullHint(Spatial.CullHint.Inherit);
}
}
// CharacterControl setzt den Spatial auf den Kapsel-Mittelpunkt: radius=0.4, halfCyl=0.5 → 0.9m über dem Boden.
@@ -319,6 +331,7 @@ public class WorldScene extends BaseAppState {
if (mc != null && mc.getModelPath() != null) {
try {
Spatial loaded = assetManager.loadModel(mc.getModelPath());
stripEmbeddedClips(loaded, mc.getModelPath());
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
// Auf 1.8 m skalieren Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
@@ -327,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
@@ -342,6 +361,9 @@ public class WorldScene extends BaseAppState {
Node rotNode = new Node("charRot");
loaded.setLocalTranslation(0, offsetY, 0);
rotNode.attachChild(loaded);
// Verstecken bis idle-Animation läuft: Armature hat Rx(90°) aus GLB-Import
// → ohne Animation liegt das Mesh (SkinningControl inaktiv oder Bind-Pose unklar).
rotNode.setCullHint(Spatial.CullHint.Always);
Node wrapper = new Node("character");
wrapper.attachChild(rotNode);
@@ -358,6 +380,46 @@ public class WorldScene extends BaseAppState {
return buildCharacter();
}
private static final String[] MODEL_SAVE_ROOTS = {
"blight-assets/src/main/resources",
"blight-assets/bin/main",
"blight-assets/build/resources/main",
"assets",
};
private void stripEmbeddedClips(Spatial model, String modelPath) {
com.jme3.anim.AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(model);
if (ac == null || ac.getAnimClips().isEmpty()) {
log.info("[WorldScene] Keine eingebetteten Clips in '{}' ({})",
modelPath, ac != null ? "AnimComposer leer" : "kein AnimComposer");
return;
}
int count = ac.getAnimClips().size();
log.info("[WorldScene] Entferne {} eingebettete Clips aus '{}'", count, modelPath);
for (com.jme3.anim.AnimClip c : new java.util.ArrayList<>(ac.getAnimClips())) {
ac.removeAnimClip(c);
}
String rel = modelPath.replace('/', java.io.File.separatorChar);
int saved = 0;
for (String root : MODEL_SAVE_ROOTS) {
java.nio.file.Path file = java.nio.file.Paths.get(root).resolve(rel);
if (!java.nio.file.Files.exists(file)) continue;
try {
com.jme3.export.binary.BinaryExporter.getInstance().save(model, file.toFile());
log.info("[WorldScene] Gespeichert ({}): {}", root, file.toAbsolutePath());
saved++;
} catch (Exception e) {
log.warn("[WorldScene] Speichern fehlgeschlagen ({}): {}", file, e.getMessage());
}
}
if (saved == 0) {
log.warn("[WorldScene] Modell nicht gespeichert kein Pfad gefunden für '{}' (CWD={})",
modelPath, java.nio.file.Paths.get(".").toAbsolutePath());
}
assetManager.deleteFromCache(new com.jme3.asset.ModelKey(modelPath));
}
private MainCharacter findMainCharacter() {
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
for (GameCharacter c : CharacterIO.loadAll(charDir)) {

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

@@ -9,6 +9,7 @@ import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.input.controls.MouseButtonTrigger;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.model.Bed;
@@ -16,11 +17,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 +31,43 @@ import java.util.List;
/**
* Steuert die Interaktion des Hauptcharakters mit Betten und Bänken.
*
* <h2>Ablauf Bett</h2>
* <h2>Ablauf Bank</h2>
* <pre>
* Interact-Taste (E)
* → WALKING : walk zu Punkt neben dem Bett (via PathFinder)
* → LIE_ANIM : lie_down Animation (Charakter gleitet in Liegeposition)
* → RESTING : Charakter liegt; alle Eingaben gesperrt
* → GET_UP : Rechtsklick startet lie_up Animation
* → WALKING_BACK : Charakter kehrt zur Ausgangsposition zurück
* E (< 5m)
* → WALKING : CharacterNavigator → Sitzpunkt (umlaufen wenn nötig)
* → PLAY_ANIM : Drehen (Rücken zur Bank) → sit_down_bench
* → RESTING : sitting-Loop; Eingaben gesperrt
* → GET_UP : Rechtsklick → get_up_sitting → IDLE
* </pre>
*
* Bank läuft analog mit sit_down / sit_up und einem 0,5m-Pfeil.
* <h2>Ablauf Bett</h2>
* <pre>
* E (< 6m)
* → WALKING : CharacterNavigator → Anfahrtspunkt
* → PLAY_ANIM : Drehen → lie_down
* → RESTING : lying-Loop; Eingaben gesperrt
* → GET_UP : Rechtsklick → lie_up
* → WALKING_BACK : Rückkehr zur Ausgangsposition
* </pre>
*/
public class WorldInteractableState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldInteractableState.class);
private static final float INTERACT_RANGE = 6f;
private static final float REACH_DIST = 0.35f;
private static final float WALK_TIMEOUT = 12f;
private static final float BENCH_RANGE = 5f;
private static final float BED_RANGE = 6f;
private static final float WALK_TIMEOUT = 12f;
private static final float BENCH_SIT_MOVE_DIST = 0.5f;
// 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;
/** Blickrichtung des Charakters beim Hinsetzen (weg von der Bank); für Positions-Versatz nach Anim. */
private Vector3f currentBenchSitDir = null;
// ── Abhängigkeiten ────────────────────────────────────────────────────────
@@ -59,9 +77,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 +87,22 @@ public class WorldInteractableState extends BaseAppState {
private final List<InteractableEntry> entries = new ArrayList<>();
// ── Zustandsmaschine ─────────────────────────────────────────────────────
// ── Zustandsmaschine ─────────────────────────────────────────────────────
private enum Phase {
IDLE,
WALKING, // Annäherung an Interactable
PLAY_ANIM, // Einlege-/Sitz-Animation läuft
RESTING, // Charakter liegt/sitzt; nur Rechtsklick erlaubt
GET_UP_ANIM, // Aufsteh-Animation läuft
WALKING_BACK // Rückkehr zur Ausgangsposition
WALKING,
PLAY_ANIM,
RESTING,
GET_UP_ANIM,
WALKING_BACK
}
private Phase phase = Phase.IDLE;
private int targetIdx = -1;
private float walkTimer = 0f;
private Phase phase = Phase.IDLE;
private int targetIdx = -1;
private float walkTimer = 0f;
/** Ziel-Weltpunkt, zu dem der Charakter laufen soll (neben dem Objekt). */
private Vector3f approachTarget = null;
/** Position des Charakters vor der Interaktion (für Rückkehr). */
private Vector3f originPos = null;
/** Aktive Pfadliste für Annäherung / Rückkehr. */
private List<WorldPoint> currentPath = new ArrayList<>();
private int pathStep = 0;
private PathFinder pathFinder = null;
/** Sitz-/Liegeposition des aktuell angesteuerten Interactables (für Bypass-Berechnung). */
private Vector3f interactableSitPt = null;
private Vector3f originPos = null;
// ── Eingabe-Mapping ───────────────────────────────────────────────────────
@@ -116,10 +122,6 @@ public class WorldInteractableState extends BaseAppState {
@Override
protected void initialize(Application app) {
this.inputManager = app.getInputManager();
try { pathFinder = PathFinder.load(); }
catch (IOException e) { log.warn("[WorldInteractable] Wegnetz nicht ladbar: {}", e.getMessage()); }
try {
List<PlacedModel> models = PlacedModelIO.load();
for (PlacedModel m : models) {
@@ -154,95 +156,21 @@ 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 -> {}
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();
}
}
}
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,225 +183,239 @@ 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;
if (benchPendingId != null && benchPendingId.equals(entry.interactableId())) {
benchPendingId = null; // gleiche Bank wird gleich wieder disabled
}
phase = Phase.WALKING;
log.info("[WorldInteractable] Annäherung an {} [{}]", entry.type(), entry.interactableId());
log.info("[WorldInteractable] Annäherung {} [{}]", entry.type(), entry.interactableId());
float radius = isBench(entry) ? 0.05f : -1f;
playerInput.navigateTo(target, CharacterNavigator.Speed.WALK, radius,
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;
}
// 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()) {
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);
Vector3f sitDir = new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY));
if (isBench(entry)) {
currentBenchSitDir = sitDir.clone();
}
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
// Sink-Wert kommt aus AnimSet-Konfiguration (Animationseditor)
playerInput.requestAnimation(action, 0f, () -> {
teleportToRestPos(entry);
playerInput.requestAnimation(downAction, 0f, () -> {
if (isBench(entry) && currentBenchSitDir != null) {
Vector3f cur = physicsChar.getPhysicsLocation();
Vector3f delta = currentBenchSitDir.mult(-BENCH_SIT_MOVE_DIST);
physicsChar.setPhysicsLocation(cur.add(delta));
// Sofortiges Spatial-Update verhindert 1-Frame-Kamerazuckeln
Spatial phySpatial = physicsChar.getSpatial();
if (phySpatial != null) {
phySpatial.setLocalTranslation(phySpatial.getLocalTranslation().add(delta));
}
log.info("[WorldInteractable] Charakter 50cm zur Bank verschoben.");
}
if (!isBench(entry)) snapToSitPos(entry);
playerInput.lockInPlace();
if (isBench(entry)) playerInput.setNextAnimTransition(0.2);
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, () -> {
if (isBench(entry)) {
// Nach stand_up_bench: Charakter 50cm von Bank wegbewegen
if (currentBenchSitDir != null) {
Vector3f cur = physicsChar.getPhysicsLocation();
Vector3f delta = currentBenchSitDir.mult(BENCH_SIT_MOVE_DIST);
physicsChar.setPhysicsLocation(cur.add(delta));
Spatial phySpatial = physicsChar.getSpatial();
if (phySpatial != null) {
phySpatial.setLocalTranslation(phySpatial.getLocalTranslation().add(delta));
}
currentBenchSitDir = null;
playerInput.setGroundGrace(4);
playerInput.setNextAnimTransition(0.2);
log.info("[WorldInteractable] Charakter 50cm von Bank wegbewegt.");
}
playerInput.clearAnimOffset();
benchPendingId = entry.interactableId();
benchPendingX = entry.worldX();
benchPendingZ = entry.worldZ();
benchPendingTimer = 0f;
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) return;
if (entry.type() == InteractableType.BED) {
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
if (bed != null && bed.isLiegeSet())
physicsChar.setPhysicsLocation(new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ()));
} else if (entry.type() == InteractableType.BENCH) {
// X/Z aus dem Sitzpunkt, Y bleibt bei der aktuellen Physik-Position (Charakter ist
// bereits auf Bodenhöhe und durch Terrain geerdet — kein Sprung nach oben)
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
if (bench != null && bench.isSitzSet()) {
float currentY = physicsChar.getPhysicsLocation().y;
physicsChar.setPhysicsLocation(new Vector3f(bench.getSitzX(), currentY, bench.getSitzZ()));
}
// ── Abbruch ───────────────────────────────────────────────────────────────
private void cancelInteraction() {
playerInput.stopNavigation();
if (phase == Phase.RESTING || phase == Phase.GET_UP_ANIM) {
playerInput.unlockFromPlace();
}
if (targetIdx >= 0 && targetIdx < entries.size()) {
setTargetPhysicsEnabled(entries.get(targetIdx), true);
}
flushBenchReEnable();
phase = Phase.IDLE;
targetIdx = -1;
}
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 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) {
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);
}
private void cancelInteraction() {
playerInput.setAutopilotDirection(null);
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;
}
}

View File

@@ -2,7 +2,7 @@
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
<level>INFO</level>
</filter>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>