Compare commits
10 Commits
e669e29096
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 63aa7aa104 | |||
| 6d061cd621 | |||
| cd350a92fa | |||
| b44d583dc3 | |||
| ba0b80f524 | |||
| 79f9cf12a3 | |||
| 914bf6e673 | |||
| b3b943e588 | |||
| d3c6e8ed77 | |||
| 7a3b2b8733 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -208,7 +208,8 @@ public class GrassState extends BaseAppState
|
||||
node.attachChild(geo);
|
||||
}
|
||||
if (node.getChildren().isEmpty()) return;
|
||||
node.setCullHint(Spatial.CullHint.Always); // sichtbar erst wenn LOD0
|
||||
boolean visibleNow = terrainChunkState.getChunkLod(cx, cz) == 0;
|
||||
node.setCullHint(visibleNow ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
|
||||
chunkNodes[idx] = node;
|
||||
grassNode.attachChild(node);
|
||||
}
|
||||
|
||||
@@ -257,6 +257,13 @@ public class TerrainChunkState extends BaseAppState {
|
||||
public void addChunkListener(ChunkListener l) { listeners.add(l); }
|
||||
public void removeChunkListener(ChunkListener l) { listeners.remove(l); }
|
||||
|
||||
/** Aktueller LOD eines Chunks (cx/cz in Chunk-Koordinaten), -1 = nicht geladen/versteckt. */
|
||||
public int getChunkLod(int cx, int cz) {
|
||||
int ci = ChunkTerrainIO.chunkIndex(cx, cz);
|
||||
if (ci < 0 || ci >= TOTAL) return -1;
|
||||
return chunkLod[ci];
|
||||
}
|
||||
|
||||
// ── Chunk-Mesh-Aufbau ─────────────────────────────────────────────────────
|
||||
|
||||
private void rebuildChunkMesh(int cx, int cz, int lod, int[] targetLod) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user