diff --git a/assets/imported/animations/Crouched To Standing.fbx b/assets/imported/animations/Crouched To Standing.fbx index a2533c3..1a1c357 100644 Binary files a/assets/imported/animations/Crouched To Standing.fbx and b/assets/imported/animations/Crouched To Standing.fbx differ diff --git a/assets/imported/animations/Crouched Walking.fbx b/assets/imported/animations/Crouched Walking.fbx new file mode 100644 index 0000000..07bdc89 Binary files /dev/null and b/assets/imported/animations/Crouched Walking.fbx differ diff --git a/assets/imported/animations/Crouching Idle.fbx b/assets/imported/animations/Crouching Idle.fbx index 9738ed3..54572bb 100644 Binary files a/assets/imported/animations/Crouching Idle.fbx and b/assets/imported/animations/Crouching Idle.fbx differ diff --git a/assets/imported/animations/Standing To Crouched.fbx b/assets/imported/animations/Standing To Crouched.fbx index 9b04050..c222a99 100644 Binary files a/assets/imported/animations/Standing To Crouched.fbx and b/assets/imported/animations/Standing To Crouched.fbx 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 4d7e9f1..d7fbc63 100644 --- a/blight-assets/src/main/resources/Characters/sets/human.animset.json +++ b/blight-assets/src/main/resources/Characters/sets/human.animset.json @@ -81,7 +81,8 @@ "SWORD_DEATH": "Sword And Shield Death", "SWORD_IDLE_BORED": "Sword And Shield Idle boring", "SWORD_IDLE_BORED_2": "Sword And Shield Idle boring 2", - "SWORD_IDLE_BORED_3": "Sword And Shield Idle boring 3" + "SWORD_IDLE_BORED_3": "Sword And Shield Idle boring 3", + "STAND_TO_CROUCH": "Standing To Crouched" }, "previewModelPath": "Characters/Models/hero.j3o", "animOffsets": { diff --git a/blight-common/src/main/java/de/blight/common/SaveGame.java b/blight-common/src/main/java/de/blight/common/SaveGame.java index ebfad44..6c88fac 100644 --- a/blight-common/src/main/java/de/blight/common/SaveGame.java +++ b/blight-common/src/main/java/de/blight/common/SaveGame.java @@ -34,16 +34,25 @@ public class SaveGame { public static class WorldSave { /** UUIDs der PlacedItems, die der Spieler aufgesammelt hat. */ public Set pickedUpItems = new HashSet<>(); - /** IDs besiegter Gegner (für zukünftige Implementierung). */ + /** IDs besiegter Gegner. */ public Set defeatedEnemies = new HashSet<>(); /** NPC-ID → aktuell verfügbare Dialog-Option-IDs. */ public Map> npcDialogState = new HashMap<>(); + /** NPC-ID → Leichen-Zustand (Position, verbleibende Items). */ + public Map deadNpcs = new LinkedHashMap<>(); /** Bodennässe 0..1 — wird von RainState persistiert. */ public float wetness = 0f; /** Wetter-Zustand — wird von WeatherState persistiert. */ public WeatherSave weather = new WeatherSave(); } + public static class DeadNpcSave { + public float x, y, z, rotY; + /** Verbleibende Item-IDs; leer = vollständig gelooted. */ + public List remainingItemIds = new ArrayList<>(); + public boolean fullyLooted = false; + } + public static class WeatherSave { public String weather = "SUNNY"; public float changeTimer = 180f; diff --git a/blight-common/src/main/java/de/blight/common/model/Lootable.java b/blight-common/src/main/java/de/blight/common/model/Lootable.java new file mode 100644 index 0000000..f5a79f8 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Lootable.java @@ -0,0 +1,9 @@ +package de.blight.common.model; + +import java.util.List; + +public interface Lootable { + String getLootId(); + String getLootTitleKey(); + List getLootItems(); +} diff --git a/blight-game/src/main/java/de/blight/game/BlightGame.java b/blight-game/src/main/java/de/blight/game/BlightGame.java index b050767..2f8a9c3 100644 --- a/blight-game/src/main/java/de/blight/game/BlightGame.java +++ b/blight-game/src/main/java/de/blight/game/BlightGame.java @@ -709,6 +709,66 @@ public class BlightGame extends SimpleApplication { } }); + console.registerCommand("list", args -> { + if (args.length < 2) + return "Syntax: list item | list npc nearby | list npc dead"; + return switch (args[1].toLowerCase()) { + case "item" -> { + de.blight.game.state.WorldNpcsState wns = + stateManager.getState(de.blight.game.state.WorldNpcsState.class); + if (wns == null) yield "Welt nicht geladen"; + java.util.List items = wns.listAllItems(); + yield items.isEmpty() ? "Keine Items gefunden." + : items.size() + " Items:\n" + String.join("\n", items); + } + case "npc" -> { + if (args.length < 3) + yield "Syntax: list npc nearby | list npc dead"; + yield switch (args[2].toLowerCase()) { + case "nearby" -> { + de.blight.game.state.WorldNpcsState wns = + stateManager.getState(de.blight.game.state.WorldNpcsState.class); + if (wns == null) yield "Welt nicht geladen"; + java.util.List npcs = wns.listNearbyNpcs(); + yield npcs.isEmpty() ? "Keine NPCs in der Nähe." + : npcs.size() + " NPCs:\n" + String.join("\n", npcs); + } + case "dead" -> { + de.blight.game.state.SaveGameState sgs = + stateManager.getState(de.blight.game.state.SaveGameState.class); + if (sgs == null) yield "Save-System nicht aktiv"; + java.util.Map dead = + sgs.getDeadNpcs(); + if (dead.isEmpty()) yield "Keine toten NPCs gespeichert."; + StringBuilder sb = new StringBuilder(dead.size() + " tote NPCs:\n"); + dead.forEach((id, dns) -> sb + .append(String.format("%-30s looted=%b items=%d%n", + id, dns.fullyLooted, dns.remainingItemIds.size()))); + yield sb.toString().trim(); + } + default -> "Unbekannter Subbefehl. Syntax: list npc nearby | list npc dead"; + }; + } + default -> "Unbekannter Typ. Syntax: list item | list npc nearby | list npc dead"; + }; + }); + + console.registerCommand("kill", args -> { + if (args.length < 2) return "Syntax: kill "; + de.blight.game.state.WorldNpcsState wns = + stateManager.getState(de.blight.game.state.WorldNpcsState.class); + if (wns == null) return "Welt nicht geladen"; + return wns.killNpcById(args[1]); + }); + + console.registerCommand("revive", args -> { + if (args.length < 2) return "Syntax: revive "; + de.blight.game.state.WorldNpcsState wns = + stateManager.getState(de.blight.game.state.WorldNpcsState.class); + if (wns == null) return "Welt nicht geladen"; + return wns.reviveNpcById(args[1]); + }); + console.registerCommand("set", args -> { if (args.length < 3) return "Syntax: set " 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 fecf66d..fd1385a 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 @@ -15,7 +15,7 @@ public enum AnimationAction { JUMP, RUNNING_JUMP, ROLL, - DUCK, + STAND_TO_CROUCH, CROUCH_IDLE, CROUCH_TO_STAND, SNEAK, @@ -85,7 +85,7 @@ public enum AnimationAction { case JUMP -> TextRegistry.resolve(null, key, "Jump"); case RUNNING_JUMP -> TextRegistry.resolve(null, key, "Running Jump"); case ROLL -> TextRegistry.resolve(null, key, "Rolle"); - case DUCK -> TextRegistry.resolve(null, key, "Hinducken"); + case STAND_TO_CROUCH -> TextRegistry.resolve(null, key, "Hinknieen"); case CROUCH_IDLE -> TextRegistry.resolve(null, key, "Ducken (Idle)"); case CROUCH_TO_STAND -> TextRegistry.resolve(null, key, "Aufstehen (Ducken)"); case SNEAK -> TextRegistry.resolve(null, key, "Schleichen"); diff --git a/blight-game/src/main/java/de/blight/game/config/KeyBindings.java b/blight-game/src/main/java/de/blight/game/config/KeyBindings.java index 9e8517d..c37d7b3 100644 --- a/blight-game/src/main/java/de/blight/game/config/KeyBindings.java +++ b/blight-game/src/main/java/de/blight/game/config/KeyBindings.java @@ -22,8 +22,8 @@ public class KeyBindings { public int drawMeleeWeapon = KeyInput.KEY_1; public int drawRangedWeapon = KeyInput.KEY_2; public int sheathWeapon = KeyInput.KEY_X; - public int roll = KeyInput.KEY_LCONTROL; - public int sneak = KeyInput.KEY_X; + public int roll = KeyInput.KEY_LSHIFT; + public int sneak = KeyInput.KEY_LCONTROL; /** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */ public static final String[][] ENTRIES = { diff --git a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java index efcf668..4904c0b 100644 --- a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java +++ b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java @@ -35,7 +35,7 @@ public class JmeConsole extends BaseAppState { private static final Logger log = LoggerFactory.getLogger(JmeConsole.class); // ── Layout ──────────────────────────────────────────────────────────────── - private static final int HISTORY = 6; + private static final int HISTORY = 10; private static final float LINE_H = 18f; private static final float PAD = 6f; private static final float BG_H = PAD + LINE_H * (HISTORY + 1) + PAD; @@ -146,10 +146,13 @@ public class JmeConsole extends BaseAppState { public void feedEnter() { String raw = inputBuf.toString().trim(); inputBuf.setLength(0); execute(raw); refreshInput(); } public void feedEscape() { hide(); } - /** Gibt eine Zeile in der Konsole aus. */ + /** Gibt eine oder mehrere Zeilen in der Konsole aus. */ public void print(String msg) { - history.addFirst(msg); - while (history.size() > HISTORY) history.pollLast(); + String[] lines = msg.split("\n", -1); + for (String line : lines) { + history.addFirst(line); + while (history.size() > HISTORY) history.pollLast(); + } refreshHistory(); } diff --git a/blight-game/src/main/java/de/blight/game/control/NpcWeaponAttachmentControl.java b/blight-game/src/main/java/de/blight/game/control/NpcWeaponAttachmentControl.java index 01568c6..bbed4f5 100644 --- a/blight-game/src/main/java/de/blight/game/control/NpcWeaponAttachmentControl.java +++ b/blight-game/src/main/java/de/blight/game/control/NpcWeaponAttachmentControl.java @@ -57,7 +57,9 @@ public class NpcWeaponAttachmentControl extends AbstractControl { private Spatial weaponSpatial; private Spatial shieldSpatial; - private boolean drawn = false; + private boolean drawn = false; + private boolean weaponHidden = false; + private boolean shieldHidden = false; public NpcWeaponAttachmentControl(AssetManager assetManager, Item weaponItem, Item shieldItem) { this.assetManager = assetManager; @@ -69,6 +71,16 @@ public class NpcWeaponAttachmentControl extends AbstractControl { this.drawn = drawn; } + public void hideWeapon() { + weaponHidden = true; + if (weaponSpatial != null) { weaponSpatial.setCullHint(Spatial.CullHint.Always); } + } + + public void hideShield() { + shieldHidden = true; + if (shieldSpatial != null) { shieldSpatial.setCullHint(Spatial.CullHint.Always); } + } + @Override protected void controlUpdate(float tpf) { if (skinCtrl == null) { @@ -94,8 +106,14 @@ public class NpcWeaponAttachmentControl extends AbstractControl { weaponItem != null ? weaponItem.getItemId() : "null", shieldItem != null ? shieldItem.getItemId() : "null"); - if (weaponItem != null) { weaponSpatial = loadAndAttach(weaponItem); } - if (shieldItem != null) { shieldSpatial = loadAndAttach(shieldItem); } + if (weaponItem != null) { + weaponSpatial = loadAndAttach(weaponItem); + if (weaponHidden && weaponSpatial != null) { weaponSpatial.setCullHint(Spatial.CullHint.Always); } + } + if (shieldItem != null) { + shieldSpatial = loadAndAttach(shieldItem); + if (shieldHidden && shieldSpatial != null) { shieldSpatial.setCullHint(Spatial.CullHint.Always); } + } log.info("[NpcWeaponAttach] Geladen: weaponSpatial={} shieldSpatial={}", weaponSpatial != null, shieldSpatial != null); @@ -113,7 +131,7 @@ public class NpcWeaponAttachmentControl extends AbstractControl { Vector3f charWorldPos = spatial.getWorldTranslation(); // ── Waffe ──────────────────────────────────────────────────────────────── - if (weaponSpatial != null) { + if (weaponSpatial != null && !weaponHidden) { Node boneNode = drawn ? handNode : backNode; if (boneNode != null) { Vector3f boneInChar = charWorldRotInv.mult(boneNode.getWorldTranslation().subtract(charWorldPos)); @@ -132,7 +150,7 @@ public class NpcWeaponAttachmentControl extends AbstractControl { } // ── Schild ──────────────────────────────────────────────────────────────── - if (shieldSpatial != null) { + if (shieldSpatial != null && !shieldHidden) { ItemGrip grip = shieldItem != null ? shieldItem.getGrip() : null; if (drawn && leftForeArmNode != null) { Vector3f boneInChar = charWorldRotInv.mult(leftForeArmNode.getWorldTranslation().subtract(charWorldPos)); 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 4f67e56..e6a042e 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 @@ -308,6 +308,12 @@ public class PlayerInputControl implements CombatStyleBase.AnimReceiver { private float sneakTransitionRemaining = 0f; private boolean sneakKeyHeld = false; + // ── Idle-Override (z.B. CROUCH_IDLE während Looten) ────────────────────── + private AnimationAction idleOverride = null; + + public void setIdleOverride(AnimationAction action) { idleOverride = action; currentAnim = null; } + public void clearIdleOverride() { idleOverride = null; currentAnim = null; } + // ── Dodge Roll ──────────────────────────────────────────────────────────── private boolean rollActive = false; private float rollRemaining = 0f; @@ -1201,6 +1207,9 @@ public class PlayerInputControl implements CombatStyleBase.AnimReceiver { if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) { boredIdleTimer = 0f; boredAnimRemaining = 0f; target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP; + } else if (idleOverride != null && !moving) { + boredIdleTimer = 0f; boredAnimRemaining = 0f; + target = idleOverride; } else if (sneaking) { boredIdleTimer = 0f; boredAnimRemaining = 0f; target = moving ? AnimationAction.SNEAK : AnimationAction.CROUCH_IDLE; @@ -1647,10 +1656,10 @@ public class PlayerInputControl implements CombatStyleBase.AnimReceiver { && !turnActive && !lockedInPlace && !combatAnimActive) { sneakPhase = SneakPhase.DOWN; boolean moving = forward || backward || left || right; - sneakTransitionRemaining = resolveClipLength(AnimationAction.DUCK, 0.5f) * (moving ? 0.5f : 1f); + sneakTransitionRemaining = resolveClipLength(AnimationAction.STAND_TO_CROUCH, 0.5f) * (moving ? 0.5f : 1f); nextTransitionLength = 0.1f; - playAction(AnimationAction.DUCK); - currentAnim = AnimationAction.DUCK; + playAction(AnimationAction.STAND_TO_CROUCH); + currentAnim = AnimationAction.STAND_TO_CROUCH; } else if (!pressed && sneakPhase == SneakPhase.ACTIVE) { startSneakUp(); } @@ -1666,6 +1675,7 @@ public class PlayerInputControl implements CombatStyleBase.AnimReceiver { } private void triggerRollFromInput() { + if (!weaponDrawn) return; if (rollActive || pickupActive || blockingAnimActive || turnActive || lockedInPlace || combatAnimActive || isBlocking()) return; cam.getDirection(scratchCamDir).setY(0).normalizeLocal(); diff --git a/blight-game/src/main/java/de/blight/game/state/LootHudState.java b/blight-game/src/main/java/de/blight/game/state/LootHudState.java new file mode 100644 index 0000000..16f2a82 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/LootHudState.java @@ -0,0 +1,252 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.font.BitmapText; +import com.jme3.input.MouseInput; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.AnalogListener; +import com.jme3.input.controls.MouseAxisTrigger; +import com.jme3.input.controls.MouseButtonTrigger; +import com.jme3.scene.Node; +import de.blight.common.model.Item; +import de.blight.common.model.Lootable; +import de.blight.game.config.OverlayState; +import de.blight.game.config.UiTheme; +import de.blight.lang.TextResolver; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Loot-HUD: öffnet sich wenn der Spieler eine Leiche (NPC oder später Tier) untersucht. + * Zeigt die verbleibenden Items als scrollbare Liste; Doppelklick = einzeln nehmen, + * "Alles nehmen"-Button = alles auf einmal. + */ +public class LootHudState extends OverlayState { + + private static final float ROW_H = 34f; + private static final float BTN_H = 36f; + private static final float BTN_GAP = 10f; + private static final long DBLCLICK_MS = 350L; + + private static final String MAP_CLICK = "_LootClick"; + private static final String MAP_SCROLL_U = "_LootScrollU"; + private static final String MAP_SCROLL_D = "_LootScrollD"; + + // ── Zustand ─────────────────────────────────────────────────────────────── + + private Lootable source; + private Consumer onItemTaken; + private Runnable onClosed; + + /** Lokale Kopie der angezeigten Items (wird beim Nehmen verkleinert). */ + private List displayItems = new ArrayList<>(); + + private Node panel; + private Node listNode; + private float scrollY = 0f; + private float maxScrollY = 0f; + private float listAreaH; + + // Double-click-Erkennung + private int lastClickIdx = -1; + private long lastClickTime = -1L; + + // Button-Bereich [x, y, w, h] in virtuellen Koordinaten + private float[] btnBounds; + + // ── Öffentliche API ─────────────────────────────────────────────────────── + + /** + * Öffnet das Loot-HUD für das angegebene {@link Lootable}. + * + * @param source Loot-Quelle (NPC-Leiche, Tier, …) + * @param onItemTaken Callback: wird für jedes genommene Item aufgerufen + */ + public void open(Lootable source, Consumer onItemTaken) { + open(source, onItemTaken, null); + } + + public void open(Lootable source, Consumer onItemTaken, Runnable onClosed) { + this.source = source; + this.onItemTaken = onItemTaken; + this.onClosed = onClosed; + this.displayItems = source.getLootItems() != null + ? new ArrayList<>(source.getLootItems()) : new ArrayList<>(); + setEnabled(true); + } + + // ── OverlayState-Implementierung ────────────────────────────────────────── + + @Override + protected String getTitle() { + if (source == null) { return "Beute"; } + return TextResolver.get().resolveId(source.getLootTitleKey()); + } + + @Override + protected void initOverlayKeys() { + app.getInputManager().addMapping(MAP_CLICK, new MouseButtonTrigger(MouseInput.BUTTON_LEFT)); + app.getInputManager().addMapping(MAP_SCROLL_U, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, false)); + app.getInputManager().addMapping(MAP_SCROLL_D, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, true)); + } + + @Override + protected void cleanupOverlayKeys() { + app.getInputManager().deleteMapping(MAP_CLICK); + app.getInputManager().deleteMapping(MAP_SCROLL_U); + app.getInputManager().deleteMapping(MAP_SCROLL_D); + } + + @Override + protected void onEnable() { + scrollY = 0f; + lastClickIdx = -1; + lastClickTime = -1L; + super.onEnable(); + app.getInputManager().addListener(clickListener, MAP_CLICK); + app.getInputManager().addListener(scrollListener, MAP_SCROLL_U, MAP_SCROLL_D); + } + + @Override + protected void onDisable() { + app.getInputManager().removeListener(clickListener); + app.getInputManager().removeListener(scrollListener); + panel = null; + listNode = null; + super.onDisable(); + if (onClosed != null) { Runnable cb = onClosed; onClosed = null; cb.run(); } + } + + @Override + protected void cleanup(Application app) { + super.cleanup(app); + } + + @Override + protected void buildContent(Node parentPanel, float scale) { + this.panel = parentPanel; + + listAreaH = CH - BTN_H - BTN_GAP; + + // ── "Alles nehmen"-Button ───────────────────────────────────────────── + float btnW = 180f; + float btnX = CX + CW - btnW; + float btnY = CY; + btnBounds = new float[]{ btnX, btnY, btnW, BTN_H }; + addQuad(parentPanel, btnX, btnY, btnW, BTN_H, UiTheme.COL_HDR, -18f); + + String takeAllTxt = TextResolver.get().resolveId("ui.loot.take_all"); + BitmapText btnLbl = crispText(takeAllTxt, UiTheme.FONT_BODY, UiTheme.COL_WHITE); + float btnTxtW = btnLbl.getLineWidth() * 0.5f; + btnLbl.setLocalTranslation(btnX + (btnW - btnTxtW) * 0.5f, btnY + BTN_H - 8f, -17f); + parentPanel.attachChild(btnLbl); + + // ── Item-Liste ──────────────────────────────────────────────────────── + listNode = new Node("lootList"); + parentPanel.attachChild(listNode); + rebuildList(); + } + + // ── Item-Liste aufbauen ─────────────────────────────────────────────────── + + private void rebuildList() { + if (listNode == null) { return; } + listNode.detachAllChildren(); + + float listTop = CY + BTN_H + BTN_GAP + listAreaH; + + maxScrollY = Math.max(0f, displayItems.size() * ROW_H - listAreaH); + scrollY = Math.max(0f, Math.min(maxScrollY, scrollY)); + + if (displayItems.isEmpty()) { + BitmapText empty = crispText( + TextResolver.get().resolveId("ui.loot.empty"), + UiTheme.FONT_BODY, UiTheme.COL_MUTED); + empty.setLocalTranslation(CX + 8f, CY + BTN_H + BTN_GAP + listAreaH * 0.5f + 8f, -17f); + listNode.attachChild(empty); + return; + } + + for (int i = 0; i < displayItems.size(); i++) { + Item item = displayItems.get(i); + float rowBottomY = listTop - (i + 1) * ROW_H + scrollY; + + if (rowBottomY + ROW_H < CY + BTN_H + BTN_GAP) { continue; } + if (rowBottomY > listTop) { continue; } + + addQuad(listNode, CX, rowBottomY, CW, ROW_H - 2f, + i % 2 == 0 ? UiTheme.COL_ITEM_BG : UiTheme.COL_DETAIL_BG, -18f); + + BitmapText lbl = crispText(item.getDisplayText(), UiTheme.FONT_BODY, UiTheme.COL_WHITE); + lbl.setLocalTranslation(CX + 8f, rowBottomY + ROW_H - 6f, -17f); + listNode.attachChild(lbl); + } + } + + // ── Interaktion ─────────────────────────────────────────────────────────── + + private void handleClick(float[] v) { + // Button: "Alles nehmen" + if (btnBounds != null + && v[0] >= btnBounds[0] && v[0] <= btnBounds[0] + btnBounds[2] + && v[1] >= btnBounds[1] && v[1] <= btnBounds[1] + btnBounds[3]) { + takeAll(); + return; + } + + // Item-Zeilen: Doppelklick + float listTop = CY + BTN_H + BTN_GAP + listAreaH; + for (int i = 0; i < displayItems.size(); i++) { + float rowBottomY = listTop - (i + 1) * ROW_H + scrollY; + if (v[0] >= CX && v[0] <= CX + CW + && v[1] >= rowBottomY && v[1] <= rowBottomY + ROW_H) { + long now = System.currentTimeMillis(); + if (lastClickIdx == i && (now - lastClickTime) <= DBLCLICK_MS) { + takeItem(i); + lastClickIdx = -1; + lastClickTime = -1L; + } else { + lastClickIdx = i; + lastClickTime = now; + } + return; + } + } + } + + private void takeItem(int idx) { + if (idx < 0 || idx >= displayItems.size()) { return; } + Item item = displayItems.remove(idx); + if (source != null) { source.getLootItems().remove(item); } + if (onItemTaken != null) { onItemTaken.accept(item); } + rebuildList(); + if (displayItems.isEmpty()) { setEnabled(false); } + } + + private void takeAll() { + List taken = new ArrayList<>(displayItems); + displayItems.clear(); + for (Item item : taken) { + if (source != null) { source.getLootItems().remove(item); } + if (onItemTaken != null) { onItemTaken.accept(item); } + } + setEnabled(false); + } + + // ── Listener ────────────────────────────────────────────────────────────── + + private final ActionListener clickListener = (name, pressed, tpf) -> { + if (!pressed || panel == null) { return; } + float[] v = toVirtual(app.getInputManager().getCursorPosition()); + handleClick(v); + }; + + private final AnalogListener scrollListener = (name, value, tpf) -> { + float step = 50f * value; + if (MAP_SCROLL_U.equals(name)) { scrollY = Math.max(0f, scrollY - step); } + else { scrollY = Math.min(maxScrollY, scrollY + step); } + rebuildList(); + }; +} diff --git a/blight-game/src/main/java/de/blight/game/state/SaveGameState.java b/blight-game/src/main/java/de/blight/game/state/SaveGameState.java index 2e686cd..964e0cb 100644 --- a/blight-game/src/main/java/de/blight/game/state/SaveGameState.java +++ b/blight-game/src/main/java/de/blight/game/state/SaveGameState.java @@ -11,7 +11,10 @@ import de.blight.common.model.MainCharacter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import de.blight.common.model.Item; + import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -84,12 +87,55 @@ public class SaveGameState extends BaseAppState { persist(); } - /** Meldet einen besiegten Gegner (für zukünftige Implementierung). */ + /** Meldet einen besiegten Gegner. */ public void reportEnemyDefeated(String enemyId) { save.world.defeatedEnemies.add(enemyId); persist(); } + /** Speichert einen getöteten NPC als Leiche mit Position und Loot-Items. */ + public void reportNpcKilled(String npcId, float x, float y, float z, float rotY, List items) { + SaveGame.DeadNpcSave dns = new SaveGame.DeadNpcSave(); + dns.x = x; dns.y = y; dns.z = z; dns.rotY = rotY; + if (items != null) { + for (Item item : items) { + if (item.getItemId() != null) { dns.remainingItemIds.add(item.getItemId()); } + } + } + dns.fullyLooted = dns.remainingItemIds.isEmpty(); + save.world.deadNpcs.put(npcId, dns); + save.world.defeatedEnemies.add(npcId); + persist(); + } + + /** Entfernt ein Item aus dem Loot-Inventar der Leiche; setzt fullyLooted wenn leer. */ + public void reportNpcItemTaken(String npcId, String itemId) { + SaveGame.DeadNpcSave dns = save.world.deadNpcs.get(npcId); + if (dns == null) { return; } + dns.remainingItemIds.remove(itemId); + if (dns.remainingItemIds.isEmpty()) { dns.fullyLooted = true; } + persist(); + } + + /** Markiert die Leiche als vollständig gelooted (z.B. bei leeren NPCs nach Interact). */ + public void reportNpcFullyLooted(String npcId) { + SaveGame.DeadNpcSave dns = save.world.deadNpcs.get(npcId); + if (dns == null) { return; } + dns.fullyLooted = true; + persist(); + } + + /** Entfernt eine Leiche dauerhaft aus dem Save (nach vollständigem Loot + Entladen). */ + public void removeDeadNpcRecord(String npcId) { + save.world.deadNpcs.remove(npcId); + persist(); + } + + /** Gibt alle gespeicherten Leichen zurück. */ + public Map getDeadNpcs() { + return save.world.deadNpcs; + } + /** Verwertet den aktuellen Spielstand und startet ein neues Spiel. */ public void resetForNewGame() { try { java.nio.file.Files.deleteIfExists(de.blight.common.SaveGameIO.getSavePath()); } diff --git a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java index 306854c..54e5de2 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java @@ -40,6 +40,7 @@ import de.blight.game.config.MenuCanvas; import de.blight.game.config.NinePatch; import de.blight.common.PlacedModel; import de.blight.common.PlacedModelIO; +import de.blight.common.SaveGame; import de.blight.common.SaveGameIO; import de.blight.common.model.*; import de.blight.common.model.trigger.ChangeRoutineTrigger; @@ -154,6 +155,9 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback Node hpBarNode = null; Geometry hpBarFg = null; + // ── Hüft-Knochen Attachment (lazy) ─────────────────────────────────── + com.jme3.scene.Node hipsNode = null; + // ── Physik-Kapsel (Kollision) ───────────────────────────────────────── RigidBodyControl rbc = null; @@ -166,6 +170,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float dodgeDirX = 0f; float dodgeDirZ = 0f; float knockbackTimer = 0f; + List lootItems = null; // null = lebend; leer = vollst. gelooted boolean paradeFromParry = false; // true = Konterparade (applyParriedImpact wurde gespielt) float blockGraceTimer = 0f; // > 0 solange NPC blockt oder gerade aus Block zurückkehrt int konterParadeAttackId = -1; // attackId für die der NPC bereits die Parade gestartet hat @@ -197,6 +202,16 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private float checkTimer = CHECK_INTERVAL; // sofortiger erster Check private int lastHour = -1; + // ── Loot-Annäherung ─────────────────────────────────────────────────────── + + private enum LootApproach { WALKING, CROUCHING, STANDING_UP } + private LootApproach lootApproachPhase = null; + private SpawnedNpc pendingLootNpc = null; + private Vector3f lootApproachTarget = null; + + private static final float LOOT_APPROACH_DIST = 1.0f; // m vom NPC-Zentrum + private static final float LOOT_ARRIVE_THRESH = 0.25f; // innerhalb = angekommen + // ── Hover + Dialog ──────────────────────────────────────────────────────── private int hoveredIdx = -1; @@ -322,6 +337,9 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback private int lastAttackId = -1; private final Set hitThisAttack = new HashSet<>(); + // ── Loot ────────────────────────────────────────────────────────────────── + private LootHudState lootHud; + // ── Feind-HUD ───────────────────────────────────────────────────────────── private Node npcEnemyHudNode; private Geometry npcEnemyHpFill; @@ -389,6 +407,10 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback bulletAppState = app.getStateManager().getState(BulletAppState.class); playerInput.addMeleeAttackCallback(this); buildNpcEnemyHud(); + + lootHud = new LootHudState(); + lootHud.setEnabled(false); + getStateManager().attach(lootHud); } @Override @@ -452,6 +474,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback updateNpcRevert(tpf); updateAggroBehaviors(tpf); updateNpcCombat(tpf); + updateLootApproach(tpf); updateNpcEnemyHud(); updateSpeechBubble(tpf); if (dialogActive && dialogHeadTween != null) { @@ -467,7 +490,20 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // Bestehende NPCs prüfen: Position aktualisieren oder entladen toRemoveBuf.clear(); for (SpawnedNpc s : spawned) { - if (s.knockedOut) { continue; } // Tote NPCs nicht teleportieren oder überschreiben + if (s.knockedOut) { + // Leichen: nur entladen wenn außer Reichweite + float dist = dist2d(playerPos, s.visual().getWorldTranslation()); + if (dist > UNLOAD_RADIUS) { + if (s.lootItems != null && s.lootItems.isEmpty()) { + // Vollständig gelooted → dauerhaft aus Save entfernen + SaveGameState saveState = getStateManager().getState(SaveGameState.class); + if (saveState != null) { saveState.removeDeadNpcRecord(s.npc().getCharacterId()); } + } + unloadNpc(s); + toRemoveBuf.add(s); + } + continue; + } Vector3f pos = resolveRoutinePosition(s.npc(), currentHour); float dist = pos != null ? dist2d(playerPos, pos) : Float.MAX_VALUE; @@ -507,6 +543,71 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback spawnedIdsBuf.add(s.npc().getCharacterId()); } + // Gespeicherte Leichen laden + SaveGameState saveStateForCorpses = getStateManager().getState(SaveGameState.class); + if (saveStateForCorpses != null) { + for (java.util.Map.Entry entry + : saveStateForCorpses.getDeadNpcs().entrySet()) { + String npcId = entry.getKey(); + SaveGame.DeadNpcSave dns = entry.getValue(); + if (dns.fullyLooted) { continue; } + if (spawnedIdsBuf.contains(npcId)) { continue; } + Vector3f corpsePos = new Vector3f(dns.x, dns.y, dns.z); + if (dist2d(playerPos, corpsePos) > LOAD_RADIUS) { continue; } + NPC npc = allNpcs.stream() + .filter(n -> npcId.equals(n.getCharacterId())).findFirst().orElse(null); + if (npc == null) { continue; } + + Spatial vis = buildVisual(npc); + vis.setLocalTranslation(corpsePos); + vis.setLocalRotation(new com.jme3.math.Quaternion().fromAngles(0f, dns.rotY, 0f)); + npcsRoot.attachChild(vis); + SpawnedNpc s = new SpawnedNpc(npc, vis, dns.x, dns.z); + s.knockedOut = true; + s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT; + s.aggroBehavior.forceCalm(); + attachNpcCapsule(s); + + java.util.List remaining = dns.remainingItemIds; + s.lootItems = npc.getItems() != null + ? npc.getItems().stream() + .filter(it -> it.getItemId() != null && remaining.contains(it.getItemId())) + .collect(java.util.stream.Collectors.toList()) + : new ArrayList<>(); + // Ausgerüstete Items: in lootItems wenn noch vorhanden, sonst visuell ausblenden + NpcWeaponAttachmentControl wac = vis.getControl(NpcWeaponAttachmentControl.class); + Item equippedWeapon = findItemById(npc.getEquippedWeaponId()); + Item equippedShield = findItemById(npc.getEquippedShieldId()); + if (equippedWeapon != null) { + if (remaining.contains(equippedWeapon.getItemId())) { + s.lootItems.add(0, equippedWeapon); + } else if (wac != null) { + wac.hideWeapon(); + } + } + if (equippedShield != null) { + if (remaining.contains(equippedShield.getItemId())) { + s.lootItems.add(equippedWeapon != null + && remaining.contains(equippedWeapon.getItemId()) ? 1 : 0, equippedShield); + } else if (wac != null) { + wac.hideShield(); + } + } + + de.blight.game.scene.WorldScene.collectPbrMaterials(vis, s.pbrMaterials); + if (dayNight != null && !s.pbrMaterials.isEmpty()) { + com.jme3.math.ColorRGBA ac = dayNight.getCustomAmbient(); + scratchEmissive.set(ac.r, ac.g, ac.b, 1f); + for (Material mat : s.pbrMaterials) { mat.setColor("Emissive", scratchEmissive); } + } + playNpcDeathAnim(s); + s.combat.hitTimer = s.combat.clipDur; // sofort auf letztem Frame einfrieren + spawned.add(s); + spawnedIdsBuf.add(npcId); + log.debug("[WorldNpcs] Leiche '{}' geladen bei ({}, {})", npcId, dns.x, dns.z); + } + } + // Neue NPCs einladen wenn in Reichweite for (NPC npc : allNpcs) { if (spawnedIdsBuf.contains(npc.getCharacterId())) continue; @@ -602,7 +703,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback float d = (float) Math.sqrt(dx * dx + dz * dz); if (d > INTERACT_RANGE) continue; - Vector3f headPos = pos.add(0f, 1.5f, 0f); + Vector3f headPos = getNpcBodyCenter(s); Vector3f camToHead = headPos.subtract(cam.getLocation()); if (camToHead.dot(cam.getDirection()) <= 0f) continue; @@ -618,12 +719,111 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback // ── Listener ───────────────────────────────────────────────────────────── + private boolean isWeaponDrawn() { return playerInput != null && playerInput.isWeaponDrawn(); } + private final ActionListener interactListener = (name, isPressed, tpf) -> { - if (!isPressed || dialogActive || hoveredIdx < 0) return; - if (spawned.get(hoveredIdx).aggroBehavior.isAttacking()) return; + if (!isPressed || dialogActive || hoveredIdx < 0) { return; } + if (isWeaponDrawn()) { return; } + SpawnedNpc s = spawned.get(hoveredIdx); + if (s.knockedOut) { + if (s.lootItems != null && s.lootItems.isEmpty()) { + SaveGameState saveState = getStateManager().getState(SaveGameState.class); + if (saveState != null) { saveState.reportNpcFullyLooted(s.npc().getCharacterId()); } + } else if (s.lootItems != null && lootHud != null) { + startLootApproach(s); + } + return; + } + if (s.aggroBehavior.isAttacking()) { return; } startDialog(hoveredIdx); }; + private void startLootApproach(SpawnedNpc s) { + pendingLootNpc = s; + Vector3f npcCenter = getNpcBodyCenter(s); + Vector3f playerPos = physicsChar.getPhysicsLocation(); + Vector3f toPlayer = playerPos.subtract(npcCenter); + toPlayer.y = 0f; + float dist = toPlayer.length(); + if (dist < 0.001f) { toPlayer.set(1f, 0f, 0f); dist = 1f; } + toPlayer.divideLocal(dist); + lootApproachTarget = npcCenter.add(toPlayer.mult(LOOT_APPROACH_DIST)); + lootApproachTarget.y = playerPos.y; + + float dx = lootApproachTarget.x - playerPos.x; + float dz = lootApproachTarget.z - playerPos.z; + if ((float) Math.sqrt(dx * dx + dz * dz) < LOOT_ARRIVE_THRESH) { + beginLootCrouch(); + } else { + lootApproachPhase = LootApproach.WALKING; + Vector3f walkDir = new Vector3f(dx, 0f, dz).normalizeLocal(); + playerInput.setAutopilotDirection(walkDir); + } + } + + private void updateLootApproach(float tpf) { + if (lootApproachPhase != LootApproach.WALKING || pendingLootNpc == null) { return; } + Vector3f playerPos = physicsChar.getPhysicsLocation(); + float dx = lootApproachTarget.x - playerPos.x; + float dz = lootApproachTarget.z - playerPos.z; + float distLeft = (float) Math.sqrt(dx * dx + dz * dz); + if (distLeft < LOOT_ARRIVE_THRESH) { + playerInput.setAutopilotDirection(null); + beginLootCrouch(); + } else { + // Richtung jedes Frame neu berechnen (Physik kann abrutschen) + playerInput.setAutopilotDirection(new Vector3f(dx, 0f, dz).normalizeLocal()); + } + } + + private void beginLootCrouch() { + lootApproachPhase = LootApproach.CROUCHING; + Vector3f npcPos = pendingLootNpc.visual().getWorldTranslation(); + Vector3f playerPos = physicsChar.getPhysicsLocation(); + Vector3f dir = npcPos.subtract(playerPos); + dir.y = 0f; + if (dir.lengthSquared() > 0.001f) { playerInput.requestTurn(dir.normalizeLocal(), 0.15f, null); } + SpawnedNpc target = pendingLootNpc; + playerInput.requestAnimation(AnimationAction.STAND_TO_CROUCH, 0f, () -> { + // CROUCH_IDLE mit sehr langer Dauer fixieren – läuft auch wenn Welt pausiert (OverlayState) + playerInput.requestAnimation(AnimationAction.CROUCH_IDLE, Float.MAX_VALUE, null); + openLootHud(target); + }); + } + + private void startStandUp() { + lootApproachPhase = LootApproach.STANDING_UP; + pendingLootNpc = null; + lootApproachTarget = null; + playerInput.requestAnimation(AnimationAction.CROUCH_TO_STAND, 0f, () -> lootApproachPhase = null); + } + + private void openLootHud(SpawnedNpc s) { + lootHud.open(new SpawnedNpcLootable(s), item -> { + mainCharacter.getInventar().collect(item); + if (item.getItemId() != null) { + NpcWeaponAttachmentControl wac = + s.visual().getControl(NpcWeaponAttachmentControl.class); + if (wac != null) { + if (item.getItemId().equals(s.npc().getEquippedWeaponId())) { wac.hideWeapon(); } + if (item.getItemId().equals(s.npc().getEquippedShieldId())) { wac.hideShield(); } + } + SaveGameState saveState = getStateManager().getState(SaveGameState.class); + if (saveState != null) { + saveState.reportNpcItemTaken(s.npc().getCharacterId(), item.getItemId()); + } + } + }, () -> startStandUp()); + } + + private static class SpawnedNpcLootable implements de.blight.common.model.Lootable { + private final SpawnedNpc s; + SpawnedNpcLootable(SpawnedNpc s) { this.s = s; } + @Override public String getLootId() { return s.npc().getCharacterId(); } + @Override public String getLootTitleKey() { return s.npc().getLabelKey(); } + @Override public java.util.List getLootItems() { return s.lootItems; } + } + /** * Spielt einen Dialog-Animations-Clip einmalig auf dem aktuellen Dialog-NPC. * Falls der NPC sitzt, wird stattdessen "talk_sitting" gespielt. @@ -906,8 +1106,7 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback public Vector3f getHoveredWorldPos() { if (hoveredIdx < 0 || hoveredIdx >= spawned.size()) return null; - Vector3f pos = spawned.get(hoveredIdx).visual().getLocalTranslation(); - return pos.add(0f, 1.5f, 0f); + return getNpcBodyCenter(spawned.get(hoveredIdx)); } // ── Hilfsmethoden ──────────────────────────────────────────────────────── @@ -2320,6 +2519,22 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback s.knockedOut = true; s.combat.phase = WeaponCombatState.Phase.KNOCKED_OUT; s.aggroBehavior.forceCalm(); + + s.lootItems = s.npc().getItems() != null + ? new ArrayList<>(s.npc().getItems()) : new ArrayList<>(); + Item equippedWeapon = findItemById(s.npc().getEquippedWeaponId()); + Item equippedShield = findItemById(s.npc().getEquippedShieldId()); + if (equippedWeapon != null) { s.lootItems.add(0, equippedWeapon); } + if (equippedShield != null) { s.lootItems.add(equippedWeapon != null ? 1 : 0, equippedShield); } + + SaveGameState saveState = getStateManager().getState(SaveGameState.class); + if (saveState != null) { + Vector3f pos = s.visual().getWorldTranslation(); + float[] angles = s.visual().getWorldRotation().toAngles(null); + saveState.reportNpcKilled(s.npc().getCharacterId(), + pos.x, pos.y, pos.z, angles[1], s.lootItems); + } + if (weaponSoundSystem != null) { weaponSoundSystem.play("argh"); } playNpcDeathAnim(s); log.info("[NPC {}] K.O.!", s.npc().getCharacterId()); @@ -2482,6 +2697,98 @@ public class WorldNpcsState extends BaseAppState implements MeleeAttackCallback return SwordShieldCombat.resolveIdleImpactAction(s.combatWeaponType); } + /** + * Gibt die Weltposition der Hüfte zurück (mixamorig:Hips). + * Bewegt sich mit der Death-Animation mit, sodass das Label auch bei liegenden NPCs korrekt sitzt. + * Fallback: visual.getWorldTranslation() + 1.0m Y. + */ + private Vector3f getNpcBodyCenter(SpawnedNpc s) { + if (s.hipsNode == null) { + com.jme3.anim.SkinningControl sc = + de.blight.game.animation.RetargetingSystem.findSkinningControl(s.visual()); + if (sc != null) { + try { s.hipsNode = sc.getAttachmentsNode("mixamorig:Hips"); } + catch (Exception ignored) {} + } + } + if (s.hipsNode != null) { + return s.hipsNode.getWorldTranslation().add(0f, 0.5f, 0f); + } + return s.visual().getWorldTranslation().add(0f, 1.5f, 0f); + } + + // ── Konsolen-API ────────────────────────────────────────────────────────── + + /** Gibt alle gespawnten (lebenden) NPCs als "id (HP cur/max)" zurück. */ + public List listNearbyNpcs() { + List out = new ArrayList<>(); + for (SpawnedNpc s : spawned) { + if (!s.knockedOut) { + out.add(String.format("%-30s HP %d/%d", s.npc().getCharacterId(), + s.currentHp, s.maxHp)); + } + } + return out; + } + + /** Tötet einen NPC anhand seiner ID sofort (wie 0 HP). */ + public String killNpcById(String npcId) { + for (SpawnedNpc s : spawned) { + if (npcId.equals(s.npc().getCharacterId())) { + if (s.knockedOut) { return npcId + " ist bereits tot."; } + s.currentHp = 0; + knockoutNpc(s); + return npcId + " getötet."; + } + } + return "NPC '" + npcId + "' nicht in der Nähe."; + } + + /** Versetzt einen toten NPC zurück in den Lebendig-Zustand. */ + public String reviveNpcById(String npcId) { + // Aus dem Save entfernen (auch defeatedEnemies) + SaveGameState saveState = getStateManager().getState(SaveGameState.class); + boolean inSave = saveState != null && saveState.getDeadNpcs().containsKey(npcId); + if (saveState != null) { + saveState.removeDeadNpcRecord(npcId); + saveState.getSave().world.defeatedEnemies.remove(npcId); + saveState.persist(); + } + // Falls noch als Leiche gespawnt: entladen → respawnt automatisch + for (SpawnedNpc s : spawned) { + if (npcId.equals(s.npc().getCharacterId()) && s.knockedOut) { + unloadNpc(s); + spawned.remove(s); + return npcId + " wiederbelebt (neu gespawnt)."; + } + } + return inSave ? npcId + " wiederbelebt." : "NPC '" + npcId + "' nicht tot oder nicht gefunden."; + } + + /** Listet alle Items im items-Verzeichnis auf. */ + public List listAllItems() { + List out = new ArrayList<>(); + try { + java.nio.file.Path itemsDir = AnimationLibrary.findAssetRoot().resolve("items"); + try (java.util.stream.Stream walk = java.nio.file.Files.walk(itemsDir)) { + walk.filter(p -> p.getFileName().toString().endsWith(".item")) + .sorted() + .forEach(p -> { + String id = p.getFileName().toString().replace(".item", ""); + try { + de.blight.common.model.Item item = de.blight.common.model.ItemIO.load(p); + out.add(String.format("%-30s %s", id, item.getDisplayText())); + } catch (Exception e) { + out.add(id); + } + }); + } + } catch (Exception e) { + out.add("Fehler beim Laden der Items: " + e.getMessage()); + } + return out; + } + private float resolveNpcClipLength(SpawnedNpc s, AnimationAction action, float fallback) { if (animLib == null) { return fallback; } String setName = s.npc().getAnimSetPath();