Weiter am Kampfsystem gearbeitet, hier Schwertkampf und die animationen

This commit is contained in:
2026-09-20 17:16:43 +02:00
parent bdaeed056c
commit 836b5d3d99
23 changed files with 376 additions and 136 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,7 +9,12 @@
"T-Pose",
"Walking",
"Jumping",
"Sword And Shield Slash"
"Sword And Shield Slash",
"Sword And Shield Idle",
"Sword And Shield Run",
"Sword And Shield Walk",
"Sword And Shield Attack",
"Sword And Shield Slash 2"
],
"actionMap": {
"DEFAULT": "Idle",
@@ -22,7 +27,11 @@
"IDLE": "Idle",
"JUMP": "Jumping",
"SWORD_ATTACK_1": "Sword And Shield Slash",
"SWORD_ATTACK_2": "Sword And Shield Slash"
"SWORD_IDLE": "Sword And Shield Idle",
"SWORD_WALK": "Sword And Shield Walk",
"SWORD_RUN": "Sword And Shield Run",
"SWORD_ATTACK_3": "Sword And Shield Attack",
"SWORD_ATTACK_2": "Sword And Shield Slash 2"
},
"previewModelPath": "Characters/Models/hero.j3o",
"animOffsets": {
@@ -67,5 +76,16 @@
"SIT_UP",
"IDLE"
]
}
},
"snapClips": [
"Idle",
"Walking",
"Running",
"Sprint",
"Jumping",
"Sword And Shield Idle",
"Sword And Shield Run",
"Sword And Shield Walk",
"Sword And Shield Attack"
]
}

View File

@@ -1,4 +1,4 @@
#Tue Sep 15 12:31:37 CEST 2026
#Sun Sep 20 15:31:32 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0

View File

