1905 lines
85 KiB
Java
1905 lines
85 KiB
Java
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()));
|
||
}
|
||
|
||
}
|
||
}
|