Zwischenstand commited

This commit is contained in:
2026-08-19 07:40:30 +02:00
parent dd32ec65ca
commit 4ed0023a5c
99 changed files with 1258 additions and 291 deletions

View File

@@ -190,7 +190,6 @@ public class EditorApp extends Application {
private ListView<String> animClipListView;
private Label animPreviewStatusLabel;
private ComboBox<String> animPreviewModelCombo;
private ComboBox<String> addAnimComboField;
private double animPrevDragX, animPrevDragY;
// Cache: Menge aller j3o-Pfade die ein Skelett besitzen (null = Scan noch nicht abgeschlossen)
private java.util.Set<String> skeletalModelPaths = null;
@@ -413,6 +412,7 @@ public class EditorApp extends Application {
private TreeItem<String> jmeModelsNode;
private TreeItem<String> jmeTexturesNode;
private TreeItem<String> animationsNode;
private TreeItem<String> charactersNode;
private TreeItem<String> itemsNode;
// ── JavaFX Entry-Point ───────────────────────────────────────────────────
@@ -620,8 +620,8 @@ public class EditorApp extends Application {
}
if (input.animImportCompleted) {
input.animImportCompleted = false;
refreshAddAnimCombo(addAnimComboField);
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
if (charactersNode != null) refreshCategoryNode(charactersNode, shouldPreserveExpansion());
if (animationsNode != null) refreshCategoryNode(animationsNode, shouldPreserveExpansion());
}
String animOp = input.animOpStatus;
@@ -630,6 +630,33 @@ public class EditorApp extends Application {
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animOp);
}
SharedInput.CharModelImportResult charImport = input.charModelImportResult.getAndSet(null);
if (charImport != null) {
if (charImport.error() != null) {
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.ERROR);
alert.initOwner(primaryStage);
alert.setTitle("Character-Import fehlgeschlagen");
alert.setHeaderText("FBX konnte nicht als Character-Modell importiert werden");
alert.setContentText(charImport.error());
alert.showAndWait();
if (charEditorStatusLabel != null)
charEditorStatusLabel.setText("Import fehlgeschlagen");
} else {
String relPath = charImport.relPath();
if (charModelCombo != null && !charModelCombo.getItems().contains(relPath)) {
charModelCombo.getItems().add(relPath);
}
if (charModelCombo != null) charModelCombo.setValue(relPath);
if (charactersNode != null) refreshCategoryNode(charactersNode, shouldPreserveExpansion());
if (animSetModelCombo != null && !animSetModelCombo.getItems().contains(relPath))
animSetModelCombo.getItems().add(relPath);
if (charEditorStatusLabel != null)
charEditorStatusLabel.setText("Modell importiert: " + relPath);
setStatus("Character-Modell importiert: " + relPath);
}
}
String embedStatus = input.animEmbedStatus;
if (embedStatus != null) {
input.animEmbedStatus = null;
@@ -663,9 +690,9 @@ public class EditorApp extends Application {
if (input.refreshAssets) {
input.refreshAssets = false;
boolean pe = shouldPreserveExpansion();
refreshCategoryNode(modelsNode, pe);
refreshCategoryNode(animationsNode, pe);
refreshAddAnimCombo(addAnimComboField);
if (modelsNode != null) refreshCategoryNode(modelsNode, pe);
if (animationsNode != null) refreshCategoryNode(animationsNode, pe);
if (charactersNode != null) refreshCategoryNode(charactersNode, pe);
}
if (input.refreshTreeFolders) {
@@ -1494,26 +1521,29 @@ public class EditorApp extends Application {
MenuItem importTexSetItem = new MenuItem("Textur-Set importieren…");
MenuItem importAudioItem = new MenuItem("Audio…");
MenuItem importAnimItem = new MenuItem("Animationen…");
importModelLodItem.setOnAction(e -> openModelImport(primaryStage));
importTexItem.setOnAction(e -> handleTextureImport(primaryStage));
importTexZipItem.setOnAction(e -> handleZipTextureImport(primaryStage));
importTexSetItem.setOnAction(e -> openTextureSetImport(primaryStage));
importAudioItem.setOnAction(e -> handleAudioImport(primaryStage));
importAnimItem.setOnAction(e -> handleAnimationImport(primaryStage));
MenuItem importCharModelItem = new MenuItem("Character-Modell (FBX)…");
importModelLodItem.setOnAction(e -> openModelImport(primaryStage));
importTexItem.setOnAction(e -> handleTextureImport(primaryStage));
importTexZipItem.setOnAction(e -> handleZipTextureImport(primaryStage));
importTexSetItem.setOnAction(e -> openTextureSetImport(primaryStage));
importAudioItem.setOnAction(e -> handleAudioImport(primaryStage));
importAnimItem.setOnAction(e -> handleAnimationImport(primaryStage));
importCharModelItem.setOnAction(e -> handleCharacterModelImport(primaryStage));
importMenu.getItems().addAll(importModelLodItem, importTexItem, importTexZipItem,
importTexSetItem, importAudioItem, importAnimItem);
importTexSetItem, importAudioItem, importAnimItem,
new javafx.scene.control.SeparatorMenuItem(), importCharModelItem);
Menu toolsMenu = new Menu("Werkzeuge");
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("Animationseditor");
MenuItem animPrevItem = new MenuItem("Character-Editor");
MenuItem objEditorItem = new MenuItem("Model Editor");
MenuItem thumbItem = new MenuItem("Thumbnail-Verwaltung");
vegetationsItem.setOnAction(e -> switchToVegetationGenerator());
ezTreeItem.setOnAction(e -> switchToEzTree());
tripoItem.setOnAction(e -> switchToTripo());
animPrevItem.setOnAction(e -> switchToAnimPreview());
animPrevItem.setOnAction(e -> openCharacterEditor());
objEditorItem.setOnAction(e -> openModelEditor(null, null));
thumbItem.setOnAction(e -> switchToThumbnailManager());
toolsMenu.getItems().addAll(vegetationsItem, ezTreeItem, tripoItem,
@@ -3410,7 +3440,7 @@ public class EditorApp extends Application {
ComboBox<String> animCombo = new ComboBox<>();
animCombo.setMaxWidth(Double.MAX_VALUE);
animCombo.getItems().add("(keine)");
Path animDir = ASSET_ROOT.resolve("animations");
Path animDir = ASSET_ROOT.resolve("Characters").resolve("clips");
if (java.nio.file.Files.isDirectory(animDir)) {
try (java.util.stream.Stream<Path> walk = java.nio.file.Files.walk(animDir)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
@@ -5069,6 +5099,7 @@ public class EditorApp extends Application {
assetTree = tree;
tree.setShowRoot(false);
VBox.setVgrow(tree, Priority.ALWAYS);
tree.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
tree.setCellFactory(tv -> buildAssetCell());
// F2 → Umbenennen
@@ -5076,7 +5107,8 @@ public class EditorApp extends Application {
if (e.getCode() != KeyCode.F2) return;
TreeItem<String> sel = tree.getSelectionModel().getSelectedItem();
if (sel == null || sel == modelsNode || sel == texturesNode
|| sel == audioNode || sel == animationsNode || sel == itemsNode) return;
|| sel == audioNode || sel == animationsNode || sel == itemsNode
|| sel == charactersNode) return;
renameAsset(sel);
e.consume();
});
@@ -5116,9 +5148,9 @@ public class EditorApp extends Application {
boolean isSkeletal = skeletalModelPaths == null /* noch nicht gescannt zulassen */
|| skeletalModelPaths.contains(relPath);
// Im Animationseditor: j3o aus Models/ oder animations/ direkt laden
// Im Animations Viewer: j3o direkt laden
if ("animpreview".equals(currentTool)
&& (cat == modelsNode || cat == animationsNode)
&& (cat == modelsNode || cat == animationsNode || cat == charactersNode)
&& relPath.endsWith(".j3o")) {
if (!isSkeletal) { setStatus("Kein Skelett: " + relPath); return; }
input.animPreviewLoadPath = relPath;
@@ -5148,7 +5180,7 @@ public class EditorApp extends Application {
if (objPlaceBtn != null) objPlaceBtn.setSelected(true);
root.setRight(buildObjectPlacePanel());
setStatus("Textur: " + relPath + " | Wird auf platzierte Primitive angewendet");
} else if (cat == animationsNode && relPath.endsWith(".j3o") && isSkeletal) {
} else if ((cat == animationsNode || cat == charactersNode) && relPath.endsWith(".j3o") && isSkeletal) {
switchToAnimPreview();
input.animPreviewLoadPath = relPath;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
@@ -5179,7 +5211,10 @@ public class EditorApp extends Application {
miTexZip.setOnAction(ev -> handleZipTextureImport(importBtn.getScene().getWindow()));
miAudio.setOnAction(ev -> handleAudioImport(importBtn.getScene().getWindow()));
miAnim.setOnAction(ev -> handleAnimationImport(importBtn.getScene().getWindow()));
popup.getItems().addAll(miModel, miTex, miTexZip, miAudio, miAnim);
MenuItem miCharModel = new MenuItem("Character-Modell (FBX)…");
miCharModel.setOnAction(ev -> handleCharacterModelImport(importBtn.getScene().getWindow()));
popup.getItems().addAll(miModel, miTex, miTexZip, miAudio, miAnim,
new javafx.scene.control.SeparatorMenuItem(), miCharModel);
popup.show(importBtn, javafx.geometry.Side.TOP, 0, 0);
});
@@ -5396,7 +5431,7 @@ public class EditorApp extends Application {
if (jmePaths.containsKey(item) || jmeFolderNodes.contains(item)) return;
// Kategorie-Wurzeln (Models, Textures, …) nicht verschiebbar
if (item == modelsNode || item == texturesNode || item == audioNode
|| item == animationsNode || item == itemsNode) return;
|| item == animationsNode || item == itemsNode || item == charactersNode) return;
draggedItem = item;
Dragboard db = cell.startDragAndDrop(TransferMode.MOVE);
ClipboardContent cc = new ClipboardContent();
@@ -5477,6 +5512,53 @@ public class EditorApp extends Application {
if (jmeFolderNodes.contains(item) || jmePaths.containsKey(item)) return;
ContextMenu ctx = new ContextMenu();
// ── Multi-Select: Auswahl löschen ────────────────────────────────
var selectedItems = assetTree.getSelectionModel().getSelectedItems();
java.util.List<TreeItem<String>> deletableSelection = selectedItems.stream()
.filter(si -> si != null && !isAssetFolder(si)
&& itemPaths.containsKey(si)
&& !jmePaths.containsKey(si)
&& !jmeFolderNodes.contains(si))
.collect(java.util.stream.Collectors.toList());
if (deletableSelection.size() > 1) {
MenuItem multiDel = new MenuItem("🗑 Auswahl löschen (" + deletableSelection.size() + " Dateien)");
multiDel.setStyle("-fx-text-fill: #c0392b; -fx-font-weight: bold;");
multiDel.setOnAction(ev -> {
StringBuilder names = new StringBuilder();
for (int i = 0; i < Math.min(5, deletableSelection.size()); i++) {
names.append(" ").append(deletableSelection.get(i).getValue()).append('\n');
}
if (deletableSelection.size() > 5)
names.append(" … und ").append(deletableSelection.size() - 5).append(" weitere");
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION,
deletableSelection.size() + " Dateien löschen?\n\n" + names,
ButtonType.OK, ButtonType.CANCEL);
confirm.initOwner(primaryStage);
confirm.setHeaderText(null);
confirm.showAndWait().filter(b -> b == ButtonType.OK).ifPresent(b -> {
int deleted = 0;
for (TreeItem<String> si : new java.util.ArrayList<>(deletableSelection)) {
Path p = itemPaths.get(si);
if (p == null) continue;
try {
Files.deleteIfExists(p);
if (p.toString().endsWith(".j3o")) deleteJ3oSideFiles(p);
si.getParent().getChildren().remove(si);
itemPaths.remove(si);
deleted++;
} catch (IOException ex) {
setStatus("Fehler beim Löschen: " + p.getFileName());
}
}
setStatus(deleted + " Datei(en) gelöscht");
});
});
ctx.getItems().addAll(multiDel, new SeparatorMenuItem());
ctx.show(cell, e.getScreenX(), e.getScreenY());
e.consume();
return;
}
// Texture-Set-Knoten: eigenes Menü
if (textureSetNodes.contains(item)) {
Path setDir = itemPaths.get(item);
@@ -5527,7 +5609,8 @@ public class EditorApp extends Application {
ctx.getItems().add(newSub);
boolean isCatRoot = (item == modelsNode || item == texturesNode
|| item == audioNode || item == animationsNode || item == itemsNode);
|| item == audioNode || item == animationsNode || item == itemsNode
|| item == charactersNode);
Path dir = itemPaths.get(item);
// Ordner unter Models: Zufällig-Platzieren-Eintrag
@@ -5554,7 +5637,15 @@ public class EditorApp extends Application {
ctx.getItems().add(importAnim);
}
Path setsDir = ASSET_ROOT.resolve("animations").resolve("sets");
if (item == charactersNode) {
MenuItem importChar = new MenuItem("⊕ Character-Modell importieren (FBX)…");
importChar.setOnAction(ev -> handleCharacterModelImport(ctx.getOwnerWindow()));
MenuItem importAnim = new MenuItem("⊕ Animation hinzufügen…");
importAnim.setOnAction(ev -> handleAnimationImport(ctx.getOwnerWindow()));
ctx.getItems().addAll(importChar, importAnim);
}
Path setsDir = ASSET_ROOT.resolve("Characters").resolve("sets");
if (dir != null && setsDir.equals(dir)) {
MenuItem newSet = new MenuItem(" Neues Set…");
newSet.setOnAction(ev -> createNewAnimSet());
@@ -5714,11 +5805,11 @@ public class EditorApp extends Application {
return p != null && Files.isDirectory(p);
}
/** Findet die übergeordnete Kategorie (modelsNode / texturesNode / audioNode / animationsNode / itemsNode). */
/** Findet die übergeordnete Kategorie (modelsNode / texturesNode / audioNode / animationsNode / itemsNode / charactersNode). */
private TreeItem<String> getCategoryRoot(TreeItem<String> item) {
for (TreeItem<String> cur = item; cur != null; cur = cur.getParent())
if (cur == modelsNode || cur == texturesNode || cur == audioNode
|| cur == animationsNode || cur == itemsNode) return cur;
|| cur == animationsNode || cur == itemsNode || cur == charactersNode) return cur;
return null;
}
@@ -6161,6 +6252,7 @@ public class EditorApp extends Application {
private void populateAssetTree(TreeItem<String> root) {
modelsNode = null; texturesNode = null;
audioNode = null; animationsNode = null; itemsNode = null;
charactersNode = null;
jmeModelsNode = null; jmeTexturesNode = null;
jmeFolderNodes.clear();
textureSetNodes.clear();
@@ -6203,7 +6295,8 @@ public class EditorApp extends Application {
animationsNode = node;
addAnimClipSubNodes(node);
}
case "items" -> itemsNode = node;
case "characters" -> charactersNode = node;
case "items" -> itemsNode = node;
}
}
}
@@ -6279,7 +6372,7 @@ public class EditorApp extends Application {
private void clearItemPathsFor(TreeItem<String> item) {
for (TreeItem<String> child : item.getChildren()) clearItemPathsFor(child);
if (item != modelsNode && item != texturesNode && item != audioNode && item != animationsNode)
if (item != modelsNode && item != texturesNode && item != audioNode && item != animationsNode && item != charactersNode)
itemPaths.remove(item);
textureSetNodes.remove(item);
}
@@ -10256,6 +10349,63 @@ public class EditorApp extends Application {
// ── AnimSet-Editor ────────────────────────────────────────────────────────
private void openCharacterEditor() {
Path setsDir = ASSET_ROOT.resolve("Characters").resolve("sets");
java.util.List<String> available = new java.util.ArrayList<>();
if (java.nio.file.Files.isDirectory(setsDir)) {
try (var walk = java.nio.file.Files.walk(setsDir, 1)) {
walk.filter(p -> p.toString().endsWith(".animset.json"))
.map(p -> p.getFileName().toString().replaceFirst("\\.animset\\.json$", ""))
.sorted()
.forEach(available::add);
} catch (IOException ignored) {}
}
// 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.setHeaderText("AnimSet auswählen oder neu anlegen");
ListView<String> listView = new ListView<>();
listView.getItems().addAll(available);
listView.setPrefHeight(200);
listView.setPrefWidth(320);
ButtonType openType = new ButtonType("Öffnen", javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
ButtonType newType = new ButtonType("Neu…", javafx.scene.control.ButtonBar.ButtonData.OTHER);
ButtonType cancelType = ButtonType.CANCEL;
dlg.getDialogPane().getButtonTypes().addAll(openType, newType, cancelType);
javafx.scene.Node openNode = dlg.getDialogPane().lookupButton(openType);
openNode.setDisable(true);
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> openNode.setDisable(nv == null));
listView.setOnMouseClicked(e -> {
if (e.getClickCount() == 2 && listView.getSelectionModel().getSelectedItem() != null) {
dlg.setResult(listView.getSelectionModel().getSelectedItem());
dlg.close();
}
});
dlg.getDialogPane().setContent(listView);
dlg.setResultConverter(bt -> {
if (bt == openType) return listView.getSelectionModel().getSelectedItem();
if (bt == newType) return "__NEW__";
return null;
});
dlg.showAndWait().ifPresent(result -> {
if ("__NEW__".equals(result)) {
createNewAnimSet();
} else if (result != null) {
Path setFile = setsDir.resolve(result + ".animset.json");
String relPath = ASSET_ROOT.relativize(setFile).toString().replace('\\', '/');
openAnimSetEditor(relPath, setFile);
}
});
}
private void openAnimSetEditor(String relPath, Path absPath) {
String setName = absPath.getFileName().toString().replaceFirst("\\.animset\\.json$", "");
Path setDir = absPath.getParent();
@@ -10327,15 +10477,16 @@ public class EditorApp extends Application {
animSetModelCombo = new ComboBox<>();
animSetModelCombo.setMaxWidth(Double.MAX_VALUE);
animSetModelCombo.setPromptText("j3o-Datei wählen…");
for (String dir : new String[]{"Models", "animations"}) {
Path base = ASSET_ROOT.resolve(dir);
if (!java.nio.file.Files.isDirectory(base)) continue;
try (var walk = java.nio.file.Files.walk(base)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/'))
.sorted()
.forEach(animSetModelCombo.getItems()::add);
} catch (IOException ignored) {}
{
Path base = ASSET_ROOT.resolve("Characters").resolve("Models");
if (java.nio.file.Files.isDirectory(base)) {
try (var walk = java.nio.file.Files.walk(base)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/'))
.sorted()
.forEach(animSetModelCombo.getItems()::add);
} catch (IOException ignored) {}
}
}
// Gespeicherten Modell-Pfad vorauswählen
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
@@ -10352,7 +10503,10 @@ public class EditorApp extends Application {
animSet.setPreviewModelPath(path);
animSetDirty = true;
});
inner.getChildren().addAll(animSetModelCombo, loadModelBtn);
Button importCharBtn = new Button("Character-Modell importieren (FBX)…");
importCharBtn.setMaxWidth(Double.MAX_VALUE);
importCharBtn.setOnAction(e -> handleCharacterModelImport(importCharBtn.getScene().getWindow()));
inner.getChildren().addAll(animSetModelCombo, loadModelBtn, importCharBtn);
// Modell beim Öffnen automatisch laden, wenn Pfad bekannt
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
@@ -10368,7 +10522,7 @@ public class EditorApp extends Application {
animSetClipListView.setPrefHeight(180);
editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips());
Path animRootDir = ASSET_ROOT.resolve("animations");
Path animRootDir = ASSET_ROOT.resolve("Characters");
Button addClipBtn = new Button("+ Hinzufügen…");
Button removeClipBtn = new Button("- Entfernen");
@@ -10411,9 +10565,10 @@ public class EditorApp extends Application {
String name = cp.getFileName().toString();
boolean isAnim = name.endsWith(".j3o") || name.endsWith(".glb")
|| name.endsWith(".gltf") || name.endsWith(".fbx");
boolean notSets = !animRootDir.relativize(cp).startsWith("sets");
_log.debug("[ClipScan] {} | isAnim={} notSets={}", cp, isAnim, notSets);
return isAnim && notSets;
java.nio.file.Path rel = animRootDir.relativize(cp);
boolean onlyClips = rel.startsWith("clips");
_log.debug("[ClipScan] {} | isAnim={} onlyClips={}", cp, isAnim, onlyClips);
return isAnim && onlyClips;
})
.map(cp -> cp.getFileName().toString().replaceFirst("\\.[^.]+$", ""))
.distinct()
@@ -10486,7 +10641,12 @@ public class EditorApp extends Application {
HBox clipBtns = new HBox(6, addClipBtn, removeClipBtn);
HBox.setHgrow(addClipBtn, Priority.ALWAYS);
HBox.setHgrow(removeClipBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetClipListView, clipBtns);
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);
// ── Clip-Teile (Sub-Clips) ────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clip-Teile"), new Separator());
@@ -10818,14 +10978,14 @@ public class EditorApp extends Application {
}
private String findAnimClipPath(String clipName) {
Path animDir = ASSET_ROOT.resolve("animations");
Path charDir = ASSET_ROOT.resolve("Characters");
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (java.nio.file.Files.exists(animDir.resolve("clips").resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
if (java.nio.file.Files.exists(animDir.resolve(clipName + ext)))
return "animations/" + clipName + ext;
if (java.nio.file.Files.exists(charDir.resolve("clips").resolve(clipName + ext)))
return "Characters/clips/" + clipName + ext;
if (java.nio.file.Files.exists(charDir.resolve(clipName + ext)))
return "Characters/" + clipName + ext;
}
return "animations/clips/" + clipName + ".j3o";
return "Characters/clips/" + clipName + ".j3o";
}
private void showAddActionToSetDialog() {
@@ -11041,12 +11201,12 @@ public class EditorApp extends Application {
dlg.showAndWait().ifPresent(raw -> {
String name = raw.trim();
if (name.isBlank()) return;
Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
Path setFile = setDir.resolve(name + ".animset.json");
try {
Files.createDirectories(setDir);
new de.blight.game.animation.AnimSet().save(setDir, name);
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
refreshCategoryNode(charactersNode, shouldPreserveExpansion());
String relPath = ASSET_ROOT.relativize(setFile).toString().replace('\\', '/');
openAnimSetEditor(relPath, setFile);
} catch (IOException ex) {
@@ -11058,7 +11218,7 @@ public class EditorApp extends Application {
private ToolBar buildAnimPreviewToolBar() {
Button backBtn = new Button("← Welteneditor");
backBtn.setOnAction(e -> switchToWorldEditor());
Label label = new Label("Animationseditor");
Label label = new Label("Animations Viewer");
label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;");
ToolBar tb = new ToolBar();
tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label);
@@ -11069,39 +11229,8 @@ public class EditorApp extends Application {
VBox inner = new VBox(8);
inner.setPadding(new Insets(10));
// ── Modell-Auswahl ────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Modell"), new Separator());
animPreviewModelCombo = new ComboBox<>();
animPreviewModelCombo.setMaxWidth(Double.MAX_VALUE);
animPreviewModelCombo.setPromptText("j3o-Datei wählen…");
for (String dir : new String[]{"Models", "animations"}) {
Path base = ASSET_ROOT.resolve(dir);
if (!java.nio.file.Files.isDirectory(base)) continue;
try (var walk = java.nio.file.Files.walk(base)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/'))
.sorted()
.forEach(animPreviewModelCombo.getItems()::add);
} catch (IOException ignored) {}
}
Button loadBtn = new Button("Laden");
loadBtn.setMaxWidth(Double.MAX_VALUE);
loadBtn.setStyle("-fx-font-weight: bold;");
loadBtn.setOnAction(e -> {
String path = animPreviewModelCombo.getValue();
if (path == null || path.isEmpty()) return;
input.animPreviewLoadPath = path;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
if (animClipListView != null) animClipListView.getItems().clear();
});
Button reimportBtn = new Button("Modell neu importieren (GLB/GLTF)…");
reimportBtn.setMaxWidth(Double.MAX_VALUE);
reimportBtn.setOnAction(e -> reimportModelForPreview(reimportBtn.getScene().getWindow()));
inner.getChildren().addAll(new Label("Modell:"), animPreviewModelCombo, loadBtn, reimportBtn);
// ── Animationen ───────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Animationen"), new Separator());
inner.getChildren().addAll(sectionTitle("Animationen"), new Separator());
animClipListView = new ListView<>();
animClipListView.setPrefHeight(200);
animClipListView.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.SINGLE);
@@ -11116,7 +11245,6 @@ public class EditorApp extends Application {
if (clip != null) input.animPreviewPlayClip = clip;
});
stopBtn.setOnAction(e -> input.animPreviewPlayClip = "");
// Doppelklick auf Clip → direkt abspielen
animClipListView.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
String clip = animClipListView.getSelectionModel().getSelectedItem();
@@ -11128,26 +11256,6 @@ public class EditorApp extends Application {
HBox.setHgrow(stopBtn, Priority.ALWAYS);
inner.getChildren().addAll(animClipListView, btnRow);
Button renameClipBtn = new Button("Clip umbenennen / exportieren…");
renameClipBtn.setMaxWidth(Double.MAX_VALUE);
renameClipBtn.setOnAction(e -> {
String clip = animClipListView.getSelectionModel().getSelectedItem();
if (clip == null) return;
javafx.scene.control.TextInputDialog dlg = new javafx.scene.control.TextInputDialog(clip);
dlg.setTitle("Clip umbenennen");
dlg.setHeaderText("Clip '" + clip + "' unter neuem Namen speichern");
dlg.setContentText("Neuer Name:");
dlg.showAndWait().ifPresent(newName -> {
if (!newName.isBlank()) input.clipRenameRequest.set(new SharedInput.ClipRenameRequest(clip, newName.trim()));
});
});
Button saveSetBtn = new Button("Als Animations-Set speichern…");
saveSetBtn.setMaxWidth(Double.MAX_VALUE);
saveSetBtn.setStyle("-fx-font-weight: bold;");
saveSetBtn.setOnAction(e -> showSaveAnimSetDialog());
inner.getChildren().addAll(renameClipBtn, saveSetBtn);
// ── Steuerung ─────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Steuerung"), new Separator());
@@ -11163,51 +11271,11 @@ public class EditorApp extends Application {
speedSlider.valueProperty().addListener((obs, ov, nv) -> input.animPreviewSpeed = nv.floatValue());
inner.getChildren().addAll(loopCB, speedLbl, withField(speedSlider, "%.2f"));
// ── Clip importieren ──────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clip importieren"), new Separator());
addAnimComboField = new ComboBox<>();
ComboBox<String> addAnimCombo = addAnimComboField;
// Beim Hinzufügen wird der Clip direkt in animations/clips/ gespeichert
Label animHint = new Label("Clip wird retargeted und direkt in animations/clips/ gespeichert. Mixamo: \"With Skin\" wählen.");
animHint.setWrapText(true);
animHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
Button importAnimBtn = new Button("Animation importieren (GLB/GLTF)…");
importAnimBtn.setMaxWidth(Double.MAX_VALUE);
importAnimBtn.setOnAction(e ->
handleAnimationImport(importAnimBtn.getScene().getWindow()));
addAnimCombo.setMaxWidth(Double.MAX_VALUE);
addAnimCombo.setPromptText("Animation aus animations/ wählen…");
refreshAddAnimCombo(addAnimCombo);
Button addAnimBtn = new Button("Animation hinzufügen");
addAnimBtn.setMaxWidth(Double.MAX_VALUE);
addAnimBtn.setStyle("-fx-font-weight: bold;");
addAnimBtn.setOnAction(e -> {
String animPath = addAnimCombo.getValue();
if (animPath == null || animPath.isEmpty()) {
if (animPreviewStatusLabel != null)
animPreviewStatusLabel.setText("Bitte eine Animation auswählen");
return;
}
input.animImportQueue.offer(animPath);
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…");
});
inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn);
// ── Diagnose ──────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Diagnose"), new Separator());
Button dumpBtn = new Button("Skelett-Info ins Log dumpen");
dumpBtn.setMaxWidth(Double.MAX_VALUE);
dumpBtn.setOnAction(e -> input.animDumpRequested = true);
inner.getChildren().add(dumpBtn);
// ── Status ────────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator());
animPreviewStatusLabel = new Label("Kein Modell geladen");
animPreviewStatusLabel = new Label("");
animPreviewStatusLabel.setWrapText(true);
animPreviewStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;");
animPreviewStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #c00;");
inner.getChildren().add(animPreviewStatusLabel);
ScrollPane scroll = new ScrollPane(inner);
@@ -11221,24 +11289,6 @@ public class EditorApp extends Application {
return panel;
}
private void refreshAddAnimCombo(ComboBox<String> combo) {
if (combo == null) return;
String current = combo.getValue();
combo.getItems().clear();
Path animBase = ASSET_ROOT.resolve("animations");
if (java.nio.file.Files.isDirectory(animBase)) {
try (var walk = java.nio.file.Files.walk(animBase)) {
walk.filter(ap -> {
String s = ap.toString();
return s.endsWith(".j3o") || s.endsWith(".glb") || s.endsWith(".gltf");
})
.map(ap -> ASSET_ROOT.relativize(ap).toString().replace('\\', '/'))
.sorted()
.forEach(combo.getItems()::add);
} catch (IOException ignored) {}
}
if (current != null && combo.getItems().contains(current)) combo.setValue(current);
}
/**
* Kopiert die Original-Quelldatei nach {@code <projekt-root>/assets/imported/<assetType>/}.
@@ -11259,9 +11309,9 @@ public class EditorApp extends Application {
private void handleAnimationImport(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Animation importieren (GLB/GLTF)");
fc.setTitle("Animation importieren (FBX/GLB/GLTF)");
fc.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Animationen (GLTF, GLB)", "*.gltf", "*.glb"));
new FileChooser.ExtensionFilter("Animationen (FBX, GLTF, GLB)", "*.fbx", "*.gltf", "*.glb"));
var files = fc.showOpenMultipleDialog(owner);
if (files == null) return;
for (File file : files) {
@@ -11273,6 +11323,20 @@ public class EditorApp extends Application {
}
}
private void handleCharacterModelImport(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Character-Modell importieren (Mixamo T-Pose FBX)");
fc.getExtensionFilters().add(
new FileChooser.ExtensionFilter("FBX-Modelle", "*.fbx"));
File file = fc.showOpenDialog(owner);
if (file == null) return;
archiveOriginal(file, "models");
input.charModelImportQueue.offer(file.getAbsolutePath());
if (charEditorStatusLabel != null)
charEditorStatusLabel.setText("Importiere: " + file.getName() + "");
setStatus("Importiere Character-Modell: " + file.getName() + "");
}
private void reimportModelForPreview(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Modell (GLB/GLTF) neu importieren");
@@ -11370,7 +11434,7 @@ public class EditorApp extends Application {
java.util.List<String> allClips = animClipListView.getItems();
Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
// Aktions-Zuweisung: Liste mit Add/Remove
javafx.scene.control.ListView<String> actionList = new javafx.scene.control.ListView<>();
@@ -11511,7 +11575,7 @@ public class EditorApp extends Application {
topBar.getChildren().set(1, buildCharacterEditorToolBar());
dialogEditorView = new de.blight.editor.ui.DialogEditorView();
dialogEditorView.setDisable(true);
routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("character"));
routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("Characters"));
routineEditorView.setDisable(true);
monologueEditorView = new de.blight.editor.ui.MonologueEditorView();
@@ -11690,7 +11754,7 @@ public class EditorApp extends Application {
}
private ScrollPane buildCharacterSettingsPane() {
Path charDir = ASSET_ROOT.resolve("character");
Path charDir = ASSET_ROOT.resolve("Characters");
VBox inner = new VBox(8);
inner.setPadding(new Insets(10));
@@ -11698,7 +11762,13 @@ public class EditorApp extends Application {
Button newCharBtn = new Button("Neuer Charakter");
newCharBtn.setMaxWidth(Double.MAX_VALUE);
newCharBtn.setOnAction(e -> clearCharacterForm());
inner.getChildren().add(newCharBtn);
Button importCharModelBtn = new Button("Character-Modell importieren (FBX)…");
importCharModelBtn.setMaxWidth(Double.MAX_VALUE);
importCharModelBtn.setStyle("-fx-font-weight: bold;");
importCharModelBtn.setOnAction(e -> handleCharacterModelImport(importCharModelBtn.getScene().getWindow()));
inner.getChildren().addAll(newCharBtn, importCharModelBtn);
charEditorStatusLabel = new Label("Wähle einen Character im Baum oder erstelle einen neuen");
charEditorStatusLabel.setWrapText(true);
@@ -11891,7 +11961,7 @@ public class EditorApp extends Application {
if (charAnimSetCombo == null) return;
String cur = charAnimSetCombo.getValue();
charAnimSetCombo.getItems().clear();
Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
if (java.nio.file.Files.isDirectory(setDir)) {
try (var walk = java.nio.file.Files.walk(setDir)) {
walk.filter(p -> p.toString().endsWith(".animset.json"))
@@ -11912,7 +11982,7 @@ public class EditorApp extends Application {
charActionLabelsBox.getChildren().add(hint);
return;
}
Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
de.blight.game.animation.AnimSet animSet;
try {
animSet = de.blight.game.animation.AnimSet.load(setDir, setName);
@@ -12695,7 +12765,7 @@ public class EditorApp extends Application {
}
private void deleteCharacterById(String id) {
Path charFile = ASSET_ROOT.resolve("character").resolve(id + ".character");
Path charFile = ASSET_ROOT.resolve("Characters").resolve(id + ".character");
try {
java.nio.file.Files.deleteIfExists(charFile);
} catch (IOException ex) {
@@ -12728,7 +12798,7 @@ public class EditorApp extends Application {
dlg.setContentText("ID:");
dlg.showAndWait().ifPresent(newId -> {
if (newId == null || newId.isBlank()) return;
Path charDir = ASSET_ROOT.resolve("character");
Path charDir = ASSET_ROOT.resolve("Characters");
Path src = charDir.resolve(sourceId + ".character");
Path dest = charDir.resolve(newId.trim() + ".character");
try {
@@ -12763,7 +12833,7 @@ public class EditorApp extends Application {
contentTreeUpdating = true;
try {
contentCharNode.getChildren().clear();
Path charDir = ASSET_ROOT.resolve("character");
Path charDir = ASSET_ROOT.resolve("Characters");
if (!java.nio.file.Files.isDirectory(charDir)) return;
java.util.List<javafx.scene.control.TreeItem<String>> items = new java.util.ArrayList<>();
try (var walk = java.nio.file.Files.list(charDir)) {
@@ -12780,7 +12850,7 @@ public class EditorApp extends Application {
private void loadCharacterById(String id) {
if (id == null || id.isBlank()) return;
Path charDir = ASSET_ROOT.resolve("character");
Path charDir = ASSET_ROOT.resolve("Characters");
try {
de.blight.common.model.GameCharacter c =
de.blight.common.model.CharacterIO.load(charDir.resolve(id + ".character"));

View File

@@ -619,6 +619,12 @@ public class SharedInput {
public volatile String animPreviewPlayClip = null;
/** 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/). */
public final ConcurrentLinkedQueue<String> charModelImportQueue = new ConcurrentLinkedQueue<>();
/** JME3 → JavaFX: Ergebnis des Character-Modell-Imports. relPath=null wenn Fehler. */
public record CharModelImportResult(String relPath, String error) {}
public final java.util.concurrent.atomic.AtomicReference<CharModelImportResult>
charModelImportResult = new java.util.concurrent.atomic.AtomicReference<>();
/** JavaFX → JME3: Clip-Name zum Entfernen aus dem geladenen Modell. null = kein Auftrag. */
public volatile String animPreviewRemoveClip = null;
/** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */

View File

@@ -85,6 +85,8 @@ public class AnimPreviewState extends BaseAppState {
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
assets.registerLoader(com.github.stephengold.wrench.LwjglAssetLoader.class,
"fbx", "dae", "bvh");
previewFB = buildFrameBuffer(PREVIEW_SIZE, PREVIEW_SIZE);
@@ -153,6 +155,11 @@ public class AnimPreviewState extends BaseAppState {
addAnimation(addAnimPath);
}
String charModelPath = input.charModelImportQueue.poll();
if (charModelPath != null) {
importFbxAsCharacterModel(charModelPath);
}
String removeClip = input.animPreviewRemoveClip;
if (removeClip != null) {
input.animPreviewRemoveClip = null;
@@ -193,7 +200,8 @@ public class AnimPreviewState extends BaseAppState {
double length = currentAction.getLength();
double time = ac.getTime();
if (length <= 0) {
LOG.warn("[AnimPreview] Loop-Check: length=0, Clip hat keine Dauer!");
LOG.warn("[AnimPreview] Clip '{}' hat keine Dauer, stoppe.", currentClipName);
stopAll();
} else if (time >= length - 0.02) {
LOG.trace("[AnimPreview] Loop-Check fired: time={} length={}", time, length);
if (input.animPreviewLoop) {
@@ -267,7 +275,7 @@ 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={}",
LOG.debug("[AnimPreview] Modell-Controls: AnimComposer={}, SkinningControl={}, EmbeddedClips={}",
previewAC != null ? "gefunden" : "NULL",
previewSC != null ? "gefunden" : "NULL",
previewAC != null ? previewAC.getAnimClips().size() : "n/a");
@@ -365,7 +373,10 @@ public class AnimPreviewState extends BaseAppState {
private void collectClips(Spatial s, List<String> out) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null) {
List<String> names = new ArrayList<>(ac.getAnimClipsNames());
List<String> names = new ArrayList<>();
for (String n : ac.getAnimClipsNames()) {
if (!n.startsWith("__")) names.add(n);
}
Collections.sort(names);
out.addAll(names);
}
@@ -395,7 +406,38 @@ public class AnimPreviewState extends BaseAppState {
if (currentAction != null) {
currentAction.setSpeed(input.animPreviewSpeed);
input.animPreviewCurrentClipDuration = (float) currentAction.getLength();
LOG.info("[AnimPreview] Play '{}' length={}", clipName, currentAction.getLength());
// DIAG: Clip-Inhalt + aktueller Hips-Zustand
AnimClip diagClip = ac.getAnimClip(clipName);
if (diagClip != null) {
LOG.info("[DIAG] Play '{}' length={} tracks={}", clipName,
currentAction.getLength(), diagClip.getTracks().length);
for (com.jme3.anim.AnimTrack<?> t : diagClip.getTracks()) {
if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue;
if (!(tt.getTarget() instanceof com.jme3.anim.Joint j)) continue;
if (!j.getName().contains("Hips")) continue;
com.jme3.math.Vector3f[] tr = tt.getTranslations();
com.jme3.math.Quaternion[] rr = tt.getRotations();
String tStr = (tr != null && tr.length > 0) ? tr[0].toString() : "null";
String rStr = (rr != null && rr.length > 0) ? rr[0].toString() : "null";
LOG.info("[DIAG] Track '{}' target='{}' frames={} t[0]={} r[0]={}",
clipName, j.getName(), tt.getTimes().length, tStr, rStr);
}
}
// DIAG: Modell-Hips localTransform + IBM
SkinningControl diaSC = findControl(currentModel, SkinningControl.class);
if (diaSC != null) {
com.jme3.anim.Armature diaArm = diaSC.getArmature();
for (int di = 0; di < diaArm.getJointCount(); di++) {
com.jme3.anim.Joint dj = diaArm.getJoint(di);
if (dj.getParent() == null) {
com.jme3.math.Vector3f lt = dj.getLocalTransform().getTranslation();
com.jme3.math.Matrix4f ibm = dj.getInverseModelBindMatrix();
LOG.info("[DIAG] Model-Root '{}': localT=({},{},{}) IBM_transl=({},{},{})",
dj.getName(), lt.x, lt.y, lt.z,
ibm != null ? ibm.m30 : 0, ibm != null ? ibm.m31 : 0, ibm != null ? ibm.m32 : 0);
}
}
}
}
} catch (Exception e) {
input.animPreviewStatus = "Abspielen fehlgeschlagen: " + e.getMessage();
@@ -499,40 +541,39 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null;
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
if (srcArm != null) {
LOG.trace("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
for (var j : srcArm.getJointList()) LOG.trace(" src: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl in Quelle!");
}
if (dstArm != null) {
LOG.trace("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
for (var j : dstArm.getJointList()) LOG.trace(" dst: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl im Modell!");
if (srcArm == null) {
LOG.debug("[Retarget] Quelle hat keine SkinningControl (j3o-Clip oder reines Anim-FBX).");
} else if (dstArm == null) {
LOG.debug("[Retarget] Kein Charakter-Modell geladen Clip wird ohne Retargeting gespeichert.");
}
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
if (retarget) {
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
LOG.trace("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping);
}
// Retarget wenn Ziel-Armature bekannt UND entweder andere Quelle-Armature
// oder Quelle hat gar keine Armature (z.B. j3o-Clip-Wrapper ohne SkinningControl).
// Ohne Retarget würden die Track-Targets auf fremde (Standalone-)Joints zeigen
// und die Modell-Joints gar nicht bewegen.
boolean retarget = dstArm != null && (srcArm == null || srcArm != dstArm);
java.util.Set<String> srcNames = new java.util.HashSet<>();
for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName());
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("Characters").resolve("clips");
java.nio.file.Files.createDirectories(clipsDir);
// Blender exportiert GLB-Dateien oft mit internem Namen "Action" statt dem Dateinamen.
// Bei einer GLB/GLTF-Datei mit genau einem Clip: Dateinamen als Clip-Namen verwenden.
boolean isSingleClipGlb = animAssetPath.matches(".*\\.(glb|gltf)$")
// GLB/FBX mit genau einem Clip: Dateinamen als Clip-Namen verwenden.
// Mixamo FBX nennt alle Clips "mixamo.com" unabhängig vom Inhalt.
boolean isSingleClipSource = animAssetPath.matches("(?i).*\\.(glb|gltf|fbx)$")
&& sourceAC.getAnimClips().size() == 1;
String fileBaseName = isSingleClipGlb
boolean isSingleClipGlb = isSingleClipSource; // Kompatibilität mit unterem Delete-Block
String fileBaseName = isSingleClipSource
? java.nio.file.Paths.get(animAssetPath).getFileName().toString()
.replaceFirst("\\.(glb|gltf)$", "")
.replaceFirst("(?i)\\.(glb|gltf|fbx)$", "")
: null;
// j3o-Quelle: Clip bereits gespeichert nur Preview, kein erneutes Speichern.
// snap passiert ausschließlich zur Laufzeit im Spiel via AnimationLibrary.
boolean isJ3oSource = animAssetPath.matches("(?i).*\\.j3o$");
com.jme3.anim.Armature snapArm = dstArm != null ? dstArm : srcArm;
int saved = 0;
for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName();
@@ -543,9 +584,18 @@ public class AnimPreviewState extends BaseAppState {
continue;
}
}
AnimClip result = retarget
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
: clip;
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).
result = redirectAnimClipByName(clip, dstArm);
if (result == null) {
LOG.warn("[AnimPreview] redirectByName: keine Joints gemappt, versuche Retargeting");
result = de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm);
}
}
if (result == null) continue;
String saveName = (fileBaseName != null) ? fileBaseName : name;
@@ -556,18 +606,16 @@ public class AnimPreviewState extends BaseAppState {
LOG.info("[AnimPreview] Clip '{}' als '{}' gespeichert (Dateiname-Alias)", name, saveName);
}
// XZ-Drift einfrieren bevor gespeichert wird Clip-Dateien bleiben immer sauber
com.jme3.anim.Armature snapArm = dstArm != null ? dstArm : srcArm;
toSave = de.blight.game.animation.AnimationLibrary.snapRootBoneXZ(toSave, snapArm);
// Direkt in die Clip-Bibliothek speichern das Modell wird nicht modifiziert
saveClipToFile(toSave, snapArm, clipsDir.resolve(saveName + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden (wenn geladen)
if (!isJ3oSource) {
// Direkt in die Clip-Bibliothek speichern snap passiert nur im Spiel zur Laufzeit
saveClipToFile(toSave, snapArm, clipsDir.resolve(saveName + ".j3o"));
saved++;
}
// Für den aktuellen Preview auch auf das Modell anwenden (wenn geladen)
if (targetAC != null) targetAC.addAnimClip(toSave);
saved++;
}
// Temporäre GLB aus clips/ löschen (nur wenn sie dort drin liegt nicht externe Dateien)
if (saved > 0 && animAssetPath.matches(".*\\.(glb|gltf)$")
// Temporäre GLB aus clips/ löschen (nur wenn sie dort drin liegt nicht externe Dateien, nie FBX)
if (saved > 0 && animAssetPath.matches("(?i).*\\.(glb|gltf)$")
&& !Path.of(animAssetPath).isAbsolute()) {
Path srcGlb = ASSET_ROOT.resolve(animAssetPath.replace('/', java.io.File.separatorChar));
try {
@@ -578,9 +626,13 @@ public class AnimPreviewState extends BaseAppState {
List<String> clips = new ArrayList<>();
if (currentModel != null) collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt)");
if (saved > 0) input.animImportCompleted = true;
if (isJ3oSource) {
input.animPreviewStatus = "Clip geladen (Preview)";
} else {
input.animPreviewStatus = saved + " Clip(s) in Characters/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt)");
if (saved > 0) input.animImportCompleted = true;
}
} catch (Exception e) {
LOG.error("[AnimPreview] Fehler beim Importieren von {}", animAssetPath, e);
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
@@ -897,10 +949,10 @@ public class AnimPreviewState extends BaseAppState {
ac.addAnimClip(renamed);
// kein saveModel() Quell-Modell bleibt unverändert
// Als eigenständige .j3o nach animations/clips/ exportieren
// Als eigenständige .j3o nach Characters/clips/ exportieren
try {
SkinningControl sc = findControl(currentModel, SkinningControl.class);
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("Characters").resolve("clips");
saveClipToFile(renamed, sc != null ? sc.getArmature() : null,
clipsDir.resolve(req.newName() + ".j3o"));
java.util.List<String> clips = new java.util.ArrayList<>();
@@ -916,7 +968,7 @@ public class AnimPreviewState extends BaseAppState {
private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) {
try {
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
java.nio.file.Files.createDirectories(setDir);
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(req.clips());
@@ -929,8 +981,8 @@ public class AnimPreviewState extends BaseAppState {
}
private void executeAnimEmbed(SharedInput.AnimEmbedRequest req) {
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Path setDir = ASSET_ROOT.resolve("Characters").resolve("sets");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("Characters").resolve("clips");
de.blight.game.animation.AnimSet set;
try {
@@ -1096,15 +1148,461 @@ public class AnimPreviewState extends BaseAppState {
BinaryExporter.getInstance().save(holder, outFile.toFile());
}
/** Returns "animations/clips/<name>.<ext>" for the first matching file, or null if not found. */
// ── FBX-Import ───────────────────────────────────────────────────────────
/**
* Importiert eine FBX-Datei als Character-Modell nach Models/Chars/.
* Prüft vorab ob ein Mixamo-Skelett (mixamorig:-Präfix) vorhanden ist.
* Ergebnis wird via charModelImportResult zurück an die UI gemeldet.
*/
private void importFbxAsCharacterModel(String absolutePath) {
java.io.File file = new java.io.File(absolutePath);
if (!file.exists()) {
input.charModelImportResult.set(new SharedInput.CharModelImportResult(
null, "Datei nicht gefunden: " + absolutePath));
return;
}
String basename = file.getName()
.replaceFirst("(?i)\\.[^.]+$", "")
.replaceAll("[^a-zA-Z0-9_\\-]", "_");
try {
assets.registerLocator(file.getParent(), com.jme3.asset.plugins.FileLocator.class);
String fbmBase = file.getName().replaceAll("\\.[^.]+$", "");
java.io.File fbmDir = new java.io.File(file.getParentFile(), fbmBase + ".fbm");
if (fbmDir.isDirectory()) {
assets.registerLocator(fbmDir.getAbsolutePath(), com.jme3.asset.plugins.FileLocator.class);
}
com.github.stephengold.wrench.LwjglAssetKey key =
new com.github.stephengold.wrench.LwjglAssetKey(file.getName());
key.setVerboseLogging(true);
Spatial model = (Spatial) assets.loadAsset(key);
// Mixamo-Skelett prüfen
if (!hasMixamoSkeleton(model)) {
input.charModelImportResult.set(new SharedInput.CharModelImportResult(
null, "Kein Mixamo-Skelett gefunden (kein 'mixamorig:'-Joint).\n"
+ "Bitte Mixamo-FBX mit 'With Skin' exportieren."));
return;
}
java.io.File embeddedDir = extractEmbeddedTextures(file, model);
if (embeddedDir != null) {
assets.registerLocator(embeddedDir.getAbsolutePath(), com.jme3.asset.plugins.FileLocator.class);
}
fixFbxMaterials(model);
fixFbxMeshAndSkeleton(model);
fixBrokenTexturePaths(model, file.getParentFile());
// Textur-Keys entfernen → BinaryExporter bettet Image-Daten direkt in die j3o ein.
// Filename-only Keys ("character.png") wären nach dem nächsten Editor-Start nicht
// mehr auflösbar (kein Temp-Dir mehr registriert) → gelbe Fallback-Textur.
embedTexturesInModel(model);
model.setLocalTranslation(Vector3f.ZERO);
model.updateGeometricState();
float scale = 0.01f;
if (model.getWorldBound() instanceof com.jme3.bounding.BoundingBox bb) {
float h = bb.getYExtent() * 2f;
if (h > 0.01f) scale = 2.0f / h;
}
model.setLocalScale(scale);
model.updateGeometricState();
// Animationsclips entfernen
AnimComposer ac = findControl(model, AnimComposer.class);
if (ac != null) {
for (AnimClip c : new java.util.ArrayList<>(ac.getAnimClips())) {
ac.removeAnimClip(c);
}
}
java.nio.file.Path destJ3o = ASSET_ROOT.resolve("Characters").resolve("Models").resolve(basename + ".j3o");
java.nio.file.Files.createDirectories(destJ3o.getParent());
com.jme3.export.binary.BinaryExporter.getInstance().save(model, destJ3o.toFile());
String relStr = ASSET_ROOT.relativize(destJ3o).toString().replace('\\', '/');
for (String root : new String[]{"blight-assets/bin/main", "blight-assets/build/resources/main"}) {
java.nio.file.Path mirror = java.nio.file.Paths.get(root).resolve(
ASSET_ROOT.relativize(destJ3o));
if (java.nio.file.Files.exists(mirror)) {
try {
java.nio.file.Files.copy(destJ3o, mirror,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (Exception ignored) {}
}
}
assets.deleteFromCache(new com.jme3.asset.ModelKey(relStr));
LOG.info("[CharImport] Character-Modell gespeichert: {} (scale={})", destJ3o, scale);
input.charModelImportResult.set(new SharedInput.CharModelImportResult(relStr, null));
} catch (Exception e) {
LOG.error("[CharImport] Fehler bei {}", absolutePath, e);
input.charModelImportResult.set(new SharedInput.CharModelImportResult(
null, "Import-Fehler: " + e.getMessage()));
}
}
/**
* Entfernt den AssetKey aller Texturen im Baum, so dass BinaryExporter die Image-Daten direkt
* in die j3o-Datei einbettet (kein externer Textur-Verweis → funktioniert nach Editor-Restart).
* JME3: Texture mit key != null → AssetLink-Referenz gespeichert; key == null → Image inline.
*/
private void embedTexturesInModel(Spatial s) {
if (s instanceof com.jme3.scene.Geometry geo && geo.getMaterial() != null) {
com.jme3.material.Material mat = geo.getMaterial();
for (com.jme3.material.MatParam mp : new java.util.ArrayList<>(mat.getParams())) {
if (!(mp.getValue() instanceof com.jme3.texture.Texture tex)) continue;
if (tex.getImage() == null) continue;
com.jme3.texture.Texture2D embedded = new com.jme3.texture.Texture2D(tex.getImage());
embedded.setMinFilter(tex.getMinFilter());
embedded.setMagFilter(tex.getMagFilter());
embedded.setWrap(com.jme3.texture.Texture.WrapAxis.S,
tex.getWrap(com.jme3.texture.Texture.WrapAxis.S));
embedded.setWrap(com.jme3.texture.Texture.WrapAxis.T,
tex.getWrap(com.jme3.texture.Texture.WrapAxis.T));
mat.setTexture(mp.getName(), embedded);
}
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) embedTexturesInModel(child);
}
}
/**
* Kopiert Textur-Dateien in ein permanentes Asset-Verzeichnis und setzt die Material-Referenzen
* auf asset-relative Pfade um, damit das j3o auch nach dem nächsten Editor-Start funktioniert.
*/
private void relinkTexturesAsAssets(Spatial s,
java.util.List<java.io.File> srcDirs,
java.nio.file.Path texDestDir,
String texRelBase) {
if (s instanceof com.jme3.scene.Geometry geo && geo.getMaterial() != null) {
com.jme3.material.Material mat = geo.getMaterial();
for (com.jme3.material.MatParam mp : new java.util.ArrayList<>(mat.getParams())) {
if (!(mp.getValue() instanceof com.jme3.texture.Texture tex)) continue;
com.jme3.asset.AssetKey<?> key = tex.getKey();
if (key == null) continue;
String filename = new java.io.File(key.getName()).getName();
if (filename.isEmpty()) continue;
if (key.getName().contains("/") || key.getName().contains("\\")) continue; // bereits relativ
// Quelldatei suchen
java.io.File src = null;
for (java.io.File dir : srcDirs) {
java.io.File c = new java.io.File(dir, filename);
if (c.exists()) { src = c; break; }
}
if (src == null) {
LOG.warn("[CharImport] Textur-Quelle nicht gefunden: {}", filename);
continue;
}
// In Asset-Verzeichnis kopieren
java.nio.file.Path dest = texDestDir.resolve(filename);
try {
java.nio.file.Files.copy(src.toPath(), dest,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (Exception ex) {
LOG.warn("[CharImport] Kopieren fehlgeschlagen ({}): {}", filename, ex.getMessage());
continue;
}
// Material mit asset-relativem Key neu laden und setzen
String relPath = texRelBase + "/" + filename;
try {
com.jme3.asset.TextureKey newKey = new com.jme3.asset.TextureKey(relPath, true);
newKey.setGenerateMips(true);
assets.deleteFromCache(newKey);
com.jme3.texture.Texture reloaded = assets.loadTexture(newKey);
mat.setTexture(mp.getName(), reloaded);
LOG.info("[CharImport] Textur → {}", relPath);
} catch (Exception ex) {
LOG.warn("[CharImport] Textur-Reload fehlgeschlagen ({}): {}", relPath, ex.getMessage());
}
}
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren())
relinkTexturesAsAssets(child, srcDirs, texDestDir, texRelBase);
}
}
private boolean hasMixamoSkeleton(Spatial model) {
SkinningControl sc = findControl(model, SkinningControl.class);
if (sc == null) return false;
com.jme3.anim.Armature arm = sc.getArmature();
for (int i = 0; i < arm.getJointCount(); i++) {
if (arm.getJoint(i).getName().startsWith("mixamorig:")) return true;
}
return false;
}
// ── FBX-Hilfsroutinen (portiert aus FbxTestApp) ───────────────────────────
/**
* Bereinigt FBX-Mesh-Knoten und Skelett nach MonkeyWrench-Import.
* Assimp konvertiert Animations-Tracks zu Y-up, aber NICHT die Bind-Pose-Translation
* des Root-Joints. Der Root-Joint liegt in Z-up vor: t=(x,y,z) wird zu t=(x,z,-y).
* Danach werden die inversen Bind-Matrizen aller Joints neu berechnet.
*/
private void fixFbxMeshAndSkeleton(Spatial model) {
fixFbxZeroMeshRotations(model);
SkinningControl skinCtrl = findControl(model, SkinningControl.class);
if (skinCtrl == null) return;
com.jme3.anim.Armature arm = skinCtrl.getArmature();
// Assimp konvertiert Mesh-Vertices zu Y-up, aber NICHT die Bind-Pose-Translation
// des Root-Joints der liegt noch in Z-up vor: (x,y,z) → (x,z,-y).
boolean fixed = false;
for (int i = 0; i < arm.getJointCount(); i++) {
com.jme3.anim.Joint j = arm.getJoint(i);
if (j.getParent() == null) {
com.jme3.math.Vector3f t = j.getLocalTransform().getTranslation();
float ox = t.x, oy = t.y, oz = t.z;
t.set(ox, oz, -oy);
LOG.debug("[fixSkeleton] Root-Joint '{}': ({},{},{}) -> ({},{},{})",
j.getName(), ox, oy, oz, t.x, t.y, t.z);
fixed = true;
}
}
if (!fixed) return;
// Inverse Bind-Matrizen aus den korrigierten lokalen Transforms neu berechnen.
java.util.HashMap<com.jme3.anim.Joint, com.jme3.math.Matrix4f> modelMats = new java.util.HashMap<>();
for (int i = 0; i < arm.getJointCount(); i++) {
buildFbxJointModelMatrix(arm.getJoint(i), modelMats);
}
for (int i = 0; i < arm.getJointCount(); i++) {
com.jme3.anim.Joint j = arm.getJoint(i);
com.jme3.math.Matrix4f ibm = j.getInverseModelBindMatrix();
if (ibm != null) ibm.set(modelMats.get(j).invert());
}
arm.saveBindPose();
arm.saveInitialPose();
}
private com.jme3.math.Matrix4f buildFbxJointModelMatrix(
com.jme3.anim.Joint j,
java.util.HashMap<com.jme3.anim.Joint, com.jme3.math.Matrix4f> cache) {
com.jme3.math.Matrix4f cached = cache.get(j);
if (cached != null) return cached;
com.jme3.math.Matrix4f local = j.getLocalTransform().toTransformMatrix();
com.jme3.math.Matrix4f result = (j.getParent() != null)
? buildFbxJointModelMatrix(j.getParent(), cache).mult(local)
: local;
cache.put(j, result);
return result;
}
private void fixFbxZeroMeshRotations(Spatial s) {
if (!(s instanceof Node n)) return;
boolean hasSkeleton = n.getControl(AnimComposer.class) != null
|| n.getControl(SkinningControl.class) != null;
if (!hasSkeleton) {
com.jme3.math.Quaternion rot = n.getLocalRotation();
boolean isIdentity = Math.abs(rot.getW() - 1f) < 0.01f && Math.abs(rot.getX()) < 0.01f
&& Math.abs(rot.getY()) < 0.01f && Math.abs(rot.getZ()) < 0.01f;
if (!isIdentity) n.setLocalRotation(com.jme3.math.Quaternion.IDENTITY);
}
for (Spatial child : n.getChildren()) fixFbxZeroMeshRotations(child);
}
/**
* Konvertiert PBR-Materialien in Lighting.j3md (Editor hat keine LightProbe).
* Vorhandene Lighting.j3md-Materialien werden nur um DiffuseColor ergänzt.
*/
private void fixFbxMaterials(Spatial s) {
if (s instanceof com.jme3.scene.Geometry geo && geo.getMaterial() != null) {
com.jme3.material.Material mat = geo.getMaterial();
String defName = mat.getMaterialDef().getName();
if (defName.contains("Lighting")) {
com.jme3.material.MatParam dm = mat.getParam("DiffuseMap");
if (dm != null && dm.getValue() instanceof com.jme3.texture.Texture) {
mat.setColor("Diffuse", com.jme3.math.ColorRGBA.White.clone());
mat.setColor("Ambient", new com.jme3.math.ColorRGBA(0.45f, 0.45f, 0.45f, 1f));
}
return;
}
com.jme3.texture.Texture diffuseTex = null;
com.jme3.texture.Texture normalTex = null;
com.jme3.math.ColorRGBA baseColor = null;
for (String nm : new String[]{"BaseColorMap", "DiffuseMap", "Albedo", "ColorMap"}) {
com.jme3.material.MatParam p = mat.getParam(nm);
if (p != null && p.getValue() instanceof com.jme3.texture.Texture t) { diffuseTex = t; break; }
}
for (String nm : new String[]{"NormalMap", "NormalCamera"}) {
com.jme3.material.MatParam p = mat.getParam(nm);
if (p != null && p.getValue() instanceof com.jme3.texture.Texture t) { normalTex = t; break; }
}
for (String nm : new String[]{"BaseColor", "Diffuse"}) {
com.jme3.material.MatParam p = mat.getParam(nm);
if (p != null && p.getValue() instanceof com.jme3.math.ColorRGBA c) { baseColor = c.clone(); break; }
}
com.jme3.material.Material lit = new com.jme3.material.Material(assets, "Common/MatDefs/Light/Lighting.j3md");
lit.setBoolean("UseMaterialColors", true);
if (diffuseTex != null) {
lit.setTexture("DiffuseMap", diffuseTex);
lit.setColor("Diffuse", com.jme3.math.ColorRGBA.White.clone());
lit.setColor("Ambient", new com.jme3.math.ColorRGBA(0.45f, 0.45f, 0.45f, 1f));
} else {
com.jme3.math.ColorRGBA col = baseColor != null ? baseColor : com.jme3.math.ColorRGBA.LightGray.clone();
lit.setColor("Diffuse", col);
lit.setColor("Ambient", col.mult(0.4f).setAlpha(1f));
}
if (normalTex != null) lit.setTexture("NormalMap", normalTex);
lit.setColor("Specular", new com.jme3.math.ColorRGBA(0.1f, 0.1f, 0.1f, 1f));
lit.setFloat("Shininess", 24f);
geo.setMaterial(lit);
} else if (s instanceof Node n) {
for (Spatial child : n.getChildren()) fixFbxMaterials(child);
}
}
/** Sammelt alle Dateinamen aus Textur-Params eines Spatial-Baums. */
private java.util.Set<String> collectTextureFilenames(Spatial s) {
java.util.Set<String> names = new java.util.LinkedHashSet<>();
if (s instanceof com.jme3.scene.Geometry geo && geo.getMaterial() != null) {
for (com.jme3.material.MatParam p : geo.getMaterial().getParams()) {
if (p.getValue() instanceof com.jme3.texture.Texture tex && tex.getKey() != null) {
String name = new java.io.File(tex.getKey().getName()).getName();
if (!name.isEmpty()) names.add(name);
}
}
} else if (s instanceof Node n) {
for (Spatial child : n.getChildren()) names.addAll(collectTextureFilenames(child));
}
return names;
}
/**
* Sucht eingebettete PNG-Texturen im FBX-Binär (Dateiname → PNG-Magic → IEND)
* und schreibt sie in ein temporäres Verzeichnis.
*/
private java.io.File extractEmbeddedTextures(java.io.File fbxFile, Spatial model) {
java.util.Set<String> filenames = collectTextureFilenames(model);
if (filenames.isEmpty()) return null;
try {
byte[] data = java.nio.file.Files.readAllBytes(fbxFile.toPath());
java.io.File tempDir = new java.io.File(System.getProperty("java.io.tmpdir"),
"fbxtex_" + Math.abs(fbxFile.getAbsolutePath().hashCode()));
tempDir.mkdirs();
byte[] pngMagic = {(byte)0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A};
byte[] iend = {0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,(byte)0xAE,0x42,0x60,(byte)0x82};
boolean any = false;
for (String filename : filenames) {
byte[] nameBytes = filename.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
int namePos = binaryIndexOf(data, nameBytes, 0);
if (namePos < 0) continue;
int pngPos = binaryIndexOf(data, pngMagic, namePos);
if (pngPos < 0 || pngPos - namePos > 65536) continue;
int endPos = binaryIndexOf(data, iend, pngPos + 8);
if (endPos < 0) continue;
byte[] png = java.util.Arrays.copyOfRange(data, pngPos, endPos + iend.length);
java.nio.file.Files.write(new java.io.File(tempDir, filename).toPath(), png);
LOG.info("[FbxImport] Embedded-Textur extrahiert: {} ({} B)", filename, png.length);
any = true;
}
return any ? tempDir : null;
} catch (Exception e) {
LOG.warn("[FbxImport] Embedded-Extraktion fehlgeschlagen: {}", e.getMessage());
return null;
}
}
private static int binaryIndexOf(byte[] data, byte[] pattern, int from) {
outer:
for (int i = from, max = data.length - pattern.length; i <= max; i++) {
for (int j = 0; j < pattern.length; j++) {
if (data[i + j] != pattern[j]) continue outer;
}
return i;
}
return -1;
}
/** Ersetzt kaputte Textur-Pfade (z.B. Mixamo-Server-Pfade) durch Reload per Dateiname. */
private void fixBrokenTexturePaths(Spatial s, java.io.File baseDir) {
if (s instanceof com.jme3.scene.Geometry geo && geo.getMaterial() != null) {
com.jme3.material.Material mat = geo.getMaterial();
for (com.jme3.material.MatParam mp : new java.util.ArrayList<>(mat.getParams())) {
if (!(mp.getValue() instanceof com.jme3.texture.Texture tex)) continue;
com.jme3.asset.AssetKey<?> key = tex.getKey();
if (key == null) continue;
String filename = new java.io.File(key.getName()).getName();
if (filename.isEmpty()) continue;
try {
com.jme3.asset.TextureKey texKey = new com.jme3.asset.TextureKey(filename, true);
texKey.setGenerateMips(true);
com.jme3.texture.Texture reloaded = assets.loadTexture(texKey);
mat.setTexture(mp.getName(), reloaded);
continue;
} catch (Exception ignored) {}
try {
final String fn = filename;
java.nio.file.Path found = java.nio.file.Files.walk(baseDir.toPath(), 3)
.filter(p -> p.getFileName().toString().equalsIgnoreCase(fn))
.findFirst().orElse(null);
if (found != null) {
assets.registerLocator(found.getParent().toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
com.jme3.asset.TextureKey texKey = new com.jme3.asset.TextureKey(filename, true);
texKey.setGenerateMips(true);
mat.setTexture(mp.getName(), assets.loadTexture(texKey));
}
} catch (Exception ignored) {}
}
} else if (s instanceof Node n) {
for (Spatial child : n.getChildren()) fixBrokenTexturePaths(child, baseDir);
}
}
/** Returns "Characters/clips/<name>.<ext>" for the first matching file, or null if not found. */
private static String resolveClipFile(java.nio.file.Path clipsDir, String clipName) {
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (Files.exists(clipsDir.resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
return "Characters/clips/" + clipName + ext;
}
return null;
}
/** Leitet Clip-Tracks per Gelenk-Name auf die Ziel-Armatur um (FbxTestApp-Ansatz für Same-Rig). */
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;
// 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;
}
}
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());
return result;
}
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
if (a.getJointCount() != b.getJointCount()) return false;
java.util.Set<String> namesA = new java.util.HashSet<>();

View File

@@ -1362,7 +1362,7 @@ public class DialogEditorView extends BorderPane {
odt.normal("Exportiert: " + LocalDate.now());
odt.empty();
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "character");
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "Characters");
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
if (!(gc instanceof NPC npc)) continue;
if (npc.getDialogOptions() == null || npc.getDialogOptions().isEmpty()) continue;

View File

@@ -290,7 +290,7 @@ public class TtsGeneratorDialog extends Dialog<Void> {
private List<String> collectHeroKeys() {
List<String> keys = new ArrayList<>();
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "character");
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "Characters");
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
if (!(gc instanceof NPC npc) || npc.getDialogOptions() == null) continue;
for (Map.Entry<String, DialogOption> e : npc.getDialogOptions().entrySet()) {