Compare commits

...

4 Commits

Author SHA1 Message Date
914bf6e673 Zwischenstand - animationen passen weitesgehend 2026-06-22 22:36:44 +02:00
b3b943e588 Charakter-Liegend-Fix: T-Pose im Editor, CullHint im Spiel, Strip in src+bin
Editor (AnimPreviewState):
- __tpose__ AnimClip mit Einzel-Frame-Track für Root-Joints (behebt NPE in
  ClipAction.doInterpolate; leerer Clip hatte null-tracks-Array)
- Beim Laden: eingebettete Clips automatisch strippt und in src+bin speichern;
  danach __tpose__ abspielen → Charakter steht in Bind-Pose
- stopAll(): zurück zur T-Pose statt SkinningControl deaktivieren
- Achsen-Indikator immer fest auf (0,0,0) statt bounding-box-Mitte

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

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

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

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

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

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

View File

@@ -9439,25 +9439,6 @@ public class EditorApp extends Application {
refreshCharAnimSetCombo();
charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet());
Button embedAnimBtn = new Button("Animationen einbetten");
embedAnimBtn.setMaxWidth(Double.MAX_VALUE);
embedAnimBtn.setDisable(true);
javafx.beans.value.ChangeListener<String> embedEnableListener = (obs, ov, nv) -> {
boolean ready = charModelCombo.getValue() != null && !charModelCombo.getValue().isBlank()
&& charAnimSetCombo.getValue() != null && !charAnimSetCombo.getValue().isBlank();
embedAnimBtn.setDisable(!ready);
};
charModelCombo.valueProperty().addListener(embedEnableListener);
charAnimSetCombo.valueProperty().addListener(embedEnableListener);
embedAnimBtn.setOnAction(e -> {
String modelPath = charModelCombo.getValue();
String setName = charAnimSetCombo.getValue();
if (modelPath == null || setName == null) return;
if (charEditorStatusLabel != null)
charEditorStatusLabel.setText("Bette Animationen ein…");
input.animEmbedRequest.set(new SharedInput.AnimEmbedRequest(modelPath, setName));
});
Button stripClipsBtn = new Button("Eingebettete Clips löschen");
stripClipsBtn.setMaxWidth(Double.MAX_VALUE);
stripClipsBtn.setDisable(true);
@@ -9581,7 +9562,6 @@ public class EditorApp extends Application {
charEditContainer.getChildren().addAll(
new Label("Modell:"), charModelCombo,
new Label("Anim-Set:"), charAnimSetCombo,
embedAnimBtn,
stripClipsBtn
);

View File

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

View File

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

View File

@@ -579,16 +579,22 @@ public class PlayerInputControl {
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.ensureApplied(clip, visual)) {
log.info("[Anim] tryPlay('{}') → ensureApplied FAILED", clip);
if (animComposer == null || !animLib.applyTo(clip, visual)) {
log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip);
return false;
}
// Erst Action setzen, DANN SkinningControl aktivieren
// vermeidet 1 Frame in Bind-Pose × Armature-Rx90° = liegender Charakter.
com.jme3.anim.tween.action.Action action = animComposer.setCurrentAction(clip);
log.info("[Anim] setCurrentAction('{}') → {}", clip, action != null ? "OK" : "FAILED");
if (action != null) {
runningClip = clip;
return true;
if (action == null) {
return false;
}
return false;
if (skinningControl != null && !skinningControl.isEnabled()) {
skinningControl.setEnabled(true);
log.info("[Anim] SkinningControl aktiviert nach Action '{}'", clip);
}
runningClip = clip;
return true;
}
}

View File

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