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", "T-Pose",
"Walking", "Walking",
"Jumping", "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": { "actionMap": {
"DEFAULT": "Idle", "DEFAULT": "Idle",
@@ -22,7 +27,11 @@
"IDLE": "Idle", "IDLE": "Idle",
"JUMP": "Jumping", "JUMP": "Jumping",
"SWORD_ATTACK_1": "Sword And Shield Slash", "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", "previewModelPath": "Characters/Models/hero.j3o",
"animOffsets": { "animOffsets": {
@@ -67,5 +76,16 @@
"SIT_UP", "SIT_UP",
"IDLE" "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 attachedEmitters.count=0
attachedLights.count=0 attachedLights.count=0
attachedSounds.count=0 attachedSounds.count=0

View File

@@ -11,12 +11,12 @@
"worthGold": 0, "worthGold": 0,
"damage": 0, "damage": 0,
"grip": { "grip": {
"posX": 0.24, "posX": 0.37,
"posY": 0.05, "posY": 0.08,
"posZ": 0.0, "posZ": 0.0,
"rotX": 0.0, "rotX": 0.0,
"rotY": -90.0, "rotY": -90.0,
"rotZ": 0.0 "rotZ": 90.0
}, },
"modelRef": { "modelRef": {
"path": "Models/waffen/schwerter/rostiges_kurzschwert.j3o" "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); headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot); head.setLocalRotation(headRot);
// Klingenflosse: dünnes flaches Rechteck senkrecht zum Schaft entlang +Y, // Griffachsen-Zylinder: runder Zylinder senkrecht zum Schaft (+Y),
// damit Rotation um Z (Roll) sichtbar ist. Breite 6cm, Dicke 2mm, entlang Schaft. // 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"); Material matFin = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
matFin.setColor("Color", new ColorRGBA(1f, 0.8f, 0.1f, 1f)); 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)); float axisLen = 0.06f;
fin.setMaterial(matFin); Geometry gripAxis = new Geometry("gGripAxis", new Cylinder(4, 8, 0.006f, axisLen, true));
fin.setLocalTranslation(0f, 0.03f, shaftLen * 0.5f); 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(marker);
group.attachChild(shaft); group.attachChild(shaft);
group.attachChild(fin); group.attachChild(gripAxis);
group.attachChild(head); group.attachChild(head);
gripGizmoNode.attachChild(group); gripGizmoNode.attachChild(group);
gripGizmoNode.setCullHint(Spatial.CullHint.Inherit); gripGizmoNode.setCullHint(Spatial.CullHint.Inherit);

View File

@@ -51,6 +51,7 @@ public class AnimSetController implements ToolController {
private ListView<String> animSetPartsListView; private ListView<String> animSetPartsListView;
private java.util.Map<String, de.blight.game.animation.AnimSet.SubClipDef> private java.util.Map<String, de.blight.game.animation.AnimSet.SubClipDef>
editableSubClips = new java.util.LinkedHashMap<>(); editableSubClips = new java.util.LinkedHashMap<>();
private java.util.Set<String> editableSnapClips = new java.util.LinkedHashSet<>();
private ListView<String> animSetSequenceListView; private ListView<String> animSetSequenceListView;
private java.util.Map<String, java.util.List<String>> private java.util.Map<String, java.util.List<String>>
editableSequences = new java.util.LinkedHashMap<>(); editableSequences = new java.util.LinkedHashMap<>();
@@ -76,6 +77,16 @@ public class AnimSetController implements ToolController {
animSetCurrentName = setName; animSetCurrentName = setName;
animSetCurrentDir = setDir; animSetCurrentDir = setDir;
currentAnimSet = animSet; 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 @Override
@@ -337,17 +348,14 @@ public class AnimSetController implements ToolController {
animSetClipListView.setPrefHeight(180); animSetClipListView.setPrefHeight(180);
editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips()); editableSubClips = new java.util.LinkedHashMap<>(animSet.getSubClips());
editableSequences = new java.util.LinkedHashMap<>(animSet.getSequences()); editableSequences = new java.util.LinkedHashMap<>(animSet.getSequences());
// editableSnapClips wird bereits in openWithSet gesetzt (inkl. Migration)
ctx.input().animPreviewClipOffsets.clear(); ctx.input().animPreviewClipOffsets.clear();
for (var en : animSet.getAnimOffsets().entrySet()) { for (var en : animSet.getAnimOffsets().entrySet()) {
de.blight.game.animation.AnimOffset o = en.getValue(); de.blight.game.animation.AnimOffset o = en.getValue();
ctx.input().animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz}); 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(); ctx.input().animPreviewLocomotionClips.clear();
animSet.getActionMap().forEach((action, clip) -> { ctx.input().animPreviewLocomotionClips.addAll(editableSnapClips);
if (locomotionActions.contains(action)) ctx.input().animPreviewLocomotionClips.add(clip);
});
Path animRootDir = ctx.assetRoot().resolve("Characters"); Path animRootDir = ctx.assetRoot().resolve("Characters");
@@ -454,10 +462,41 @@ public class AnimSetController implements ToolController {
autoSaveAnimSet(); 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 clipBtns = new HBox(6, addClipBtn, removeClipBtn);
HBox.setHgrow(addClipBtn, Priority.ALWAYS); HBox.setHgrow(addClipBtn, Priority.ALWAYS);
HBox.setHgrow(removeClipBtn, Priority.ALWAYS); HBox.setHgrow(removeClipBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetClipListView, clipBtns); inner.getChildren().addAll(animSetClipListView, snapCB, clipBtns);
// ── Clip-Teile (Sub-Clips) ───────────────────────────────────────────── // ── Clip-Teile (Sub-Clips) ─────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clip-Teile"), new Separator()); 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(); de.blight.game.animation.AnimOffset o = en.getValue();
ctx.input().animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz}); ctx.input().animPreviewClipOffsets.put(en.getKey(), new float[]{o.tx, o.ty, o.tz});
} }
java.util.Set<String> locoActions = java.util.Set.of( animSet.setSnapClips(new java.util.LinkedHashSet<>(editableSnapClips));
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT");
ctx.input().animPreviewLocomotionClips.clear(); ctx.input().animPreviewLocomotionClips.clear();
animSet.getActionMap().forEach((action, clip) -> { ctx.input().animPreviewLocomotionClips.addAll(editableSnapClips);
if (locoActions.contains(action)) ctx.input().animPreviewLocomotionClips.add(clip);
});
if (animSetModelCombo != null && animSetModelCombo.getValue() != null if (animSetModelCombo != null && animSetModelCombo.getValue() != null
&& !animSetModelCombo.getValue().isBlank()) { && !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue()); animSet.setPreviewModelPath(animSetModelCombo.getValue());

View File

@@ -38,12 +38,25 @@ public class AnimPlayer {
private LinearBlendSpace blendSpace; private LinearBlendSpace blendSpace;
private String pendingClip; private String pendingClip;
private float blendElapsed; 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) { public AnimPlayer(AnimComposer composer, SkinningControl skinning) {
this.composer = composer; this.composer = composer;
this.skinning = skinning; 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. * Spielt einen Clip ab.
* Bei laufendem Clip und {@code transition > 0}: nahtloser Übergang * Bei laufendem Clip und {@code transition > 0}: nahtloser Übergang
@@ -82,6 +95,7 @@ public class AnimPlayer {
blendSpace = space; blendSpace = space;
pendingClip = clipName; pendingClip = clipName;
blendElapsed = 0f; blendElapsed = 0f;
blendDuration = Math.max(0.01f, transition);
currentAction = blend; currentAction = blend;
currentClip = null; currentClip = null;
log.debug("[AnimPlayer] Blend → '{}'", clipName); log.debug("[AnimPlayer] Blend → '{}'", clipName);
@@ -101,7 +115,7 @@ public class AnimPlayer {
public void update(float tpf) { public void update(float tpf) {
if (pendingClip == null || blendSpace == null) return; if (pendingClip == null || blendSpace == null) return;
blendElapsed += tpf; blendElapsed += tpf;
float bt = Math.min(1f, blendElapsed / BLEND_DURATION); float bt = Math.min(1f, blendElapsed / blendDuration);
blendSpace.setValue(bt); blendSpace.setValue(bt);
if (bt >= 1f) finishBlend(); if (bt >= 1f) finishBlend();
} }
@@ -129,13 +143,35 @@ public class AnimPlayer {
private boolean directPlay(String clipName) { private boolean directPlay(String clipName) {
try { try {
Action act = composer.action(clipName); // Diagnose: zeigt warum length=0 entsteht
if (act instanceof BlendableAction ba) ba.setTransitionLength(0.0); 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); currentAction = composer.setCurrentAction(clipName);
currentClip = clipName; currentClip = clipName;
pendingClip = null; pendingClip = null;
blendSpace = 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; return currentAction != null;
} catch (Exception e) { } catch (Exception e) {
log.warn("[AnimPlayer] direktPlay('{}') fehlgeschlagen: {}", clipName, e.getMessage()); log.warn("[AnimPlayer] direktPlay('{}') fehlgeschlagen: {}", clipName, e.getMessage());
@@ -153,15 +189,15 @@ public class AnimPlayer {
if (composer.hasAction("__blend__")) composer.removeAction("__blend__"); if (composer.hasAction("__blend__")) composer.removeAction("__blend__");
} catch (Exception ignored) {} } catch (Exception ignored) {}
try { try {
Action next = composer.action(clip);
if (next instanceof BlendableAction ba) ba.setTransitionLength(0.0);
currentAction = composer.setCurrentAction(clip); currentAction = composer.setCurrentAction(clip);
currentClip = clip; currentClip = clip;
double len = currentAction != null ? currentAction.getLength() : 0.0; double len = currentAction != null ? currentAction.getLength() : 0.0;
// AnimLayer.setTime: kein extra controlUpdate, kein loop=false-Seiteneffekt. // AnimLayer.setTime: kein extra controlUpdate, kein loop=false-Seiteneffekt.
// Clip startet dort, wo der Übergang endete (kein sichtbarer Sprung). // Clip startet dort, wo der Übergang endete (kein sichtbarer Sprung).
if (len > 0.01 && elapsed > 0) { if (len > 0.01 && (elapsed > 0 || pendingStartOffset > 0)) {
composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(elapsed % len); float t = (pendingStartOffset + elapsed) % (float) len;
composer.getLayer(AnimComposer.DEFAULT_LAYER).setTime(t);
pendingStartOffset = 0f;
} }
log.debug("[AnimPlayer] finishBlend → '{}'", clip); log.debug("[AnimPlayer] finishBlend → '{}'", clip);
} catch (Exception e) { } catch (Exception e) {

View File

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

View File

@@ -35,17 +35,17 @@ public enum AnimationAction {
// ── Schwert ─────────────────────────────────────────────────────────────── // ── Schwert ───────────────────────────────────────────────────────────────
SWORD_ATTACK_1, SWORD_ATTACK_1,
SWORD_DRAW, SWORD_SHEATHE, SWORD_BLOCK, SWORD_HIT_TAKEN, 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, SWORD_IDLE, SWORD_WALK, SWORD_RUN,
// ── Axt ─────────────────────────────────────────────────────────────────── // ── Axt ───────────────────────────────────────────────────────────────────
AXE_DRAW, AXE_SHEATHE, AXE_BLOCK, AXE_HIT_TAKEN, 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, AXE_IDLE, AXE_WALK, AXE_RUN,
// ── Zweihänder ──────────────────────────────────────────────────────────── // ── Zweihänder ────────────────────────────────────────────────────────────
TWO_HAND_DRAW, TWO_HAND_SHEATHE, TWO_HAND_BLOCK, TWO_HAND_HIT_TAKEN, 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, TWO_HAND_IDLE, TWO_HAND_WALK, TWO_HAND_RUN,
// ── Bogen ───────────────────────────────────────────────────────────────── // ── Bogen ─────────────────────────────────────────────────────────────────
@@ -98,6 +98,7 @@ public enum AnimationAction {
case SWORD_BLOCK -> TextRegistry.resolve(null, key, "Block (Schwert)"); case SWORD_BLOCK -> TextRegistry.resolve(null, key, "Block (Schwert)");
case SWORD_HIT_TAKEN -> TextRegistry.resolve(null, key, "Treffer (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_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_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Schwert)");
case SWORD_IDLE -> TextRegistry.resolve(null, key, "Stehen (Schwert)"); case SWORD_IDLE -> TextRegistry.resolve(null, key, "Stehen (Schwert)");
case SWORD_WALK -> TextRegistry.resolve(null, key, "Gehen (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_HIT_TAKEN -> TextRegistry.resolve(null, key, "Treffer (Axt)");
case AXE_ATTACK_1 -> TextRegistry.resolve(null, key, "Angriff 1 (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_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_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Axt)");
case AXE_IDLE -> TextRegistry.resolve(null, key, "Stehen (Axt)"); case AXE_IDLE -> TextRegistry.resolve(null, key, "Stehen (Axt)");
case AXE_WALK -> TextRegistry.resolve(null, key, "Gehen (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_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_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_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_HEAVY_ATTACK -> TextRegistry.resolve(null, key, "Schwerer Angriff (Zweihänder)");
case TWO_HAND_IDLE -> TextRegistry.resolve(null, key, "Stehen (Zweihänder)"); case TWO_HAND_IDLE -> TextRegistry.resolve(null, key, "Stehen (Zweihänder)");
case TWO_HAND_WALK -> TextRegistry.resolve(null, key, "Gehen (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, …). */ /** Clips die snapRootBoneXZ erhalten (Locomotion: Walk, Run, Sprint, …). */
private final Set<String> snapClipNames = new LinkedHashSet<>(); 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 ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -103,6 +99,14 @@ public class AnimationLibrary extends BaseAppState {
if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden 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; AnimClip target;
if (srcArm != null && srcArm != sc.getArmature()) { if (srcArm != null && srcArm != sc.getArmature()) {
// Immer retarget() aufrufen – auch bei gleichen Knochen-Namen. // Immer retarget() aufrufen – auch bei gleichen Knochen-Namen.
@@ -131,8 +135,10 @@ public class AnimationLibrary extends BaseAppState {
} }
ac.addAnimClip(target); ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName()); int trackCount = target.getTracks() != null ? target.getTracks().length : 0;
if (clipName.equals("sit_down")) { log.info("[AnimLib] Clip '{}' → AnimComposer '{}': length={}s tracks={}",
clipName, model.getName(), target.getLength(), trackCount);
if (clipName.equals("sit_down") || target.getLength() == 0) {
dumpClipTracks(target); dumpClipTracks(target);
} }
return true; return true;
@@ -221,10 +227,8 @@ public class AnimationLibrary extends BaseAppState {
snapClipNames.clear(); snapClipNames.clear();
try { try {
AnimSet set = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName); AnimSet set = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName);
set.getActionMap().forEach((action, clip) -> { snapClipNames.addAll(set.getSnapClips());
if (LOCOMOTION_ACTIONS.contains(action)) snapClipNames.add(clip); log.info("[AnimLib] snapRootBoneXZ für Clips: {}", snapClipNames);
});
log.info("[AnimLib] snapRootBoneXZ für Locomotion-Clips: {}", snapClipNames);
} catch (Exception e) { } catch (Exception e) {
log.warn("[AnimLib] AnimSet '{}' nicht ladbar für Locomotion-Snap: {}", animSetName, e.getMessage()); 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) { private void loadClipFromFile(Path file) {
String fileName = file.getFileName().toString(); String fileName = file.getFileName().toString();
String clipName = fileName.replaceFirst("\\.j3o$", ""); String fileClipKey = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName; String assetKey = "Characters/clips/" + fileName;
try { try {
Spatial loaded = assetManager.loadModel(assetKey); Spatial loaded = assetManager.loadModel(assetKey);
@@ -334,11 +338,37 @@ public class AnimationLibrary extends BaseAppState {
} }
Armature armature = sc != null ? sc.getArmature() : null; Armature armature = sc != null ? sc.getArmature() : null;
AnimClip bestClip = null;
for (String name : ac.getAnimClipsNames()) { for (String name : ac.getAnimClipsNames()) {
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name); com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
clips.put(name, animClip); clips.put(name, animClip);
if (armature != null) armatures.put(name, armature); 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) { } catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage()); 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) if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint j)
srcTrackMap.put(j.getName(), tt); 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; float[] times = null;
for (String name : nameMap.keySet()) { for (String name : nameMap.keySet()) {
TransformTrack tt = srcTrackMap.get(name); 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) { if (times == null || times.length == 0) {
log.warn("[Retarget] Keine Keyframe-Zeiten für '{}'", sourceClip.getName()); log.warn("[Retarget] Keine Keyframe-Zeiten für '{}'", sourceClip.getName());
@@ -398,18 +402,10 @@ public final class RetargetingSystem {
// ── Same-rig detection & redirect ──────────────────────────────────────── // ── 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) { private static boolean isSameRig(Map<String, String> nameMap, Armature src, Armature dst) {
for (var e : nameMap.entrySet()) { for (var e : nameMap.entrySet()) {
if (!e.getKey().equals(e.getValue())) return false; 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; return true;
} }

View File

@@ -34,10 +34,18 @@ public class PlayerInputControl {
private static final Logger log = LoggerFactory.getLogger(PlayerInputControl.class); private static final Logger log = LoggerFactory.getLogger(PlayerInputControl.class);
private static final float MOVE_SPEED = 0.07f; private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f; private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f; private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f; 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_MELEE = "DrawMeleeWeapon";
private static final String ACT_DRAW_RANGED = "DrawRangedWeapon"; private static final String ACT_DRAW_RANGED = "DrawRangedWeapon";
@@ -64,11 +72,17 @@ public class PlayerInputControl {
private boolean weaponDrawn = false; private boolean weaponDrawn = false;
private ItemSubCategory drawnWeaponType = null; private ItemSubCategory drawnWeaponType = null;
private boolean isAiming = false; private boolean isAiming = false;
private boolean meleeAttackToggle = false;
private boolean rightMouseHeld = false; private boolean rightMouseHeld = false;
private boolean combatAnimActive = false; private boolean combatAnimActive = false;
private float combatAnimRemaining = 0f; private float combatAnimRemaining = 0f;
private Runnable combatAnimCallback = null; 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 AnimationLibrary animLib;
private String animSetName; private String animSetName;
@@ -121,6 +135,7 @@ public class PlayerInputControl {
private int jumpFrames = 0; private int jumpFrames = 0;
private int groundGraceFrames = 0; private int groundGraceFrames = 0;
private double nextTransitionLength = 0.0; private double nextTransitionLength = 0.0;
private float nextStartOffset = 0f;
private boolean pickupActive = false; private boolean pickupActive = false;
private float pickupRemaining = 0f; private float pickupRemaining = 0f;
@@ -241,6 +256,7 @@ public class PlayerInputControl {
public void setMainCharacter(MainCharacter mc) { this.mainCharacter = mc; } public void setMainCharacter(MainCharacter mc) { this.mainCharacter = mc; }
public boolean isWeaponDrawn() { return weaponDrawn; } public boolean isWeaponDrawn() { return weaponDrawn; }
public ItemSubCategory getDrawnWeaponType() { return drawnWeaponType; } public ItemSubCategory getDrawnWeaponType() { return drawnWeaponType; }
public boolean isLastAttackCritical() { return lastAttackCritical; }
public com.jme3.anim.SkinningControl getSkinningControl() { return skinningControl; } public com.jme3.anim.SkinningControl getSkinningControl() { return skinningControl; }
public boolean isPaused() { return paused; } public boolean isPaused() { return paused; }
@@ -255,6 +271,8 @@ public class PlayerInputControl {
rightMouseHeld = false; rightMouseHeld = false;
combatAnimActive = false; combatAnimActive = false;
combatAnimCallback = null; combatAnimCallback = null;
comboStep = 0;
comboWindowActive = false;
} }
} }
@@ -657,12 +675,32 @@ public class PlayerInputControl {
// Kampf-Anim-Timer // Kampf-Anim-Timer
if (combatAnimActive) { if (combatAnimActive) {
combatAnimRemaining -= tpf; combatAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (combatAnimRemaining <= 0f) { if (combatAnimRemaining <= 0f) {
combatAnimActive = false; combatAnimActive = false;
currentAnim = null; currentAnim = null;
nextTransitionLength = COMBAT_BLEND;
Runnable cb = combatAnimCallback; Runnable cb = combatAnimCallback;
combatAnimCallback = null; combatAnimCallback = null;
if (cb != null) cb.run(); 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 (jumpFrames > 0) jumpFrames--;
if (groundGraceFrames > 0) groundGraceFrames--; 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; AnimationAction target;
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) { if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP; target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
@@ -707,7 +747,8 @@ public class PlayerInputControl {
} }
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action); String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
log.info("[Anim] {} → clip='{}' (set={})", action, clip, animSetName); 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 (clip != null && tryPlay(clip)) return;
if (action != AnimationAction.DEFAULT) { if (action != AnimationAction.DEFAULT) {
String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT); String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
@@ -765,16 +806,25 @@ public class PlayerInputControl {
} }
private void handleCombatAttack() { 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)) { if (isMeleeType(drawnWeaponType)) {
AnimationAction atk = meleeAttackToggle int step = comboStep;
? getMeleeAttack2Action(drawnWeaponType) boolean isComboFollow = comboWindowActive; // war Kombo-Fenster offen?
: getMeleeAttack1Action(drawnWeaponType); AnimationAction atk = getMeleeComboAction(drawnWeaponType, step);
meleeAttackToggle = !meleeAttackToggle; lastAttackCritical = (step == 3);
comboStep = (step + 1) % 4;
comboWindowActive = false;
float dur = resolveClipLength(atk, 0.6f); 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; combatAnimActive = true;
// Blend-out zu Idle startet BLEND_DURATION früher → kein sichtbares Einfrieren am letzten Frame combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimRemaining = Math.max(0.1f, dur - de.blight.game.animation.AnimPlayer.BLEND_DURATION);
combatAnimCallback = null; combatAnimCallback = null;
playAction(atk); playAction(atk);
currentAnim = atk; currentAnim = atk;
@@ -785,14 +835,16 @@ public class PlayerInputControl {
float fireDur = resolveClipLength(fireAnim, 0.5f); float fireDur = resolveClipLength(fireAnim, 0.5f);
float reloadDur = resolveClipLength(reloadAnim, 0.8f); float reloadDur = resolveClipLength(reloadAnim, 0.8f);
combatAnimActive = true; combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, fireDur - de.blight.game.animation.AnimPlayer.BLEND_DURATION); combatAnimRemaining = Math.max(0.1f, fireDur - COMBAT_BLEND);
combatAnimCallback = () -> { combatAnimCallback = () -> {
combatAnimActive = true; 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; combatAnimCallback = null;
nextTransitionLength = COMBAT_BLEND;
playAction(reloadAnim); playAction(reloadAnim);
currentAnim = reloadAnim; currentAnim = reloadAnim;
}; };
nextTransitionLength = COMBAT_BLEND;
playAction(fireAnim); playAction(fireAnim);
currentAnim = 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) { private static AnimationAction getMeleeBlockAction(ItemSubCategory s) {
return switch (s) { return switch (s) {
case AXE -> AnimationAction.AXE_BLOCK; case AXE -> AnimationAction.AXE_BLOCK;
@@ -1185,9 +1254,11 @@ public class PlayerInputControl {
if (animPlayer == null) { if (animPlayer == null) {
animPlayer = new AnimPlayer(animComposer, skinningControl); animPlayer = new AnimPlayer(animComposer, skinningControl);
} }
float transLen = (float) nextTransitionLength; float transLen = (float) nextTransitionLength;
float startOff = nextStartOffset;
nextTransitionLength = 0.0; 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"); log.info("[Anim] AnimPlayer.play('{}') transLen={} → {}", clip, transLen, ok ? "OK" : "FAILED");
if (ok) { if (ok) {
runningClip = animPlayer.getCurrentClip(); 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_SPINE = "mixamorig:Spine2";
private static final String BONE_HAND = "mixamorig:RightHand"; 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 DEG_TO_RAD = (float) Math.PI / 180f;
private static final float HALF_PI = (float) Math.PI * 0.5f; 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.0f, 0.40f, -0.13f);
private static final Vector3f BACK_GRIP_OFFSET = new Vector3f(0.05f, 0.40f, -0.08f); /** 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 AssetManager assetManager;
private final PlayerInputControl playerInput; private final PlayerInputControl playerInput;
private final MainCharacter mc; private final MainCharacter mc;
private com.jme3.anim.SkinningControl skinCtrl; private com.jme3.anim.SkinningControl skinCtrl;
private Node backNode; // Spine2-Attachment-Node (im skalierten Char-Modell) private Node backNode;
private Node handNode; // RightHand-Attachment-Node 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; 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 Item lastEquippedWeapon;
private boolean lastDrawnState = false; private boolean lastDrawnState = false;
private Spatial weaponSpatial; private Spatial weaponSpatial;
private boolean gripDiagLogged = false;
public WeaponAttachmentControl(AssetManager assetManager, public WeaponAttachmentControl(AssetManager assetManager,
PlayerInputControl playerInput, PlayerInputControl playerInput,
@@ -62,38 +67,45 @@ public class WeaponAttachmentControl extends AbstractControl {
@Override @Override
protected void controlUpdate(float tpf) { protected void controlUpdate(float tpf) {
// Lazy init: SkinningControl erst nach setAnimationContext() verfügbar
if (skinCtrl == null) { if (skinCtrl == null) {
skinCtrl = playerInput.getSkinningControl(); skinCtrl = playerInput.getSkinningControl();
if (skinCtrl == null) return; if (skinCtrl == null) return;
try { try {
backNode = skinCtrl.getAttachmentsNode(BONE_SPINE); backNode = skinCtrl.getAttachmentsNode(BONE_SPINE);
log.info("[WeaponAttach] backNode ({}): {}", BONE_SPINE, backNode);
} catch (Exception e) { } catch (Exception e) {
log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_SPINE, e.getMessage()); log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_SPINE, e.getMessage());
} }
try { try {
handNode = skinCtrl.getAttachmentsNode(BONE_HAND); handNode = skinCtrl.getAttachmentsNode(BONE_HAND);
log.info("[WeaponAttach] handNode ({}): {}", BONE_HAND, handNode);
} catch (Exception e) { } catch (Exception e) {
log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_HAND, e.getMessage()); log.warn("[WeaponAttach] Bone '{}' nicht gefunden: {}", BONE_HAND, e.getMessage());
} }
handBindRotInChar = extractJointBindRot(skinCtrl, BONE_HAND); 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(); Item current = mc.getEquippedMeleeWeapon();
boolean drawn = playerInput.isWeaponDrawn(); boolean drawn = playerInput.isWeaponDrawn();
if (current != lastEquippedWeapon) { if (current != lastEquippedWeapon) {
log.info("[WeaponAttach] Waffe geändert: {} → {}",
lastEquippedWeapon != null ? lastEquippedWeapon.getItemId() : "null",
current != null ? current.getItemId() : "null");
detachWeapon(); detachWeapon();
lastEquippedWeapon = current; lastEquippedWeapon = current;
if (current != null) { if (current != null) loadWeapon(current);
loadWeapon(current);
}
} }
if (weaponSpatial != null) { if (weaponSpatial != null) {
@@ -111,12 +123,8 @@ public class WeaponAttachmentControl extends AbstractControl {
} }
try { try {
weaponSpatial = assetManager.loadModel(item.getModelRef().getPath()); 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); ((Node) spatial).attachChild(weaponSpatial);
log.info("[WeaponAttach] Waffe geladen und an character-Node gehängt: {}", log.info("[WeaponAttach] Waffe geladen: {}", item.getModelRef().getPath());
item.getModelRef().getPath());
} catch (Exception e) { } catch (Exception e) {
log.warn("[WeaponAttach] Waffenmodell nicht ladbar ({}): {}", log.warn("[WeaponAttach] Waffenmodell nicht ladbar ({}): {}",
item.getModelRef().getPath(), e.getMessage()); item.getModelRef().getPath(), e.getMessage());
@@ -130,31 +138,23 @@ public class WeaponAttachmentControl extends AbstractControl {
Node boneNode = drawn ? handNode : backNode; Node boneNode = drawn ? handNode : backNode;
if (boneNode == null) return; 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(); Quaternion charWorldRotInv = spatial.getWorldRotation().inverse();
Vector3f boneInChar = charWorldRotInv.mult( Vector3f charWorldPos = spatial.getWorldTranslation();
boneWorldPos.subtract(spatial.getWorldTranslation()));
Quaternion boneRotInChar = charWorldRotInv.mult(boneWorldRot); Vector3f boneInChar = charWorldRotInv.mult(
boneNode.getWorldTranslation().subtract(charWorldPos));
Quaternion boneRotInChar = charWorldRotInv.mult(boneNode.getWorldRotation());
if (drawn) { if (drawn) {
applyHandTransform(boneInChar, boneRotInChar, grip); Vector3f attachInChar = (palmOffsetLocal != null)
? boneInChar.add(boneRotInChar.mult(palmOffsetLocal.mult(0.5f)))
: boneInChar;
applyHandTransform(attachInChar, boneRotInChar, grip);
} else { } else {
applyBackTransform(boneInChar, boneRotInChar, grip); 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) { private void applyHandTransform(Vector3f bonePosInChar, Quaternion boneRotInChar, ItemGrip grip) {
Quaternion gripRot = (grip != null) Quaternion gripRot = (grip != null)
? new Quaternion().fromAngles(DEG_TO_RAD * grip.getRotX(), DEG_TO_RAD * grip.getRotY(), DEG_TO_RAD * grip.getRotZ()) ? 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()) ? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ())
: Vector3f.ZERO; : 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) Quaternion animDelta = (handBindRotInChar != null)
? boneRotInChar.mult(handBindRotInChar.inverse()) ? boneRotInChar.mult(handBindRotInChar.inverse())
: boneRotInChar; : 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.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) { private static Quaternion extractJointBindRot(com.jme3.anim.SkinningControl sc, String jointName) {
com.jme3.anim.Armature arm = sc.getArmature(); com.jme3.anim.Armature arm = sc.getArmature();
if (arm == null) return null; if (arm == null) return null;
@@ -190,22 +234,13 @@ public class WeaponAttachmentControl extends AbstractControl {
return bindMat.toRotationQuat().normalizeLocal(); 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) { 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); Quaternion weaponRot = boneRotInChar.mult(backRot);
Vector3f gripTarget = bonePosInChar.add(boneRotInChar.mult(BACK_GRIP_OFFSET));
// Zielposition des Griff-Punktes: BACK_GRIP_OFFSET im Knochen-lokalen Raum 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()) ? new Vector3f(grip.getPosX(), grip.getPosY(), grip.getPosZ())
: Vector3f.ZERO; : Vector3f.ZERO;
// weaponPos + weaponRot * gripPos = gripTarget
weaponSpatial.setLocalRotation(weaponRot); weaponSpatial.setLocalRotation(weaponRot);
weaponSpatial.setLocalTranslation(gripTarget.subtract(weaponRot.mult(gripPos))); weaponSpatial.setLocalTranslation(gripTarget.subtract(weaponRot.mult(gripPos)));
} }