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

View File

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

View File

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

View File

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

View File

@@ -43,10 +43,6 @@ public class AnimationLibrary extends BaseAppState {
/** Clips die snapRootBoneXZ erhalten (Locomotion: Walk, Run, Sprint, …). */
private final Set<String> snapClipNames = new LinkedHashSet<>();
/** Standard-Locomotion-Aktionen, deren gemappte Clips snapRootBoneXZ erhalten. */
private static final Set<String> LOCOMOTION_ACTIONS = Set.of(
"WALK", "RUN", "SPRINT", "JUMP", "RUNNING_JUMP", "IDLE", "DEFAULT"
);
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -103,6 +99,14 @@ public class AnimationLibrary extends BaseAppState {
if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden
{
com.jme3.anim.AnimTrack<?>[] st = src.getTracks();
int sn0 = 0;
if (st != null && st.length > 0 && st[0] instanceof TransformTrack stt)
sn0 = stt.getTimes() != null ? stt.getTimes().length : 0;
log.info("[AnimLib] applyTo pre-retarget '{}': srcLen={}s srcTracks={} srcT0Frames={}",
clipName, src.getLength(), st != null ? st.length : 0, sn0);
}
AnimClip target;
if (srcArm != null && srcArm != sc.getArmature()) {
// Immer retarget() aufrufen – auch bei gleichen Knochen-Namen.
@@ -131,8 +135,10 @@ public class AnimationLibrary extends BaseAppState {
}
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
int trackCount = target.getTracks() != null ? target.getTracks().length : 0;
log.info("[AnimLib] Clip '{}' → AnimComposer '{}': length={}s tracks={}",
clipName, model.getName(), target.getLength(), trackCount);
if (clipName.equals("sit_down") || target.getLength() == 0) {
dumpClipTracks(target);
}
return true;
@@ -221,10 +227,8 @@ public class AnimationLibrary extends BaseAppState {
snapClipNames.clear();
try {
AnimSet set = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName);
set.getActionMap().forEach((action, clip) -> {
if (LOCOMOTION_ACTIONS.contains(action)) snapClipNames.add(clip);
});
log.info("[AnimLib] snapRootBoneXZ für Locomotion-Clips: {}", snapClipNames);
snapClipNames.addAll(set.getSnapClips());
log.info("[AnimLib] snapRootBoneXZ für Clips: {}", snapClipNames);
} catch (Exception e) {
log.warn("[AnimLib] AnimSet '{}' nicht ladbar für Locomotion-Snap: {}", animSetName, e.getMessage());
}
@@ -320,9 +324,9 @@ public class AnimationLibrary extends BaseAppState {
}
private void loadClipFromFile(Path file) {
String fileName = file.getFileName().toString();
String clipName = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName;
String fileName = file.getFileName().toString();
String fileClipKey = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "Characters/clips/" + fileName;
try {
Spatial loaded = assetManager.loadModel(assetKey);
@@ -334,11 +338,37 @@ public class AnimationLibrary extends BaseAppState {
}
Armature armature = sc != null ? sc.getArmature() : null;
AnimClip bestClip = null;
for (String name : ac.getAnimClipsNames()) {
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
com.jme3.anim.AnimTrack<?>[] rawTracks = animClip.getTracks();
int n0 = 0;
if (rawTracks != null && rawTracks.length > 0 && rawTracks[0] instanceof TransformTrack tt0) {
n0 = tt0.getTimes() != null ? tt0.getTimes().length : 0;
}
log.info("[AnimLib] Clip geladen: '{}' aus {} | clipLen={}s tracks={} t0Frames={}",
name, assetKey, animClip.getLength(),
rawTracks != null ? rawTracks.length : 0, n0);
if (bestClip == null || animClip.getLength() > bestClip.getLength()) {
bestClip = animClip;
}
}
// Kanonischen Dateiname-Key auf den längsten Clip mappen.
// Verhindert dass ein leerer Null-Frame-Clip einen guten Clip überschreibt,
// wenn die j3o mehrere Clips mit leicht unterschiedlichen Namen enthält
// (z.B. "clip_Sword And Shield Slash" vs. "Sword And Shield Slash").
if (bestClip != null) {
AnimClip current = clips.get(fileClipKey);
if (current == null || bestClip.getLength() > current.getLength()) {
clips.put(fileClipKey, bestClip);
if (armature != null) armatures.put(fileClipKey, armature);
if (!fileClipKey.equals(bestClip.getName())) {
log.info("[AnimLib] Key '{}' → '{}' (clipLen={}s, besser)",
fileClipKey, bestClip.getName(), bestClip.getLength());
}
}
}
} catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());

View File

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

View File

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

View File

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