Umbau der Animations Editor Geschichte

This commit is contained in:
2026-08-19 17:40:16 +02:00
parent 4ed0023a5c
commit ec0865d09b
45 changed files with 515 additions and 97 deletions

View File

@@ -202,7 +202,6 @@ public class EditorApp extends Application {
private ListView<String> animSetActionListView;
private String animSetPendingPlayClip = null;
private ComboBox<String> animSetModelCombo;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
// Anim-Offset-Editor (innerhalb AnimSet-Editor)
@@ -215,6 +214,10 @@ public class EditorApp extends Application {
private ListView<String> animSetPartsListView;
private java.util.Map<String, de.blight.game.animation.AnimSet.SubClipDef>
editableSubClips = new java.util.LinkedHashMap<>();
// Sequenz-Editor (innerhalb AnimSet-Editor)
private ListView<String> animSetSequenceListView;
private java.util.Map<String, java.util.List<String>>
editableSequences = new java.util.LinkedHashMap<>();
// Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView;
@@ -1537,7 +1540,7 @@ public class EditorApp extends Application {
MenuItem vegetationsItem = new MenuItem("Vegetations Generator");
MenuItem ezTreeItem = new MenuItem("Baum Generator (EZ Tree)");
MenuItem tripoItem = new MenuItem("AI Modell-Generator (Tripo3D)");
MenuItem animPrevItem = new MenuItem("Character-Editor");
MenuItem animPrevItem = new MenuItem("Animationset Editor");
MenuItem objEditorItem = new MenuItem("Model Editor");
MenuItem thumbItem = new MenuItem("Thumbnail-Verwaltung");
vegetationsItem.setOnAction(e -> switchToVegetationGenerator());
@@ -5542,7 +5545,14 @@ public class EditorApp extends Application {
if (p == null) continue;
try {
Files.deleteIfExists(p);
if (p.toString().endsWith(".j3o")) deleteJ3oSideFiles(p);
if (p.toString().endsWith(".j3o")) {
deleteJ3oSideFiles(p);
String rel = ASSET_ROOT.relativize(p).toString().replace('\\', '/');
if (rel.startsWith("Characters/clips/")) {
String clipName = p.getFileName().toString().replaceFirst("\\.j3o$", "");
removeClipFromAllAnimSets(clipName);
}
}
si.getParent().getChildren().remove(si);
itemPaths.remove(si);
deleted++;
@@ -5769,7 +5779,14 @@ public class EditorApp extends Application {
Files.deleteIfExists(p);
item.getParent().getChildren().remove(item);
itemPaths.remove(item);
if (pStr.endsWith(".j3o")) deleteJ3oSideFiles(p);
if (pStr.endsWith(".j3o")) {
deleteJ3oSideFiles(p);
String rel = ASSET_ROOT.relativize(p).toString().replace('\\', '/');
if (rel.startsWith("Characters/clips/")) {
String clipName = p.getFileName().toString().replaceFirst("\\.j3o$", "");
removeClipFromAllAnimSets(clipName);
}
}
setStatus("Gelöscht: " + p.getFileName());
} catch (IOException ex) {
setStatus("Fehler: " + ex.getMessage());
@@ -6244,6 +6261,41 @@ public class EditorApp extends Application {
} catch (IOException ignored) {}
}
/**
* Entfernt einen Animations-Clip aus allen AnimSet-Dateien in Characters/sets/.
* Wird aufgerufen wenn eine .j3o-Datei aus Characters/clips/ gelöscht wird.
*/
private void removeClipFromAllAnimSets(String clipName) {
Path setsDir = ASSET_ROOT.resolve("Characters").resolve("sets");
if (!Files.isDirectory(setsDir)) return;
try (var walk = Files.walk(setsDir, 1)) {
walk.filter(p -> p.toString().endsWith(".animset.json")).forEach(setFile -> {
try {
String json = Files.readString(setFile);
com.google.gson.JsonObject root = com.google.gson.JsonParser.parseString(json).getAsJsonObject();
com.google.gson.JsonArray clips = root.has("clips") ? root.getAsJsonArray("clips") : null;
if (clips == null) return;
boolean changed = false;
com.google.gson.JsonArray updated = new com.google.gson.JsonArray();
for (com.google.gson.JsonElement el : clips) {
if (el.isJsonPrimitive() && el.getAsString().equals(clipName)) {
changed = true;
} else {
updated.add(el);
}
}
if (!changed) return;
root.add("clips", updated);
String pretty = new com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(root);
Files.writeString(setFile, pretty);
log.info("[AssetTree] Clip '{}' aus AnimSet '{}' entfernt", clipName, setFile.getFileName());
} catch (Exception ex) {
log.warn("[AssetTree] Fehler beim Bereinigen von {}: {}", setFile.getFileName(), ex.getMessage());
}
});
} catch (IOException ignored) {}
}
/**
* Befüllt den Asset-Baum vollständig aus dem Dateisystem.
* Bekannte Verzeichnisse werden den Kategorie-Feldern zugewiesen;
@@ -10364,7 +10416,7 @@ public class EditorApp extends Application {
// Dialog: vorhandenes AnimSet wählen oder neues anlegen
javafx.scene.control.Dialog<String> dlg = new javafx.scene.control.Dialog<>();
dlg.initOwner(primaryStage);
dlg.setTitle("Character-Editor");
dlg.setTitle("Animationset Editor");
dlg.setHeaderText("AnimSet auswählen oder neu anlegen");
ListView<String> listView = new ListView<>();
@@ -10420,7 +10472,6 @@ public class EditorApp extends Application {
animSetCurrentName = setName;
animSetCurrentDir = setDir;
animSetDirty = false;
currentTool = "animseteditor";
topBar.getChildren().set(1, buildAnimSetToolBar(setName));
@@ -10501,7 +10552,7 @@ public class EditorApp extends Application {
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
// Pfad im AnimSet merken und sofort speichern
animSet.setPreviewModelPath(path);
animSetDirty = true;
autoSaveAnimSet();
});
Button importCharBtn = new Button("Character-Modell importieren (FBX)…");
importCharBtn.setMaxWidth(Double.MAX_VALUE);
@@ -10520,7 +10571,14 @@ public class EditorApp extends Application {
animSetClipListView = new ListView<>();
animSetClipListView.getItems().addAll(animSet.getClips());
animSetClipListView.setPrefHeight(180);
editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips());
editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips());
editableSequences = new java.util.LinkedHashMap<>(animSet.getSequences());
// Clip-Offsets direkt in SharedInput spiegeln damit AnimPreviewState sie kennt
input.animPreviewClipOffsets.clear();
for (var en : animSet.getAnimOffsets().entrySet()) {
de.blight.game.animation.AnimOffset o = en.getValue();
input.animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz});
}
Path animRootDir = ASSET_ROOT.resolve("Characters");
@@ -10621,7 +10679,7 @@ public class EditorApp extends Application {
for (String clip : selected) {
if (!animSetClipListView.getItems().contains(clip)) {
animSetClipListView.getItems().add(clip);
animSetDirty = true;
autoSaveAnimSet();
}
}
});
@@ -10635,18 +10693,14 @@ public class EditorApp extends Application {
animSetActionListView.getItems().removeIf(it -> it.endsWith("" + sel));
editableSubClips.entrySet().removeIf(en -> sel.equals(en.getValue().source));
if (animSetPartsListView != null) animSetPartsListView.getItems().clear();
animSetDirty = true;
autoSaveAnimSet();
});
HBox clipBtns = new HBox(6, addClipBtn, removeClipBtn);
HBox.setHgrow(addClipBtn, Priority.ALWAYS);
HBox.setHgrow(removeClipBtn, Priority.ALWAYS);
Button importAnimBtn = new Button("Animation hinzufügen…");
importAnimBtn.setMaxWidth(Double.MAX_VALUE);
importAnimBtn.setOnAction(e -> handleAnimationImport(importAnimBtn.getScene().getWindow()));
inner.getChildren().addAll(animSetClipListView, clipBtns, importAnimBtn);
inner.getChildren().addAll(animSetClipListView, clipBtns);
// ── Clip-Teile (Sub-Clips) ────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clip-Teile"), new Separator());
@@ -10709,7 +10763,7 @@ public class EditorApp extends Application {
String name = sel.substring(0, sel.indexOf(" ["));
editableSubClips.remove(name);
animSetPartsListView.getItems().remove(sel);
animSetDirty = true;
autoSaveAnimSet();
});
HBox partsBtns = new HBox(6, addPartBtn, removePartBtn);
@@ -10739,7 +10793,7 @@ public class EditorApp extends Application {
String sel = animSetActionListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetActionListView.getItems().remove(sel);
animSetDirty = true;
autoSaveAnimSet();
}
});
@@ -10803,7 +10857,7 @@ public class EditorApp extends Application {
animSetOffsetListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetOffsetList.remove(sel);
animSetDirty = true;
autoSaveAnimSet();
}
});
@@ -10812,6 +10866,64 @@ public class EditorApp extends Application {
HBox.setHgrow(removeKfBtn, Priority.ALWAYS);
inner.getChildren().addAll(kfHint, animSetOffsetListView, kfBtns);
// ── Abläufe ───────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Abläufe"), new Separator());
Label seqHint = new Label("Aktionen aus der Zuordnung zu Abläufen zusammenstellen und testen.");
seqHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
seqHint.setWrapText(true);
animSetSequenceListView = new ListView<>();
animSetSequenceListView.setPrefHeight(120);
animSetSequenceListView.setPlaceholder(new Label("Noch keine Abläufe definiert"));
for (var en : editableSequences.entrySet())
animSetSequenceListView.getItems().add(formatSequenceEntry(en.getKey(), en.getValue()));
Button addSeqBtn = new Button("+ Ablauf…");
Button removeSeqBtn = new Button("- Entfernen");
addSeqBtn.setMaxWidth(Double.MAX_VALUE);
removeSeqBtn.setMaxWidth(Double.MAX_VALUE);
removeSeqBtn.setDisable(true);
animSetSequenceListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> {
removeSeqBtn.setDisable(nv == null);
addSeqBtn.setText(nv == null ? "+ Ablauf…" : "Bearbeiten…");
});
animSetSequenceListView.setOnMouseClicked(ev -> {
String sel = animSetSequenceListView.getSelectionModel().getSelectedItem();
if (sel == null) return;
if (ev.getClickCount() == 2) {
playSequence(sel);
}
});
addSeqBtn.setOnAction(e -> {
String sel = animSetSequenceListView.getSelectionModel().getSelectedItem();
if (sel == null) {
showSequenceBuilderDialog(null, null);
} else {
String seqName = extractSequenceName(sel);
showSequenceBuilderDialog(seqName, new java.util.ArrayList<>(
editableSequences.getOrDefault(seqName, java.util.List.of())));
}
});
removeSeqBtn.setOnAction(e -> {
String sel = animSetSequenceListView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String seqName = extractSequenceName(sel);
editableSequences.remove(seqName);
animSetSequenceListView.getItems().remove(sel);
autoSaveAnimSet();
});
HBox seqBtns = new HBox(6, addSeqBtn, removeSeqBtn);
HBox.setHgrow(addSeqBtn, Priority.ALWAYS);
HBox.setHgrow(removeSeqBtn, Priority.ALWAYS);
inner.getChildren().addAll(seqHint, animSetSequenceListView, seqBtns);
// ── Vorschau ─────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
@@ -10851,14 +10963,6 @@ public class EditorApp extends Application {
inner.getChildren().addAll(previewHint, previewBtns, loopCB,
new Label("Tempo:"), speedSlider, animPreviewStatusLabel);
// ── Speichern ─────────────────────────────────────────────────────────
inner.getChildren().add(new Separator());
Button saveBtn = new Button("💾 Speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-background-color:#2e7d32; -fx-text-fill:#fff; -fx-font-weight:bold;");
saveBtn.setOnAction(e -> saveCurrentAnimSet(setName, setDir));
inner.getChildren().add(saveBtn);
ScrollPane scroll = new ScrollPane(inner);
scroll.setFitToWidth(true);
scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
@@ -10929,11 +11033,182 @@ public class EditorApp extends Application {
existing.rz = spRZ.getValue().floatValue();
}
if (animSetOffsetListView != null) animSetOffsetListView.refresh();
animSetDirty = true;
autoSaveAnimSet();
});
}
/** Spielt den Clip der gegebenen Aktion in der Vorschau ab, um animPreviewCurrentClipDuration zu setzen. */
private String formatSequenceEntry(String name, java.util.List<String> actions) {
return name + " (" + actions.size() + " Aktionen: " + String.join("", actions) + ")";
}
private String extractSequenceName(String entry) {
int idx = entry.indexOf(" (");
return idx >= 0 ? entry.substring(0, idx) : entry;
}
private void playSequence(String listEntry) {
String seqName = extractSequenceName(listEntry);
java.util.List<String> actions = editableSequences.get(seqName);
if (actions == null || actions.isEmpty()) return;
java.util.Map<String, String> actionMap = new java.util.LinkedHashMap<>();
if (animSetActionListView != null) {
for (String it : animSetActionListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) actionMap.put(parts[0], parts[1]);
}
}
java.util.List<String> clips = new java.util.ArrayList<>();
for (String action : actions) {
String clip = actionMap.get(action);
if (clip != null) clips.add(clip);
else log.warn("[Sequenz] Keine Clip-Zuordnung für Aktion '{}'", action);
}
if (clips.isEmpty()) {
javafx.scene.control.Alert err = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.WARNING);
err.initOwner(primaryStage);
err.setHeaderText("Keine Clips für Sequenz");
err.setContentText("Die Aktionen des Ablaufs sind keinen Clips zugeordnet.");
err.showAndWait();
return;
}
// Alle Clips vorab laden (wie previewSelectedClip): animImportQueue lädt sie in den AnimComposer.
// Clip[0] wird über animSetPendingPlayClip gestartet sobald er geladen ist.
// Clips[1..n] sind währenddessen schon in der Sequenz-Queue und werden geladen während Clip[0] läuft.
input.animPreviewSequenceQueue.clear();
input.animPreviewLoop = false;
animSetPendingPlayClip = clips.get(0);
input.animImportQueue.offer(findAnimClipPath(clips.get(0)));
for (int i = 1; i < clips.size(); i++) {
input.animPreviewSequenceQueue.add(clips.get(i));
input.animImportQueue.offer(findAnimClipPath(clips.get(i)));
}
if (animPreviewStatusLabel != null)
animPreviewStatusLabel.setText("Lade Sequenz \"" + seqName + "\"");
}
/** Dialog: linke Liste = verfügbare Aktionen, rechte Liste = Ablauf-Reihenfolge. */
private void showSequenceBuilderDialog(String existingName, java.util.List<String> existingActions) {
// Verfügbare Aktionen aus animSetActionListView auslesen
java.util.List<String> availableActions = new java.util.ArrayList<>();
if (animSetActionListView != null) {
for (String it : animSetActionListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) availableActions.add(parts[0]);
}
}
if (availableActions.isEmpty()) {
javafx.scene.control.Alert err = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.WARNING);
err.initOwner(primaryStage);
err.setHeaderText("Keine Aktionen");
err.setContentText("Zuerst in der Aktions-Zuordnung Aktionen einem Clip zuweisen.");
err.showAndWait();
return;
}
// ── Namensfeld ────────────────────────────────────────────────────────
javafx.scene.control.TextField nameFld = new javafx.scene.control.TextField(
existingName != null ? existingName : "");
nameFld.setPromptText("Name des Ablaufs…");
// ── Linke Liste (Aktionen) ────────────────────────────────────────────
ListView<String> leftList = new ListView<>();
leftList.getItems().addAll(availableActions);
leftList.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
leftList.setPrefSize(200, 240);
// ── Rechte Liste (Ablauf) ────────────────────────────────────────────
ListView<String> rightList = new ListView<>();
if (existingActions != null) rightList.getItems().addAll(existingActions);
rightList.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
rightList.setPrefSize(200, 240);
// ── Buttons Mitte ────────────────────────────────────────────────────
Button addBtn = new Button("");
Button removeBtn = new Button("×");
addBtn.setMaxWidth(Double.MAX_VALUE);
removeBtn.setMaxWidth(Double.MAX_VALUE);
addBtn.setStyle("-fx-font-size: 14;");
removeBtn.setStyle("-fx-font-size: 14;");
addBtn.setOnAction(ev -> {
java.util.List<String> sel = new java.util.ArrayList<>(
leftList.getSelectionModel().getSelectedItems());
rightList.getItems().addAll(sel);
});
removeBtn.setOnAction(ev -> {
java.util.List<String> sel = new java.util.ArrayList<>(
rightList.getSelectionModel().getSelectedItems());
rightList.getItems().removeAll(sel);
});
VBox midBtns = new VBox(8, addBtn, removeBtn);
midBtns.setAlignment(javafx.geometry.Pos.CENTER);
// ── Layout ────────────────────────────────────────────────────────────
javafx.scene.layout.GridPane grid = new javafx.scene.layout.GridPane();
grid.setHgap(8);
grid.setVgap(6);
grid.add(new Label("Verfügbare Aktionen:"), 0, 0);
grid.add(new Label("Ablauf:"), 2, 0);
grid.add(leftList, 0, 1);
grid.add(midBtns, 1, 1);
grid.add(rightList, 2, 1);
javafx.scene.layout.ColumnConstraints ccFill = new javafx.scene.layout.ColumnConstraints();
ccFill.setHgrow(Priority.ALWAYS);
javafx.scene.layout.ColumnConstraints ccFixed = new javafx.scene.layout.ColumnConstraints(48);
grid.getColumnConstraints().addAll(ccFill, ccFixed, ccFill);
VBox content = new VBox(8, new Label("Ablauf-Name:"), nameFld, grid);
content.setPadding(new Insets(4));
javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType(
"Speichern", javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> dlg =
new javafx.scene.control.Dialog<>();
dlg.initOwner(primaryStage);
dlg.setTitle(existingName == null ? "Ablauf erstellen" : "Ablauf bearbeiten");
dlg.getDialogPane().setContent(content);
dlg.getDialogPane().setPrefWidth(520);
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
dlg.setResultConverter(bt -> bt);
dlg.showAndWait().ifPresent(bt -> {
if (bt != ok) return;
String name = nameFld.getText().trim();
if (name.isEmpty()) return;
java.util.List<String> actions = new java.util.ArrayList<>(rightList.getItems());
// Alten Eintrag ggf. umbenennen
if (existingName != null && !existingName.equals(name)) {
editableSequences.remove(existingName);
animSetSequenceListView.getItems().removeIf(it -> extractSequenceName(it).equals(existingName));
}
editableSequences.put(name, actions);
// ListView-Eintrag aktualisieren oder neu anlegen
String newEntry = formatSequenceEntry(name, actions);
boolean replaced = false;
if (existingName != null) {
for (int i = 0; i < animSetSequenceListView.getItems().size(); i++) {
if (extractSequenceName(animSetSequenceListView.getItems().get(i)).equals(name)) {
animSetSequenceListView.getItems().set(i, newEntry);
replaced = true;
break;
}
}
}
if (!replaced) animSetSequenceListView.getItems().add(newEntry);
autoSaveAnimSet();
});
}
private void autoPreviewClipForAction(de.blight.game.animation.AnimationAction action) {
if (animSetActionListView == null || action == null) return;
String prefix = action.name() + "";
@@ -11126,10 +11401,15 @@ public class EditorApp extends Application {
animSetPartsListView.getItems().removeIf(s -> s.startsWith(name + " ["));
animSetPartsListView.getItems().add(formatPartEntry(name, def));
}
animSetDirty = true;
autoSaveAnimSet();
});
}
private void autoSaveAnimSet() {
if (animSetCurrentName != null && animSetCurrentDir != null)
saveCurrentAnimSet(animSetCurrentName, animSetCurrentDir);
}
private void saveCurrentAnimSet(String setName, Path setDir) {
if (animSetClipListView == null) {
return;
@@ -11166,6 +11446,13 @@ public class EditorApp extends Application {
}
animSet.setAnimOffsets(offsetFinal);
animSet.setSubClips(new java.util.LinkedHashMap<>(editableSubClips));
animSet.setSequences(new java.util.LinkedHashMap<>(editableSequences));
// Clip-Offset-Map für AnimPreviewState synchronisieren
input.animPreviewClipOffsets.clear();
for (var en : offsetFinal.entrySet()) {
de.blight.game.animation.AnimOffset o = en.getValue();
input.animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz});
}
// Vorschau-Modell-Pfad beibehalten
if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue());
@@ -11180,16 +11467,7 @@ public class EditorApp extends Application {
}
private void createNewAnimSet() {
if (animSetDirty) {
Alert warn = new Alert(Alert.AlertType.CONFIRMATION,
"Das aktuelle AnimSet hat ungespeicherte Änderungen.\nTrotzdem ein neues Set anlegen?",
ButtonType.OK, ButtonType.CANCEL);
warn.initOwner(primaryStage);
warn.setHeaderText("Ungespeicherte Änderungen");
warn.showAndWait().filter(b -> b == ButtonType.OK).ifPresent(b -> doCreateNewAnimSet());
} else {
doCreateNewAnimSet();
}
doCreateNewAnimSet();
}
private void doCreateNewAnimSet() {
@@ -11746,7 +12024,7 @@ public class EditorApp extends Application {
private ToolBar buildCharacterEditorToolBar() {
Button backBtn = new Button("← Welteneditor");
backBtn.setOnAction(e -> switchToWorldEditor());
Label label = new Label("Character Editor");
Label label = new Label("Animationset Editor");
label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;");
ToolBar tb = new ToolBar();
tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label);
@@ -11804,9 +12082,9 @@ public class EditorApp extends Application {
charModelCombo = new ComboBox<>();
charModelCombo.setMaxWidth(Double.MAX_VALUE);
charModelCombo.setPromptText("Modell wählen…");
Path modelBase = ASSET_ROOT.resolve("Models");
if (java.nio.file.Files.isDirectory(modelBase)) {
try (var walk = java.nio.file.Files.walk(modelBase)) {
Path charModelBase = ASSET_ROOT.resolve("Characters").resolve("Models");
if (java.nio.file.Files.isDirectory(charModelBase)) {
try (var walk = java.nio.file.Files.walk(charModelBase)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/'))
.sorted().forEach(charModelCombo.getItems()::add);

View File

@@ -617,6 +617,8 @@ public class SharedInput {
public volatile String animPreviewLoadPath = null;
/** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */
public volatile String animPreviewPlayClip = null;
/** JavaFX → JME3: Sequenz-Warteschlange Clip-Namen werden nacheinander abgespielt (kein Loop). */
public final ConcurrentLinkedQueue<String> animPreviewSequenceQueue = new ConcurrentLinkedQueue<>();
/** JavaFX → JME3: Animation-j3o-Pfad zum Retargeting + Hinzufügen. null = kein Auftrag. */
public final ConcurrentLinkedQueue<String> animImportQueue = new ConcurrentLinkedQueue<>();
/** JavaFX → JME3: Absoluter FBX-Pfad für Character-Modell-Import (nach Models/Chars/). */
@@ -651,6 +653,9 @@ public class SharedInput {
public volatile float animPreviewOffsetTy = 0f;
public volatile float animPreviewOffsetTz = 0f;
public volatile boolean animPreviewOffsetActive = false;
/** JavaFX → JME3: Clip-Name → [tx, ty, tz] für alle Clips mit gesetztem Offset. */
public final java.util.concurrent.ConcurrentHashMap<String, float[]> animPreviewClipOffsets =
new java.util.concurrent.ConcurrentHashMap<>();
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.

View File

@@ -74,6 +74,17 @@ public class AnimPreviewState extends BaseAppState {
private String currentModelPath;
private Action currentAction;
private String currentClipName;
/** Stabiler Ziel-Offset (wird bei Clip-Wechsel sofort gesetzt). */
private final com.jme3.math.Vector3f previewOffsetCurrent = new com.jme3.math.Vector3f();
/** Startposition des laufenden Übergangs (= angezeigte Position beim letzten Offset-Wechsel). */
private final com.jme3.math.Vector3f previewOffsetFrom = new com.jme3.math.Vector3f();
/** Angezeigte Zwischenposition (kein GC-Druck pro Frame). */
private final com.jme3.math.Vector3f previewOffsetDisplay = new com.jme3.math.Vector3f();
/** Bisherige Zielwerte zum Erkennen von Änderungen. */
private float prevOffTx = 0f, prevOffTy = 0f, prevOffTz = 0f;
/** Verstrichene Zeit seit letztem Offset-Wechsel; >= TRANSITION_DURATION = stabil. */
private float transitionElapsed = 0.15f;
private static final float TRANSITION_DURATION = 0.15f;
private Node axesNode;
@@ -146,8 +157,12 @@ public class AnimPreviewState extends BaseAppState {
String playClip = input.animPreviewPlayClip;
if (playClip != null) {
input.animPreviewPlayClip = null;
if (playClip.isEmpty()) stopAll();
else playClip(playClip);
if (playClip.isEmpty()) {
input.animPreviewSequenceQueue.clear();
stopAll();
} else {
playClip(playClip);
}
}
String addAnimPath = input.animImportQueue.poll();
@@ -204,7 +219,11 @@ public class AnimPreviewState extends BaseAppState {
stopAll();
} else if (time >= length - 0.02) {
LOG.trace("[AnimPreview] Loop-Check fired: time={} length={}", time, length);
if (input.animPreviewLoop) {
String nextInSeq = input.animPreviewSequenceQueue.poll();
if (nextInSeq != null) {
LOG.debug("[AnimPreview] Sequenz weiter: '{}'", nextInSeq);
playClip(nextInSeq);
} else if (input.animPreviewLoop) {
currentAction = ac.setCurrentAction(currentClipName);
if (currentAction != null) currentAction.setSpeed(input.animPreviewSpeed);
} else {
@@ -239,14 +258,27 @@ public class AnimPreviewState extends BaseAppState {
axesNode.setLocalTranslation(Vector3f.ZERO);
}
// Anim-Offset-Vorschau: Versatz auf Modell anwenden (TX/TY/TZ in Welt-Koordinaten)
// Anim-Offset-Vorschau: Frame 0 nach Wechsel zeigt alte Position (kein Snap),
// dann linearer Übergang über TRANSITION_DURATION.
if (currentModel != null) {
if (input.animPreviewOffsetActive) {
currentModel.setLocalTranslation(
input.animPreviewOffsetTx, input.animPreviewOffsetTy, input.animPreviewOffsetTz);
} else {
currentModel.setLocalTranslation(Vector3f.ZERO);
float tgtX = input.animPreviewOffsetActive ? input.animPreviewOffsetTx : 0f;
float tgtY = input.animPreviewOffsetActive ? input.animPreviewOffsetTy : 0f;
float tgtZ = input.animPreviewOffsetActive ? input.animPreviewOffsetTz : 0f;
if (tgtX != prevOffTx || tgtY != prevOffTy || tgtZ != prevOffTz) {
float tCurr = Math.min(1f, transitionElapsed / TRANSITION_DURATION);
previewOffsetFrom.set(
previewOffsetFrom.x + tCurr * (previewOffsetCurrent.x - previewOffsetFrom.x),
previewOffsetFrom.y + tCurr * (previewOffsetCurrent.y - previewOffsetFrom.y),
previewOffsetFrom.z + tCurr * (previewOffsetCurrent.z - previewOffsetFrom.z));
previewOffsetCurrent.set(tgtX, tgtY, tgtZ);
transitionElapsed = 0f;
prevOffTx = tgtX; prevOffTy = tgtY; prevOffTz = tgtZ;
}
float t = Math.min(1f, transitionElapsed / TRANSITION_DURATION);
previewOffsetDisplay.set(previewOffsetFrom);
previewOffsetDisplay.interpolateLocal(previewOffsetCurrent, t);
currentModel.setLocalTranslation(previewOffsetDisplay);
transitionElapsed += tpf;
}
previewScene.updateLogicalState(tpf);
@@ -261,6 +293,10 @@ public class AnimPreviewState extends BaseAppState {
currentModel = null;
currentModelPath = null;
currentAction = null;
previewOffsetCurrent.set(0f, 0f, 0f);
previewOffsetFrom.set(0f, 0f, 0f);
prevOffTx = 0f; prevOffTy = 0f; prevOffTz = 0f;
transitionElapsed = TRANSITION_DURATION;
try {
Spatial model = loadFresh(assetPath);
@@ -391,6 +427,19 @@ public class AnimPreviewState extends BaseAppState {
if (currentModel == null) return;
currentAction = null;
currentClipName = null;
// Offset für diesen Clip aus der Map lesen (wird von EditorApp befüllt)
float[] off = input.animPreviewClipOffsets.get(clipName);
if (off != null && off.length >= 3) {
input.animPreviewOffsetTx = off[0];
input.animPreviewOffsetTy = off[1];
input.animPreviewOffsetTz = off[2];
input.animPreviewOffsetActive = true;
} else {
input.animPreviewOffsetTx = 0f;
input.animPreviewOffsetTy = 0f;
input.animPreviewOffsetTz = 0f;
input.animPreviewOffsetActive = false;
}
// SkinningControls auf dem gesamten Modell aktivieren AnimComposer und
// SkinningControl sitzen oft auf verschiedenen Geschwisterknoten.
setSkinningEnabled(currentModel, true);
@@ -587,12 +636,16 @@ public class AnimPreviewState extends BaseAppState {
AnimClip result;
if (!retarget) {
result = clip;
} else {
// Immer erst redirect-by-name wie FbxTestApp: mappt alle passenden Joints,
// droppt nur die wenigen fehlenden ($AssimpFbx$-Varianten die im Model nicht existieren).
} else if (isJ3oSource) {
// j3o-Clips wurden bereits beim FBX-Import normalisiert (in Modell-Raum gespeichert).
// Plain-Retarget wie FbxTestApp.retargetClip: nur Joint-Targets umbiegen, keine Delta-Korrektur.
result = redirectAnimClipByName(clip, dstArm);
} else {
// FBX-Import: Normalisierung in Modell-Raum (Orphan-Fold + Bind-Pose-Delta).
// Das resultierende j3o ist dann mit plain redirectAnimClipByName kompatibel.
result = normalizeAndRetargetClip(clip, dstArm);
if (result == null) {
LOG.warn("[AnimPreview] redirectByName: keine Joints gemappt, versuche Retargeting");
LOG.warn("[AnimPreview] normalizeAndRetarget: keine Joints gemappt, versuche Retargeting");
result = de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm);
}
}
@@ -1567,7 +1620,67 @@ public class AnimPreviewState extends BaseAppState {
}
/** Leitet Clip-Tracks per Gelenk-Name auf die Ziel-Armatur um (FbxTestApp-Ansatz für Same-Rig). */
/**
* Plain Retarget wie FbxTestApp.retargetClip: Joint-Targets per Name auf
* targetArm umbiegen, Keyframe-Werte 1:1 übernehmen. Funktioniert korrekt
* wenn Quell- und Ziel-Armature dieselbe Bind-Pose haben (z.B. j3o-Clips
* die bereits mit normalizeAndRetargetClip in Modell-Raum gespeichert wurden).
*/
private static AnimClip redirectAnimClipByName(AnimClip src, com.jme3.anim.Armature targetArm) {
List<com.jme3.anim.AnimTrack<?>> newTracks = new ArrayList<>();
for (com.jme3.anim.AnimTrack<?> track : src.getTracks()) {
if (!(track instanceof com.jme3.anim.TransformTrack tt)) continue;
if (!(tt.getTarget() instanceof com.jme3.anim.Joint srcJoint)) continue;
com.jme3.anim.Joint dstJoint = targetArm.getJoint(srcJoint.getName());
if (dstJoint == null) continue;
newTracks.add(new com.jme3.anim.TransformTrack(dstJoint, tt.getTimes(),
tt.getTranslations(), tt.getRotations(), tt.getScales()));
}
if (newTracks.isEmpty()) return null;
AnimClip result = new AnimClip(src.getName());
result.setTracks(newTracks.toArray(new com.jme3.anim.AnimTrack[0]));
LOG.debug("[AnimPreview] redirectByName '{}': {} Tracks, length={}", src.getName(), newTracks.size(), result.getLength());
return result;
}
/**
* Normalisiert einen FBX-Animations-Clip in den Modell-Koordinatenraum und
* retargeted ihn auf targetArm. Wird NUR beim FBX-Import (Speichern) verwendet.
*
* Löst das "Without Skin"-Decompositions-Problem: Mixamo "Without Skin"-FBX
* trennt Hips in Base-Pivot (Y≈0.52) + $AssimpFbx$_Translation (Y≈54.2).
* Das Modell ("With Skin") hat Hips auf voller Höhe im Base-Joint. Ohne
* Normalisierung stimmt das IBM nicht → Skinning-Deformation.
*
* Zwei Schritte:
* 1. Orphan-Fold: $AssimpFbx$_Translation-Tracks ohne Modell-Match werden
* in den nächsten Modell-Vorfahren gefaltet (beide Translations addiert).
* 2. Bind-Pose-Delta: Für jeden Track wird die Differenz zwischen
* Animations-Bind-Pose und Modell-Bind-Pose auf alle Keyframes addiert.
*/
private static AnimClip normalizeAndRetargetClip(AnimClip src, com.jme3.anim.Armature targetArm) {
// Pass 1: Orphan-$AssimpFbx$_Translation finden
java.util.Map<String, com.jme3.anim.TransformTrack> orphanByParent = new java.util.HashMap<>();
java.util.Map<String, com.jme3.math.Vector3f> orphanInitT = new java.util.HashMap<>();
for (com.jme3.anim.AnimTrack<?> t : src.getTracks()) {
if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue;
if (!(tt.getTarget() instanceof com.jme3.anim.Joint srcJ)) continue;
if (targetArm.getJoint(srcJ.getName()) != null) continue;
if (!srcJ.getName().contains("$AssimpFbx$_Translation")) continue;
if (tt.getTranslations() == null) continue;
com.jme3.anim.Joint ancestor = srcJ.getParent();
while (ancestor != null && targetArm.getJoint(ancestor.getName()) == null) {
ancestor = ancestor.getParent();
}
if (ancestor == null) continue;
orphanByParent.put(ancestor.getName(), tt);
orphanInitT.put(ancestor.getName(), srcJ.getInitialTransform().getTranslation().clone());
LOG.info("[AnimPreview] Normalisierung: Falte '{}' in '{}'", srcJ.getName(), ancestor.getName());
}
// Pass 2: Tracks umbiegen + Orphan-Fold
// Delta wird NUR angewendet wenn ein Orphan gefaltet wurde (verhindert falschen Offset
// bei Sitz-/Liege-Animationen, deren Hips-initT != Steh-Bindpose).
List<com.jme3.anim.AnimTrack<?>> newTracks = new ArrayList<>();
for (com.jme3.anim.AnimTrack<?> track : src.getTracks()) {
if (!(track instanceof com.jme3.anim.TransformTrack tt)) continue;
@@ -1575,31 +1688,37 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Joint dstJoint = targetArm.getJoint(srcJoint.getName());
if (dstJoint == null) continue;
// Bind-Pose-Delta: "Without Skin"-FBX kann dieselbe Gesamt-Translation
// anders auf den Base-Joint und $AssimpFbx$-Helper aufteilen als "With Skin".
// Alle Translation-Keyframes um die Differenz (dstInit - srcInit) verschieben,
// damit die Modell-Bind-Pose korrekt abgebildet wird.
com.jme3.math.Vector3f[] translations = tt.getTranslations();
if (translations != null && translations.length > 0) {
com.jme3.math.Vector3f srcInitT = srcJoint.getInitialTransform().getTranslation();
com.jme3.math.Vector3f dstInitT = dstJoint.getInitialTransform().getTranslation();
com.jme3.math.Vector3f delta = dstInitT.subtract(srcInitT);
if (delta.lengthSquared() > 1e-6f) {
com.jme3.math.Vector3f[] retargeted = new com.jme3.math.Vector3f[translations.length];
for (int i = 0; i < translations.length; i++) {
retargeted[i] = translations[i].add(delta);
}
translations = retargeted;
}
}
com.jme3.anim.TransformTrack orphan = orphanByParent.get(srcJoint.getName());
com.jme3.math.Vector3f oInit = orphanInitT.get(srcJoint.getName());
com.jme3.math.Vector3f[] oT = orphan != null ? orphan.getTranslations() : null;
com.jme3.math.Vector3f delta = null;
if (orphan != null) {
com.jme3.math.Vector3f srcInitT = srcJoint.getInitialTransform().getTranslation();
com.jme3.math.Vector3f dstInitT = dstJoint.getInitialTransform().getTranslation();
delta = dstInitT.subtract(srcInitT.add(oInit));
LOG.debug("[AnimPreview] Orphan-Delta '{}': srcInit+orphan={} dstInit={} delta={}",
srcJoint.getName(), srcInitT.add(oInit), dstInitT, delta);
}
com.jme3.math.Vector3f[] retargeted = new com.jme3.math.Vector3f[translations.length];
for (int i = 0; i < translations.length; i++) {
com.jme3.math.Vector3f frameT = translations[i].clone();
if (oT != null && i < oT.length) frameT.addLocal(oT[i]);
if (delta != null) frameT.addLocal(delta);
retargeted[i] = frameT;
}
translations = retargeted;
}
newTracks.add(new com.jme3.anim.TransformTrack(dstJoint, tt.getTimes(),
translations, tt.getRotations(), tt.getScales()));
}
if (newTracks.isEmpty()) return null;
AnimClip result = new AnimClip(src.getName());
result.setTracks(newTracks.toArray(new com.jme3.anim.AnimTrack[0]));
LOG.debug("[AnimPreview] redirectByName '{}': {} Tracks, length={}", src.getName(), newTracks.size(), result.getLength());
LOG.debug("[AnimPreview] normalizeAndRetarget '{}': {} Tracks, length={}", src.getName(), newTracks.size(), result.getLength());
return result;
}