@@ -11,12 +11,12 @@
"worthGold": 0,
"damage": 0,
"grip": {
"posX": 0.24,
"posY": 0.05,
"posX": 0.37,
"posY": 0.08,
"posZ": 0.0,
"rotX": 0.0,
"rotY": -90.0,
"rotZ": 0.0
"rotZ": 90.0
},
"modelRef": {
"path": "Models/waffen/schwerter/rostiges_kurzschwert.j3o"

View File

@@ -1562,17 +1562,22 @@ public class ModelEditorState extends BaseAppState {
headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot);
// Klingenflosse: dünnes flaches Rechteck senkrecht zum Schaft entlang +Y,
// damit Rotation um Z (Roll) sichtbar ist. Breite 6cm, Dicke 2mm, entlang Schaft.
// Griffachsen-Zylinder: runder Zylinder senkrecht zum Schaft (+Y),
// zeigt die Roll-Richtung (ersetzt die frühere flache Klingenflosse).
// JME3-Cylinder liegt standardmäßig auf +Z → -90° um X dreht ihn nach +Y.
Material matFin = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
matFin.setColor("Color", new ColorRGBA(1f, 0.8f, 0.1f, 1f));
Geometry fin = new Geometry("gFin", new Box(0.001f, 0.03f, shaftLen * 0.5f));
fin.setMaterial(matFin);
fin.setLocalTranslation(0f, 0.03f, shaftLen * 0.5f);
float axisLen = 0.06f;
Geometry gripAxis = new Geometry("gGripAxis", new Cylinder(4, 8, 0.006f, axisLen, true));
gripAxis.setMaterial(matFin);
Quaternion axisRot = new Quaternion();
axisRot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
gripAxis.setLocalRotation(axisRot);
gripAxis.setLocalTranslation(0f, axisLen * 0.5f, 0f);
group.attachChild(marker);
group.attachChild(shaft);
group.attachChild(fin);
group.attachChild(gripAxis);
group.attachChild(head);
gripGizmoNode.attachChild(group);
gripGizmoNode.setCullHint(Spatial.CullHint.Inherit);

View File

@@ -51,6 +51,7 @@ public class AnimSetController implements ToolController {
private ListView<String> animSetPartsListView;
private java.util.Map<String, de.blight.game.animation.AnimSet.SubClipDef>
editableSubClips = new java.util.LinkedHashMap<>();
private java.util.Set<String> editableSnapClips = new java.util.LinkedHashSet<>();
private ListView<String> animSetSequenceListView;
private java.util.Map<String, java.util.List<String>>
editableSequences = new java.util.LinkedHashMap<>();
@@ -76,6 +77,16 @@ public class AnimSetController implements ToolController {
animSetCurrentName = setName;
animSetCurrentDir = setDir;
currentAnimSet = animSet;
editableSnapClips = new java.util.LinkedHashSet<>(animSet.getSnapClips());
// Migration: wenn snapClips noch leer, Fallback-Clips (LOCOMOTION_ACTIONS) einmalig übernehmen
// damit der Checkbox-Zustand die tatsächliche Snap-Konfiguration widerspiegelt.
if (editableSnapClips.isEmpty()) {
java.util.Set<String> locoFallback = java.util.Set.of(
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT");
animSet.getActionMap().forEach((action, clip) -> {
if (locoFallback.contains(action)) editableSnapClips.add(clip);
});
}
}
@Override
@@ -337,17 +348,14 @@ public class AnimSetController implements ToolController {
animSetClipListView.setPrefHeight(180);
editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips());
editableSequences = new java.util.LinkedHashMap<>(animSet.getSequences());
// editableSnapClips wird bereits in openWithSet gesetzt (inkl. Migration)
ctx.input().animPreviewClipOffsets.clear();
for (var en : animSet.getAnimOffsets().entrySet()) {
de.blight.game.animation.AnimOffset o = en.getValue();
ctx.input().animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz});
}
java.util.Set<String> locomotionActions = java.util.Set.of(
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT");
ctx.input().animPreviewLocomotionClips.clear();
animSet.getActionMap().forEach((action, clip) -> {
if (locomotionActions.contains(action)) ctx.input().animPreviewLocomotionClips.add(clip);
});
ctx.input().animPreviewLocomotionClips.addAll(editableSnapClips);
Path animRootDir = ctx.assetRoot().resolve("Characters");
@@ -454,10 +462,41 @@ public class AnimSetController implements ToolController {
autoSaveAnimSet();
});
CheckBox snapCB = new CheckBox("Root-Motion entfernen (auf der Stelle)");
snapCB.setDisable(true);
snapCB.setTooltip(new Tooltip("Bewegt sich die Animation durch den Raum (Walk/Run)?\nMit dieser Option bleibt der Charakter an Ort und Stelle\nund das Spiel übernimmt die Fortbewegung."));
final boolean[] snapCbUpdating = {false};
animSetClipListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> {
snapCB.setDisable(nv == null);
if (nv != null) {
snapCbUpdating[0] = true;
snapCB.setSelected(editableSnapClips.contains(nv));
snapCbUpdating[0] = false;
}
});
snapCB.selectedProperty().addListener((obs, ov, nv) -> {
if (snapCbUpdating[0]) return;
String sel = animSetClipListView.getSelectionModel().getSelectedItem();
if (sel == null) return;
if (nv) {
editableSnapClips.add(sel);
} else {
editableSnapClips.remove(sel);
}
ctx.input().animPreviewLocomotionClips.clear();
ctx.input().animPreviewLocomotionClips.addAll(editableSnapClips);
// Clip neu laden damit Snap-Änderung sofort in der Vorschau sichtbar wird
ctx.input().animImportQueue.offer(findAnimClipPath(sel));
autoSaveAnimSet();
});
HBox clipBtns = new HBox(6, addClipBtn, removeClipBtn);
HBox.setHgrow(addClipBtn, Priority.ALWAYS);
HBox.setHgrow(removeClipBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetClipListView, clipBtns);
inner.getChildren().addAll(animSetClipListView, snapCB, clipBtns);
// ── Clip-Teile (Sub-Clips) ─────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clip-Teile"), new Separator());
@@ -1172,12 +1211,9 @@ public class AnimSetController implements ToolController {
de.blight.game.animation.AnimOffset o = en.getValue();
ctx.input().animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz});
}
java.util.Set<String> locoActions = java.util.Set.of(
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT");
animSet.setSnapClips(new java.util.LinkedHashSet<>(editableSnapClips));
ctx.input().animPreviewLocomotionClips.clear();
animSet.getActionMap().forEach((action, clip) -> {
if (locoActions.contains(action)) ctx.input().animPreviewLocomotionClips.add(clip);
});
ctx.input().animPreviewLocomotionClips.addAll(editableSnapClips);
if (animSetModelCombo != null && animSetModelCombo.getValue() != null
&& !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue());

View File

@@ -38,12 +38,25 @@ public class AnimPlayer {
private LinearBlendSpace blendSpace;
private String pendingClip;
private float blendElapsed;
private float blendDuration;
/** Einsprungzeit: Clip startet bei diesem Zeitwert statt bei 0 (überspringt z.B. das Ausholen). */
private float pendingStartOffset;
public AnimPlayer(AnimComposer composer, SkinningControl skinning) {
this.composer = composer;
this.skinning = skinning;
}
/**
* Spielt einen Clip ab, optional ab einem Zeitversatz (z.B. Ausholen überspringen).
*
* @param startTimeOffset Clip startet ab dieser Zeit statt bei 0; 0 = von Anfang
*/
public boolean play(String clipName, float transition, float startTimeOffset) {
this.pendingStartOffset = startTimeOffset;
return play(clipName, transition);
}
/**
* Spielt einen Clip ab.
* Bei laufendem Clip und {@code transition > 0}: nahtloser Übergang
@@ -82,6 +95,7 @@ public class AnimPlayer {
blendSpace = space;
pendingClip = clipName;
blendElapsed = 0f;
blendDuration = Math.max(0.01f, transition);
currentAction = blend;
currentClip = null;
log.debug("[AnimPlayer] Blend → '{}'", clipName);
@@ -101,7 +115,7 @@ public class AnimPlayer {
public void update(float tpf) {
if (pendingClip == null || blendSpace == null) return;
blendElapsed += tpf;
float bt = Math.min(1f, blendElapsed / BLEND_DURATION);
float bt = Math.min(1f, blendElapsed / blendDuration);
blendSpace.setValue(bt);
if (bt >= 1f) finishBlend();
}
@@ -129,13 +143,35 @@ public class AnimPlayer {
private boolean directPlay(String clipName) {
try {
Action act = composer.action(clipName);
if (act instanceof BlendableAction ba) ba.setTransitionLength(0.0);
// Diagnose: zeigt warum length=0 entsteht
com.jme3.anim.AnimClip rawClip = composer.getAnimClip(clipName);
if (rawClip != null) {
com.jme3.anim.AnimTrack[] rawTracks = rawClip.getTracks();
if (rawTracks != null && rawTracks.length > 0 && rawTracks[0] instanceof com.jme3.anim.TransformTrack tt) {
float[] times = tt.getTimes();
log.info("[AnimDiag] '{}' clipLen={}s tracks={} t0Len={}s times=[{}..{}] n={}",
clipName, rawClip.getLength(), rawTracks.length, tt.getLength(),
times != null && times.length > 0 ? times[0] : -1,
times != null && times.length > 0 ? times[times.length-1] : -1,
times != null ? times.length : 0);
} else {
log.info("[AnimDiag] '{}' clipLen={}s tracks={}", clipName, rawClip.getLength(),
rawTracks != null ? rawTracks.length : -1);
}
} else {
log.info("[AnimDiag] '{}' → clip NOT in animClipMap!", clipName);
}
currentAction = composer.setCurrentAction(clipName);
currentClip = clipName;
pendingClip = null;
blendSpace = null;
log.debug("[AnimPlayer] direktPlay → '{}'", clipName);
double len = currentAction != null ? currentAction.getLength() : -1;
if (pendingStartOffset > 0f && currentAction != null && len > 0.01) {
composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(pendingStartOffset % (float) len);
}
pendingStartOffset = 0f;
log.info("[AnimPlayer] direktPlay → '{}' length={}s", clipName, len);
return currentAction != null;
} catch (Exception e) {
log.warn("[AnimPlayer] direktPlay('{}') fehlgeschlagen: {}", clipName, e.getMessage());
@@ -153,15 +189,15 @@ public class AnimPlayer {
if (composer.hasAction("__blend__")) composer.removeAction("__blend__");
} catch (Exception ignored) {}
try {
Action next = composer.action(clip);
if (next instanceof BlendableAction ba) ba.setTransitionLength(0.0);
currentAction = composer.setCurrentAction(clip);
currentClip = clip;
double len = currentAction != null ? currentAction.getLength() : 0.0;
// AnimLayer.setTime: kein extra controlUpdate, kein loop=false-Seiteneffekt.
// Clip startet dort, wo der Übergang endete (kein sichtbarer Sprung).
if (len > 0.01 && elapsed > 0) {
composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(elapsed % len);
if (len > 0.01 && (elapsed > 0 || pendingStartOffset > 0)) {
float t = (pendingStartOffset + elapsed) % (float) len;
composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(t);
pendingStartOffset = 0f;
}
log.debug("[AnimPlayer] finishBlend → '{}'", clip);
} catch (Exception e) {

View File

@@ -7,8 +7,10 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die
@@ -32,6 +34,8 @@ public class AnimSet {
private Map<String, SubClipDef> subClips = new LinkedHashMap<>();
/** Ablauf-Definitionen: Name → geordnete Liste von Aktions-Namen (aus actionMap). */
private Map<String, List<String>> sequences = new LinkedHashMap<>();
/** Clip-Namen die snapRootBoneXZ erhalten sollen (Root-Motion entfernen, Bewegung auf der Stelle). */
private Set<String> snapClips = new LinkedHashSet<>();
/** Beschreibt einen Sub-Clip: Zeitfenster [start, end] innerhalb eines kombinierten Quell-Clips. */
public static class SubClipDef {
@@ -60,6 +64,10 @@ public class AnimSet {
return sequences != null ? sequences : new LinkedHashMap<>();
}
public void setSequences(Map<String, List<String>> sequences) { this.sequences = sequences; }
public Set<String> getSnapClips() {
return snapClips != null ? snapClips : new LinkedHashSet<>();
}
public void setSnapClips(Set<String> snapClips) { this.snapClips = snapClips; }
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
public void save(Path setDir, String setName) throws IOException {

View File

@@ -35,17 +35,17 @@ public enum AnimationAction {
// ── Schwert ───────────────────────────────────────────────────────────────
SWORD_ATTACK_1,
SWORD_DRAW, SWORD_SHEATHE, SWORD_BLOCK, SWORD_HIT_TAKEN,
SWORD_ATTACK_2, SWORD_HEAVY_ATTACK,
SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK,
SWORD_IDLE, SWORD_WALK, SWORD_RUN,
// ── Axt ───────────────────────────────────────────────────────────────────
AXE_DRAW, AXE_SHEATHE, AXE_BLOCK, AXE_HIT_TAKEN,
AXE_ATTACK_1, AXE_ATTACK_2, AXE_HEAVY_ATTACK,
AXE_ATTACK_1, AXE_ATTACK_2, AXE_ATTACK_3, AXE_HEAVY_ATTACK,
AXE_IDLE, AXE_WALK, AXE_RUN,
// ── Zweihänder ────────────────────────────────────────────────────────────
TWO_HAND_DRAW, TWO_HAND_SHEATHE, TWO_HAND_BLOCK, TWO_HAND_HIT_TAKEN,
TWO_HAND_ATTACK_1, TWO_HAND_ATTACK_2, TWO_HAND_HEAVY_ATTACK,
TWO_HAND_ATTACK_1, TWO_HAND_ATTACK_2, TWO_HAND_ATTACK_3, TWO_HAND_HEAVY_ATTACK,
TWO_HAND_IDLE, TWO_HAND_WALK, TWO_HAND_RUN,
// ── Bogen ─────────────────────────────────────────────────────────────────
@@ -98,6 +98,7 @@ public enum AnimationAction {
case SWORD_BLOCK -> TextRegistry.resolve(null, key, "Block (Schwert)");
case SWORD_HIT_TAKEN -> TextRegistry.resolve(null, key, "Treffer (Schwert)");
case SWORD_ATTACK_2 -> TextRegistry.resolve(null, key, "Angriff 2 (Schwert)");
case SWORD_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Schwert)");
case SWORD_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Schwert)");
case SWORD_IDLE -> TextRegistry.resolve(null, key, "Stehen (Schwert)");
case SWORD_WALK -> TextRegistry.resolve(null, key, "Gehen (Schwert)");
@@ -109,6 +110,7 @@ public enum AnimationAction {
case AXE_HIT_TAKEN -> TextRegistry.resolve(null, key, "Treffer (Axt)");
case AXE_ATTACK_1 -> TextRegistry.resolve(null, key, "Angriff 1 (Axt)");
case AXE_ATTACK_2 -> TextRegistry.resolve(null, key, "Angriff 2 (Axt)");
case AXE_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Axt)");
case AXE_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Axt)");
case AXE_IDLE -> TextRegistry.resolve(null, key, "Stehen (Axt)");
case AXE_WALK -> TextRegistry.resolve(null, key, "Gehen (Axt)");
@@ -120,6 +122,7 @@ public enum AnimationAction {
case TWO_HAND_HIT_TAKEN -> TextRegistry.resolve(null, key, "Treffer (Zweihänder)");
case TWO_HAND_ATTACK_1 -> TextRegistry.resolve(null, key, "Angriff 1 (Zweihänder)");
case TWO_HAND_ATTACK_2 -> TextRegistry.resolve(null, key, "Angriff 2 (Zweihänder)");
case TWO_HAND_ATTACK_3 -> TextRegistry.resolve(null, key, "Angriff 3 – Kritisch (Zweihänder)");
case TWO_HAND_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Zweihänder)");
case TWO_HAND_IDLE -> TextRegistry.resolve(null, key, "Stehen (Zweihänder)");
case TWO_HAND_WALK -> TextRegistry.resolve(null, key, "Gehen (Zweihänder)");

View File

@@ -43,10 +43,6 @@ public class AnimationLibrary extends BaseAppState {
/** Clips die snapRootBoneXZ erhalten (Locomotion: Walk, Run, Sprint, …). */
private final Set<String> snapClipNames = new LinkedHashSet<>();
/** Standard-Locomotion-Aktionen, deren gemappte Clips snapRootBoneXZ erhalten. */
private static final Set<String> LOCOMOTION_ACTIONS = Set.of(
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT"
);
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -103,6 +99,14 @@ public class AnimationLibrary extends BaseAppState {
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.
@@ -131,8 +135,10 @@ public class AnimationLibrary extends BaseAppState {
}
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
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;
@@ -221,10 +227,8 @@ public class AnimationLibrary extends BaseAppState {
snapClipNames.clear();
try {
AnimSet set = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName);
set.getActionMap().forEach((action, clip) -> {
if (LOCOMOTION_ACTIONS.contains(action)) snapClipNames.add(clip);
});
log.info("[AnimLib] snapRootBoneXZ für Locomotion-Clips: {}", snapClipNames);
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());
}
@@ -320,9 +324,9 @@ public class AnimationLibrary extends BaseAppState {
}
private void loadClipFromFile(Path file) {
String fileName = file.getFileName().toString();
String clipName = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName;
String fileName = file.getFileName().toString();
String fileClipKey = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName;
try {
Spatial loaded = assetManager.loadModel(assetKey);
@@ -334,11 +338,37 @@ public class AnimationLibrary extends BaseAppState {
}
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);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
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());

View File

@@ -81,11 +81,15 @@ public final class RetargetingSystem {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint j)
srcTrackMap.put(j.getName(), tt);
// Reference keyframe times
// Reference keyframe times – pick the longest array (some bind-pose tracks have only 1 frame)
float[] times = null;
for (String name : nameMap.keySet()) {
TransformTrack tt = srcTrackMap.get(name);
if (tt != null) { times = tt.getTimes(); break; }
if (tt != null && tt.getTimes() != null) {
if (times == null || tt.getTimes().length > times.length) {
times = tt.getTimes();
}
}
}
if (times == null || times.length == 0) {
log.warn("[Retarget] Keine Keyframe-Zeiten für '{}'", sourceClip.getName());
@@ -398,18 +402,10 @@ public final class RetargetingSystem {
// ── Same-rig detection & redirect ────────────────────────────────────────
/** True nur wenn alle Namen exakt selbst-gemappt UND alle Bind-Posen übereinstimmen. */
/** True wenn alle Namen exakt selbst-gemappt sind (gleiche Knochen-Namen → gleicher Rig-Typ). */
private static boolean isSameRig(Map<String, String> nameMap, Armature src, Armature dst) {
for (var e : nameMap.entrySet()) {
if (!e.getKey().equals(e.getValue())) return false;
Joint srcJ = src.getJoint(e.getKey());
Joint dstJ = dst.getJoint(e.getValue());
if (srcJ == null || dstJ == null) continue;
Quaternion sb = srcJ.getInitialTransform() != null
? srcJ.getInitialTransform().getRotation() : new Quaternion();
Quaternion db = dstJ.getInitialTransform() != null
? dstJ.getInitialTransform().getRotation() : new Quaternion();
if (Math.abs(sb.dot(db)) < 0.9999f) return false;
}
return true;
}

View File

@@ -34,10 +34,18 @@ public class PlayerInputControl {
private static final Logger log = LoggerFactory.getLogger(PlayerInputControl.class);
private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f;
private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f;
/** Kürzere Überblend-Zeit für Kampf-Animationen (Angriff fühlt sich "snappy" an). */
private static final float COMBAT_BLEND = 0.05f;
/** Zeit in Sekunden nach einem Angriff, in der der nächste Kombo-Schlag gedrückt werden kann. */
private static final float COMBO_WINDOW = 0.6f;
// Combo-Overlap-Utility: MELEE_COMBO_ACTIVE + COMBO_ATK2_OFFSET hier eintragen,
// sobald die Animationen in Blender angeglichen wurden (passende Übergangsposen).
// AnimPlayer.play(clip, transition, startOffset) und nextStartOffset in tryPlay() sind
// bereits verdrahtet — nur die Konstanten reaktivieren und in handleCombatAttack() einsetzen.
private static final String ACT_DRAW_MELEE = "DrawMeleeWeapon";
private static final String ACT_DRAW_RANGED = "DrawRangedWeapon";
@@ -64,11 +72,17 @@ public class PlayerInputControl {
private boolean weaponDrawn = false;
private ItemSubCategory drawnWeaponType = null;
private boolean isAiming = false;
private boolean meleeAttackToggle = false;
private boolean rightMouseHeld = false;
private boolean combatAnimActive = false;
private float combatAnimRemaining = 0f;
private Runnable combatAnimCallback = null;
/** Aktueller Schritt in der Kombo-Sequenz (0–3): 0=ATK1, 1=ATK2, 2=ATK1, 3=ATK3-kritisch. */
private int comboStep = 0;
/** Zeitfenster nach einem Angriff, in dem der nächste Kombo-Schlag akzeptiert wird. */
private boolean comboWindowActive = false;
private float comboWindowRemaining = 0f;
/** Letzter Angriff war ein kritischer Treffer (Kombo-Schritt 3). Für das Schadenssystem. */
private boolean lastAttackCritical = false;
private AnimationLibrary animLib;
private String animSetName;
@@ -121,6 +135,7 @@ public class PlayerInputControl {
private int jumpFrames = 0;
private int groundGraceFrames = 0;
private double nextTransitionLength = 0.0;
private float nextStartOffset = 0f;
private boolean pickupActive = false;
private float pickupRemaining = 0f;
@@ -241,6 +256,7 @@ public class PlayerInputControl {
public void setMainCharacter(MainCharacter mc) { this.mainCharacter = mc; }
public boolean isWeaponDrawn() { return weaponDrawn; }
public ItemSubCategory getDrawnWeaponType() { return drawnWeaponType; }
public boolean isLastAttackCritical() { return lastAttackCritical; }
public com.jme3.anim.SkinningControl getSkinningControl() { return skinningControl; }
public boolean isPaused() { return paused; }
@@ -255,6 +271,8 @@ public class PlayerInputControl {
rightMouseHeld = false;
combatAnimActive = false;
combatAnimCallback = null;
comboStep = 0;
comboWindowActive = false;
}
}
@@ -657,12 +675,32 @@ public class PlayerInputControl {
// Kampf-Anim-Timer
if (combatAnimActive) {
combatAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (combatAnimRemaining <= 0f) {
combatAnimActive = false;
currentAnim = null;
nextTransitionLength = COMBAT_BLEND;
Runnable cb = combatAnimCallback;
combatAnimCallback = null;
if (cb != null) cb.run();
if (!combatAnimActive && isMeleeType(drawnWeaponType)) {
comboWindowActive = true;
comboWindowRemaining = COMBO_WINDOW;
log.info("[Combat] Kombo-Fenster geöffnet (step={} next={})", comboStep, comboStep);
}
} else {
pollFootsteps(tpf);
return;
}
}
// Kombo-Fenster: läuft nach einem Nahkampfangriff; verfällt es → Kombo-Reset
if (comboWindowActive) {
comboWindowRemaining -= tpf;
if (comboWindowRemaining <= 0f) {
comboWindowActive = false;
comboStep = 0;
log.info("[Combat] Kombo-Fenster abgelaufen → Reset");
}
}
@@ -670,7 +708,9 @@ public class PlayerInputControl {
if (jumpFrames > 0) jumpFrames--;
if (groundGraceFrames > 0) groundGraceFrames--;
if (!combatAnimActive) {
// Während des Kombo-Fensters läuft die letzte Angriffs-Animation weiter —
// kein Wechsel zu IDLE/WALK, sonst entsteht ein sichtbares Zucken beim nächsten Schlag.
if (!combatAnimActive && !comboWindowActive) {
AnimationAction target;
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
@@ -707,7 +747,8 @@ public class PlayerInputControl {
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
log.info("[Anim] {} → clip='{}' (set={})", action, clip, animSetName);
if (runningClip != null) nextTransitionLength = 0.15;
// Negativer Wert = Hard-Cut angefordert (kein Blend); 0 = noch nicht gesetzt → Default 0.15s
if (runningClip != null && nextTransitionLength == 0.0) nextTransitionLength = 0.15;
if (clip != null && tryPlay(clip)) return;
if (action != AnimationAction.DEFAULT) {
String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
@@ -765,16 +806,25 @@ public class PlayerInputControl {
}
private void handleCombatAttack() {
if (!weaponDrawn || blockingAnimActive || combatAnimActive) return;
if (!weaponDrawn || blockingAnimActive || combatAnimActive) {
log.info("[Combat] BLOCKIERT: weaponDrawn={} blockingAnim={} combatAnim={}",
weaponDrawn, blockingAnimActive, combatAnimActive);
return;
}
if (isMeleeType(drawnWeaponType)) {
AnimationAction atk = meleeAttackToggle
? getMeleeAttack2Action(drawnWeaponType)
: getMeleeAttack1Action(drawnWeaponType);
meleeAttackToggle = !meleeAttackToggle;
int step = comboStep;
boolean isComboFollow = comboWindowActive; // war Kombo-Fenster offen?
AnimationAction atk = getMeleeComboAction(drawnWeaponType, step);
lastAttackCritical = (step == 3);
comboStep = (step + 1) % 4;
comboWindowActive = false;
float dur = resolveClipLength(atk, 0.6f);
// Kombo-Folgeschlag: kurzer Blend für sauberen Übergang; erster Schlag: Hard-Cut
nextTransitionLength = isComboFollow ? COMBAT_BLEND : -1.0;
log.info("[Combat] Kombo-Schritt {}: action={} dur={}s kritisch={}",
step, atk, dur, lastAttackCritical);
combatAnimActive = true;
// Blend-out zu Idle startet BLEND_DURATION früher → kein sichtbares Einfrieren am letzten Frame
combatAnimRemaining = Math.max(0.1f, dur - de.blight.game.animation.AnimPlayer.BLEND_DURATION);
combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimCallback = null;
playAction(atk);
currentAnim = atk;
@@ -785,14 +835,16 @@ public class PlayerInputControl {
float fireDur = resolveClipLength(fireAnim, 0.5f);
float reloadDur = resolveClipLength(reloadAnim, 0.8f);
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, fireDur - de.blight.game.animation.AnimPlayer.BLEND_DURATION);
combatAnimRemaining = Math.max(0.1f, fireDur - COMBAT_BLEND);
combatAnimCallback = () -> {
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, reloadDur - de.blight.game.animation.AnimPlayer.BLEND_DURATION);
combatAnimRemaining = Math.max(0.1f, reloadDur - COMBAT_BLEND);
combatAnimCallback = null;
nextTransitionLength = COMBAT_BLEND;
playAction(reloadAnim);
currentAnim = reloadAnim;
};
nextTransitionLength = COMBAT_BLEND;
playAction(fireAnim);
currentAnim = fireAnim;
}
@@ -909,6 +961,23 @@ public class PlayerInputControl {
};
}
private static AnimationAction getMeleeAttack3Action(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_ATTACK_3;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_ATTACK_3;
default -> AnimationAction.SWORD_ATTACK_3;
};
}
/** Kombo-Sequenz: 0=ATK1, 1=ATK2, 2=ATK1, 3=ATK3 (kritisch). */
private static AnimationAction getMeleeComboAction(ItemSubCategory type, int step) {
return switch (step) {
case 1 -> getMeleeAttack2Action(type);
case 3 -> getMeleeAttack3Action(type);
default -> getMeleeAttack1Action(type);
};
}
private static AnimationAction getMeleeBlockAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_BLOCK;
@@ -1185,9 +1254,11 @@ public class PlayerInputControl {
if (animPlayer == null) {
animPlayer = new AnimPlayer(animComposer, skinningControl);
}
float transLen = (float) nextTransitionLength;
float transLen = (float) nextTransitionLength;
float startOff = nextStartOffset;
nextTransitionLength = 0.0;
boolean ok = animPlayer.play(clip, transLen);
nextStartOffset = 0f;
boolean ok = animPlayer.play(clip, transLen, startOff);
log.info("[Anim] AnimPlayer.play('{}') transLen={} → {}", clip, transLen, ok ? "OK" : "FAILED");
if (ok) {
runningClip = animPlayer.getCurrentClip();

View File

@@ -29,28 +29,33 @@ public class WeaponAttachmentControl extends AbstractControl {
private static final String BONE_SPINE = "mixamorig:Spine2";
private static final String BONE_HAND = "mixamorig:RightHand";
private static final String BONE_PALM = "mixamorig:RightHandMiddle1";
private static final float DEG_TO_RAD = (float) Math.PI / 180f;
private static final float HALF_PI = (float) Math.PI * 0.5f;
// Grip-Zielposition im Spine2-lokalen Raum (Meter): Griff ~40cm über dem Brustbein
private static final Vector3f BACK_GRIP_OFFSET = new Vector3f(0.05f, 0.40f, -0.08f);
private static final Vector3f BACK_GRIP_OFFSET = new Vector3f(0.0f, 0.40f, -0.13f);
/** Zusatz-Versatz beim gezückten Schwert in lokalem Waffen-Space (X=entlang Klinge, Y=entlang Griff, Z=senkrecht zur Schneide). */
private static final Vector3f HAND_GRIP_OFFSET = new Vector3f(0.1f, 0.0f, 0.05f);
private final AssetManager assetManager;
private final PlayerInputControl playerInput;
private final MainCharacter mc;
private com.jme3.anim.SkinningControl skinCtrl;
private Node backNode; // Spine2-Attachment-Node (im skalierten Char-Modell)
private Node handNode; // RightHand-Attachment-Node
private Node backNode;
private Node handNode;
private Node palmNodeRef;
private int palmOffsetFrameDelay = 0;
// Bind-Pose-Rotation des RightHand-Knochens in Character-lokalem Raum (T-Pose).
// Wird einmalig beim Initialisieren aus dem InverseBindMatrix abgeleitet.
// Erlaubt, die Grip-Rotation aus dem Modell-Editor direkt im Spiel zu verwenden.
private Quaternion handBindRotInChar;
// Offset Handgelenk → Fingerknöchel in Hand-Bone-Local-Space.
// Wird im 2. Frame aus den Runtime-Node-Positionen berechnet (skalierungs-unabhängig).
private Vector3f palmOffsetLocal;
private Item lastEquippedWeapon;
private boolean lastDrawnState = false;
private Spatial weaponSpatial;
private boolean gripDiagLogged = false;
public WeaponAttachmentControl(AssetManager assetManager,
PlayerInputControl playerInput,
@@ -62,38 +67,45 @@ public class WeaponAttachmentControl extends AbstractControl {
@Override
protected void controlUpdate(float tpf) {
// Lazy init: SkinningControl erst nach setAnimationContext() verfügbar
if (skinCtrl == null) {
skinCtrl = playerInput.getSkinningControl();
if (skinCtrl == null) return;
try {
backNode = skinCtrl.getAttachmentsNode(BONE_SPINE);
log.info("[WeaponAttach] backNode ({}): {}", BONE_SPINE, backNode);
} catch (Exception e) {
log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_SPINE, e.getMessage());
}
try {
handNode = skinCtrl.getAttachmentsNode(BONE_HAND);
log.info("[WeaponAttach] handNode ({}): {}", BONE_HAND, handNode);
} catch (Exception e) {
log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_HAND, e.getMessage());
}
handBindRotInChar = extractJointBindRot(skinCtrl, BONE_HAND);
log.info("[WeaponAttach] handBindRotInChar: {}", handBindRotInChar);
try {
palmNodeRef = skinCtrl.getAttachmentsNode(BONE_PALM);
palmOffsetFrameDelay = 2; // SkinningControl muss mindestens 1x laufen
} catch (Exception e) {
log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_PALM, e.getMessage());
}
log.info("[WeaponAttach] handBindRotInChar={}", handBindRotInChar);
}
// Palm-Offset verzögert berechnen (SkinningControl muss mindestens 1x gelaufen sein)
if (palmOffsetFrameDelay > 0 && palmNodeRef != null && handNode != null) {
palmOffsetFrameDelay--;
if (palmOffsetFrameDelay == 0) {
palmOffsetLocal = computePalmOffsetFromNodes(handNode, palmNodeRef);
log.info("[WeaponAttach] palmOffsetLocal (from nodes)={}", palmOffsetLocal);
}
}
Item current = mc.getEquippedMeleeWeapon();
boolean drawn = playerInput.isWeaponDrawn();
if (current != lastEquippedWeapon) {
log.info("[WeaponAttach] Waffe geändert: {} → {}",
lastEquippedWeapon != null ? lastEquippedWeapon.getItemId() : "null",
current != null ? current.getItemId() : "null");
detachWeapon();
lastEquippedWeapon = current;
if (current != null) {
loadWeapon(current);
}
if (current != null) loadWeapon(current);
}
if (weaponSpatial != null) {
@@ -111,12 +123,8 @@ public class WeaponAttachmentControl extends AbstractControl {
}
try {
weaponSpatial = assetManager.loadModel(item.getModelRef().getPath());
// Wichtig: Waffe direkt am character-Node hängen (ohne geerbten 0.02-Scale
// aus der Charakter-Modell-Hierarchie). Position/Rotation wird jedes Frame
// aus den Bone-World-Transforms berechnet.
((Node) spatial).attachChild(weaponSpatial);
log.info("[WeaponAttach] Waffe geladen und an character-Node gehängt: {}",
item.getModelRef().getPath());
log.info("[WeaponAttach] Waffe geladen: {}", item.getModelRef().getPath());
} catch (Exception e) {
log.warn("[WeaponAttach] Waffenmodell nicht ladbar ({}): {}",
item.getModelRef().getPath(), e.getMessage());
@@ -130,31 +138,23 @@ public class WeaponAttachmentControl extends AbstractControl {
Node boneNode = drawn ? handNode : backNode;
if (boneNode == null) return;
// Bone-World-Transform (korrekt, auch wenn Eltern skaliert sind)
Vector3f boneWorldPos = boneNode.getWorldTranslation();
Quaternion boneWorldRot = boneNode.getWorldRotation();
// In den lokalen Raum des character-Nodes transformieren
// (character hat in der Regel nur Translation + Rotation, keinen Scale)
Quaternion charWorldRotInv = spatial.getWorldRotation().inverse();
Vector3f boneInChar = charWorldRotInv.mult(
boneWorldPos.subtract(spatial.getWorldTranslation()));
Quaternion boneRotInChar = charWorldRotInv.mult(boneWorldRot);
Vector3f charWorldPos = spatial.getWorldTranslation();
Vector3f boneInChar = charWorldRotInv.mult(
boneNode.getWorldTranslation().subtract(charWorldPos));
Quaternion boneRotInChar = charWorldRotInv.mult(boneNode.getWorldRotation());
if (drawn) {
applyHandTransform(boneInChar, boneRotInChar, grip);
Vector3f attachInChar = (palmOffsetLocal != null)
? boneInChar.add(boneRotInChar.mult(palmOffsetLocal.mult(0.5f)))
: boneInChar;
applyHandTransform(attachInChar, boneRotInChar, grip);
} else {
applyBackTransform(boneInChar, boneRotInChar, grip);
}
}
/**
* Waffe in der Hand: Griff-Punkt soll am Handknochen liegen, Rotation aus ItemGrip.
*
* Die Grip-Rotation ist im Character-/Welt-Raum definiert (wie im Grip-Editor sichtbar).
* Der Animations-Delta (Bewegung relativ zur Bind-Pose) wird zusätzlich aufaddiert,
* damit die Waffe den Handknochen durch alle Animationen folgt.
*/
private void applyHandTransform(Vector3f bonePosInChar, Quaternion boneRotInChar, ItemGrip grip) {
Quaternion gripRot = (grip != null)
? new Quaternion().fromAngles(DEG_TO_RAD * grip.getRotX(), DEG_TO_RAD * grip.getRotY(), DEG_TO_RAD * grip.getRotZ())
@@ -163,21 +163,65 @@ public class WeaponAttachmentControl extends AbstractControl {
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ())
: Vector3f.ZERO;
// animDelta = wie weit der Knochen sich von der Bind-Pose bewegt hat.
// In der Bind-Pose (T-Pose / Idle) ist animDelta ≈ identity → weaponRot ≈ gripRot.
// Während Angriffsanimationen dreht animDelta die Waffe mit der Hand mit.
Quaternion animDelta = (handBindRotInChar != null)
? boneRotInChar.mult(handBindRotInChar.inverse())
: boneRotInChar;
Quaternion weaponRot = animDelta.mult(gripRot.inverse());
Quaternion weaponRot = animDelta.mult(gripRot);
if (!gripDiagLogged && weaponSpatial != null) {
gripDiagLogged = true;
float[] wa = weaponRot.toAngles(null);
Vector3f bx = weaponRot.mult(Vector3f.UNIT_X);
Vector3f by = weaponRot.mult(Vector3f.UNIT_Y);
Vector3f bz = weaponRot.mult(Vector3f.UNIT_Z);
float[] ad = animDelta.toAngles(null);
float[] gr = gripRot.toAngles(null);
log.info("[GripDiag] animDelta(°): x={} y={} z={}",
Math.round(Math.toDegrees(ad[0])), Math.round(Math.toDegrees(ad[1])), Math.round(Math.toDegrees(ad[2])));
log.info("[GripDiag] gripRot(°): x={} y={} z={}",
Math.round(Math.toDegrees(gr[0])), Math.round(Math.toDegrees(gr[1])), Math.round(Math.toDegrees(gr[2])));
log.info("[GripDiag] weaponRot(°): x={} y={} z={}",
Math.round(Math.toDegrees(wa[0])), Math.round(Math.toDegrees(wa[1])), Math.round(Math.toDegrees(wa[2])));
log.info("[GripDiag] Waffe +X in CharSpace: ({},{},{})", round1(bx.x), round1(bx.y), round1(bx.z));
log.info("[GripDiag] Waffe +Y in CharSpace: ({},{},{})", round1(by.x), round1(by.y), round1(by.z));
log.info("[GripDiag] Waffe +Z in CharSpace: ({},{},{})", round1(bz.x), round1(bz.y), round1(bz.z));
// Vergleich RightHand vs RightHandMiddle1 – welcher Bone gibt die bessere Orientierung?
Quaternion charWorldRotInv2 = spatial.getWorldRotation().inverse();
if (palmNodeRef != null) {
Quaternion palmRotInChar = charWorldRotInv2.mult(palmNodeRef.getWorldRotation());
Quaternion palmBind = extractJointBindRot(skinCtrl, BONE_PALM);
Quaternion palmDelta = (palmBind != null) ? palmRotInChar.mult(palmBind.inverse()) : palmRotInChar;
float[] pd = palmDelta.toAngles(null);
Vector3f px = palmDelta.mult(Vector3f.UNIT_X);
Vector3f py = palmDelta.mult(Vector3f.UNIT_Y);
log.info("[GripDiag] palmDelta(°) [Middle1]: x={} y={} z={}",
Math.round(Math.toDegrees(pd[0])), Math.round(Math.toDegrees(pd[1])), Math.round(Math.toDegrees(pd[2])));
log.info("[GripDiag] palm +X in CharSpace: ({},{},{})", round1(px.x), round1(px.y), round1(px.z));
log.info("[GripDiag] palm +Y in CharSpace: ({},{},{})", round1(py.x), round1(py.y), round1(py.z));
}
}
// Waffenposition: Griff-Punkt liegt am Knochen-Ursprung
Vector3f weaponPos = bonePosInChar.subtract(weaponRot.mult(gripPos));
weaponPos = weaponPos.add(weaponRot.mult(HAND_GRIP_OFFSET));
weaponSpatial.setLocalRotation(weaponRot);
weaponSpatial.setLocalTranslation(bonePosInChar.subtract(weaponRot.mult(gripPos)));
weaponSpatial.setLocalTranslation(weaponPos);
}
private static float round1(float v) { return Math.round(v * 10f) / 10f; }
/**
* Berechnet den Offset Handgelenk → Fingerknöchel in Hand-Bone-Local-Space
* direkt aus den World-Positionen der Attachment-Nodes.
* Dieser Ansatz umgeht alle InverseBindMatrix-Skalierungsprobleme.
* Muss aufgerufen werden NACHDEM SkinningControl mindestens einmal gelaufen ist.
*/
private static Vector3f computePalmOffsetFromNodes(Node handNode, Node palmNode) {
Vector3f deltaWorld = palmNode.getWorldTranslation().subtract(handNode.getWorldTranslation());
// Delta in Hand-Bone-Local-Space ausdrücken (rotiert mit der Hand mit)
Quaternion handWorldRotInv = handNode.getWorldRotation().inverse();
return handWorldRotInv.mult(deltaWorld);
}
/** Liest die Bind-Pose-Rotation eines Knochens (Model-Raum ≈ Character-lokal) aus dem InverseBindMatrix. */
private static Quaternion extractJointBindRot(com.jme3.anim.SkinningControl sc, String jointName) {
com.jme3.anim.Armature arm = sc.getArmature();
if (arm == null) return null;
@@ -190,22 +234,13 @@ public class WeaponAttachmentControl extends AbstractControl {
return bindMat.toRotationQuat().normalizeLocal();
}
/**
* Waffe am Rücken: BACK_GRIP_OFFSET über Spine2, Klinge zeigt nach unten.
*/
private void applyBackTransform(Vector3f bonePosInChar, Quaternion boneRotInChar, ItemGrip grip) {
// fromAngles(0, 0, HALF_PI): Spine2-FBX-Flip kompensiert, Klinge zeigt nach unten
Quaternion backRot = new Quaternion().fromAngles(0f, 0f, HALF_PI);
Quaternion backRot = new Quaternion().fromAngles(0f, 0f, HALF_PI);
Quaternion weaponRot = boneRotInChar.mult(backRot);
// Zielposition des Griff-Punktes: BACK_GRIP_OFFSET im Knochen-lokalen Raum
Vector3f gripTarget = bonePosInChar.add(boneRotInChar.mult(BACK_GRIP_OFFSET));
Vector3f gripPos = (grip != null)
Vector3f gripTarget = bonePosInChar.add(boneRotInChar.mult(BACK_GRIP_OFFSET));
Vector3f gripPos = (grip != null)
? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ())
: Vector3f.ZERO;
// weaponPos + weaponRot * gripPos = gripTarget
weaponSpatial.setLocalRotation(weaponRot);
weaponSpatial.setLocalTranslation(gripTarget.subtract(weaponRot.mult(gripPos)));
}