Animation bezüglich schwert und schild im kampfsystem abgeschlossen

This commit is contained in:
2026-09-22 20:44:22 +02:00
parent ef061b2726
commit 3cef09bb29
26 changed files with 1141 additions and 49 deletions

View File

@@ -38,6 +38,7 @@ public enum AnimationAction {
SWORD_ATTACK_2, SWORD_ATTACK_3, SWORD_HEAVY_ATTACK,
SWORD_IDLE_TO_BLOCK, SWORD_COUNTERATTACK, SWORD_BLOCK_IMPACT, SWORD_IDLE_IMPACT,
SWORD_IDLE, SWORD_WALK, SWORD_RUN,
SWORD_BLOCK_MOVE, SWORD_BLOCK_MOVE_BACKWARD, SWORD_BLOCK_STRAFE_LEFT, SWORD_BLOCK_STRAFE_RIGHT,
// ── Axt ───────────────────────────────────────────────────────────────────
AXE_DRAW, AXE_SHEATHE, AXE_BLOCK,
@@ -109,6 +110,10 @@ public enum AnimationAction {
case SWORD_IDLE -> TextRegistry.resolve(null, key, "Stehen (Schwert)");
case SWORD_WALK -> TextRegistry.resolve(null, key, "Gehen (Schwert)");
case SWORD_RUN -> TextRegistry.resolve(null, key, "Laufen (Schwert)");
case SWORD_BLOCK_MOVE -> TextRegistry.resolve(null, key, "Block Vorwärts (Schwert)");
case SWORD_BLOCK_MOVE_BACKWARD -> TextRegistry.resolve(null, key, "Block Rückwärts (Schwert)");
case SWORD_BLOCK_STRAFE_LEFT -> TextRegistry.resolve(null, key, "Block Strafe Links (Schwert)");
case SWORD_BLOCK_STRAFE_RIGHT -> TextRegistry.resolve(null, key, "Block Strafe Rechts (Schwert)");
case AXE_DRAW -> TextRegistry.resolve(null, key, "Ziehen (Axt)");
case AXE_SHEATHE -> TextRegistry.resolve(null, key, "Wegstecken (Axt)");

View File

@@ -0,0 +1,25 @@
package de.blight.game.combat;
/**
* Berechnet den finalen Schadenswert eines Angriffs.
* Erweiterbar für Level-Boni, Fähigkeiten, Rüstungs-Durchdringung etc.
*/
public final class DamageCalculator {
private static final int UNARMED_BASE = 5;
private DamageCalculator() {}
/**
* @param baseDamage Basisschaden der Waffe (≤0 = unbewaffnet)
* @param critMultiplier Schadens-Multiplikator (1.0 = normal, 2.0 = Kombo-Krit, 1.5–3.0 = Schwerer Angriff)
* @return Schadenswert (min. 1)
*/
public static int calculate(int baseDamage, float critMultiplier) {
int dmg = baseDamage > 0 ? baseDamage : UNARMED_BASE;
if (critMultiplier > 1.0f) {
dmg = Math.round(dmg * critMultiplier);
}
return Math.max(1, dmg);
}
}

View File

@@ -0,0 +1,18 @@
package de.blight.game.combat;
import com.jme3.math.Vector3f;
/**
* Wird aufgerufen wenn der Spieler einen Nahkampfangriff auslöst.
* Implementierende Klassen können z.B. Treffererkennung auf Zielobjekten durchführen.
*/
public interface MeleeAttackCallback {
/**
* @param charPosition Weltposition des Charakters beim Angriff
* @param charForward Blickrichtung des Charakters (normiert, Y=0)
* @param baseDamage Basisschaden der Waffe (0 = unbewaffnet)
* @param critMultiplier Schadens-Multiplikator (1.0 = normal, 2.0 = Kombo-5, 1.5–3.0 = Schwerer Angriff)
*/
void onMeleeAttack(Vector3f charPosition, Vector3f charForward, int baseDamage, float critMultiplier);
}

View File

@@ -1,6 +1,9 @@
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;
@@ -20,6 +23,7 @@ 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;
@@ -44,6 +48,33 @@ public class PlayerInputControl {
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__";
/** 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
@@ -89,7 +120,40 @@ public class PlayerInputControl {
private boolean comboWindowActive = false;
private float comboWindowRemaining = 0f;
/** Letzter Angriff war ein kritischer Treffer (Kombo-Schritt 3). Für das Schadenssystem. */
private boolean lastAttackCritical = false;
private 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;
// 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;
@@ -187,8 +251,39 @@ public class PlayerInputControl {
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) sheathCurrentWeapon(); }
case ACT_COMBAT_ATK -> { if (isPressed) handleCombatAttack(); }
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);
}
};
@@ -232,6 +327,17 @@ public class PlayerInputControl {
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)
@@ -261,11 +367,12 @@ public class PlayerInputControl {
}
}
public void setMainCharacter(MainCharacter mc) { this.mainCharacter = mc; }
public boolean isWeaponDrawn() { return weaponDrawn; }
public ItemSubCategory getDrawnWeaponType() { return drawnWeaponType; }
public boolean isLastAttackCritical() { return lastAttackCritical; }
public com.jme3.anim.SkinningControl getSkinningControl() { return skinningControl; }
public 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 boolean isPaused() { return paused; }
@@ -664,14 +771,17 @@ public class PlayerInputControl {
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;
if (moving) {
scratchMoveDir.normalizeLocal();
float speed = (inDeepWater || walk) ? MOVE_SPEED * WALK_MULT
: (sprint && !weaponDrawn) ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
float speed = (inDeepWater || walk || inBlockStance) ? MOVE_SPEED * WALK_MULT
: (sprint && !weaponDrawn) ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
physicsChar.setWalkDirection(scratchWalk.set(scratchMoveDir).multLocal(speed));
if (visual != null) {
if (visual != null && !inBlockStance) {
scratchRot.lookAt(scratchMoveDir, Vector3f.UNIT_Y);
scratchCurRot.set(visual.getLocalRotation());
scratchCurRot.slerp(scratchRot, ROTATE_SPEED * tpf);
@@ -681,25 +791,71 @@ public class PlayerInputControl {
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;
physicsChar.setWalkDirection(Vector3f.ZERO);
boolean inLungeWindow = heavyLungeVel != null
&& combatAnimRemaining <= heavyLungeStartRemaining
&& combatAnimRemaining >= heavyLungeEndRemaining;
physicsChar.setWalkDirection(inLungeWindow ? heavyLungeVel : Vector3f.ZERO);
if (combatAnimRemaining <= 0f) {
combatAnimActive = false;
combatAnimActive = false;
heavyLungeVel = null;
heavyLungeStartRemaining = 0f;
heavyLungeEndRemaining = 0f;
currentAnim = null;
nextTransitionLength = COMBAT_BLEND;
Runnable cb = combatAnimCallback;
combatAnimCallback = null;
boolean skipWindow = combatAnimSkipComboWindow;
combatAnimSkipComboWindow = false;
if (cb != null) cb.run();
if (!skipWindow && !combatAnimActive && isMeleeType(drawnWeaponType)) {
comboWindowActive = true;
comboWindowRemaining = COMBO_WINDOW;
log.info("[Combat] Kombo-Fenster geöffnet (step={} next={})", comboStep, comboStep);
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);
}
pollFootsteps(tpf);
return;
}
@@ -740,6 +896,19 @@ public class PlayerInputControl {
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);
@@ -768,16 +937,13 @@ public class PlayerInputControl {
private void handleDrawMelee() {
if (mainCharacter == null || mainCharacter.getEquippedMeleeWeapon() == null) return;
if (blockingAnimActive || combatAnimActive) return;
if (blockingAnimActive || combatAnimActive || drawAnimActive) return;
ItemSubCategory sub = mainCharacter.getEquippedMeleeWeapon().getSubCategory();
if (!weaponDrawn) {
AnimationAction drawAnim = getMeleeDrawAction(sub);
float dur = resolveClipLength(drawAnim, 0.5f);
requestAnimation(drawAnim, dur, () -> {
weaponDrawn = true;
drawnWeaponType = sub;
currentAnim = null;
});
boolean hasShield = mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield,
() -> { weaponDrawn = true; drawnWeaponType = sub; currentAnim = null; },
null);
} else if (isMeleeType(drawnWeaponType)) {
sheathCurrentWeapon();
}
@@ -802,16 +968,15 @@ public class PlayerInputControl {
private void sheathCurrentWeapon() {
if (drawnWeaponType == null) { weaponDrawn = false; return; }
isAiming = false;
rightMouseHeld = false;
blockPhase = BlockPhase.NONE;
AnimationAction sheathAnim = getSheathAction(drawnWeaponType);
float dur = resolveClipLength(sheathAnim, 0.5f);
requestAnimation(sheathAnim, dur, () -> {
weaponDrawn = false;
drawnWeaponType = null;
currentAnim = null;
});
isAiming = false;
rightMouseHeld = false;
attackHoldPending = false;
heavyLungeVel = null;
blockPhase = BlockPhase.NONE;
boolean hasShield = mainCharacter != null && mainCharacter.getEquippedShield() != null;
startDrawAnimation(hasShield,
() -> { weaponDrawn = false; drawnWeaponType = null; currentAnim = null; },
null);
}
private void handleCombatAttack() {
@@ -824,8 +989,9 @@ public class PlayerInputControl {
int step = comboStep;
boolean isComboFollow = comboWindowActive; // war Kombo-Fenster offen?
AnimationAction atk = getMeleeComboAction(drawnWeaponType, step);
lastAttackCritical = (step == 3);
comboStep = (step + 1) % 4;
lastAttackCritical = (step == 4);
fireMeleeCallback(lastAttackCritical ? 2.0f : 1.0f);
comboStep = (step + 1) % 5;
comboWindowActive = false;
float dur = resolveClipLength(atk, 0.6f);
// Kombo-Folgeschlag: kurzer Blend für sauberen Übergang; erster Schlag: Hard-Cut
@@ -834,9 +1000,25 @@ public class PlayerInputControl {
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);
@@ -919,7 +1101,8 @@ public class PlayerInputControl {
blockPhase = BlockPhase.ENTERING;
AnimationAction idleToBlock = getMeleeIdleToBlockAction(drawnWeaponType);
float dur = resolveClipLength(idleToBlock, 0.5f);
blockPhaseTimer = dur;
// 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;
@@ -929,7 +1112,7 @@ public class PlayerInputControl {
blockPhase = BlockPhase.EXITING;
AnimationAction idleToBlock = getMeleeIdleToBlockAction(drawnWeaponType);
float dur = resolveClipLength(idleToBlock, 0.5f);
blockPhaseTimer = dur;
blockPhaseTimer = Math.max(0.1f, dur - 0.05f);
nextTransitionLength = COMBAT_BLEND;
nextPlayReversed = true;
playAction(idleToBlock);
@@ -1069,11 +1252,12 @@ public class PlayerInputControl {
};
}
/** Kombo-Sequenz: 0=ATK1, 1=ATK2, 2=ATK1, 3=ATK3 (kritisch). */
/** 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 -> getMeleeAttack3Action(type);
case 3 -> getMeleeAttack2Action(type);
case 4 -> getMeleeAttack3Action(type);
default -> getMeleeAttack1Action(type);
};
}
@@ -1118,6 +1302,78 @@ public class PlayerInputControl {
};
}
private void fireMeleeCallback(float critMultiplier) {
if (meleeAttackCallback == null || physicsChar == null) {
return;
}
int base = (mainCharacter != null && mainCharacter.getEquippedMeleeWeapon() != null)
? mainCharacter.getEquippedMeleeWeapon().getDamage() : 0;
Vector3f pos = physicsChar.getPhysicsLocation();
Vector3f fwd = (visual != null)
? visual.getLocalRotation().mult(new Vector3f(0f, 0f, -1f))
: new Vector3f(0f, 0f, -1f);
fwd.y = 0f;
if (fwd.lengthSquared() > 0.001f) {
fwd.normalizeLocal();
}
meleeAttackCallback.onMeleeAttack(pos, fwd, 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;
}
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;
}
float mult = computeHeavyMultiplier();
fireMeleeCallback(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;
@@ -1403,4 +1659,104 @@ public class PlayerInputControl {
}
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()));
}
}
}

View File

@@ -374,6 +374,8 @@ public class WorldScene extends BaseAppState {
new WorldItemsState(keyBindings, physicsChar, mc, playerInput));
app.getStateManager().attach(
new WorldInteractableState(keyBindings, physicsChar, playerInput));
app.getStateManager().attach(
new de.blight.game.state.WorldDummyState(physicsChar, playerInput, keyBindings));
de.blight.common.MonologueRegistry.init(de.blight.common.MonologueIO.load());
de.blight.game.state.DialogHudState dialogHud = new de.blight.game.state.DialogHudState();
dialogHud.setMainCharacter(mc);

View File

@@ -29,6 +29,7 @@ public class InteractionHudState extends BaseAppState {
private WorldItemsState worldItems;
private WorldInteractableState worldInteractables;
private WorldNpcsState worldNpcs;
private WorldDummyState worldDummies;
private DialogHudState dialogHud;
private BitmapText labelText;
@@ -55,6 +56,7 @@ public class InteractionHudState extends BaseAppState {
worldItems = getStateManager().getState(WorldItemsState.class);
worldInteractables = getStateManager().getState(WorldInteractableState.class);
worldNpcs = getStateManager().getState(WorldNpcsState.class);
worldDummies = getStateManager().getState(WorldDummyState.class);
dialogHud = getStateManager().getState(DialogHudState.class);
if (worldItems == null)
log.warn("[InteractionHud] WorldItemsState nicht gefunden.");
@@ -94,6 +96,15 @@ public class InteractionHudState extends BaseAppState {
}
}
// Kampfpuppe (Aufstellen) hat Vorrang vor Interactable
if (labelKey == null && worldDummies != null) {
String key = worldDummies.getHoveredLabelKey();
if (key != null && !key.isBlank()) {
labelKey = key;
worldPos = worldDummies.getHoveredWorldPos();
}
}
// Interactable hat Vorrang vor Item
if (labelKey == null && worldInteractables != null) {
String key = worldInteractables.getHoveredLabelKey();

View File

@@ -0,0 +1,579 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
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 com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.BillboardControl;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Quad;
import com.jme3.util.BufferUtils;
import de.blight.common.ModelCategory;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.model.CombatDummy;
import de.blight.common.model.CombatDummyIO;
import de.blight.game.combat.DamageCalculator;
import de.blight.game.combat.MeleeAttackCallback;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* Verwaltet Kampfpuppen auf der Karte: HP-Tracking, Treffer-Erkennung,
* Umfallen bei 0 HP, Aufstellen per Interaktion, Feind-HP-Bar oben.
*/
public class WorldDummyState extends BaseAppState implements MeleeAttackCallback {
private static final Logger log = LoggerFactory.getLogger(WorldDummyState.class);
// ── Spielwelt-Konstanten ──────────────────────────────────────────────────
private static final float ATTACK_RANGE = 2.5f;
private static final float ATTACK_CONE_DOT = 0.2f;
private static final float FOCUS_RANGE = 6f;
private static final float RESTORE_DELAY = 60f;
private static final float HP_BAR_SHOW_TIME = 5f;
private static final float STAND_UP_RANGE = 3f;
private static final int DEFAULT_MAX_HP = 250;
private static final float FALL_DURATION = 0.35f;
private static final float MODEL_HALF_H = 0.75f;
// ── Welt-HP-Bar-Konstanten ────────────────────────────────────────────────
private static final float WB_WIDTH = 0.8f;
private static final float WB_HEIGHT = 0.08f;
// ── Feind-HUD-Konstanten ──────────────────────────────────────────────────
private static final float EHB_W = 200f;
private static final float EHB_H = 18f;
private static final float EHB_BORDER = 2f;
private static final float EHB_Y_FROM_TOP = 55f;
private static final String INTERACT_ACTION = "InteractDummy";
// ── Abhängigkeiten ────────────────────────────────────────────────────────
private final CharacterControl physicsChar;
private final PlayerInputControl playerInput;
private final KeyBindings keyBindings;
private AssetManager assetManager;
private InputManager inputManager;
private Node rootNode;
private Node guiNode;
private Camera cam;
// ── Laufzeit-Zustand ─────────────────────────────────────────────────────
private final List<DummyRuntime> dummies = new ArrayList<>();
private int hoveredDummyIdx = -1; // gefallene Puppe in Aufstell-Reichweite
private int focusedDummyIdx = -1; // nächste Puppe in Fokus-Reichweite (Waffe gezückt)
// ── Feind-HUD ─────────────────────────────────────────────────────────────
private Node enemyHudNode;
private Geometry enemyHpFill;
private BitmapText enemyNameText;
private BitmapText enemyHpText;
/** x-Koordinate des linken Randes der Feind-HP-Bar (Screen-Pixel). */
private float ehbX;
/** y-Koordinate des unteren Randes der Feind-HP-Bar (Screen-Pixel). */
private float ehbY;
// ── Konstruktor ───────────────────────────────────────────────────────────
public WorldDummyState(CharacterControl physicsChar, PlayerInputControl playerInput, KeyBindings kb) {
this.physicsChar = physicsChar;
this.playerInput = playerInput;
this.keyBindings = kb;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
SimpleApplication sapp = (SimpleApplication) app;
this.assetManager = app.getAssetManager();
this.inputManager = app.getInputManager();
this.rootNode = sapp.getRootNode();
this.guiNode = sapp.getGuiNode();
this.cam = app.getCamera();
loadDummies();
buildEnemyHud();
playerInput.setMeleeAttackCallback(this);
}
@Override
protected void onEnable() {
inputManager.addMapping(INTERACT_ACTION,
new MouseButtonTrigger(MouseInput.BUTTON_LEFT),
new KeyTrigger(keyBindings.interact));
inputManager.addListener(standUpListener, INTERACT_ACTION);
}
@Override
protected void onDisable() {
inputManager.removeListener(standUpListener);
if (inputManager.hasMapping(INTERACT_ACTION)) {
inputManager.deleteMapping(INTERACT_ACTION);
}
if (enemyHudNode != null) {
enemyHudNode.setCullHint(Spatial.CullHint.Always);
}
}
@Override
protected void cleanup(Application app) {
for (DummyRuntime dr : dummies) {
rootNode.detachChild(dr.dummyNode);
}
dummies.clear();
if (enemyHudNode != null) {
guiNode.detachChild(enemyHudNode);
enemyHudNode = null;
}
}
// ── Laden ─────────────────────────────────────────────────────────────────
private void loadDummies() {
List<PlacedModel> models;
try {
models = PlacedModelIO.load();
} catch (IOException e) {
log.warn("[WorldDummy] PlacedModels nicht ladbar: {}", e.getMessage());
return;
}
for (PlacedModel m : models) {
if (m.modelCategory() != ModelCategory.DUMMY) {
continue;
}
int maxHp = DEFAULT_MAX_HP;
if (m.interactableId() != null && !m.interactableId().isBlank()) {
maxHp = CombatDummyIO.load(m.interactableId())
.map(CombatDummy::getMaxHp)
.orElse(DEFAULT_MAX_HP);
}
DummyRuntime dr = buildDummy(m, maxHp);
if (dr != null) {
dummies.add(dr);
}
}
log.info("[WorldDummy] {} Kampfpuppen geladen.", dummies.size());
}
private DummyRuntime buildDummy(PlacedModel m, int maxHp) {
Node dummyNode = new Node("dummy_" + m.interactableId());
Spatial model = loadModel(m);
dummyNode.attachChild(model);
Node hpBarNode = buildWorldHpBar();
hpBarNode.setLocalTranslation(0f, MODEL_HALF_H * 2f + 0.2f, 0f);
hpBarNode.setCullHint(Spatial.CullHint.Always);
dummyNode.attachChild(hpBarNode);
dummyNode.setLocalTranslation(m.x(), m.y(), m.z());
Quaternion rot = new Quaternion().fromAngles(m.rotX(), m.rotY(), m.rotZ());
dummyNode.setLocalRotation(rot);
dummyNode.setLocalScale(m.scale());
rootNode.attachChild(dummyNode);
Geometry hpFg = (Geometry) ((Node) hpBarNode.getChild(0)).getChild(1);
return new DummyRuntime(
m.interactableId() != null ? m.interactableId() : "?",
m.x(), m.y(), m.z(), dummyNode, hpBarNode, hpFg, maxHp);
}
private Spatial loadModel(PlacedModel m) {
String path = m.modelPath();
if (path == null || path.isBlank() || "@box".equals(path) || "@sphere".equals(path)) {
return buildBoxModel();
}
try {
return assetManager.loadModel(path);
} catch (Exception e) {
log.warn("[WorldDummy] Modell '{}' nicht ladbar, Fallback auf Box: {}", path, e.getMessage());
return buildBoxModel();
}
}
private Geometry buildBoxModel() {
Box box = new Box(0.15f, MODEL_HALF_H, 0.15f);
Geometry geom = new Geometry("dummyBox", box);
geom.setLocalTranslation(0f, MODEL_HALF_H, 0f);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.55f, 0.35f, 0.15f, 1f));
geom.setMaterial(mat);
geom.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
return geom;
}
// ── Welt-HP-Bar (Billboard über Puppe) ────────────────────────────────────
private Node buildWorldHpBar() {
Node hpBarNode = new Node("hpBar");
Node billChild = new Node("hpBillboard");
billChild.addControl(new BillboardControl());
Geometry bg = new Geometry("hpBg", new Quad(WB_WIDTH, WB_HEIGHT));
bg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f, 0f);
Material bgMat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
bgMat.setColor("Color", new ColorRGBA(0.15f, 0.15f, 0.15f, 0.85f));
bgMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
bg.setMaterial(bgMat);
bg.setQueueBucket(RenderQueue.Bucket.Transparent);
billChild.attachChild(bg); // index 0
Geometry fg = new Geometry("hpFg", new Quad(WB_WIDTH, WB_HEIGHT - 0.01f));
fg.setLocalTranslation(-WB_WIDTH * 0.5f, -WB_HEIGHT * 0.5f + 0.005f, 0.001f);
Material fgMat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
fgMat.setColor("Color", new ColorRGBA(0.2f, 0.8f, 0.2f, 1f));
fg.setMaterial(fgMat);
fg.setQueueBucket(RenderQueue.Bucket.Transparent);
billChild.attachChild(fg); // index 1
hpBarNode.attachChild(billChild);
return hpBarNode;
}
// ── Feind-HUD (oben, Screen-Space) ────────────────────────────────────────
private void buildEnemyHud() {
float sw = cam.getWidth();
float sh = cam.getHeight();
ehbX = sw * 0.5f - (EHB_W + EHB_BORDER * 2) * 0.5f;
ehbY = sh - EHB_Y_FROM_TOP - (EHB_H + EHB_BORDER * 2);
enemyHudNode = new Node("enemyHud");
// Hintergrund
Geometry bg = solidRect("ehpBg",
EHB_W + EHB_BORDER * 2, EHB_H + EHB_BORDER * 2,
new ColorRGBA(0.08f, 0.08f, 0.08f, 0.85f));
bg.setLocalTranslation(ehbX, ehbY, 1f);
bg.setQueueBucket(RenderQueue.Bucket.Gui);
enemyHudNode.attachChild(bg);
// Füllbalken (rot)
Mesh fillMesh = new Mesh();
buildFillMesh(fillMesh, EHB_W, 1f);
Material fillMat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
fillMat.setColor("Color", new ColorRGBA(0.85f, 0.10f, 0.10f, 1f));
fillMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
enemyHpFill = new Geometry("ehpFill", fillMesh);
enemyHpFill.setMaterial(fillMat);
enemyHpFill.setQueueBucket(RenderQueue.Bucket.Gui);
enemyHpFill.setLocalTranslation(ehbX + EHB_BORDER, ehbY + EHB_BORDER, 2f);
enemyHudNode.attachChild(enemyHpFill);
BitmapFont font = assetManager.loadFont("Interface/Fonts/Default.fnt");
float renderedSize = font.getCharSet().getRenderedSize();
// Gegner-Name (über Bar)
enemyNameText = new BitmapText(font);
enemyNameText.setSize(renderedSize * 2f);
enemyNameText.setLocalScale(0.5f);
enemyNameText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f));
enemyNameText.setQueueBucket(RenderQueue.Bucket.Gui);
enemyHudNode.attachChild(enemyNameText);
// HP-Zahlen (in Bar)
enemyHpText = new BitmapText(font);
enemyHpText.setSize(renderedSize * 2f);
enemyHpText.setLocalScale(0.5f);
enemyHpText.setColor(ColorRGBA.White);
enemyHpText.setQueueBucket(RenderQueue.Bucket.Gui);
enemyHudNode.attachChild(enemyHpText);
guiNode.attachChild(enemyHudNode);
enemyHudNode.setCullHint(Spatial.CullHint.Always);
}
private Geometry solidRect(String name, float w, float h, ColorRGBA color) {
Geometry g = new Geometry(name, new Quad(w, h));
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
g.setMaterial(mat);
return g;
}
/** Analoges Mesh wie in HudState: UV-geclippter Füllbalken. */
private static void buildFillMesh(Mesh mesh, float w, float maxU) {
mesh.setBuffer(VertexBuffer.Type.Position, 3,
BufferUtils.createFloatBuffer(0, 0, 0, w, 0, 0, w, EHB_H, 0, 0, EHB_H, 0));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2,
BufferUtils.createFloatBuffer(0f, 0f, maxU, 0f, maxU, 1f, 0f, 1f));
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createShortBuffer((short) 0, (short) 1, (short) 2,
(short) 0, (short) 2, (short) 3));
mesh.setMode(Mesh.Mode.Triangles);
mesh.updateBound();
}
private void applyEnemyHpRatio(float ratio) {
ratio = Math.max(0f, Math.min(1f, ratio));
float w = EHB_W * ratio;
Mesh mesh = enemyHpFill.getMesh();
FloatBuffer pos = (FloatBuffer) mesh.getBuffer(VertexBuffer.Type.Position).getData();
pos.put(3, w);
pos.put(6, w);
mesh.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded();
FloatBuffer uv = (FloatBuffer) mesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(2, ratio);
uv.put(4, ratio);
mesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
mesh.updateBound();
}
private void updateEnemyHudContent(DummyRuntime dr) {
float sw = cam.getWidth();
float ratio = dr.maxHp > 0 ? (float) dr.currentHp / dr.maxHp : 0f;
applyEnemyHpRatio(ratio);
String name = "Kampfpuppe";
enemyNameText.setText(name);
float nameW = enemyNameText.getLineWidth() * 0.5f;
enemyNameText.setLocalTranslation(
sw * 0.5f - nameW,
ehbY + EHB_H + EHB_BORDER * 2 + 6f, 3f);
String hpStr = dr.currentHp + " / " + dr.maxHp;
enemyHpText.setText(hpStr);
float hpW = enemyHpText.getLineWidth() * 0.5f;
enemyHpText.setLocalTranslation(
sw * 0.5f - hpW,
ehbY + EHB_BORDER + 1f, 3f);
}
// ── MeleeAttackCallback ───────────────────────────────────────────────────
@Override
public void onMeleeAttack(Vector3f charPos, Vector3f charForward, int baseDamage, float critMultiplier) {
for (DummyRuntime dr : dummies) {
if (dr.fallen) {
continue;
}
float dx = dr.worldX - charPos.x;
float dz = dr.worldZ - charPos.z;
float distSq = dx * dx + dz * dz;
if (distSq > ATTACK_RANGE * ATTACK_RANGE) {
continue;
}
float len = (float) Math.sqrt(distSq);
if (len > 0.05f) {
float dot = (dx / len) * charForward.x + (dz / len) * charForward.z;
if (dot < ATTACK_CONE_DOT) {
continue;
}
}
applyDamage(dr, DamageCalculator.calculate(baseDamage, critMultiplier));
}
}
// ── Schaden / Zustand ─────────────────────────────────────────────────────
private void applyDamage(DummyRuntime dr, int damage) {
dr.currentHp = Math.max(0, dr.currentHp - damage);
dr.restoreTimer = 0f;
dr.hpBarTimer = HP_BAR_SHOW_TIME;
dr.hpBarNode.setCullHint(Spatial.CullHint.Inherit);
refreshWorldHpBar(dr);
log.info("[Dummy {}] -{} HP → {}/{}{}", dr.id, damage, dr.currentHp, dr.maxHp,
dr.currentHp <= 0 ? " K.O.!" : "");
if (dr.currentHp <= 0) {
knockout(dr);
}
}
private void knockout(DummyRuntime dr) {
dr.fallen = true;
dr.fallenLerp = 0f;
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
}
private void standUp(DummyRuntime dr) {
dr.fallen = false;
dr.fallenLerp = 0f;
dr.currentHp = dr.maxHp;
dr.restoreTimer = 0f;
dr.dummyNode.setLocalRotation(new Quaternion());
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
refreshWorldHpBar(dr);
log.info("[Dummy {}] Aufgestellt.", dr.id);
}
private void refreshWorldHpBar(DummyRuntime dr) {
float frac = (float) dr.currentHp / dr.maxHp;
ColorRGBA col;
if (frac > 0.5f) col = new ColorRGBA(0.2f, 0.8f, 0.2f, 1f);
else if (frac > 0.2f) col = new ColorRGBA(0.9f, 0.8f, 0.1f, 1f);
else col = new ColorRGBA(0.9f, 0.1f, 0.1f, 1f);
dr.hpBarFg.getMaterial().setColor("Color", col);
dr.hpBarFg.setLocalScale(Math.max(0.001f, frac), 1f, 1f);
}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
Vector3f playerPos = (physicsChar != null) ? physicsChar.getPhysicsLocation() : null;
boolean weaponOut = playerInput.isWeaponDrawn();
hoveredDummyIdx = -1;
focusedDummyIdx = -1;
float bestFocusSq = FOCUS_RANGE * FOCUS_RANGE;
for (int i = 0; i < dummies.size(); i++) {
DummyRuntime dr = dummies.get(i);
// Umfall-Animation
if (dr.fallen && dr.fallenLerp < 1f) {
dr.fallenLerp = Math.min(1f, dr.fallenLerp + tpf / FALL_DURATION);
Quaternion target = new Quaternion().fromAngles(0f, 0f, -FastMath.HALF_PI);
Quaternion cur = new Quaternion();
cur.slerp(Quaternion.IDENTITY, target, dr.fallenLerp);
dr.dummyNode.setLocalRotation(cur);
}
// Welt-HP-Balken-Timer
if (!dr.fallen && dr.hpBarTimer > 0f) {
dr.hpBarTimer -= tpf;
if (dr.hpBarTimer <= 0f) {
dr.hpBarTimer = 0f;
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
}
}
// Auto-Restore nach 1 Minute ohne Treffer
if (!dr.fallen && dr.currentHp < dr.maxHp) {
dr.restoreTimer += tpf;
if (dr.restoreTimer >= RESTORE_DELAY) {
dr.currentHp = dr.maxHp;
dr.restoreTimer = 0f;
dr.hpBarNode.setCullHint(Spatial.CullHint.Always);
refreshWorldHpBar(dr);
}
}
if (playerPos == null) {
continue;
}
float dx = dr.worldX - playerPos.x;
float dz = dr.worldZ - playerPos.z;
float distSq = dx * dx + dz * dz;
// Fokus (Waffe gezückt): nächste Puppe in Reichweite
if (weaponOut && distSq < bestFocusSq) {
bestFocusSq = distSq;
focusedDummyIdx = i;
}
// Hover (Waffe weggesteckt): gefallene Puppe in Aufstell-Reichweite
if (!weaponOut && dr.fallen && dr.fallenLerp >= 1f
&& distSq <= STAND_UP_RANGE * STAND_UP_RANGE) {
hoveredDummyIdx = i;
}
}
// Feind-HP-Bar (oben) aktualisieren
if (weaponOut && focusedDummyIdx >= 0 && enemyHudNode != null) {
updateEnemyHudContent(dummies.get(focusedDummyIdx));
enemyHudNode.setCullHint(Spatial.CullHint.Inherit);
} else if (enemyHudNode != null) {
enemyHudNode.setCullHint(Spatial.CullHint.Always);
}
}
// ── Interaktion-HUD-Schnittstelle ─────────────────────────────────────────
/** Gibt den Label-Schlüssel für die InteractionHudState-Anzeige zurück. */
public String getHoveredLabelKey() {
if (playerInput.isWeaponDrawn()) {
return focusedDummyIdx >= 0 ? "dummy.name" : null;
}
return hoveredDummyIdx >= 0 ? "dummy.stand_up" : null;
}
/** Gibt die Weltposition zurück, über der das HUD-Label angezeigt wird. */
public Vector3f getHoveredWorldPos() {
if (playerInput.isWeaponDrawn()) {
if (focusedDummyIdx < 0) {
return null;
}
DummyRuntime dr = dummies.get(focusedDummyIdx);
return new Vector3f(dr.worldX, dr.worldY + MODEL_HALF_H * 2f + 0.15f, dr.worldZ);
}
if (hoveredDummyIdx < 0) {
return null;
}
DummyRuntime dr = dummies.get(hoveredDummyIdx);
return new Vector3f(dr.worldX, dr.worldY + 0.5f, dr.worldZ);
}
// ── Eingabe ───────────────────────────────────────────────────────────────
private final ActionListener standUpListener = (name, isPressed, tpf2) -> {
if (!isPressed) {
return;
}
// hoveredDummyIdx wird nur gesetzt wenn Waffe weggesteckt + Puppe in Reichweite
if (hoveredDummyIdx >= 0) {
standUp(dummies.get(hoveredDummyIdx));
}
};
// ── Innere Klasse ─────────────────────────────────────────────────────────
private static class DummyRuntime {
final String id;
final float worldX, worldY, worldZ;
final Node dummyNode;
final Node hpBarNode;
final Geometry hpBarFg;
int currentHp;
final int maxHp;
float restoreTimer = 0f;
float hpBarTimer = 0f;
boolean fallen = false;
float fallenLerp = 0f;
DummyRuntime(String id, float wx, float wy, float wz,
Node dummyNode, Node hpBarNode, Geometry hpBarFg, int maxHp) {
this.id = id;
this.worldX = wx;
this.worldY = wy;
this.worldZ = wz;
this.dummyNode = dummyNode;
this.hpBarNode = hpBarNode;
this.hpBarFg = hpBarFg;
this.maxHp = maxHp;
this.currentHp = maxHp;
}
}
}

View File

@@ -197,7 +197,7 @@ public class WorldInteractableState extends BaseAppState {
// ── Hover-Erkennung ───────────────────────────────────────────────────────
private void updateHover() {
if (phase != Phase.IDLE || cam == null) {
if (phase != Phase.IDLE || cam == null || playerInput.isWeaponDrawn()) {
hoveredIdx = -1;
return;
}

View File

@@ -91,6 +91,9 @@ public class WorldObjectsState extends BaseAppState {
Node root = app.getRootNode();
int loaded = 0, failed = 0;
for (PlacedModel m : models) {
if (m.modelCategory() == de.blight.common.ModelCategory.DUMMY) {
continue; // Kampfpuppen werden von WorldDummyState verwaltet
}
try {
Spatial s = buildSpatial(m);
s.setLocalTranslation(m.x(), m.y(), m.z());