diff --git a/blight-assets/src/main/resources/animations/clips/sitting.j3o b/blight-assets/src/main/resources/animations/clips/sitting.j3o index 512e766..80ad1b2 100644 Binary files a/blight-assets/src/main/resources/animations/clips/sitting.j3o and b/blight-assets/src/main/resources/animations/clips/sitting.j3o differ diff --git a/blight-assets/src/main/resources/animations/sets/human.animset.json b/blight-assets/src/main/resources/animations/sets/human.animset.json index e865669..341baf4 100644 --- a/blight-assets/src/main/resources/animations/sets/human.animset.json +++ b/blight-assets/src/main/resources/animations/sets/human.animset.json @@ -6,13 +6,13 @@ "pickup", "running", "running_jump", - "sit_down", "sitting", "sitting_floor", "sprinting", "stand_up", "tpose", - "walking" + "walking", + "sit_down_bench" ], "actionMap": { "DEFAULT": "tpose", @@ -23,11 +23,22 @@ "RUNNING_JUMP": "running_jump", "JUMP": "idle_jump", "PICK_UP": "pickup", - "SIT_DOWN": "sit_down", "SIT_UP": "stand_up", - "SITTING": "sitting" + "SITTING": "sitting", + "SIT_DOWN": "sit_down_bench" }, "previewModelPath": "Models/Chars/mainchar.j3o", - "sinkMap": {}, - "anchorBoneMap": {} + "motionKeyframes": { + "sit_down_bench": [ + { + "time": 0.0, + "tx": 0.0, + "ty": 0.0, + "tz": 0.25, + "rx": 0.0, + "ry": 0.0, + "rz": 0.0 + } + ] + } } \ No newline at end of file diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index faed198..07fb64c 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -152,15 +152,17 @@ public class EditorApp extends Application { // AnimSet-Editor private ListView animSetClipListView; private ListView animSetActionListView; - private ListView animSetSinkListView; - private ListView animSetAnchorBoneListView; private String animSetPendingPlayClip = null; private ComboBox animSetModelCombo; private boolean animSetDirty = false; private String animSetCurrentName = null; private Path animSetCurrentDir = null; - private java.util.List animJointNames = new java.util.ArrayList<>(); - private Label animSetBonesLabel; + // Motion-Keyframe-Editor (innerhalb AnimSet-Editor) + private javafx.scene.control.ListView animSetKfListView; + private javafx.collections.ObservableList animSetKfList = + javafx.collections.FXCollections.observableArrayList(); + private java.util.Map> + animSetMotionKeyframes = 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 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; @@ -8276,7 +8265,25 @@ 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); + // Keyframes des alten Clips sichern + if (ov != null && animSetKfList != null) { + animSetMotionKeyframes.put(ov, new java.util.ArrayList<>(animSetKfList)); + } + // Keyframes des neuen Clips laden + if (animSetKfList != null) { + animSetKfList.clear(); + if (nv != null) { + var kfs = animSetMotionKeyframes.getOrDefault(nv, new java.util.ArrayList<>()); + animSetKfList.setAll(kfs); + // Clip direkt in Vorschau abspielen + input.animPreviewPlayClip = nv; + } + } + // KF-Bereich aktivieren/deaktivieren + if (animSetKfListView != null) animSetKfListView.setDisable(nv == null); + }); addClipBtn.setOnAction(e -> { org.slf4j.Logger _log = org.slf4j.LoggerFactory.getLogger("AnimSetClipScan"); @@ -8395,196 +8402,72 @@ 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()); + // ── Motion Keyframes ────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Motion Keyframes"), 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. Zeit ≤ Clip-Dauer (Clip vorab in Vorschau abspielen)."); + 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()); - } + // Keyframes aus AnimSet laden + animSetMotionKeyframes = new java.util.LinkedHashMap<>(); + for (var entry : animSet.getMotionKeyframes().entrySet()) { + animSetMotionKeyframes.put(entry.getKey(), new java.util.ArrayList<>(entry.getValue())); } + animSetKfList = javafx.collections.FXCollections.observableArrayList(); + animSetKfList.addListener((javafx.collections.ListChangeListener) + change -> updateAnimPreviewKfOffset()); - 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 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;")); - } + // ListView mit Zusammenfassung, Doppelklick öffnet Edit-Dialog + animSetKfListView = new javafx.scene.control.ListView<>(animSetKfList); + animSetKfListView.setPrefHeight(150); + animSetKfListView.setPlaceholder(new Label("Keine Keyframes – [+ Keyframe] zum Hinzufügen")); + animSetKfListView.setCellFactory(lv -> new javafx.scene.control.ListCell<>() { + @Override protected void updateItem(de.blight.game.animation.AnimKeyframe kf, boolean empty) { + super.updateItem(kf, empty); + if (empty || kf == null) { setText(null); return; } + setText(String.format("%.3fs | TX%+.3f TY%+.3f TZ%+.3f | RX%+.1f° RY%+.1f° RZ%+.1f°", + kf.time, kf.tx, kf.ty, kf.tz, kf.rx, kf.ry, kf.rz)); } - ComboBox anchorActionCombo = new ComboBox<>(); - anchorActionCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values()); - javafx.util.Callback, - javafx.scene.control.ListCell> 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 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…"); + }); + animSetKfListView.setOnMouseClicked(ev -> { + if (ev.getClickCount() == 2) { + de.blight.game.animation.AnimKeyframe sel = + animSetKfListView.getSelectionModel().getSelectedItem(); + if (sel != null) showAnimKfDialog(sel); } - - 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 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 + animSetKfListView.setDisable(true); + + Button addKfBtn = new Button("+ Keyframe"); + Button removeKfBtn = new Button("- Entfernen"); + addKfBtn.setMaxWidth(Double.MAX_VALUE); + removeKfBtn.setMaxWidth(Double.MAX_VALUE); + addKfBtn.setDisable(true); + removeKfBtn.setDisable(true); + animSetKfListView.getSelectionModel().selectedItemProperty() + .addListener((obs, ov, nv) -> removeKfBtn.setDisable(nv == null)); + // addKfBtn folgt Clip-Selektion + animSetClipListView.getSelectionModel().selectedItemProperty() + .addListener((obs, ov, nv) -> addKfBtn.setDisable(nv == null)); + + addKfBtn.setOnAction(e -> showAnimKfDialog(null)); + + removeKfBtn.setOnAction(e -> { + de.blight.game.animation.AnimKeyframe sel = + animSetKfListView.getSelectionModel().getSelectedItem(); if (sel != null) { - animSetAnchorBoneListView.getItems().remove(sel); + animSetKfList.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 actionSinkCombo = new ComboBox<>(); - actionSinkCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values()); - javafx.util.Callback, - javafx.scene.control.ListCell> 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 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 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, animSetKfListView, kfBtns); // ── Vorschau ───────────────────────────────────────────────────────── inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator()); @@ -8644,6 +8527,117 @@ public class EditorApp extends Application { return panel; } + /** Öffnet den Keyframe-Dialog (null = Hinzufügen, non-null = Bearbeiten). */ + private void showAnimKfDialog(de.blight.game.animation.AnimKeyframe existing) { + boolean isAdd = (existing == null); + // Zeitlimit: zuletzt gemessene Clip-Dauer aus Preview, sonst 999 + float maxTime = input.animPreviewCurrentClipDuration > 0.01f + ? input.animPreviewCurrentClipDuration : 999.0f; + + float initTime = isAdd + ? (animSetKfList.isEmpty() ? 0f : Math.min(animSetKfList.get(animSetKfList.size() - 1).time + 0.5f, maxTime)) + : existing.time; + 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 spTime = new Spinner<>(0.0, maxTime, initTime, 0.05); + Spinner spTX = new Spinner<>(-10.0, 10.0, initTX, 0.05); + Spinner spTY = new Spinner<>(-10.0, 10.0, initTY, 0.05); + Spinner spTZ = new Spinner<>(-10.0, 10.0, initTZ, 0.05); + Spinner spRX = new Spinner<>(-360.0, 360.0, initRX, 1.0); + Spinner spRY = new Spinner<>(-360.0, 360.0, initRY, 1.0); + Spinner spRZ = new Spinner<>(-360.0, 360.0, initRZ, 1.0); + for (Spinner sp : new Spinner[]{spTime, spTX, spTY, spTZ, spRX, spRY, spRZ}) { + sp.setEditable(true); + sp.setMaxWidth(Double.MAX_VALUE); + } + + String timeLabel = maxTime < 999f + ? String.format("Zeit (0 – %.2fs):", maxTime) + : "Zeit (s):"; + String[][] rows = { + {timeLabel}, {"TX (m):"}, {"TY (m):"}, {"TZ (m):"}, {"RX (°):"}, {"RY (°):"}, {"RZ (°):"} + }; + Spinner[] sps = {spTime, 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 kfDlg = + new javafx.scene.control.Dialog<>(); + kfDlg.setTitle(isAdd ? "Keyframe hinzufügen" : "Keyframe 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; + float t = (float) Math.min(spTime.getValue(), maxTime); + if (isAdd) { + de.blight.game.animation.AnimKeyframe kf = new de.blight.game.animation.AnimKeyframe( + t, + spTX.getValue().floatValue(), spTY.getValue().floatValue(), spTZ.getValue().floatValue(), + spRX.getValue().floatValue(), spRY.getValue().floatValue(), spRZ.getValue().floatValue()); + animSetKfList.add(kf); + } else { + existing.time = t; + 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(); + } + animSetKfList.sort(java.util.Comparator.comparingDouble(k -> k.time)); + if (animSetKfListView != null) animSetKfListView.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 updateAnimPreviewKfOffset() { + if (animSetKfList == null || animSetKfList.isEmpty()) { + input.animPreviewKfTx = 0f; + input.animPreviewKfTy = 0f; + input.animPreviewKfTz = 0f; + input.animPreviewKfActive = false; + } else { + de.blight.game.animation.AnimKeyframe first = animSetKfList.get(0); + for (de.blight.game.animation.AnimKeyframe k : animSetKfList) { + if (k.time < first.time) { first = k; } + } + input.animPreviewKfTx = first.tx; + input.animPreviewKfTy = first.ty; + input.animPreviewKfTz = first.tz; + input.animPreviewKfActive = true; + } + } + private void previewSelectedClip() { if (animSetClipListView == null) return; String clip = animSetClipListView.getSelectionModel().getSelectedItem(); @@ -8749,28 +8743,21 @@ public class EditorApp extends Application { } } animSet.setActionMap(actionMap); - java.util.Map 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) {} - } + // Motion Keyframes: aktuell sichtbare Liste (des selektierten Clips) sichern, dann schreiben + if (animSetClipListView != null) { + String selClip = animSetClipListView.getSelectionModel().getSelectedItem(); + if (selClip != null) { + animSetMotionKeyframes.put(selClip, new java.util.ArrayList<>(animSetKfList)); } } - animSet.setSinkMap(sinkMap); - java.util.Map 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> kfFinal = + new java.util.LinkedHashMap<>(); + for (var kfEntry : animSetMotionKeyframes.entrySet()) { + if (kfEntry.getValue() != null && !kfEntry.getValue().isEmpty()) { + kfFinal.put(kfEntry.getKey(), kfEntry.getValue()); } } - animSet.setAnchorBoneMap(anchorBoneMap); + animSet.setMotionKeyframes(kfFinal); // Vorschau-Modell-Pfad beibehalten if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) { animSet.setPreviewModelPath(animSetModelCombo.getValue()); diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 068c769..bbaa2e3 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -567,9 +567,16 @@ public class SharedInput { /** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */ public final java.util.concurrent.atomic.AtomicReference> 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> 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: Motion-Keyframe-Vorschau-Versatz (erster KF, tx/ty/tz in Metern). */ + public volatile float animPreviewKfTx = 0f; + public volatile float animPreviewKfTy = 0f; + public volatile float animPreviewKfTz = 0f; + public volatile boolean animPreviewKfActive = false; /** * JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells. diff --git a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java index 7b2a90a..6c91327 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java @@ -232,6 +232,16 @@ public class AnimPreviewState extends BaseAppState { axesNode.setLocalTranslation(Vector3f.ZERO); } + // Motion-Keyframe-Vorschau: Versatz auf Modell anwenden (TX/TY/TZ in Welt-Koordinaten) + if (currentModel != null) { + if (input.animPreviewKfActive) { + currentModel.setLocalTranslation( + input.animPreviewKfTx, input.animPreviewKfTy, input.animPreviewKfTz); + } else { + currentModel.setLocalTranslation(Vector3f.ZERO); + } + } + previewScene.updateLogicalState(tpf); previewScene.updateGeometricState(); } @@ -297,13 +307,15 @@ public class AnimPreviewState extends BaseAppState { previewSC != null ? "ok" : "NULL"); } - // Kamera: immer auf Hüfthöhe (0, 1, 0) zielen; Distanz aus BoundingBox + // 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; + previewCamDist = Math.max(ext * 2.8f, 3f) + 2f; } else { - previewCamDist = 3f; + previewCamDist = 5f; } previewTarget.set(0, 1, 0); input.animPreviewZoom = 1.0f; @@ -381,6 +393,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) { diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimKeyframe.java b/blight-game/src/main/java/de/blight/game/animation/AnimKeyframe.java new file mode 100644 index 0000000..5a115e5 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/AnimKeyframe.java @@ -0,0 +1,27 @@ +package de.blight.game.animation; + +/** + * Ein Keyframe für manuellen Positions-/Rotations-Versatz während einer blockierenden 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). + * + * Keyframes einer Aktion werden nach {@code time} sortiert und linear interpoliert. + */ +public class AnimKeyframe { + + public float time; // Sekunden seit Animations-Start + public float tx, ty, tz; // Positions-Versatz (Meter) + public float rx, ry, rz; // Rotations-Versatz (Grad) + + public AnimKeyframe() {} + + public AnimKeyframe(float time, + float tx, float ty, float tz, + float rx, float ry, float rz) { + this.time = time; + this.tx = tx; this.ty = ty; this.tz = tz; + this.rx = rx; this.ry = ry; this.rz = rz; + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java index 57cbad0..b0e1a94 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java @@ -26,15 +26,8 @@ public class AnimSet { private Map 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 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 anchorBoneMap = new LinkedHashMap<>(); + /** Manueller Positions-/Rotations-Versatz: Aktion → sortierte Keyframe-Liste. */ + private Map> motionKeyframes = new LinkedHashMap<>(); public List getClips() { return clips; } public void setClips(List clips) { this.clips = clips; } @@ -42,10 +35,12 @@ public class AnimSet { public void setActionMap(Map actionMap) { this.actionMap = actionMap; } public String getPreviewModelPath() { return previewModelPath; } public void setPreviewModelPath(String previewModelPath) { this.previewModelPath = previewModelPath; } - public Map getSinkMap() { return sinkMap != null ? sinkMap : new LinkedHashMap<>(); } - public void setSinkMap(Map sinkMap) { this.sinkMap = sinkMap; } - public Map getAnchorBoneMap() { return anchorBoneMap != null ? anchorBoneMap : new LinkedHashMap<>(); } - public void setAnchorBoneMap(Map anchorBoneMap) { this.anchorBoneMap = anchorBoneMap; } + public Map> getMotionKeyframes() { + return motionKeyframes != null ? motionKeyframes : new LinkedHashMap<>(); + } + public void setMotionKeyframes(Map> motionKeyframes) { + this.motionKeyframes = motionKeyframes; + } /** Speichert dieses Set als {@code .animset.json} im Verzeichnis {@code setDir}. */ public void save(Path setDir, String setName) throws IOException { diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index 1b61bd2..ebf1ca7 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -49,16 +49,8 @@ public class PlayerInputControl { private AnimComposer animComposer; private String runningClip; - private java.util.Map animSinkMap = java.util.Map.of(); - private java.util.Map 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 com.jme3.anim.SkinningControl skinningControl = null; private int jumpFrames = 0; private boolean pickupActive = false; private float pickupRemaining = 0f; @@ -69,14 +61,13 @@ 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; + /** Manueller Motion-Override: pro Aktion konfigurierbare Keyframe-Liste. */ + private java.util.Map> + motionKeyframesMap = java.util.Map.of(); + private java.util.List + currentMotionKfs = null; + private Vector3f preMotionTranslation = null; + private Quaternion preMotionRotation = null; /** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */ private boolean turnActive = false; @@ -125,21 +116,19 @@ 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 + skinningControl = findSkinningControl(visual); + log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null); if (animSetName != null && assetRoot != null) { try { java.nio.file.Path setDir = assetRoot.resolve("animations").resolve("sets"); de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, animSetName); - animSinkMap = set.getSinkMap(); - animAnchorBoneMap = set.getAnchorBoneMap(); + motionKeyframesMap = set.getMotionKeyframes(); + log.info("[AnimCtx] MotionKeyframes geladen: {} Aktionen: {}", + motionKeyframesMap.size(), motionKeyframesMap.keySet()); } catch (Exception e) { - animSinkMap = java.util.Map.of(); - animAnchorBoneMap = java.util.Map.of(); + motionKeyframesMap = java.util.Map.of(); } } - skinningControl = findSkinningControl(visual); - log.info("[AnimCtx] SkinningControl gefunden: {}, AnchorBoneMap: {}", - skinningControl != null, animAnchorBoneMap); if (animSetName != null) { String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE); if (clip != null && tryPlay(clip)) { @@ -200,31 +189,25 @@ 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); + String kfClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action); + currentMotionKfs = kfClip != null ? motionKeyframesMap.get(kfClip) : null; + if (currentMotionKfs != null && !currentMotionKfs.isEmpty() && visual != null) { + // KF-Werte sind absolute Positionen im Charakter-Lokalraum (nicht additiv) + preMotionTranslation = Vector3f.ZERO; + preMotionRotation = visual.getLocalRotation().clone(); } else { - currentAnchorBone = null; - preAnimAnchorBoneModel = null; - preAnimVisualTranslation = null; - // Fallback: manuellen Sink aus AnimSet-Konfiguration laden - if (animSinkMap.containsKey(action.name())) { - visualSinkTarget = animSinkMap.get(action.name()); + // Keine Keyframes: visuelle Verschiebung aus vorheriger Keyframe-Aktion zurücksetzen + if (visual != null) { + visual.setLocalTranslation(Vector3f.ZERO); } + currentMotionKfs = null; + preMotionTranslation = null; + preMotionRotation = null; } blockingAnimActive = true; blockingAnimRemaining = duration; blockingAnimTotal = duration; blockingAnimCallback = onComplete; - visualSinkStart = visualSinkCurrent; autopilotDir = null; forward = backward = left = right = false; if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); @@ -232,14 +215,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) { @@ -295,10 +270,27 @@ public class PlayerInputControl { /** * Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands. * Nur sinnvoll nach {@link #lockInPlace()}. + * Hat die Aktion Motion Keyframes, wird der erste Keyframe (time=0) als statischer + * Versatz auf den Visual-Node angewendet. */ public void playLockedAnimation(AnimationAction action) { playAction(action); currentAnim = action; + String lockedClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action); + java.util.List kfs = + lockedClip != null ? motionKeyframesMap.get(lockedClip) : null; + log.info("[AnimKF] playLockedAnimation({}) clip='{}' → KFs gefunden: {}", + action, lockedClip, kfs != null ? kfs.size() : "null"); + if (kfs != null && !kfs.isEmpty() && visual != null) { + currentMotionKfs = kfs; + preMotionTranslation = Vector3f.ZERO; + preMotionRotation = visual.getLocalRotation().clone(); + applyMotionKeyframes(0f); + log.info("[AnimKF] Offset angewendet: visual.localTranslation={}", visual.getLocalTranslation()); + currentMotionKfs = null; + preMotionTranslation = null; + preMotionRotation = null; + } } private void registerMappings(KeyBindings kb) { @@ -366,34 +358,22 @@ public class PlayerInputControl { 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(); - } - } + float elapsed = blockingAnimTotal - blockingAnimRemaining; + applyMotionKeyframes(elapsed); if (blockingAnimRemaining <= 0f) { blockingAnimActive = false; - if (currentAnchorBone != null && preAnimAnchorBoneModel != null) { - // Bone-Anchoring: letzten Kompensationswert einrasten - applyBoneAnchorOffset(currentAnchorBone); - } else { - // Fallback: Zielwert einrasten - visualSinkCurrent = visualSinkTarget; - applyVisualSink(); - } + applyMotionKeyframes(blockingAnimTotal); // Endwert einrasten + currentMotionKfs = null; + preMotionTranslation = null; + preMotionRotation = null; Runnable cb = blockingAnimCallback; blockingAnimCallback = null; if (cb != null) cb.run(); + // Kein Ruhezustand nach der Animation → visuellen Versatz zurücksetzen + if (!lockedInPlace && visual != null) { + visual.setLocalTranslation(Vector3f.ZERO); + } } else { return; } @@ -489,67 +469,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,14 +489,59 @@ public class PlayerInputControl { return null; } - private void applyVisualSink() { - if (visual == null) { - return; + /** + * Interpoliert die Motion-Keyframes der laufenden Aktion für den Zeitpunkt {@code time} + * und setzt Translation + Rotation des Visual-Nodes. + * TX/TZ werden im Charakter-lokalen Raum (Rotation zu Animations-Start) angewendet. + * Dabei gilt: positive TX = rechts vom Charakter, positives TZ = vor dem Charakter + * (= weg von der Bank), negatives TZ = hinter den Charakter (= zur Bank hin). + * TY ist Welt-Y. RX/RY/RZ sind additiv zur Startrotation. + */ + private void applyMotionKeyframes(float time) { + if (currentMotionKfs == null || currentMotionKfs.isEmpty()) return; + if (preMotionRotation == null || visual == null) return; + + de.blight.game.animation.AnimKeyframe before = null, after = null; + for (de.blight.game.animation.AnimKeyframe kf : currentMotionKfs) { + if (kf.time <= time) { before = kf; } + else if (after == null) { after = kf; break; } } - com.jme3.math.Vector3f t = visual.getLocalTranslation(); - visual.setLocalTranslation(t.x, visualSinkCurrent, t.z); + + float tx, ty, tz, rx, ry, rz; + if (before == null && after == null) { return; } + else if (before == null) { + tx = after.tx; ty = after.ty; tz = after.tz; + rx = after.rx; ry = after.ry; rz = after.rz; + } else if (after == null) { + tx = before.tx; ty = before.ty; tz = before.tz; + rx = before.rx; ry = before.ry; rz = before.rz; + } else { + float t = Math.max(0f, Math.min(1f, + (time - before.time) / (after.time - before.time))); + tx = lerp(before.tx, after.tx, t); + ty = lerp(before.ty, after.ty, t); + tz = lerp(before.tz, after.tz, t); + rx = lerp(before.rx, after.rx, t); + ry = lerp(before.ry, after.ry, t); + rz = lerp(before.rz, after.rz, t); + } + + // TX/TZ im Charakter-lokalen Raum: preMotionRotation dreht den Offset in Welt-Raum. + // Konvention: local -Z = forward (lookAt-Konvention), also tz negativ = hinter Charakter. + Vector3f localXZ = preMotionRotation.mult(new Vector3f(tx, 0f, tz)); + visual.setLocalTranslation(localXZ.x, ty, localXZ.z); + + // Rotation: additiv zur Startrotation via SLERP der Euler-Offsets + Quaternion rotOffset = new Quaternion(); + rotOffset.fromAngles( + rx * FastMath.DEG_TO_RAD, + ry * FastMath.DEG_TO_RAD, + rz * FastMath.DEG_TO_RAD); + visual.setLocalRotation(preMotionRotation.mult(rotOffset)); } + private static float lerp(float a, float b, float t) { return a + (b - a) * t; } + private boolean tryPlay(String clip) { if (animComposer == null || !animLib.applyTo(clip, visual)) { log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip); diff --git a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java index 7046b21..ca3a048 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java @@ -399,9 +399,8 @@ public class WorldInteractableState extends BaseAppState { AnimationAction idleAction = isBed ? AnimationAction.LYING : AnimationAction.SITTING; // duration=0 → PlayerInputControl ermittelt die echte Clip-Länge automatisch - // Sink-Wert kommt aus AnimSet-Konfiguration (Animationseditor) playerInput.requestAnimation(action, 0f, () -> { - teleportToRestPos(entry); + if (isBed) teleportToRestPos(entry); playerInput.lockInPlace(); playerInput.playLockedAnimation(idleAction); phase = Phase.RESTING; @@ -432,19 +431,10 @@ public class WorldInteractableState extends BaseAppState { } 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())); - } + if (physicsChar == null || entry.type() != InteractableType.BED) return; + Bed bed = BedIO.load(entry.interactableId()).orElse(null); + if (bed != null && bed.isLiegeSet()) { + physicsChar.setPhysicsLocation(new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ())); } }