Files
blight/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java
2026-09-23 20:58:33 +02:00

1905 lines
85 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.blight.game.control;
import com.jme3.anim.AnimComposer;
import com.jme3.anim.ArmatureMask;
import com.jme3.anim.tween.Tween;
import com.jme3.anim.tween.action.BaseAction;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.input.InputManager;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.input.controls.MouseButtonTrigger;
import de.blight.common.model.ItemSubCategory;
import de.blight.common.model.MainCharacter;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Spatial;
import de.blight.game.animation.AnimOffset;
import de.blight.game.animation.AnimPlayer;
import de.blight.game.animation.AnimSet;
import de.blight.game.animation.AnimationAction;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.animation.RetargetingSystem;
import de.blight.game.config.KeyBindings;
import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.navigation.CharacterNavigator;
import de.blight.game.state.TerrainChunkState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
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;
/** 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;
/** Maximale Haltezeit für einen Kurz-Klick (Konterangriff); darüber = Block-Eingabe. */
private static final float BLOCK_TAP_MAX = 0.20f;
/** Maximale Haltezeit, die noch als "schneller Klick" (→ normale Kombo) gilt. */
private static final float QUICK_TAP_THRESHOLD = 0.30f;
/** Animationsanteil (0–1), ab dem die Vorwärtsbewegung beim schweren Angriff beginnt. */
private static final float LUNGE_FRAC_START = 0.15f;
/** Animationsanteil (0–1), ab dem die Vorwärtsbewegung endet (Landung). */
private static final float LUNGE_FRAC_END = 0.55f;
/** AnimComposer-Layer-Name für die prozedurale Ziehen/Wegstecken-Animation (Oberarm-Overlay). */
private static final String DRAW_LAYER = "__draw_weapon__";
// ── Ausdauer ──────────────────────────────────────────────────────────────
private static final float STAMINA_REGEN_PER_SEC = 5f;
private static final float STAMINA_SPRINT_PER_SEC = 10f;
private static final int STAMINA_ATTACK_COST = 10;
private static final int STAMINA_ATTACK5_COST = 20;
private static final int STAMINA_HEAVY_COST = 30;
private static final int STAMINA_BLOCK_HIT_COST = 30;
/** Dauer der Ziehen/Wegstecken-Animation in Sekunden. */
private static final float DRAW_DURATION = 0.85f;
// Ziel-Quaternions für die Ziehen-Animation: aus Heavy-Attack-Wind-Up bei t=0.933s gelesen
// (Bone: mixamorig:RightArm_$AssimpFbx$_Rotation und ...RightForeArm...)
private static Quaternion quatFromDeg(float x, float y, float z) {
return new Quaternion().fromAngles(
x * com.jme3.math.FastMath.DEG_TO_RAD,
y * com.jme3.math.FastMath.DEG_TO_RAD,
z * com.jme3.math.FastMath.DEG_TO_RAD);
}
private static final Quaternion DRAW_ARM_PEAK_Q = quatFromDeg( 54.1f, 150.0f, -50.0f);
private static final Quaternion DRAW_FOREARM_PEAK_Q = quatFromDeg( 23.0f, 30.1f, -35.1f);
// Linker Arm: Delta auf Base-Joint (alter Ansatz).
// Z positiv = rauf am linken Arm (Spiegel von rechts: dort Z negativ = rauf).
// Z negativ = runter/am Körper vorbei. Y = Blickrichtung des Chars.
// Startpunkt: Z=-80 (runter), Y=20 (leicht nach vorne). Unterarm: X=-80 (Ellenbogen knickt).
// Falls Arm falsche Richtung → Z-Vorzeichen drehen.
private static final float[] DRAW_L_ARM_DEG = { 0f, 20f, -80f };
private static final float[] DRAW_L_FOREARM_DEG = { -80f, 0f, 0f };
// 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";
private static final String ACT_SHEATH = "SheathWeapon";
private static final String ACT_COMBAT_ATK = "CombatAttack";
private static final String ACT_COMBAT_ALT = "CombatAltAction";
private static final String[] ACTION_NAMES = {
"Forward", "Backward", "Left", "Right", "Jump", "Sprint", "Walk",
ACT_DRAW_MELEE, ACT_DRAW_RANGED, ACT_SHEATH, ACT_COMBAT_ATK, ACT_COMBAT_ALT
};
private final InputManager inputManager;
private final Camera cam;
private CharacterControl physicsChar;
private Spatial visual;
private boolean forward, backward, left, right, sprint, walk;
private boolean paused = false;
private enum BlockPhase { NONE, WAITING, ENTERING, HOLDING, EXITING }
// ── Kampf-Zustand ─────────────────────────────────────────────────────────
private MainCharacter mainCharacter = null;
private boolean weaponDrawn = false;
private ItemSubCategory drawnWeaponType = null;
private boolean isAiming = false;
private boolean rightMouseHeld = false;
private BlockPhase blockPhase = BlockPhase.NONE;
private float blockPhaseTimer = 0f;
private boolean combatAnimActive = false;
private float combatAnimRemaining = 0f;
private Runnable combatAnimCallback = null;
private boolean combatAnimSkipComboWindow = false;
/** 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;
/** Linke Maustaste wird seit Drücken gehalten (schwerer Angriff in Vorbereitung). */
private boolean attackHoldPending = false;
private float attackHoldTimer = 0f;
private Vector3f heavyLungeVel = null;
/** combatAnimRemaining-Wert, ab dem die Lunge beginnt (Sprung-Startframe). */
private float heavyLungeStartRemaining = 0f;
/** combatAnimRemaining-Wert, ab dem die Lunge endet (Landung). */
private float heavyLungeEndRemaining = 0f;
/** Gesamtdauer der aktuellen Kampfanim — für Kombo-Fenster-Timing (öffnet bei 50%). */
private float combatAnimDuration = 0f;
private MeleeAttackCallback meleeAttackCallback = null;
private WeaponAttachmentControl weaponAttach = null;
private static final float HIT_WINDOW_START = 0.20f;
private static final float HIT_WINDOW_END = 0.75f;
private static final float WEAPON_BLADE_LENGTH = 1.0f;
private int attackIdCounter = 0;
private int currentAttackId = -1;
private boolean hitWindowActive = false;
private float hitWindowCritMult = 1.0f;
// Angriffs-Sound via Waffen-Geschwindigkeit
private static final float ATTACK_SOUND_VELOCITY = 3.5f;
// Attacken mit Backswing → Sound erst nach Mindest-Fortschritt erlauben
private static final float ATTACK2_SOUND_MIN_PROGRESS = 0.35f;
private static final float ATTACK5_SOUND_MIN_PROGRESS = 0.35f;
private boolean attackSoundFired = false;
private int currentComboStep = 0;
private float staminaAccum = 0f; // Fließkomma-Übertrag für kontinuierliche Ausdauer-Änderungen
private boolean staminaExhausted = false; // Sprint gesperrt bis 25% Ausdauer wiederhergestellt
private final Vector3f lastGripPos = new Vector3f();
private final Vector3f scratchGripVel = new Vector3f();
// Draw/Sheath Animation System
private boolean drawAnimActive = false;
private float drawAnimTimer = 0f;
private boolean drawMidFired = false;
private Runnable drawMidCallback = null;
private Runnable drawEndCallback = null;
private boolean drawWithShield = false;
// Arm-Joints für prozedurale Ziehen/Wegstecken-Animation
// Die $AssimpFbx$_Rotation-Nodes enthalten die tatsächlichen Animations-Rotationen
private com.jme3.anim.Joint jointRightArm = null;
private com.jme3.anim.Joint jointRightForeArm = null;
private com.jme3.anim.Joint jointLeftArm = null;
private com.jme3.anim.Joint jointLeftForeArm = null;
private com.jme3.anim.Joint jointRightArmFbxRot = null;
private com.jme3.anim.Joint jointRightForeArmFbxRot = null;
private com.jme3.anim.Joint jointLeftArmFbxRot = null;
private com.jme3.anim.Joint jointLeftForeArmFbxRot = null;
// Start-Pose (beim Ziehen-Start aus der laufenden Animation gesampelt)
private Quaternion drawStartArmQ = null;
private Quaternion drawStartForeArmQ = null;
private Quaternion drawStartLeftArmQ = null;
private Quaternion drawStartLeftForeArmQ = null;
private AnimationLibrary animLib;
private String animSetName;
private Path assetRoot;
private AnimationAction currentAnim;
private boolean animCtxLogged = false;
private AnimComposer animComposer;
private AnimPlayer animPlayer;
private String runningClip;
private com.jme3.anim.SkinningControl skinningControl = null;
private com.jme3.anim.Armature armature = null;
// ── Fußgeräusche ─────────────────────────────────────────────────────────
// Bone-Namen nach Phase-0-Bone-Log eintragen:
private static final String BONE_LEFT_FOOT = "mixamorig:LeftFoot";
private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot";
/** Wassertiefe (m) ab der Laufen/Sprinten gesperrt ist und Watgeräusch spielt. */
public static final float WATER_WADING_DEPTH = 0.75f;
private de.blight.game.audio.FootstepSystem footstepSystem;
private de.blight.game.audio.WeaponSoundSystem weaponSoundSystem;
private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery;
private java.util.function.BiFunction<Float, Float, Float> waterDepthQuery;
private com.jme3.audio.AudioNode wadingNode;
/** Aktuell berechnete Wassertiefe – einmal pro Update() gesetzt, in pollFootsteps() gelesen. */
private float currentWaterDepth = 0f;
// Velocity-based footstep detection: fires at the local Y-minimum of each foot (no fixed threshold).
private float lastLeftFootY = Float.MAX_VALUE;
private float lastRightFootY = Float.MAX_VALUE;
private boolean leftFootDescending = false;
private boolean rightFootDescending = false;
private float stepCooldown = 0f;
private static final float MIN_STEP_INTERVAL = 0.15f;
// ── Anim-Offsets ─────────────────────────────────────────────────────────
private Map<String, AnimOffset> animOffsets = new LinkedHashMap<>();
/** Basis-Local-Translation des Visual-Nodes; wird beim Laden des AnimSet einmalig gespeichert. */
private Vector3f visualBaseTranslation = new Vector3f();
private final Vector3f animOffsetCurrent = new Vector3f();
private final Vector3f animOffsetFrom = new Vector3f();
private final Vector3f animOffsetTarget = new Vector3f();
// Zeit-basierter Lerp über AnimPlayer.BLEND_DURATION – exakt wie im AnimPreviewState (Editor).
private float animOffsetElapsed = Float.MAX_VALUE; // MAX_VALUE = stabil / kein Lerp aktiv
// ── Navigation ────────────────────────────────────────────────────────────
private CharacterNavigator navigator = null;
private de.blight.game.navigation.PathFinder navPathFinder = null;
private TerrainChunkState navTerrain = null;
private int jumpFrames = 0;
private int groundGraceFrames = 0;
private double nextTransitionLength = 0.0;
private float nextStartOffset = 0f;
private boolean nextPlayReversed = false;
private boolean pickupActive = false;
private float pickupRemaining = 0f;
/** Allgemeine blockierende Animation (z. B. LIE_DOWN, SIT_DOWN). */
private boolean blockingAnimActive = false;
private float blockingAnimRemaining = 0f;
private float blockingAnimTotal = 0f;
private Runnable blockingAnimCallback = null;
/** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
private boolean turnActive = false;
private float turnRemaining = 0f;
private Vector3f turnDir = null;
private Runnable turnCallback = null;
/** Sperrt Bewegungseingabe dauerhaft (Ruhezustand: liegen/sitzen). */
private boolean lockedInPlace = false;
/** Autopilot: wenn gesetzt, geht der Charakter automatisch in diese (normalisierte) Richtung. */
private Vector3f autopilotDir = null;
// Pre-allokierte Scratch-Objekte — keine Heap-Allokation pro Frame in update()
private final Vector3f scratchCamDir = new Vector3f();
private final Vector3f scratchCamLeft = new Vector3f();
private final Vector3f scratchMoveDir = new Vector3f();
private final Vector3f scratchWalk = new Vector3f();
private final Quaternion scratchRot = new Quaternion();
private final Quaternion scratchCurRot = new Quaternion();
private final Vector3f scratchTransl = new Vector3f();
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return;
switch (name) {
case "Forward" -> forward = isPressed;
case "Backward" -> backward = isPressed;
case "Left" -> left = isPressed;
case "Right" -> right = isPressed;
case "Sprint" -> sprint = isPressed;
case "Walk" -> walk = isPressed;
case "Jump" -> { if (isPressed && physicsChar != null) { physicsChar.jump(); jumpFrames = 12; } }
case ACT_DRAW_MELEE -> { if (isPressed) handleDrawMelee(); }
case ACT_DRAW_RANGED -> { if (isPressed) handleDrawRanged(); }
case ACT_SHEATH -> { if (isPressed && weaponDrawn && !blockingAnimActive && !drawAnimActive) sheathCurrentWeapon(); }
case ACT_COMBAT_ATK -> {
if (isPressed) {
if (!weaponDrawn || blockingAnimActive || drawAnimActive || isBlocking()) break;
if (combatAnimActive) {
if (comboWindowActive) {
// Kombo-Follow während zweiter Hälfte: sofort fluid weiter
combatAnimActive = false;
combatAnimRemaining = 0f;
combatAnimSkipComboWindow = false;
heavyLungeVel = null;
heavyLungeStartRemaining = 0f;
heavyLungeEndRemaining = 0f;
handleCombatAttack();
}
break;
}
attackHoldPending = true;
attackHoldTimer = 0f;
} else {
if (attackHoldPending) {
attackHoldPending = false;
if (attackHoldTimer > QUICK_TAP_THRESHOLD) {
handleHeavyAttack();
} else {
// Schneller Klick → schwere Anim abbrechen, normale Kombo starten
combatAnimActive = false;
combatAnimRemaining = 0f;
handleCombatAttack();
}
}
}
}
case ACT_COMBAT_ALT -> handleCombatAlt(isPressed);
}
};
public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) {
this.inputManager = inputManager;
this.cam = cam;
registerMappings(kb);
}
public void setPhysicsCharacter(CharacterControl physicsChar) {
this.physicsChar = physicsChar;
}
public void setVisual(Spatial visual) {
this.visual = visual;
}
/**
* Setzt die Navigationsquellen (PathFinder + Terrain). Muss vor oder beim
* ersten setAnimationContext gesetzt sein, damit der CharacterNavigator
* korrekt initialisiert wird.
*/
public void setNavigationSources(de.blight.game.navigation.PathFinder pathFinder,
TerrainChunkState terrain) {
this.navPathFinder = pathFinder;
this.navTerrain = terrain;
}
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
this.animLib = animLib;
this.animSetName = animSetName;
this.assetRoot = assetRoot;
this.currentAnim = null;
this.runningClip = null;
animLib.configureLocomotionSnap(assetRoot, animSetName);
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
skinningControl = findSkinningControl(visual);
log.info("[AnimCtx] SkinningControl gefunden: {}", skinningControl != null);
if (skinningControl != null) {
armature = skinningControl.getArmature();
logJointNames(armature);
jointRightArm = armature.getJoint("mixamorig:RightArm");
jointRightForeArm = armature.getJoint("mixamorig:RightForeArm");
jointLeftArm = armature.getJoint("mixamorig:LeftArm");
jointLeftForeArm = armature.getJoint("mixamorig:LeftForeArm");
jointRightArmFbxRot = armature.getJoint("mixamorig:RightArm_$AssimpFbx$_Rotation");
jointRightForeArmFbxRot = armature.getJoint("mixamorig:RightForeArm_$AssimpFbx$_Rotation");
jointLeftArmFbxRot = armature.getJoint("mixamorig:LeftArm_$AssimpFbx$_Rotation");
jointLeftForeArmFbxRot = armature.getJoint("mixamorig:LeftForeArm_$AssimpFbx$_Rotation");
log.info("[DrawAnim] FbxRot-Joints: R={} RF={} L={} LF={}",
jointRightArmFbxRot != null, jointRightForeArmFbxRot != null,
jointLeftArmFbxRot != null, jointLeftForeArmFbxRot != null);
}
animPlayer = (animComposer != null && skinningControl != null)
? new AnimPlayer(animComposer, skinningControl)
: null;
if (visual != null) {
visualBaseTranslation = visual.getLocalTranslation().clone();
}
try {
AnimSet as = AnimSet.load(assetRoot.resolve("Characters").resolve("sets"), animSetName);
animOffsets = as.getAnimOffsets();
log.info("[AnimCtx] {} Anim-Offset-Einträge geladen.", animOffsets.size());
} catch (Exception e) {
log.warn("[AnimCtx] AnimSet-KF nicht ladbar: {}", e.getMessage());
animOffsets = new LinkedHashMap<>();
}
// Navigator (neu) aufbauen sobald alle Abhängigkeiten bereit sind
if (navPathFinder != null && navTerrain != null && physicsChar != null && visual != null) {
navigator = new CharacterNavigator(physicsChar, visual, navPathFinder, navTerrain);
navigator.setAnimationContext(animLib, animSetName, assetRoot);
log.info("[AnimCtx] CharacterNavigator initialisiert.");
}
if (animSetName != null) {
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
if (clip != null && tryPlay(clip)) {
currentAnim = AnimationAction.IDLE;
}
}
}
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 void setMeleeAttackCallback(MeleeAttackCallback cb) { this.meleeAttackCallback = cb; }
public void setWeaponAttachmentControl(WeaponAttachmentControl w) { this.weaponAttach = w; }
public boolean isPaused() { return paused; }
public void setPaused(boolean paused) {
this.paused = paused;
if (paused) {
forward = backward = left = right = sprint = walk = false;
autopilotDir = null;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
stopWading();
rightMouseHeld = false;
blockPhase = BlockPhase.NONE;
combatAnimActive = false;
combatAnimCallback = null;
comboStep = 0;
comboWindowActive = false;
}
}
public void reloadBindings(KeyBindings kb) {
for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
registerMappings(kb);
forward = backward = left = right = sprint = walk = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
/**
* Aktiviert den Autopilot-Modus. Der Charakter läuft mit Walk-Geschwindigkeit
* in die angegebene (normalisierte) Richtung und spielt die WALK-Animation.
* {@code null} deaktiviert den Autopilot.
*/
public void setAutopilotDirection(Vector3f dir) {
this.autopilotDir = dir;
if (dir == null && physicsChar != null && !paused && !pickupActive) {
physicsChar.setWalkDirection(Vector3f.ZERO);
}
}
/**
* Spielt die PICK_UP-Animation einmalig ab und blockiert Bewegung für {@code duration} Sekunden.
*/
public void requestPickup(float duration) {
pickupActive = true;
pickupRemaining = duration;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
playAction(AnimationAction.PICK_UP);
currentAnim = AnimationAction.PICK_UP;
}
/**
* Spielt eine beliebige Animations-Aktion einmalig ab, blockiert Bewegung für {@code duration}
* Sekunden und ruft danach {@code onComplete} auf (im Update-Thread).
* Bei {@code duration <= 0} wird die echte Clip-Länge aus dem AnimComposer abgefragt.
*/
public void requestAnimation(AnimationAction action, float duration, Runnable onComplete) {
if (duration <= 0f) {
duration = resolveClipLength(action, 1.5f);
}
blockingAnimActive = true;
blockingAnimRemaining = duration;
blockingAnimTotal = duration;
blockingAnimCallback = onComplete;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
playAction(action);
currentAnim = action;
}
/** Liefert die Länge des Clips für {@code action} in Sekunden, oder {@code fallback} wenn nicht ermittelbar. */
private float resolveClipLength(AnimationAction action, float fallback) {
if (animComposer == null || animLib == null || animSetName == null) {
return fallback;
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
if (clip == null) {
return fallback;
}
animLib.ensureApplied(clip, visual);
com.jme3.anim.AnimClip ac = animComposer.getAnimClip(clip);
float len = ac != null ? (float) ac.getLength() : 0f;
return len > 0f ? len : fallback;
}
/**
* Dreht den Charakter ohne Fortbewegung in {@code dir} und ruft nach
* {@code duration} Sekunden {@code onComplete} auf.
* Am Ende wird die Zielrotation exakt eingestellt.
*/
public void requestTurn(Vector3f dir, float duration, Runnable onComplete) {
turnActive = true;
turnRemaining = duration;
turnDir = dir.normalize();
turnCallback = onComplete;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
if (currentAnim != AnimationAction.IDLE) {
playAction(AnimationAction.IDLE);
currentAnim = AnimationAction.IDLE;
}
}
/**
* Sperrt Bewegungseingaben dauerhaft (Ruhezustand: Charakter liegt oder sitzt).
* Muss mit {@link #unlockFromPlace()} wieder aufgehoben werden.
*/
public void lockInPlace() {
lockedInPlace = true;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
/** Hebt den durch {@link #lockInPlace()} gesetzten Ruhezustand auf. */
public void unlockFromPlace() {
lockedInPlace = false;
}
public boolean isLockedInPlace() { return lockedInPlace; }
// ── Neues-Spiel-Intro ────────────────────────────────────────────────────
private boolean inputsBlocked = false;
private com.jme3.anim.tween.action.Action frozenReviveAction = null;
/** Setzt Blickrichtung (Yaw in Grad, 0=+Z, 90=+X, Uhrzeigersinn von oben). */
public void setInitialFacing(float yawDegrees) {
if (visual == null) return;
float rad = -yawDegrees * com.jme3.math.FastMath.DEG_TO_RAD;
visual.setLocalRotation(new Quaternion().fromAngles(0f, rad, 0f));
}
/**
* Blockiert alle Eingaben für das Neues-Spiel-Intro (ohne Animation zu wechseln).
* Wird in NewGameIntroState.initialize() aufgerufen, damit der Spieler
* während der BLACK-Phase nicht steuern kann.
*/
public void blockForIntro() {
lockInPlace();
inputsBlocked = true;
}
/**
* Setzt die REVIVE-Animation und friert sie bei Frame 0 ein (speed=0).
* Wird unmittelbar vor dem Einblenden aufgerufen (BLACK→FADING_IN).
*/
public void startFrozenRevive(String reviveClip) {
lockInPlace();
inputsBlocked = true;
if (animComposer != null) {
if (animLib != null && visual != null) animLib.ensureApplied(reviveClip, visual);
if (animPlayer == null) animPlayer = new AnimPlayer(animComposer, skinningControl);
// transition=0: harter Schnitt, kein BlendAction → Charakter startet sofort in frame 0
animPlayer.play(reviveClip, 0f);
frozenReviveAction = animPlayer.getCurrentAction();
if (frozenReviveAction != null) {
frozenReviveAction.setSpeed(0f);
runningClip = animPlayer.getCurrentClip();
log.info("[Intro] REVIVE '{}' eingefroren (frame 0)", reviveClip);
} else {
log.warn("[Intro] REVIVE '{}' → action null", reviveClip);
}
} else {
log.warn("[Intro] animComposer null – REVIVE kann nicht eingefroren werden");
}
}
/** Gibt die REVIVE-Animation mit normaler Geschwindigkeit frei. */
public void unfreezeRevive() {
if (frozenReviveAction != null) {
frozenReviveAction.setSpeed(1f);
frozenReviveAction = null;
log.info("[Intro] REVIVE freigegeben – Animation läuft");
}
}
/**
* Sperrt alle Tastatureingaben, lässt aber die aktuelle Laufrichtung des
* Physik-Charakters bestehen (Charakter bewegt sich weiter). Wird für den
* Ertrink-Fade-out verwendet.
*/
public void blockInputsKeepMoving() {
inputsBlocked = true;
forward = backward = left = right = sprint = walk = false;
autopilotDir = null;
stopWading();
}
/** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */
public void unblockInputs() {
inputsBlocked = false;
unlockFromPlace();
currentAnim = null; // erzwingt Neubewertung in update() (z.B. IDLE nach REVIVE)
}
/** Liefert die Clipdauer der REVIVE-Animation in Sekunden, oder 3 s als Fallback. */
public float getReviveClipLength() {
return resolveClipLength(AnimationAction.REVIVE, 3f);
}
/**
* Startet die Navigation zum angegebenen Welt-Punkt.
* Während der Navigation werden WASD-Eingaben ignoriert.
* Der CharacterNavigator übernimmt Bewegung und Animation.
*
* @param target Zielposition (Y wird auf Terrain gesampled)
* @param speed {@link CharacterNavigator.Speed#WALK} oder {@link CharacterNavigator.Speed#RUN}
* @param onArrival Callback nach Ankunft (null erlaubt)
* @param onFailed Callback bei Abbruch (null erlaubt)
*/
public void navigateTo(Vector3f target, CharacterNavigator.Speed speed,
Runnable onArrival, Runnable onFailed) {
navigateTo(target, speed, -1f, onArrival, onFailed);
}
/**
* Wie {@link #navigateTo} mit explizitem Ankunftsradius (Meter).
* Werte <= 0 verwenden den Navigator-Standard (0.45m).
*/
public void navigateTo(Vector3f target, CharacterNavigator.Speed speed, float arriveRadius,
Runnable onArrival, Runnable onFailed) {
if (navigator == null) {
log.warn("[Nav] navigateTo: kein CharacterNavigator – setNavigationSources() vorher aufrufen.");
if (onFailed != null) onFailed.run();
return;
}
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
autopilotDir = null;
clearAnimOffset();
if (animPlayer != null) animPlayer.stop();
if (arriveRadius > 0f) {
navigator.navigateTo(target, speed, arriveRadius, onArrival, onFailed);
} else {
navigator.navigateTo(target, speed, onArrival, onFailed);
}
}
/** Bricht eine laufende Navigation ab (kein Callback). */
public void stopNavigation() {
if (navigator != null) navigator.stop();
}
public boolean isNavigating() {
return navigator != null && navigator.isActive();
}
/**
* Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands.
* Nur sinnvoll nach {@link #lockInPlace()}.
*/
public void playLockedAnimation(AnimationAction action) {
playAction(action);
currentAnim = action;
}
private void registerMappings(KeyBindings kb) {
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
inputManager.addMapping("Left", new KeyTrigger(kb.left));
inputManager.addMapping("Right", new KeyTrigger(kb.right));
inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
inputManager.addMapping("Walk", new KeyTrigger(kb.walk));
inputManager.addMapping(ACT_DRAW_MELEE, new KeyTrigger(kb.drawMeleeWeapon));
inputManager.addMapping(ACT_DRAW_RANGED, new KeyTrigger(kb.drawRangedWeapon));
inputManager.addMapping(ACT_SHEATH, new KeyTrigger(kb.sheathWeapon));
inputManager.addMapping(ACT_COMBAT_ATK, new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
inputManager.addMapping(ACT_COMBAT_ALT, new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
inputManager.addListener(actionListener, ACTION_NAMES);
}
public void update(float tpf) {
if (physicsChar == null) return;
if (animPlayer != null) {
animPlayer.update(tpf);
runningClip = animPlayer.getCurrentClip();
}
if (visual != null && animOffsetElapsed < AnimPlayer.BLEND_DURATION) {
animOffsetElapsed += tpf;
float t = Math.min(1f, animOffsetElapsed / AnimPlayer.BLEND_DURATION);
animOffsetCurrent.set(animOffsetFrom);
animOffsetCurrent.interpolateLocal(animOffsetTarget, t);
scratchTransl.set(visualBaseTranslation).addLocal(animOffsetCurrent);
visual.setLocalTranslation(scratchTransl);
}
if (inputsBlocked) return;
if (paused) {
if (autopilotDir != null) {
autopilotDir = null;
physicsChar.setWalkDirection(Vector3f.ZERO);
}
return;
}
// Navigator: hat Vorrang vor allem außer Pause
if (navigator != null && navigator.isActive()) {
navigator.update(tpf);
return;
}
// Pickup-Animation hat höchste Priorität
if (pickupActive) {
pickupRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (pickupRemaining <= 0f) {
pickupActive = false;
currentAnim = null;
} else {
return;
}
}
// Drehung auf der Stelle (kein Vorwärtsbewegen)
if (turnActive) {
turnRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (visual != null && turnDir != null) {
scratchRot.lookAt(turnDir, Vector3f.UNIT_Y);
scratchCurRot.set(visual.getLocalRotation());
scratchCurRot.slerp(scratchRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(scratchCurRot);
}
if (turnRemaining <= 0f) {
turnActive = false;
// Zielrotation exakt einrasten
if (visual != null && turnDir != null) {
scratchRot.lookAt(turnDir, Vector3f.UNIT_Y);
visual.setLocalRotation(scratchRot);
}
Runnable cb = turnCallback;
turnCallback = null;
if (cb != null) cb.run();
}
return;
}
// Blockierende Einmal-Animation (lie_down, lie_up …)
if (blockingAnimActive) {
blockingAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (blockingAnimRemaining <= 0f) {
blockingAnimActive = false;
Runnable cb = blockingAnimCallback;
blockingAnimCallback = null;
if (cb != null) cb.run();
} else {
return;
}
}
// Ruhezustand (liegen / sitzen) – blockiert alle Bewegungseingaben
if (lockedInPlace) {
physicsChar.setWalkDirection(Vector3f.ZERO);
return;
}
// Autopilot: Charakter läuft automatisch in eine vorgegebene Richtung (WALK-Animation)
if (autopilotDir != null) {
physicsChar.setWalkDirection(scratchWalk.set(autopilotDir).multLocal(MOVE_SPEED * WALK_MULT));
if (visual != null) {
scratchRot.lookAt(autopilotDir, Vector3f.UNIT_Y);
scratchCurRot.set(visual.getLocalRotation());
scratchCurRot.slerp(scratchRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(scratchCurRot);
}
if (AnimationAction.WALK != currentAnim) {
playAction(AnimationAction.WALK);
currentAnim = AnimationAction.WALK;
}
return;
}
// Normale Spielereingabe (WASD)
cam.getDirection(scratchCamDir).setY(0).normalizeLocal();
cam.getLeft(scratchCamLeft).setY(0).normalizeLocal();
scratchMoveDir.set(0f, 0f, 0f);
if (forward) scratchMoveDir.addLocal(scratchCamDir);
if (backward) scratchMoveDir.subtractLocal(scratchCamDir);
if (left) scratchMoveDir.addLocal(scratchCamLeft);
if (right) scratchMoveDir.subtractLocal(scratchCamLeft);
boolean moving = scratchMoveDir.lengthSquared() > 0.001f;
// Wassertiefe bestimmen – einmal pro Frame, auch in pollFootsteps() genutzt
Vector3f charPos = physicsChar.getPhysicsLocation();
currentWaterDepth = (waterDepthQuery != null) ? waterDepthQuery.apply(charPos.x, charPos.z) : 0f;
boolean inDeepWater = currentWaterDepth > WATER_WADING_DEPTH;
// Schon ab WAITING Gehgeschwindigkeit erzwingen, damit beim Blockstart kein Sprint-Frame entsteht
boolean inBlockStance = isBlocking() || blockPhase == BlockPhase.WAITING;
// Ausdauer: Regen + Sprint-Verbrauch
boolean isSprinting = sprint && !weaponDrawn && moving && !staminaExhausted;
tickStamina(tpf, isSprinting);
if (moving) {
scratchMoveDir.normalizeLocal();
boolean canSprint = sprint && !weaponDrawn && !staminaExhausted
&& (mainCharacter == null || mainCharacter.getMaxStamina() <= 0
|| mainCharacter.getCurrentStamina() > 0);
float speed = (inDeepWater || walk || inBlockStance) ? MOVE_SPEED * WALK_MULT
: canSprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
physicsChar.setWalkDirection(scratchWalk.set(scratchMoveDir).multLocal(speed));
if (visual != null && !inBlockStance) {
scratchRot.lookAt(scratchMoveDir, Vector3f.UNIT_Y);
scratchCurRot.set(visual.getLocalRotation());
scratchCurRot.slerp(scratchRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(scratchCurRot);
}
} else {
physicsChar.setWalkDirection(Vector3f.ZERO);
}
// Im Block-Modus dreht der Char immer mit der Kamera (Blickrichtung folgt Kamera, nicht WASD)
if (visual != null && isBlocking() && scratchCamDir.lengthSquared() > 0.001f) {
scratchRot.lookAt(scratchCamDir, Vector3f.UNIT_Y);
scratchCurRot.set(visual.getLocalRotation());
scratchCurRot.slerp(scratchRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(scratchCurRot);
}
// Hold-Timer muss jede Frame akkumulieren, unabhängig vom Anim-Status
if (attackHoldPending) {
attackHoldTimer += tpf;
// Erst wenn klar ist dass es ein schwerer Angriff wird → Animation starten
if (attackHoldTimer > QUICK_TAP_THRESHOLD && !combatAnimActive) {
startHeavyCharge();
}
}
// Draw/Sheath-Animation Timer (Oberkörper-Overlay, Beine laufen weiter)
if (drawAnimActive) {
drawAnimTimer += tpf;
if (!drawMidFired && drawAnimTimer >= DRAW_DURATION * 0.5f) {
drawMidFired = true;
if (drawMidCallback != null) drawMidCallback.run();
}
if (drawAnimTimer >= DRAW_DURATION) {
drawAnimActive = false;
if (animComposer != null) animComposer.removeLayer(DRAW_LAYER);
Runnable cb = drawEndCallback;
drawEndCallback = null;
if (cb != null) cb.run();
}
}
// Kampf-Anim-Timer
if (combatAnimActive) {
combatAnimRemaining -= tpf;
boolean inLungeWindow = heavyLungeVel != null
&& combatAnimRemaining <= heavyLungeStartRemaining
&& combatAnimRemaining >= heavyLungeEndRemaining;
physicsChar.setWalkDirection(inLungeWindow ? heavyLungeVel : Vector3f.ZERO);
if (combatAnimRemaining <= 0f) {
combatAnimActive = false;
hitWindowActive = false;
heavyLungeVel = null;
heavyLungeStartRemaining = 0f;
heavyLungeEndRemaining = 0f;
currentAnim = null;
nextTransitionLength = COMBAT_BLEND;
Runnable cb = combatAnimCallback;
combatAnimCallback = null;
combatAnimSkipComboWindow = false;
if (cb != null) cb.run();
if (!combatAnimActive && comboWindowActive) {
// Fenster nicht genutzt → Kombo-Reset
comboWindowActive = false;
comboStep = 0;
log.info("[Combat] Kombo-Fenster verpasst → Reset");
}
} else {
// Kombo-Fenster öffnet sich in der zweiten Hälfte der Anim
if (!comboWindowActive && !combatAnimSkipComboWindow
&& isMeleeType(drawnWeaponType)
&& combatAnimRemaining <= combatAnimDuration * 0.5f) {
comboWindowActive = true;
log.info("[Combat] Kombo-Fenster geöffnet (step={}, remaining={}s)", comboStep, combatAnimRemaining);
}
// Per-Frame Waffen-Treffercheck + Geschwindigkeits-Sound
if (isMeleeType(drawnWeaponType) && weaponAttach != null) {
com.jme3.scene.Node hn = weaponAttach.getHandNode();
if (hn != null && !attackSoundFired && weaponSoundSystem != null) {
Vector3f grip = hn.getWorldTranslation();
scratchGripVel.set(grip).subtractLocal(lastGripPos).divideLocal(tpf);
float speed = scratchGripVel.length();
float progress = combatAnimDuration > 0f
? (combatAnimDuration - combatAnimRemaining) / combatAnimDuration : 0f;
float minProg = switch (currentComboStep) {
case 1 -> ATTACK2_SOUND_MIN_PROGRESS;
case 4 -> ATTACK5_SOUND_MIN_PROGRESS;
default -> 0f;
};
if (progress >= minProg && speed >= ATTACK_SOUND_VELOCITY) {
attackSoundFired = true;
weaponSoundSystem.play("attack");
}
lastGripPos.set(grip);
}
}
if (hitWindowActive && isMeleeType(drawnWeaponType) && combatAnimDuration > 0f) {
float t = (combatAnimDuration - combatAnimRemaining) / combatAnimDuration;
if (t >= HIT_WINDOW_START && t <= HIT_WINDOW_END) {
fireWeaponCallback(hitWindowCritMult);
} else if (t > HIT_WINDOW_END) {
hitWindowActive = false;
}
}
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");
}
}
updateBlockPhase(tpf);
// Animations-Auswahl
if (jumpFrames > 0) jumpFrames--;
if (groundGraceFrames > 0) groundGraceFrames--;
// Block-Phase verwaltet ihre eigenen Animationen.
if (!combatAnimActive && !isBlocking()) {
AnimationAction target;
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) {
target = (inDeepWater || walk) ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
} else {
target = AnimationAction.IDLE;
}
target = resolveWeaponAction(target);
if (target != currentAnim) {
playAction(target);
currentAnim = target;
}
} else if (blockPhase == BlockPhase.HOLDING && !combatAnimActive) {
// Beim Blocken: Bewegungsanimation nach Taste, Strafe hat Vorrang
AnimationAction blockMoveAnim;
if (right) blockMoveAnim = AnimationAction.SWORD_BLOCK_STRAFE_RIGHT;
else if (left) blockMoveAnim = AnimationAction.SWORD_BLOCK_STRAFE_LEFT;
else if (forward) blockMoveAnim = AnimationAction.SWORD_BLOCK_MOVE;
else if (backward) blockMoveAnim = AnimationAction.SWORD_BLOCK_MOVE_BACKWARD;
else blockMoveAnim = getMeleeBlockAction(drawnWeaponType);
if (blockMoveAnim != currentAnim) {
playAction(blockMoveAnim);
currentAnim = blockMoveAnim;
}
}
pollFootsteps(tpf);
}
private void playAction(AnimationAction action) {
if (animLib == null || visual == null || animSetName == null) {
if (!animCtxLogged) {
animCtxLogged = true;
log.info("[Anim] Kein Animations-Kontext: animLib={} visual={} setName={}", animLib, visual, animSetName);
}
return;
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
log.info("[Anim] {} → clip='{}' (set={})", action, clip, animSetName);
// 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);
if (defClip != null) tryPlay(defClip);
}
}
// ── Kampf-Hilfsmethoden ───────────────────────────────────────────────────
private void handleDrawMelee() {
if (mainCharacter == null || mainCharacter.getEquippedMeleeWeapon() == null) return;
if (blockingAnimActive || combatAnimActive || drawAnimActive) return;
ItemSubCategory sub = mainCharacter.getEquippedMeleeWeapon().getSubCategory();
if (!weaponDrawn) {
boolean hasShield = mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield,
() -> {
weaponDrawn = true; drawnWeaponType = sub; currentAnim = null;
if (weaponSoundSystem != null) weaponSoundSystem.play("unsheath");
},
null);
} else if (isMeleeType(drawnWeaponType)) {
sheathCurrentWeapon();
}
}
private void handleDrawRanged() {
if (mainCharacter == null || mainCharacter.getEquippedRangedWeapon() == null) return;
if (blockingAnimActive || combatAnimActive) return;
ItemSubCategory sub = mainCharacter.getEquippedRangedWeapon().getSubCategory();
if (!weaponDrawn) {
AnimationAction drawAnim = getRangedDrawAction(sub);
float dur = resolveClipLength(drawAnim, 0.5f);
requestAnimation(drawAnim, dur, () -> {
weaponDrawn = true;
drawnWeaponType = sub;
currentAnim = null;
});
} else if (isRangedType(drawnWeaponType)) {
sheathCurrentWeapon();
}
}
private void sheathCurrentWeapon() {
if (drawnWeaponType == null) { weaponDrawn = false; return; }
isAiming = false;
rightMouseHeld = false;
attackHoldPending = false;
heavyLungeVel = null;
blockPhase = BlockPhase.NONE;
if (weaponSoundSystem != null) weaponSoundSystem.play("sheath");
boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield,
() -> { weaponDrawn = false; drawnWeaponType = null; currentAnim = null; },
null);
}
private void handleCombatAttack() {
if (!weaponDrawn || blockingAnimActive || combatAnimActive || isBlocking()) {
log.info("[Combat] BLOCKIERT: weaponDrawn={} blockingAnim={} combatAnim={} blocking={}",
weaponDrawn, blockingAnimActive, combatAnimActive, isBlocking());
return;
}
if (isMeleeType(drawnWeaponType)) {
int step = comboStep;
boolean isComboFollow = comboWindowActive; // war Kombo-Fenster offen?
int staminaCost = (step == 4) ? STAMINA_ATTACK5_COST : STAMINA_ATTACK_COST;
if (!spendStamina(staminaCost)) return;
AnimationAction atk = getMeleeComboAction(drawnWeaponType, step);
lastAttackCritical = (step == 4);
currentAttackId = ++attackIdCounter;
hitWindowActive = true;
hitWindowCritMult = lastAttackCritical ? 2.0f : 1.0f;
currentComboStep = step;
comboStep = (step + 1) % 5;
comboWindowActive = false;
attackSoundFired = false;
if (weaponAttach != null && weaponAttach.getHandNode() != null)
lastGripPos.set(weaponAttach.getHandNode().getWorldTranslation());
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;
combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimDuration = dur;
combatAnimCallback = null;
playAction(atk);
currentAnim = atk;
// Finisher (step 4): leichte Vorwärtsbewegung
if (step == 4) {
Vector3f fwd = (visual != null)
? visual.getWorldRotation().mult(new Vector3f(0f, 0f, 1f))
: cam.getDirection().clone();
fwd.y = 0f;
if (fwd.lengthSquared() > 0.001f) fwd.normalizeLocal();
heavyLungeVel = fwd.mult(MOVE_SPEED * 0.1f);
heavyLungeStartRemaining = dur * (1f - LUNGE_FRAC_START);
heavyLungeEndRemaining = dur * (1f - LUNGE_FRAC_END);
} else {
heavyLungeVel = null;
heavyLungeStartRemaining = 0f;
heavyLungeEndRemaining = 0f;
}
} else if (isRangedType(drawnWeaponType) && isAiming) {
isAiming = false;
AnimationAction fireAnim = getRangedFireAction(drawnWeaponType);
AnimationAction reloadAnim = getRangedReloadAction(drawnWeaponType);
float fireDur = resolveClipLength(fireAnim, 0.5f);
float reloadDur = resolveClipLength(reloadAnim, 0.8f);
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, fireDur - COMBAT_BLEND);
combatAnimCallback = () -> {
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, reloadDur - COMBAT_BLEND);
combatAnimCallback = null;
nextTransitionLength = COMBAT_BLEND;
playAction(reloadAnim);
currentAnim = reloadAnim;
};
nextTransitionLength = COMBAT_BLEND;
playAction(fireAnim);
currentAnim = fireAnim;
}
}
private void handleCombatAlt(boolean isPressed) {
if (!weaponDrawn || blockingAnimActive) return;
if (isMeleeType(drawnWeaponType)) {
if (isPressed) {
if (blockPhase == BlockPhase.NONE) {
blockPhase = BlockPhase.WAITING;
blockPhaseTimer = 0f;
}
} else {
switch (blockPhase) {
case WAITING -> {
blockPhase = BlockPhase.NONE;
playCounterAttack();
}
case ENTERING, HOLDING -> startBlockExit();
default -> {}
}
}
} else if (isRangedType(drawnWeaponType) && isPressed) {
isAiming = !isAiming;
}
}
private void updateBlockPhase(float tpf) {
switch (blockPhase) {
case WAITING -> {
blockPhaseTimer += tpf;
if (blockPhaseTimer >= BLOCK_TAP_MAX) {
if (hasShieldEquipped()) {
startBlockEnter();
} else {
blockPhase = BlockPhase.NONE;
}
}
}
case ENTERING -> {
blockPhaseTimer -= tpf;
if (blockPhaseTimer <= 0f) {
blockPhase = BlockPhase.HOLDING;
AnimationAction blockAct = getMeleeBlockAction(drawnWeaponType);
nextTransitionLength = COMBAT_BLEND;
playAction(blockAct);
currentAnim = blockAct;
}
}
case EXITING -> {
blockPhaseTimer -= tpf;
if (blockPhaseTimer <= 0f) {
blockPhase = BlockPhase.NONE;
currentAnim = null;
}
}
default -> {}
}
}
private void startBlockEnter() {
blockPhase = BlockPhase.ENTERING;
AnimationAction idleToBlock = getMeleeIdleToBlockAction(drawnWeaponType);
float dur = resolveClipLength(idleToBlock, 0.5f);
// Etwas kürzer als Clip-Länge damit die Animation nicht einen Frame lang re-startet bevor HOLDING greift
blockPhaseTimer = Math.max(0.1f, dur - 0.05f);
nextTransitionLength = COMBAT_BLEND;
playAction(idleToBlock);
currentAnim = idleToBlock;
}
private void startBlockExit() {
blockPhase = BlockPhase.EXITING;
AnimationAction idleToBlock = getMeleeIdleToBlockAction(drawnWeaponType);
float dur = resolveClipLength(idleToBlock, 0.5f);
blockPhaseTimer = Math.max(0.1f, dur - 0.05f);
nextTransitionLength = COMBAT_BLEND;
nextPlayReversed = true;
playAction(idleToBlock);
currentAnim = idleToBlock;
}
private void playCounterAttack() {
if (combatAnimActive) return;
AnimationAction counterAct = getMeleeCounterAttackAction(drawnWeaponType);
float dur = resolveClipLength(counterAct, 0.7f);
nextTransitionLength = COMBAT_BLEND;
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimCallback = null;
combatAnimSkipComboWindow = true;
playAction(counterAct);
currentAnim = counterAct;
}
public boolean isBlocking() {
return blockPhase == BlockPhase.ENTERING
|| blockPhase == BlockPhase.HOLDING
|| blockPhase == BlockPhase.EXITING;
}
/** Wird aufgerufen wenn ein blockierter Treffer landet. Kostet 30 Ausdauer; unter 0 → Block bricht. */
public void triggerBlockImpact() {
if (blockPhase != BlockPhase.HOLDING || drawnWeaponType == null) return;
if (mainCharacter != null && mainCharacter.getMaxStamina() > 0) {
if (mainCharacter.getCurrentStamina() < STAMINA_BLOCK_HIT_COST) {
mainCharacter.setCurrentStamina(0);
startBlockExit();
return;
}
mainCharacter.setCurrentStamina(mainCharacter.getCurrentStamina() - STAMINA_BLOCK_HIT_COST);
}
AnimationAction impact = getMeleeBlockImpactAction(drawnWeaponType);
float dur = resolveClipLength(impact, 0.8f);
hitWindowActive = false;
attackSoundFired = true;
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimDuration = dur;
combatAnimCallback = null;
combatAnimSkipComboWindow = true;
nextTransitionLength = COMBAT_BLEND;
playAction(impact);
currentAnim = impact;
}
// ── Ausdauer-Hilfsmethoden ────────────────────────────────────────────────
private void tickStamina(float tpf, boolean isSprinting) {
if (mainCharacter == null || mainCharacter.getMaxStamina() <= 0) return;
float delta = STAMINA_REGEN_PER_SEC * tpf;
if (isSprinting) delta -= STAMINA_SPRINT_PER_SEC * tpf;
staminaAccum += delta;
int intPart = (int) staminaAccum;
if (intPart != 0) {
staminaAccum -= intPart;
int cur = mainCharacter.getCurrentStamina() + intPart;
mainCharacter.setCurrentStamina(Math.max(0, Math.min(mainCharacter.getMaxStamina(), cur)));
}
int stamina = mainCharacter.getCurrentStamina();
int max = mainCharacter.getMaxStamina();
if (!staminaExhausted && stamina == 0) {
staminaExhausted = true;
} else if (staminaExhausted && stamina >= max / 4) {
staminaExhausted = false;
}
}
/** Zieht {@code cost} Ausdauer ab. Gibt false zurück (Aktion verweigert) wenn nicht genug vorhanden. */
private boolean spendStamina(int cost) {
if (mainCharacter == null || mainCharacter.getMaxStamina() <= 0) return true;
if (mainCharacter.getCurrentStamina() < cost) return false;
mainCharacter.setCurrentStamina(mainCharacter.getCurrentStamina() - cost);
return true;
}
private boolean hasShieldEquipped() {
return mainCharacter != null && mainCharacter.getEquippedShield() != null;
}
private AnimationAction resolveWeaponAction(AnimationAction base) {
if (!weaponDrawn || drawnWeaponType == null) return base;
return switch (base) {
case IDLE -> weaponIdleAction();
case WALK -> weaponWalkAction();
case RUN, SPRINT -> weaponRunAction();
default -> base;
};
}
private AnimationAction weaponIdleAction() {
if (isAiming) {
return switch (drawnWeaponType) {
case BOW -> AnimationAction.BOW_AIM;
case CROSSBOW -> AnimationAction.CROSSBOW_AIM;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_AIM;
default -> weaponStandAction();
};
}
return weaponStandAction();
}
private AnimationAction weaponStandAction() {
return switch (drawnWeaponType) {
case SWORD -> AnimationAction.SWORD_IDLE;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE;
case AXE -> AnimationAction.AXE_IDLE;
case BOW -> AnimationAction.BOW_IDLE;
case CROSSBOW -> AnimationAction.CROSSBOW_IDLE;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_IDLE;
default -> AnimationAction.IDLE;
};
}
private AnimationAction weaponWalkAction() {
return switch (drawnWeaponType) {
case SWORD -> AnimationAction.SWORD_WALK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_WALK;
case AXE -> AnimationAction.AXE_WALK;
case BOW -> AnimationAction.BOW_WALK;
case CROSSBOW -> AnimationAction.CROSSBOW_WALK;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_WALK;
default -> AnimationAction.WALK;
};
}
private AnimationAction weaponRunAction() {
return switch (drawnWeaponType) {
case SWORD -> AnimationAction.SWORD_RUN;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_RUN;
case AXE -> AnimationAction.AXE_RUN;
case BOW -> AnimationAction.BOW_RUN;
case CROSSBOW -> AnimationAction.CROSSBOW_RUN;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_RUN;
default -> AnimationAction.RUN;
};
}
private static boolean isMeleeType(ItemSubCategory s) {
return s == ItemSubCategory.SWORD
|| s == ItemSubCategory.AXE
|| s == ItemSubCategory.TWO_HANDED_SWORD;
}
private static boolean isRangedType(ItemSubCategory s) {
return s == ItemSubCategory.BOW
|| s == ItemSubCategory.CROSSBOW
|| s == ItemSubCategory.MAGIC_CRYSTAL;
}
private static AnimationAction getMeleeDrawAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_DRAW;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_DRAW;
default -> AnimationAction.SWORD_DRAW;
};
}
private static AnimationAction getMeleySheathAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_SHEATHE;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_SHEATHE;
default -> AnimationAction.SWORD_SHEATHE;
};
}
private static AnimationAction getMeleeAttack1Action(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_ATTACK_1;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_ATTACK_1;
default -> AnimationAction.SWORD_ATTACK_1;
};
}
private static AnimationAction getMeleeAttack2Action(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_ATTACK_2;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_ATTACK_2;
default -> AnimationAction.SWORD_ATTACK_2;
};
}
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=ATK2, 4=ATK3 (kritisch 2x). */
private static AnimationAction getMeleeComboAction(ItemSubCategory type, int step) {
return switch (step) {
case 1 -> getMeleeAttack2Action(type);
case 3 -> getMeleeAttack2Action(type);
case 4 -> getMeleeAttack3Action(type);
default -> getMeleeAttack1Action(type);
};
}
private static AnimationAction getMeleeBlockAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_BLOCK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK;
default -> AnimationAction.SWORD_BLOCK;
};
}
private static AnimationAction getMeleeIdleToBlockAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_IDLE_TO_BLOCK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_TO_BLOCK;
default -> AnimationAction.SWORD_IDLE_TO_BLOCK;
};
}
private static AnimationAction getMeleeCounterAttackAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_COUNTERATTACK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_COUNTERATTACK;
default -> AnimationAction.SWORD_COUNTERATTACK;
};
}
public static AnimationAction getMeleeBlockImpactAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_BLOCK_IMPACT;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_BLOCK_IMPACT;
default -> AnimationAction.SWORD_BLOCK_IMPACT;
};
}
public static AnimationAction getMeleeIdleImpactAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_IDLE_IMPACT;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_IDLE_IMPACT;
default -> AnimationAction.SWORD_IDLE_IMPACT;
};
}
private void fireWeaponCallback(float critMultiplier) {
if (meleeAttackCallback == null || weaponAttach == null) {
return;
}
com.jme3.scene.Spatial ws = weaponAttach.getWeaponSpatial();
com.jme3.scene.Node hn = weaponAttach.getHandNode();
if (ws == null || hn == null) {
return;
}
int base = (mainCharacter != null && mainCharacter.getEquippedMeleeWeapon() != null)
? mainCharacter.getEquippedMeleeWeapon().getDamage() : 0;
Vector3f grip = hn.getWorldTranslation().clone();
Vector3f bladeDir = ws.getWorldRotation().mult(com.jme3.math.Vector3f.UNIT_Y).normalizeLocal();
meleeAttackCallback.onMeleeAttack(currentAttackId, grip, bladeDir, WEAPON_BLADE_LENGTH, base, critMultiplier);
}
private float computeHeavyMultiplier() {
return 3.0f;
}
private static AnimationAction getMeleeHeavyAttackAction(ItemSubCategory s) {
return switch (s) {
case AXE -> AnimationAction.AXE_HEAVY_ATTACK;
case TWO_HANDED_SWORD -> AnimationAction.TWO_HAND_HEAVY_ATTACK;
default -> AnimationAction.SWORD_HEAVY_ATTACK;
};
}
/** Startet sofort die schwere-Angriff-Animation beim Drücken der Taste. */
private void startHeavyCharge() {
if (drawnWeaponType == null || !isMeleeType(drawnWeaponType)) {
return;
}
if (!spendStamina(STAMINA_HEAVY_COST)) return;
comboStep = 0;
comboWindowActive = false;
AnimationAction action = getMeleeHeavyAttackAction(drawnWeaponType);
float dur = resolveClipLength(action, 0.8f);
nextTransitionLength = -1.0;
combatAnimActive = true;
combatAnimRemaining = Math.max(0.1f, dur - COMBAT_BLEND);
combatAnimDuration = dur;
combatAnimSkipComboWindow = true;
combatAnimCallback = null;
playAction(action);
currentAnim = action;
// Blickrichtung des Chars in Weltkoordinaten
Vector3f fwd = (visual != null)
? visual.getWorldRotation().mult(new Vector3f(0f, 0f, 1f))
: cam.getDirection().clone();
fwd.y = 0f;
if (fwd.lengthSquared() > 0.001f) fwd.normalizeLocal();
heavyLungeVel = fwd.mult(MOVE_SPEED * 0.25f);
// Lunge nur während der Sprungphase: combatAnimRemaining-Fenster vorausberechnen
heavyLungeStartRemaining = dur * (1f - LUNGE_FRAC_START);
heavyLungeEndRemaining = dur * (1f - LUNGE_FRAC_END);
log.info("[Combat] Schwerer Angriff Windup gestartet.");
}
/** Feuert den Schaden bei Release; Animation läuft bereits seit Press. */
private void handleHeavyAttack() {
if (!weaponDrawn || drawnWeaponType == null || !isMeleeType(drawnWeaponType)) {
return;
}
currentAttackId = ++attackIdCounter;
currentComboStep = -1;
attackSoundFired = false;
if (weaponAttach != null && weaponAttach.getHandNode() != null)
lastGripPos.set(weaponAttach.getHandNode().getWorldTranslation());
float mult = computeHeavyMultiplier();
fireWeaponCallback(mult);
log.info("[Combat] Schwerer Angriff ausgelöst | Haltezeit={}s Multiplikator={}x",
String.format("%.2f", attackHoldTimer), String.format("%.2f", mult));
}
private static AnimationAction getRangedDrawAction(ItemSubCategory s) {
return switch (s) {
case CROSSBOW -> AnimationAction.CROSSBOW_DRAW;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_DRAW;
default -> AnimationAction.BOW_DRAW;
};
}
private static AnimationAction getRangedSheathAction(ItemSubCategory s) {
return switch (s) {
case CROSSBOW -> AnimationAction.CROSSBOW_SHEATHE;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_SHEATHE;
default -> AnimationAction.BOW_SHEATHE;
};
}
private static AnimationAction getRangedFireAction(ItemSubCategory s) {
return switch (s) {
case CROSSBOW -> AnimationAction.CROSSBOW_FIRE;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_FIRE;
default -> AnimationAction.BOW_FIRE;
};
}
private static AnimationAction getRangedReloadAction(ItemSubCategory s) {
return switch (s) {
case CROSSBOW -> AnimationAction.CROSSBOW_RELOAD;
case MAGIC_CRYSTAL -> AnimationAction.CRYSTAL_RELOAD;
default -> AnimationAction.BOW_RELOAD;
};
}
private static AnimationAction getSheathAction(ItemSubCategory s) {
return isMeleeType(s) ? getMeleySheathAction(s) : getRangedSheathAction(s);
}
public void setFootstepSystem(de.blight.game.audio.FootstepSystem fs,
java.util.function.BiFunction<Float, Float, float[]> query) {
this.footstepSystem = fs;
this.surfaceQuery = query;
}
public void setWeaponSoundSystem(de.blight.game.audio.WeaponSoundSystem wss) {
this.weaponSoundSystem = wss;
}
public void setWaterDepthQuery(java.util.function.BiFunction<Float, Float, Float> query) {
this.waterDepthQuery = query;
}
public void setWadingSound(com.jme3.audio.AudioNode node) {
this.wadingNode = node;
}
private void logJointNames(com.jme3.anim.Armature arm) {
if (arm == null) return;
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
for (int i = 0; i < arm.getJointCount(); i++) {
sb.append("\n [").append(i).append("] ").append(arm.getJoint(i).getName());
}
log.info("{}", sb);
}
private void resetFootstepState() {
lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE;
leftFootDescending = false;
rightFootDescending = false;
stepCooldown = 0f;
}
private void pollFootsteps(float tpf) {
// Wading-Ambient: immer wenn im Wasser, auch im Stand oder blockiert
if (currentWaterDepth > 0f) {
startWading();
} else {
stopWading();
}
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
resetFootstepState();
return;
}
float speed = physicsChar.getWalkDirection().length();
if (speed < 0.001f) {
resetFootstepState();
return;
}
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
String gait = currentAnimToGait(currentAnim);
if (gait == null) return;
stepCooldown = Math.max(0f, stepCooldown - tpf);
com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT);
com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT);
if (lf != null) {
float y = lf.getModelTransform().getTranslation().y;
// Descend→ascend transition = local Y-minimum = foot touches ground
boolean desc = (lastLeftFootY - y) > 0.001f;
if (!desc && leftFootDescending && stepCooldown <= 0f) {
triggerStep(gait);
stepCooldown = MIN_STEP_INTERVAL;
}
leftFootDescending = desc;
lastLeftFootY = y;
}
if (rf != null) {
float y = rf.getModelTransform().getTranslation().y;
boolean desc = (lastRightFootY - y) > 0.001f;
if (!desc && rightFootDescending && stepCooldown <= 0f) {
triggerStep(gait);
stepCooldown = MIN_STEP_INTERVAL;
}
rightFootDescending = desc;
lastRightFootY = y;
}
}
private void startWading() {
if (wadingNode == null) return;
if (wadingNode.getStatus() != com.jme3.audio.AudioSource.Status.Playing) {
wadingNode.play();
}
}
private void stopWading() {
if (wadingNode == null) return;
if (wadingNode.getStatus() == com.jme3.audio.AudioSource.Status.Playing) {
wadingNode.stop();
}
}
private String currentAnimToGait(AnimationAction anim) {
if (anim == AnimationAction.WALK) return "walking";
if (anim == AnimationAction.RUN) return "running";
if (anim == AnimationAction.SPRINT) return "sprinting";
return null;
}
private void triggerStep(String gait) {
if (currentWaterDepth > 0f) {
// Im Wasser: Wassergeräusche aus footsteps/water/walking bzw. /running
float[] weights = new float[de.blight.game.audio.SurfaceType.values().length];
weights[de.blight.game.audio.SurfaceType.WATER.ordinal()] = 1f;
footstepSystem.play(weights, gait);
} else {
Vector3f pos = physicsChar.getPhysicsLocation();
float[] weights = surfaceQuery.apply(pos.x, pos.z);
footstepSystem.play(weights, gait);
}
}
/** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */
private com.jme3.anim.SkinningControl findSkinningControl(Spatial s) {
if (s == null) {
return null;
}
com.jme3.anim.SkinningControl sc = s.getControl(com.jme3.anim.SkinningControl.class);
if (sc != null) {
return sc;
}
if (s instanceof com.jme3.scene.Node n) {
for (Spatial child : n.getChildren()) {
sc = findSkinningControl(child);
if (sc != null) {
return sc;
}
}
}
return null;
}
private void applyAnimOffset(String clip) {
if (visual == null) return;
AnimOffset off = (clip != null) ? animOffsets.get(clip) : null;
if (off == null) { clearAnimOffset(); return; }
Quaternion facing = visual.getLocalRotation().clone();
Vector3f worldOffset = facing.mult(new Vector3f(off.tx, off.ty, off.tz));
if (animOffsetCurrent.distanceSquared(worldOffset) > 1e-4f) {
animOffsetFrom.set(animOffsetCurrent);
animOffsetTarget.set(worldOffset);
animOffsetElapsed = 0f;
}
log.debug("[AnimOffset] Clip '{}' → Ziel ({},{},{})", clip, worldOffset.x, worldOffset.y, worldOffset.z);
}
/**
* Setzt den Anim-Offset für eine AnimationAction sofort (kein Lerp).
* Verwenden bei Übergängen, bei denen der Lerp die Skelett-Animation kompensieren würde
* (z.B. sit-down: Offset-Lerp 0→-0.5m hebt Hip-Bone-Vorwärtsbewegung auf → Hüfte wirkt statisch).
*/
public void applyActionOffsetInstant(AnimationAction action) {
if (visual == null || animLib == null || animSetName == null) return;
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
if (clip == null) return;
AnimOffset off = animOffsets.get(clip);
if (off == null) { clearAnimOffsetInstant(); return; }
Quaternion facing = visual.getLocalRotation().clone();
Vector3f worldOffset = facing.mult(new Vector3f(off.tx, off.ty, off.tz));
animOffsetFrom.set(worldOffset);
animOffsetTarget.set(worldOffset);
animOffsetCurrent.set(worldOffset);
animOffsetElapsed = Float.MAX_VALUE;
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(animOffsetCurrent));
log.info("[AnimOffset] Sofort: action={} clip='{}' → ({},{},{})", action, clip,
worldOffset.x, worldOffset.y, worldOffset.z);
}
public void clearAnimOffset() {
log.info("[AnimOffset] clearAnimOffset() → Lerp zu 0, current=({},{},{})", animOffsetCurrent.x, animOffsetCurrent.y, animOffsetCurrent.z);
if (animOffsetCurrent.lengthSquared() > 1e-4f) {
animOffsetFrom.set(animOffsetCurrent);
animOffsetTarget.set(0, 0, 0);
animOffsetElapsed = 0f;
}
}
/**
* Setzt den Visual-Versatz sofort (kein Lerp), ohne den Physik-Körper zu bewegen.
* tx/ty/tz in charakter-lokalem Raum (tz = vorwärts/rückwärts in Blickrichtung).
*/
public void setAnimOffsetInstant(float tx, float ty, float tz) {
if (visual == null) return;
Quaternion facing = visual.getLocalRotation().clone();
Vector3f worldOffset = facing.mult(new Vector3f(tx, ty, tz));
animOffsetFrom.set(worldOffset);
animOffsetTarget.set(worldOffset);
animOffsetCurrent.set(worldOffset);
animOffsetElapsed = Float.MAX_VALUE;
visual.setLocalTranslation(visualBaseTranslation.clone().addLocal(animOffsetCurrent));
log.info("[AnimOffset] Instant: ({},{},{}) → world ({},{},{})", tx, ty, tz, worldOffset.x, worldOffset.y, worldOffset.z);
}
/** Setzt den Visual-Versatz sofort auf 0 zurück (kein Lerp). */
public void clearAnimOffsetInstant() {
animOffsetFrom.set(0, 0, 0);
animOffsetTarget.set(0, 0, 0);
animOffsetCurrent.set(0, 0, 0);
animOffsetElapsed = Float.MAX_VALUE;
if (visual != null) {
visual.setLocalTranslation(visualBaseTranslation.clone());
}
log.info("[AnimOffset] Instant-Clear");
}
/** Verhindert für {@code frames} Frames, dass die JUMP-Animation durch kurzes onGround=false nach Teleport ausgelöst wird. */
public void setGroundGrace(int frames) {
groundGraceFrames = frames;
}
/** Setzt eine einmalige Überblend-Zeit für den nächsten Animations-Wechsel (sanfter Skelett-Übergang). */
public void setNextAnimTransition(double seconds) {
nextTransitionLength = seconds;
}
/** Gibt die konfigurierte Bank-Approach-Distanz (|sitting.tz|) zurück; 0.25m als Fallback. */
public float getBenchApproachDist() {
AnimOffset off = animOffsets.get("sitting");
if (off == null) return 0.25f;
return Math.abs(off.tz) > 0.01f ? Math.abs(off.tz) : 0.25f;
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.applyTo(clip, visual)) {
log.info("[Anim] tryPlay('{}') → applyTo FAILED", clip);
return false;
}
if (animPlayer == null) {
animPlayer = new AnimPlayer(animComposer, skinningControl);
}
float transLen = (float) nextTransitionLength;
float startOff = nextStartOffset;
boolean reversed = nextPlayReversed;
nextTransitionLength = 0.0;
nextStartOffset = 0f;
nextPlayReversed = false;
boolean ok = reversed
? animPlayer.playReversed(clip, transLen)
: animPlayer.play(clip, transLen, startOff);
log.info("[Anim] AnimPlayer.{}('{}') transLen={} → {}", reversed ? "playReversed" : "play",
clip, transLen, ok ? "OK" : "FAILED");
if (ok) {
runningClip = animPlayer.getCurrentClip();
applyAnimOffset(clip);
}
return ok;
}
// ── Prozedurale Ziehen/Wegstecken-Animation ───────────────────────────────
private void startDrawAnimation(boolean withShield, Runnable onMid, Runnable onEnd) {
if (drawAnimActive) return;
drawAnimActive = true;
drawAnimTimer = 0f;
drawMidFired = false;
drawMidCallback = onMid;
drawEndCallback = onEnd;
drawWithShield = withShield;
if (animComposer == null || armature == null) {
// Kein AnimComposer verfügbar: sofort Callbacks ausführen
drawAnimActive = false;
if (onMid != null) onMid.run();
if (onEnd != null) onEnd.run();
return;
}
// Start-Pose der $AssimpFbx$_Rotation-Nodes für SLERP sampeln
drawStartArmQ = jointRightArmFbxRot != null
? jointRightArmFbxRot.getLocalRotation().clone() : new Quaternion();
drawStartForeArmQ = jointRightForeArmFbxRot != null
? jointRightForeArmFbxRot.getLocalRotation().clone() : new Quaternion();
drawStartLeftArmQ = jointLeftArmFbxRot != null
? jointLeftArmFbxRot.getLocalRotation().clone() : new Quaternion();
drawStartLeftForeArmQ = jointLeftForeArmFbxRot != null
? jointLeftForeArmFbxRot.getLocalRotation().clone() : new Quaternion();
try {
ArmatureMask mask = ArmatureMask.createMask(armature, "mixamorig:RightArm");
if (withShield && jointLeftArm != null) {
mask.addFromJoint(armature, "mixamorig:LeftArm");
}
DrawArmTween tween = new DrawArmTween(withShield);
BaseAction action = new BaseAction(tween);
animComposer.addAction(DRAW_LAYER, action);
animComposer.makeLayer(DRAW_LAYER, mask);
animComposer.setCurrentAction(DRAW_LAYER, DRAW_LAYER);
} catch (Exception e) {
log.warn("[DrawAnim] Layer-Setup fehlgeschlagen: {}", e.getMessage());
drawAnimActive = false;
if (onMid != null) onMid.run();
if (onEnd != null) onEnd.run();
}
}
private final class DrawArmTween implements Tween {
private final boolean withShield;
DrawArmTween(boolean withShield) {
this.withShield = withShield;
}
@Override
public double getLength() { return Double.MAX_VALUE; }
@Override
public boolean interpolate(double t) {
if (!drawAnimActive) return true;
float progress = drawAnimTimer / DRAW_DURATION;
if (progress <= 0f || progress >= 1f) return true;
// alpha: 0→1 beim Hochheben (0→50%), 1→0 beim Zurückkommen (50→100%)
float alpha;
if (progress < 0.5f) {
float p = progress * 2f;
alpha = p * p * (3f - 2f * p);
} else {
float p = (1f - progress) * 2f;
alpha = p * p * (3f - 2f * p);
}
// SLERP von Start-Pose (gesampelt beim Anim-Start) zur Wind-Up-Pose (aus Heavy Attack)
if (jointRightArmFbxRot != null && drawStartArmQ != null) {
Quaternion q = new Quaternion();
q.slerp(drawStartArmQ, DRAW_ARM_PEAK_Q, alpha);
jointRightArmFbxRot.setLocalRotation(q);
}
if (jointRightForeArmFbxRot != null && drawStartForeArmQ != null) {
Quaternion q = new Quaternion();
q.slerp(drawStartForeArmQ, DRAW_FOREARM_PEAK_Q, alpha);
jointRightForeArmFbxRot.setLocalRotation(q);
}
if (withShield) {
applyDelta(jointLeftArm, DRAW_L_ARM_DEG, alpha);
applyDelta(jointLeftForeArm, DRAW_L_FOREARM_DEG, alpha);
}
return true;
}
private void applyDelta(com.jme3.anim.Joint joint, float[] deg, float alpha) {
if (joint == null) return;
Quaternion delta = new Quaternion().fromAngles(
com.jme3.math.FastMath.DEG_TO_RAD * deg[0] * alpha,
com.jme3.math.FastMath.DEG_TO_RAD * deg[1] * alpha,
com.jme3.math.FastMath.DEG_TO_RAD * deg[2] * alpha
);
joint.setLocalRotation(delta.mult(joint.getLocalRotation()));
}
}
}