diff --git a/assets/imported/animations/Sword And Shield Block Walk Backwards.fbx b/assets/imported/animations/Sword And Shield Block Walk Backwards.fbx new file mode 100644 index 0000000..48a2d78 Binary files /dev/null and b/assets/imported/animations/Sword And Shield Block Walk Backwards.fbx differ diff --git a/assets/imported/animations/Sword And Shield Block Walk.fbx b/assets/imported/animations/Sword And Shield Block Walk.fbx new file mode 100644 index 0000000..57755e6 Binary files /dev/null and b/assets/imported/animations/Sword And Shield Block Walk.fbx differ diff --git a/assets/imported/animations/Sword And Shield Heavy Attack.fbx b/assets/imported/animations/Sword And Shield Heavy Attack.fbx new file mode 100644 index 0000000..64829fb Binary files /dev/null and b/assets/imported/animations/Sword And Shield Heavy Attack.fbx differ diff --git a/assets/imported/animations/Sword And Shield Strafe Left.fbx b/assets/imported/animations/Sword And Shield Strafe Left.fbx new file mode 100644 index 0000000..72c61ce Binary files /dev/null and b/assets/imported/animations/Sword And Shield Strafe Left.fbx differ diff --git a/assets/imported/animations/Sword And Shield Strafe Right.fbx b/assets/imported/animations/Sword And Shield Strafe Right.fbx new file mode 100644 index 0000000..4fab02f Binary files /dev/null and b/assets/imported/animations/Sword And Shield Strafe Right.fbx differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk Backwards.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk Backwards.j3o new file mode 100644 index 0000000..9da73dc Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk Backwards.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk.j3o new file mode 100644 index 0000000..64638e9 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Block Walk.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Heavy Attack.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Heavy Attack.j3o new file mode 100644 index 0000000..7b121f5 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Heavy Attack.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Left.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Left.j3o new file mode 100644 index 0000000..d9c9bde Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Left.j3o differ diff --git a/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Right.j3o b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Right.j3o new file mode 100644 index 0000000..04268b7 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/clips/Sword And Shield Strafe Right.j3o differ diff --git a/blight-assets/src/main/resources/Characters/sets/human.animset.json b/blight-assets/src/main/resources/Characters/sets/human.animset.json index fb36aa0..b160906 100644 --- a/blight-assets/src/main/resources/Characters/sets/human.animset.json +++ b/blight-assets/src/main/resources/Characters/sets/human.animset.json @@ -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" ] } \ No newline at end of file diff --git a/blight-common/src/main/java/de/blight/common/ModelCategory.java b/blight-common/src/main/java/de/blight/common/ModelCategory.java index 34c9f6f..dd4d064 100644 --- a/blight-common/src/main/java/de/blight/common/ModelCategory.java +++ b/blight-common/src/main/java/de/blight/common/ModelCategory.java @@ -5,7 +5,8 @@ public enum ModelCategory { TREE("Baum"), PALM("Palme"), BUILDING("Gebäude"), - BUSH("Busch"); + BUSH("Busch"), + DUMMY("Kampfpuppe"); private final String label; diff --git a/blight-common/src/main/java/de/blight/common/model/CombatDummy.java b/blight-common/src/main/java/de/blight/common/model/CombatDummy.java new file mode 100644 index 0000000..d21e3a9 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CombatDummy.java @@ -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; } +} diff --git a/blight-common/src/main/java/de/blight/common/model/CombatDummyIO.java b/blight-common/src/main/java/de/blight/common/model/CombatDummyIO.java new file mode 100644 index 0000000..12be02f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CombatDummyIO.java @@ -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 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/InteractableType.java b/blight-common/src/main/java/de/blight/common/model/InteractableType.java index 5adca77..bb1216d 100644 --- a/blight-common/src/main/java/de/blight/common/model/InteractableType.java +++ b/blight-common/src/main/java/de/blight/common/model/InteractableType.java @@ -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; diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index 351112d..3f0a6fc 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -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)"); diff --git a/blight-game/src/main/java/de/blight/game/combat/DamageCalculator.java b/blight-game/src/main/java/de/blight/game/combat/DamageCalculator.java new file mode 100644 index 0000000..d1ee978 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/combat/DamageCalculator.java @@ -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); + } +} diff --git a/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java b/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java new file mode 100644 index 0000000..658c72b --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/combat/MeleeAttackCallback.java @@ -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); +} diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index e7a8878..b6335e8 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -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())); + } + + } } diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index d1ef144..74bc384 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java index 087e3c3..c97fb20 100644 --- a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java @@ -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(); diff --git a/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java b/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java new file mode 100644 index 0000000..66445ed --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WorldDummyState.java @@ -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 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 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; + } + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java index 562e315..e855697 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java @@ -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; } diff --git a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java index 1f8ecec..22c650a 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java @@ -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()); diff --git a/blight-lang/src/main/resources/lang/messages_de.properties b/blight-lang/src/main/resources/lang/messages_de.properties index 95e8515..20e97fa 100644 --- a/blight-lang/src/main/resources/lang/messages_de.properties +++ b/blight-lang/src/main/resources/lang/messages_de.properties @@ -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 diff --git a/blight-lang/src/main/resources/lang/messages_en.properties b/blight-lang/src/main/resources/lang/messages_en.properties index 5a93401..929734a 100644 --- a/blight-lang/src/main/resources/lang/messages_en.properties +++ b/blight-lang/src/main/resources/lang/messages_en.properties @@ -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