diff --git a/assets/imported/animations/Sword And Shield Attack.fbx b/assets/imported/animations/Sword And Shield Attack.fbx new file mode 100644 index 0000000..a07a434 Binary files /dev/null and b/assets/imported/animations/Sword And Shield Attack.fbx differ diff --git a/assets/imported/animations/Sword And Shield Idle.fbx b/assets/imported/animations/Sword And Shield Idle.fbx new file mode 100644 index 0000000..8381ddd Binary files /dev/null and b/assets/imported/animations/Sword And Shield Idle.fbx differ diff --git a/assets/imported/animations/Sword And Shield Run.fbx b/assets/imported/animations/Sword And Shield Run.fbx new file mode 100644 index 0000000..d17e7fb Binary files /dev/null and b/assets/imported/animations/Sword And Shield Run.fbx differ diff --git a/assets/imported/animations/Sword And Shield Slash 2.fbx b/assets/imported/animations/Sword And Shield Slash 2.fbx new file mode 100644 index 0000000..68f859b Binary files /dev/null and b/assets/imported/animations/Sword And Shield Slash 2.fbx differ diff --git a/assets/imported/animations/Sword And Shield Walk.fbx b/assets/imported/animations/Sword And Shield Walk.fbx new file mode 100644 index 0000000..4be1248 Binary files /dev/null and b/assets/imported/animations/Sword And Shield Walk.fbx differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Attack.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Attack.j3o new file mode 100644 index 0000000..cb5020b Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Attack.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Idle.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Idle.j3o new file mode 100644 index 0000000..e738813 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Idle.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Run.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Run.j3o new file mode 100644 index 0000000..687b7bc Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Run.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Slash 2.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Slash 2.j3o new file mode 100644 index 0000000..76e500e Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Slash 2.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Walk.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Walk.j3o new file mode 100644 index 0000000..242dae6 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Walk.j3o differ diff --git a/blight-assets/src/main/resources/Characters/sets/human.animset.json b/blight-assets/src/main/resources/Characters/sets/human.animset.json index 22283f4..1974614 100644 --- a/blight-assets/src/main/resources/Characters/sets/human.animset.json +++ b/blight-assets/src/main/resources/Characters/sets/human.animset.json @@ -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" + ] } \ No newline at end of file diff --git a/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o b/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o index c97810d..3a32c9b 100644 Binary files a/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o and b/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o differ diff --git a/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o.meta b/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o.meta index 9a19eb7..4afbe2f 100644 --- a/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o.meta +++ b/blight-assets/src/main/resources/Models/waffen/schwerter/rostiges_kurzschwert.j3o.meta @@ -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 diff --git a/blight-assets/src/main/resources/items/weapon/rostiges_kurzschwert.item b/blight-assets/src/main/resources/items/weapon/rostiges_kurzschwert.item index 89b1af7..51c394a 100644 --- a/blight-assets/src/main/resources/items/weapon/rostiges_kurzschwert.item +++ b/blight-assets/src/main/resources/items/weapon/rostiges_kurzschwert.item @@ -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" diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java index e8726fb..b535723 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java @@ -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); diff --git a/blight-editor/src/main/java/de/blight/editor/toolset/anim/AnimSetController.java b/blight-editor/src/main/java/de/blight/editor/toolset/anim/AnimSetController.java index 35749a7..2d87624 100644 --- a/blight-editor/src/main/java/de/blight/editor/toolset/anim/AnimSetController.java +++ b/blight-editor/src/main/java/de/blight/editor/toolset/anim/AnimSetController.java @@ -51,6 +51,7 @@ public class AnimSetController implements ToolController { private ListView animSetPartsListView; private java.util.Map editableSubClips = new java.util.LinkedHashMap<>(); + private java.util.Set editableSnapClips = new java.util.LinkedHashSet<>(); private ListView animSetSequenceListView; private java.util.Map> 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 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 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 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()); diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java b/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java index 3493c08..173dc67 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimPlayer.java @@ -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) { diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java index 3c4f14b..7123206 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java @@ -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 subClips = new LinkedHashMap<>(); /** Ablauf-Definitionen: Name → geordnete Liste von Aktions-Namen (aus actionMap). */ private Map> sequences = new LinkedHashMap<>(); + /** Clip-Namen die snapRootBoneXZ erhalten sollen (Root-Motion entfernen, Bewegung auf der Stelle). */ + private Set 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> sequences) { this.sequences = sequences; } + public Set getSnapClips() { + return snapClips != null ? snapClips : new LinkedHashSet<>(); + } + public void setSnapClips(Set snapClips) { this.snapClips = snapClips; } /** Speichert dieses Set als {@code .animset.json} im Verzeichnis {@code setDir}. */ public void save(Path setDir, String setName) throws IOException { diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index 2eb589c..b8e8ac1 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -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)"); diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java index fdd55f6..e9c8383 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java @@ -43,10 +43,6 @@ public class AnimationLibrary extends BaseAppState { /** Clips die snapRootBoneXZ erhalten (Locomotion: Walk, Run, Sprint, …). */ private final Set snapClipNames = new LinkedHashSet<>(); - /** Standard-Locomotion-Aktionen, deren gemappte Clips snapRootBoneXZ erhalten. */ - private static final Set 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()); diff --git a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java index ea5f327..cacebbc 100644 --- a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java +++ b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java @@ -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 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; } diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index d988474..44e93f6 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -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(); diff --git a/blight-game/src/main/java/de/blight/game/control/WeaponAttachmentControl.java b/blight-game/src/main/java/de/blight/game/control/WeaponAttachmentControl.java index 9ac6b9f..87ec5cb 100644 --- a/blight-game/src/main/java/de/blight/game/control/WeaponAttachmentControl.java +++ b/blight-game/src/main/java/de/blight/game/control/WeaponAttachmentControl.java @@ -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))); }