Files
blight/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java

498 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.blight.game.animation;
import com.jme3.anim.*;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.scene.Spatial;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
/**
* Lädt alle Clip-Dateien aus {@code animations/clips/} beim Start.
* Clip-Schlüssel entsprechen dem Dateinamen ohne Extension (= Clip-Name).
*/
public class AnimationLibrary extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(AnimationLibrary.class);
private static final String[] ASSET_BASES = {
"blight-assets/src/main/resources", // CWD = Projekt-Root (Editor-Subprocess)
"../blight-assets/src/main/resources", // CWD = blight-game/ (Direktstart)
"assets",
".",
};
private AssetManager assetManager;
/** clip name → clip (an Quell-Armatur gebunden; wird bei Bedarf retargeted) */
private final Map<String, AnimClip> clips = new LinkedHashMap<>();
/** clip name → Armatur der Quell-Datei */
private final Map<String, Armature> armatures = new LinkedHashMap<>();
/** Clips die snapRootBoneXZ erhalten (Locomotion: Walk, Run, Sprint, …). */
private final Set<String> snapClipNames = new LinkedHashSet<>();
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
assetManager = ((SimpleApplication) app).getAssetManager();
Path assetRoot = findAssetRoot();
try {
assetManager.registerLocator(
assetRoot.toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
log.info("[AnimLib] Asset-Root registriert: {}", assetRoot.toAbsolutePath());
} catch (Exception e) {
log.warn("[AnimLib] Asset-Root konnte nicht registriert werden: {}", e.getMessage());
}
loadAll();
}
@Override protected void cleanup(Application app) { clips.clear(); armatures.clear(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Public API ────────────────────────────────────────────────────────────
/** Alle geladenen Clip-Namen. */
public Collection<String> getClipKeys() {
return Collections.unmodifiableSet(clips.keySet());
}
/**
* Retargeted den Clip auf das Skeleton von {@code model} und registriert
* ihn im AnimComposer des Modells (idempotent).
*
* @return true wenn der Clip erfolgreich angewendet wurde
*/
public boolean applyTo(String clipName, Spatial model) {
AnimClip src = clips.get(clipName);
Armature srcArm = armatures.get(clipName);
if (src == null) {
log.warn("[AnimLib] applyTo: Clip '{}' nicht in Bibliothek (verfügbar: {})", clipName, clips.keySet());
return false;
}
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
SkinningControl sc = RetargetingSystem.findSkinningControl(model);
if (ac == null) {
log.warn("[AnimLib] applyTo: Kein AnimComposer in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
if (sc == null) {
log.warn("[AnimLib] applyTo: Kein SkinningControl in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden
{
com.jme3.anim.AnimTrack<?>[] st = src.getTracks();
int sn0 = 0;
if (st != null && st.length > 0 && st[0] instanceof TransformTrack stt)
sn0 = stt.getTimes() != null ? stt.getTimes().length : 0;
log.info("[AnimLib] applyTo pre-retarget '{}': srcLen={}s srcTracks={} srcT0Frames={}",
clipName, src.getLength(), st != null ? st.length : 0, sn0);
}
AnimClip target;
if (srcArm != null && srcArm != sc.getArmature()) {
// Immer retarget() aufrufen – auch bei gleichen Knochen-Namen.
// retarget() nutzt intern den "redirect"-Schnellpfad für gleiche Rigs,
// erstellt aber korrekte TransformTrack-Referenzen auf die Ziel-Armatur.
// Direkte Nutzung des Quell-Clips würde Transforms auf die falschen
// (entkoppelten) Joint-Objekte der Quell-j3o anwenden.
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
} else {
target = src;
}
// Der interne Clip-Name kann vom Library-Schlüssel abweichen (z. B. Blender-Default
// "Action" statt "sit_down_new"). AnimComposer.setCurrentAction() sucht per Name,
// daher muss der Name des gespeicherten Clips dem clipName entsprechen.
if (target != null && !clipName.equals(target.getName())) {
AnimClip renamed = new AnimClip(clipName);
renamed.setTracks(target.getTracks());
target = renamed;
}
if (target == null) {
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
return false;
}
if (snapClipNames.contains(clipName)) {
target = snapRootBoneXZ(target, sc.getArmature());
}
ac.addAnimClip(target);
int trackCount = target.getTracks() != null ? target.getTracks().length : 0;
log.info("[AnimLib] Clip '{}' → AnimComposer '{}': length={}s tracks={}",
clipName, model.getName(), target.getLength(), trackCount);
if (clipName.equals("sit_down") || target.getLength() == 0) {
dumpClipTracks(target);
}
return true;
}
/** 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++;
}
if (applied > 0)
log.info("[AnimLib] {} Clips auf '{}' angewendet", applied, model.getName());
}
/**
* Stellt sicher dass der Clip auf das Modell angewendet ist und SkinningControl aktiv ist,
* startet ihn aber NICHT (das übernimmt der Aufrufer via AnimComposer.setCurrentAction).
*
* @return true wenn der Clip bereit ist
*/
public boolean ensureApplied(String clipName, Spatial model) {
if (!applyTo(clipName, model)) return false;
enableSkinningControls(model);
return true;
}
/**
* Wendet den Clip an und startet ihn sofort.
*
* @return true bei Erfolg
*/
public boolean playOn(String clipName, Spatial model) {
if (!applyTo(clipName, model)) return false;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
if (ac == null) return false;
enableSkinningControls(model);
log.info("[AnimLib] setCurrentAction('{}') auf '{}'", clipName, model.getName());
com.jme3.anim.tween.action.Action action = ac.setCurrentAction(clipName);
log.info("[AnimLib] Action: {} length={}", action, action != null ? action.getLength() : "N/A");
return action != null;
}
private static void enableSkinningControls(Spatial s) {
SkinningControl sc = s.getControl(SkinningControl.class);
if (sc != null && !sc.isEnabled()) {
sc.setEnabled(true);
log.info("[AnimLib] SkinningControl aktiviert auf '{}'", s.getName());
}
if (s instanceof com.jme3.scene.Node n) {
for (Spatial child : n.getChildren()) enableSkinningControls(child);
}
}
/**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Set zugeordnet ist.
*
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
* @param setName Name des Animations-Sets (ohne Pfad/Extension, z. B. {@code "human"})
* @param action semantische Aktion
* @return Clip-Name oder {@code null}
*/
public static String getClipForAction(Path assetRoot, String setName, AnimationAction action) {
Path setDir = assetRoot.resolve("Characters").resolve("sets");
try {
AnimSet set = AnimSet.load(setDir, setName);
return set.getActionMap().get(action.name());
} catch (Exception e) {
return null;
}
}
/**
* Bestimmt anhand des AnimSets welche Clips snapRootBoneXZ erhalten sollen
* (Locomotion-Aktionen: Walk, Run, Sprint, Jump, Idle, Default).
* Muss vor dem ersten {@link #applyTo}-Aufruf für das Player-Modell gesetzt werden.
*/
public void configureLocomotionSnap(Path assetRoot, String animSetName) {
snapClipNames.clear();
try {
AnimSet set = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName);
snapClipNames.addAll(set.getSnapClips());
log.info("[AnimLib] snapRootBoneXZ für Clips: {}", snapClipNames);
} catch (Exception e) {
log.warn("[AnimLib] AnimSet '{}' nicht ladbar für Locomotion-Snap: {}", animSetName, e.getMessage());
}
}
// ── Loading ───────────────────────────────────────────────────────────────
private void loadAll() {
Path clipsDir = findClipsDir();
log.info("[AnimLib] Asset-Root: {}", findAssetRoot().toAbsolutePath());
if (clipsDir == null) {
log.warn("[AnimLib] Kein clips-Verzeichnis gefunden – Bibliothek leer.");
return;
}
log.info("[AnimLib] Scanne clips-Verzeichnis: {}", clipsDir.toAbsolutePath());
try (var walk = Files.walk(clipsDir, 1)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.forEach(this::loadClipFromFile);
} catch (IOException e) {
log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage());
}
if (clips.isEmpty()) {
log.warn("[AnimLib] KEINE Clips geladen! Prüfe ob der Asset-Root korrekt ist.");
} else {
log.info("[AnimLib] {} Clips geladen: {}", clips.size(), clips.keySet());
}
Path setDir = findAssetRoot().resolve("Characters").resolve("sets");
try {
AnimSet animSet = AnimSet.load(setDir, "human");
createSubClips(animSet);
} catch (Exception e) {
log.warn("[AnimLib] Sub-Clips konnten nicht erstellt werden: {}", e.getMessage());
}
}
private void createSubClips(AnimSet animSet) {
for (Map.Entry<String, AnimSet.SubClipDef> entry : animSet.getSubClips().entrySet()) {
String subName = entry.getKey();
AnimSet.SubClipDef def = entry.getValue();
AnimClip source = clips.get(def.source);
Armature arm = armatures.get(def.source);
if (source == null) {
log.warn("[AnimLib] SubClip '{}': Quelle '{}' nicht gefunden – übersprungen", subName, def.source);
continue;
}
AnimClip sub = extractSubClip(source, subName, def.start, def.end);
clips.put(subName, sub);
if (arm != null) {
armatures.put(subName, arm);
}
log.info("[AnimLib] SubClip '{}' extrahiert [{}-{}s] aus '{}'",
subName, def.start, def.end, def.source);
}
}
@SuppressWarnings("rawtypes")
private static AnimClip extractSubClip(AnimClip source, String name, float startSec, float endSec) {
List<AnimTrack<?>> newTracks = new ArrayList<>();
for (AnimTrack<?> track : source.getTracks()) {
if (!(track instanceof TransformTrack tt)) {
newTracks.add(track);
continue;
}
float[] times = tt.getTimes();
int first = 0;
while (first < times.length - 1 && times[first] < startSec - 1e-5f) {
first++;
}
int last = times.length - 1;
while (last > 0 && times[last] > endSec + 1e-5f) {
last--;
}
int count = last - first + 1;
if (count <= 0) {
continue;
}
float[] newTimes = new float[count];
for (int i = 0; i < count; i++) {
newTimes[i] = times[first + i] - startSec;
}
Vector3f[] trans = tt.getTranslations();
Quaternion[] rots = tt.getRotations();
Vector3f[] scales = tt.getScales();
Vector3f[] newTrans = trans != null ? Arrays.copyOfRange(trans, first, last + 1) : null;
Quaternion[] newRots = rots != null ? Arrays.copyOfRange(rots, first, last + 1) : null;
Vector3f[] newScales = scales != null ? Arrays.copyOfRange(scales, first, last + 1) : null;
newTracks.add(new TransformTrack(tt.getTarget(), newTimes, newTrans, newRots, newScales));
}
AnimClip sub = new AnimClip(name);
sub.setTracks(newTracks.toArray(new AnimTrack[0]));
return sub;
}
private void loadClipFromFile(Path file) {
String fileName = file.getFileName().toString();
String fileClipKey = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName;
try {
Spatial loaded = assetManager.loadModel(assetKey);
AnimComposer ac = RetargetingSystem.findAnimComposer(loaded);
SkinningControl sc = RetargetingSystem.findSkinningControl(loaded);
if (ac == null) {
log.warn("[AnimLib] Kein AnimComposer in {} – übersprungen", assetKey);
return;
}
Armature armature = sc != null ? sc.getArmature() : null;
AnimClip bestClip = null;
for (String name : ac.getAnimClipsNames()) {
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
com.jme3.anim.AnimTrack<?>[] rawTracks = animClip.getTracks();
int n0 = 0;
if (rawTracks != null && rawTracks.length > 0 && rawTracks[0] instanceof TransformTrack tt0) {
n0 = tt0.getTimes() != null ? tt0.getTimes().length : 0;
}
log.info("[AnimLib] Clip geladen: '{}' aus {} | clipLen={}s tracks={} t0Frames={}",
name, assetKey, animClip.getLength(),
rawTracks != null ? rawTracks.length : 0, n0);
if (bestClip == null || animClip.getLength() > bestClip.getLength()) {
bestClip = animClip;
}
}
// Kanonischen Dateiname-Key auf den längsten Clip mappen.
// Verhindert dass ein leerer Null-Frame-Clip einen guten Clip überschreibt,
// wenn die j3o mehrere Clips mit leicht unterschiedlichen Namen enthält
// (z.B. "clip_Sword And Shield Slash" vs. "Sword And Shield Slash").
if (bestClip != null) {
AnimClip current = clips.get(fileClipKey);
if (current == null || bestClip.getLength() > current.getLength()) {
clips.put(fileClipKey, bestClip);
if (armature != null) armatures.put(fileClipKey, armature);
if (!fileClipKey.equals(bestClip.getName())) {
log.info("[AnimLib] Key '{}' → '{}' (clipLen={}s, besser)",
fileClipKey, bestClip.getName(), bestClip.getLength());
}
}
}
} catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());
}
}
/** Gibt den Asset-Wurzelpfad zurück (erstes Verzeichnis mit {@code Characters/}-Unterordner). */
public static Path findAssetRoot() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base);
if (Files.isDirectory(p.resolve("Characters"))) return p;
}
return Paths.get(ASSET_BASES[0]);
}
private static Path findClipsDir() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base, "Characters", "clips");
if (Files.isDirectory(p)) return p;
}
return null;
}
/**
* Entfernt Root-Motion aus dem flachsten Bone mit Translation-Track (typischerweise Hips).
* Nur dieser eine Bone wird modifiziert — alle anderen Bones bleiben unverändert.
*
* Achsenbelegung dieses Mixamo-Exports im Hips-Bone-Local-Space:
* Local X → JME3-X (seitlich) → wird auf 0 eingefroren
* Local Y → JME3-Y (Höhe oben) → IMMER frei lassen (Charakter-Höhe erhalten)
* Local Z → JME3-Z (vorwärts) → wird auf 0 eingefroren (alle Clips auf der Stelle)
*/
@SuppressWarnings("rawtypes")
public static AnimClip snapRootBoneXZ(AnimClip clip, Armature armature) {
if (clip == null || armature == null) return clip;
// Tiefe des flachsten Joints mit Translation-Track bestimmen
int minDepth = Integer.MAX_VALUE;
for (AnimTrack<?> track : clip.getTracks()) {
if (!(track instanceof TransformTrack tt) || !(tt.getTarget() instanceof Joint j)) continue;
Vector3f[] tr = tt.getTranslations();
if (tr != null && tr.length > 0) {
minDepth = Math.min(minDepth, jointDepth(j));
}
}
if (minDepth == Integer.MAX_VALUE) return clip;
List<AnimTrack<?>> newTracks = new ArrayList<>();
boolean modified = false;
for (AnimTrack<?> track : clip.getTracks()) {
if (!(track instanceof TransformTrack tt) || !(tt.getTarget() instanceof Joint j)) {
newTracks.add(track);
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;
}
// 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='{}' xRange={} yRange={} zRange={}",
clip.getName(), j.getName(),
String.format("%.3f", xRange),
String.format("%.3f", yMax - yMin),
String.format("%.3f", zRange));
Vector3f[] snapped = new Vector3f[translations.length];
for (int i = 0; i < translations.length; i++) {
snapped[i] = new Vector3f(
0f, // X = 0 (kein seitlicher Drift)
translations[i].y, // Y frei (Höhe; Y=oben in bone-local / JME3-Y)
0f // Z = 0 (kein Vorwärts-Drift; Z=vorwärts in bone-local)
);
}
newTracks.add(new TransformTrack(j, tt.getTimes(), snapped, tt.getRotations(), tt.getScales()));
modified = true;
}
if (!modified) return clip;
AnimClip result = new AnimClip(clip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
return result;
}
private static int jointDepth(Joint j) {
int d = 0;
Joint p = j.getParent();
while (p != null) { d++; p = p.getParent(); }
return d;
}
/** Loggt alle Tracks eines Clips: Bone-Name, hat Translation (T), Rotation (R), Scale (S). */
private void dumpClipTracks(com.jme3.anim.AnimClip clip) {
log.info("[ClipDump] '{}' length={}s tracks={}",
clip.getName(), clip.getLength(), clip.getTracks().length);
int tracksWithTranslation = 0;
for (com.jme3.anim.AnimTrack<?> t : clip.getTracks()) {
if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue;
boolean hasT = tt.getTranslations() != null && tt.getTranslations().length > 0;
boolean hasR = tt.getRotations() != null && tt.getRotations().length > 0;
boolean hasS = tt.getScales() != null && tt.getScales().length > 0;
String target = tt.getTarget() instanceof com.jme3.anim.Joint j ? j.getName() : "?";
if (hasT) {
tracksWithTranslation++;
com.jme3.math.Vector3f t0 = tt.getTranslations()[0];
com.jme3.math.Vector3f tN = tt.getTranslations()[tt.getTranslations().length - 1];
log.info("[ClipDump] TRANSLATE '{}' frames={} start=({},{},{}) end=({},{},{}) deltaY={}",
target, tt.getTranslations().length,
t0.x, t0.y, t0.z, tN.x, tN.y, tN.z, tN.y - t0.y);
} else {
log.info("[ClipDump] rot-only '{}' T={} R={} S={}", target, hasT, hasR, hasS);
}
}
log.info("[ClipDump] Gesamt: {} Tracks mit Translation (von {})", tracksWithTranslation, clip.getTracks().length);
}
}