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

@@ -19,7 +19,12 @@
"Sword And Shield Block",
"Sword And Shield Block Idle",
"Sword And Shield Block Impact",
"Sword And Shield Impact"
"Sword And Shield Impact",
"Sword And Shield Block Walk",
"Sword And Shield Block Walk Backwards",
"Sword And Shield Strafe Left",
"Sword And Shield Strafe Right",
"Sword And Shield Heavy Attack"
],
"actionMap": {
"DEFAULT": "Idle",
@@ -41,7 +46,12 @@
"SWORD_IDLE_TO_BLOCK": "Sword And Shield Block",
"SWORD_BLOCK_IMPACT": "Sword And Shield Block Impact",
"SWORD_IDLE_IMPACT": "Sword And Shield Impact",
"SWORD_COUNTERATTACK": "Sword And Shield Attack Counterattack"
"SWORD_COUNTERATTACK": "Sword And Shield Attack Counterattack",
"SWORD_BLOCK_MOVE": "Sword And Shield Block Walk",
"SWORD_BLOCK_MOVE_BACKWARD": "Sword And Shield Block Walk Backwards",
"SWORD_BLOCK_STRAFE_LEFT": "Sword And Shield Strafe Left",
"SWORD_BLOCK_STRAFE_RIGHT": "Sword And Shield Strafe Right",
"SWORD_HEAVY_ATTACK": "Sword And Shield Heavy Attack"
},
"previewModelPath": "Characters/Models/hero.j3o",
"animOffsets": {
@@ -96,6 +106,11 @@
"Sword And Shield Idle",
"Sword And Shield Run",
"Sword And Shield Walk",
"Sword And Shield Attack"
"Sword And Shield Attack",
"Sword And Shield Block Walk",
"Sword And Shield Block Walk Backwards",
"Sword And Shield Strafe Left",
"Sword And Shield Strafe Right",
"Sword And Shield Heavy Attack"
]
}

View File

@@ -5,7 +5,8 @@ public enum ModelCategory {
TREE("Baum"),
PALM("Palme"),
BUILDING("Gebäude"),
BUSH("Busch");
BUSH("Busch"),
DUMMY("Kampfpuppe");
private final String label;

View File

@@ -0,0 +1,15 @@
package de.blight.common.model;
public class CombatDummy {
private final String id;
private final int maxHp;
public CombatDummy(String id, int maxHp) {
this.id = id;
this.maxHp = maxHp > 0 ? maxHp : 250;
}
public String getId() { return id; }
public int getMaxHp() { return maxHp; }
}

View File

@@ -0,0 +1,57 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import de.blight.common.MapIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
public final class CombatDummyIO {
private static final Logger log = LoggerFactory.getLogger(CombatDummyIO.class);
private static final String EXTENSION = ".dummy";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private CombatDummyIO() {}
public static Path getDir() {
return MapIO.getMapPath().resolveSibling("dummies");
}
public static void save(CombatDummy d) throws IOException {
if (d.getId() == null) throw new IOException("CombatDummy ohne ID kann nicht gespeichert werden.");
Path dir = getDir();
Files.createDirectories(dir);
Dto dto = new Dto();
dto.id = d.getId();
dto.maxHp = d.getMaxHp();
Files.writeString(dir.resolve(d.getId() + EXTENSION), GSON.toJson(dto), StandardCharsets.UTF_8);
}
public static Optional<CombatDummy> load(String id) {
Path file = getDir().resolve(id + EXTENSION);
if (!Files.exists(file)) return Optional.empty();
try {
Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class);
return Optional.of(new CombatDummy(dto.id != null ? dto.id : id, dto.maxHp));
} catch (IOException e) {
log.warn("[CombatDummyIO] Fehler beim Laden von {}: {}", id, e.getMessage());
return Optional.empty();
}
}
public static void delete(String id) throws IOException {
Files.deleteIfExists(getDir().resolve(id + EXTENSION));
}
private static class Dto {
String id;
int maxHp;
}
}

View File

@@ -4,7 +4,8 @@ public enum InteractableType {
NONE("Keines"),
CRAFTING_TABLE("Handwerkstisch"),
BED("Bett"),
BENCH("Bank");
BENCH("Bank"),
COMBAT_DUMMY("Kampfpuppe");
private final String label;

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());

View File

@@ -7,6 +7,8 @@ dialog.speaker.player=Spieler
# ── Interactables ─────────────────────────────────────────────────────────────
interactable.bench.name=Bank
interactable.bed.name=Bett
dummy.stand_up=Aufstellen [F]
dummy.name=Kampfpuppe
# ── Items ────────────────────────────────────────────────────────────────────
item.driftwood.name=Treibholz

View File

@@ -7,6 +7,8 @@ dialog.speaker.player=Player
# ── Interactables ─────────────────────────────────────────────────────────────
interactable.bench.name=Bench
interactable.bed.name=Bed
dummy.stand_up=Stand Up [F]
dummy.name=Training Dummy
# ── Items ────────────────────────────────────────────────────────────────────
item.driftwood.name=Driftwood