From ac0f3cc396d241d824d6e5b695903869bef741c6 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Sat, 27 Dec 2025 16:54:03 -0800 Subject: [PATCH] Enhance testing and integration of inventory and character management systems Add comprehensive tests for the InventoryManager and InventoryContainer to validate item management functionalities. Implement integration tests for the CharacterSheet component, ensuring proper interaction with the inventory system. Update the Explorer class to support new inventory features and maintain backward compatibility. Refactor related components for improved clarity and performance. --- package.json | 3 +- specs/Character_Sheet.spec.md | 111 ++ specs/Inventory.spec.md | 156 ++ specs/Skill_Tree.spec.md | 109 ++ src/assets/data/missions/mission-schema.md | 20 + src/assets/data/missions/mission.d.ts | 15 + .../mission_tutorial_01.description.md | 75 + .../data/missions/mission_tutorial_01.json | 25 +- .../skill_trees/template_standard_30.json | 222 +++ src/core/GameLoop.js | 616 +++++- src/core/GameStateManager.js | 66 +- src/factories/SkillTreeFactory.js | 75 +- src/index.html | 3 + src/index.js | 147 +- src/managers/InventoryManager.js | 310 +++ src/managers/ItemRegistry.js | 70 + src/managers/RosterManager.js | 14 +- src/managers/SkillRegistry.js | 28 + src/models/InventoryContainer.js | 178 ++ src/systems/MovementSystem.js | 61 +- src/systems/SkillTargetingSystem.js | 13 +- src/systems/TurnSystem.js | 56 +- src/ui/combat-hud.js | 39 +- src/ui/components/CharacterSheet.js | 1701 +++++++++++++++++ src/ui/components/SkillTreeUI.js | 863 +++++++++ src/ui/deployment-hud.js | 203 +- src/ui/game-viewport.js | 30 +- src/ui/team-builder.js | 30 +- src/ui/types.d.ts | 82 + src/units/Explorer.js | 192 ++ src/utils/nameGenerator.js | 25 + test/core/CombatStateSpec.test.js | 10 +- test/core/GameLoop.test.js | 571 ------ test/core/GameLoop/combat-deployment.test.js | 110 ++ .../core/GameLoop/combat-highlights-5.test.js | 76 + .../core/GameLoop/combat-highlights-8.test.js | 76 + test/core/GameLoop/combat-highlights.test.js | 94 + .../combat-movement-execution.test.js | 152 ++ test/core/GameLoop/combat-movement.test.js | 213 +++ test/core/GameLoop/combat-turns.test.js | 150 ++ test/core/GameLoop/combat.test.js | 452 +++++ test/core/GameLoop/deployment.test.js | 160 ++ test/core/GameLoop/helpers.js | 149 ++ test/core/GameLoop/initialization.test.js | 55 + .../GameLoop/inventory-integration.test.js | 104 + test/core/GameLoop/stop.test.js | 42 + test/core/GameStateManager.test.js | 43 +- .../inventory-integration.test.js | 195 ++ test/factories/SkillTreeFactory.test.js | 677 +++++++ test/managers/InventoryManager.test.js | 298 +++ test/managers/ItemRegistry.test.js | 105 + test/managers/MissionManager.test.js | 47 + test/managers/RosterManager.test.js | 65 +- test/models/InventoryContainer.test.js | 200 ++ test/systems/SkillTargetingSystem.test.js | 79 +- test/ui/character-sheet.test.js | 905 +++++++++ .../inventory-integration.test.js | 325 ++++ test/ui/deployment-hud.test.js | 433 +++++ test/ui/skill-tree-ui.test.js | 612 ++++++ test/units/Explorer.test.js | 167 ++ .../Explorer/inventory-integration.test.js | 211 ++ .../units/Explorer/starting-equipment.test.js | 115 ++ web-test-runner.config.js | 2 + 63 files changed, 11639 insertions(+), 792 deletions(-) create mode 100644 specs/Character_Sheet.spec.md create mode 100644 specs/Inventory.spec.md create mode 100644 specs/Skill_Tree.spec.md create mode 100644 src/assets/data/missions/mission_tutorial_01.description.md create mode 100644 src/assets/data/skill_trees/template_standard_30.json create mode 100644 src/managers/InventoryManager.js create mode 100644 src/managers/ItemRegistry.js create mode 100644 src/models/InventoryContainer.js create mode 100644 src/ui/components/CharacterSheet.js create mode 100644 src/ui/components/SkillTreeUI.js create mode 100644 src/ui/types.d.ts create mode 100644 src/utils/nameGenerator.js delete mode 100644 test/core/GameLoop.test.js create mode 100644 test/core/GameLoop/combat-deployment.test.js create mode 100644 test/core/GameLoop/combat-highlights-5.test.js create mode 100644 test/core/GameLoop/combat-highlights-8.test.js create mode 100644 test/core/GameLoop/combat-highlights.test.js create mode 100644 test/core/GameLoop/combat-movement-execution.test.js create mode 100644 test/core/GameLoop/combat-movement.test.js create mode 100644 test/core/GameLoop/combat-turns.test.js create mode 100644 test/core/GameLoop/combat.test.js create mode 100644 test/core/GameLoop/deployment.test.js create mode 100644 test/core/GameLoop/helpers.js create mode 100644 test/core/GameLoop/initialization.test.js create mode 100644 test/core/GameLoop/inventory-integration.test.js create mode 100644 test/core/GameLoop/stop.test.js create mode 100644 test/core/GameStateManager/inventory-integration.test.js create mode 100644 test/managers/InventoryManager.test.js create mode 100644 test/managers/ItemRegistry.test.js create mode 100644 test/models/InventoryContainer.test.js create mode 100644 test/ui/character-sheet.test.js create mode 100644 test/ui/character-sheet/inventory-integration.test.js create mode 100644 test/ui/deployment-hud.test.js create mode 100644 test/ui/skill-tree-ui.test.js create mode 100644 test/units/Explorer/inventory-integration.test.js create mode 100644 test/units/Explorer/starting-equipment.test.js diff --git a/package.json b/package.json index 16f4328..7c26322 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "node build.js", "start": "web-dev-server --node-resolve --watch --root-dir dist", - "test": "web-test-runner \"test/**/*.test.js\" --node-resolve", + "test:all": "web-test-runner \"test/**/*.test.js\" --node-resolve", + "test": "web-test-runner --node-resolve", "test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --config web-test-runner.config.js" }, "repository": { diff --git a/specs/Character_Sheet.spec.md b/specs/Character_Sheet.spec.md new file mode 100644 index 0000000..d35ecac --- /dev/null +++ b/specs/Character_Sheet.spec.md @@ -0,0 +1,111 @@ +# **Character Sheet Specification: The Explorer's Dossier** + +This document defines the UI component used to view and manage an Explorer unit. It combines Stat visualization, Inventory management (Paper Doll), and Skill Tree progression into a single tabbed interface. + +## **1. Visual Layout** + +Style: High-tech/Fantasy hybrid. Dark semi-transparent backgrounds with voxel-style borders. +Container: Centered Modal (80% width/height). + +### **A. Header (The Identity)** + +- **Left:** Large 2D Portrait of the Unit. +- **Center:** Name, Class Title (e.g., "Vanguard"), and Level. +- **Bottom:** XP Bar (Gold progress fill). Displays "SP: [X]" badge if Skill Points are available. +- **Right:** "Close" button (X). + +### **B. Left Panel: Attributes (The Data)** + +- A vertical list of stats derived from getEffectiveStat(). +- **Primary:** Health (Bar), AP (Icons). +- **Secondary:** Attack, Defense, Magic, Speed, Willpower, Tech. +- **Interaction:** Hovering a stat shows a Tooltip breaking down the value (Base + Gear + Buffs). + +### **C. Center Panel: Paper Doll (The Gear)** + +- **Visual:** The Unit's 3D model (or 2D silhouette) in the center. +- **Slots:** Four large square buttons arranged around the body: + - **Left:** Primary Weapon. + - **Right:** Off-hand / Relic. + - **Body:** Armor. + - **Accessory:** Utility Device. +- **Interaction:** Clicking a slot opens the "Inventory Side-Panel" filtering for that slot type. + +### **D. Right Panel: Tabs (The Management)** + +A tabbed container switching between: + +1. **Inventory:** Grid of unequipped items in the squad's backpack. +2. **Skills:** Embeds the **Skill Tree** component (Vertical Scrolling). +3. **Mastery:** (Hub Only) Shows progress toward unlocking Tier 2 classes. + +## **2. TypeScript Interfaces (Data Model)** + +// src/types/CharacterSheet.ts + +export interface CharacterSheetProps { + unitId: string; + readOnly: boolean; // True during enemy turn or restricted events +} + +export interface CharacterSheetState { + unit: Explorer; // The full object + activeTab: 'INVENTORY' | 'SKILLS' | 'MASTERY'; + selectedSlot: 'WEAPON' | 'ARMOR' | 'RELIC' | 'UTILITY' | null; +} + +export interface StatTooltip { + label: string; // "Attack" + total: number; // 15 + breakdown: { source: string, value: number }[]; // [{s: "Base", v: 10}, {s: "Rusty Blade", v: 5}] +} + +## **3. Conditions of Acceptance (CoA)** + +**CoA 1: Stat Rendering** + +- Stats must reflect the _effective_ value. +- If a unit has a "Weakness" debuff reducing Attack, the Attack number should appear Red. If buffed, Green. + +**CoA 2: Equipment Swapping** + +- Clicking an Equipment Slot toggles the Right Panel to "Inventory" mode, filtered by that slot type. +- Clicking an item in the Inventory immediately equips it, swapping the old item back to the bag. +- Stats must verify/update immediately upon equip. + +**CoA 3: Skill Interaction** + +- The Skill Tree tab must display the `SkillTreeUI` component we designed earlier. +- Spending an SP in the tree must subtract from the Unit's `skillPoints` and update the view immediately. + +**CoA 4: Context Awareness** + +- In **Dungeon Mode**, the "Inventory" tab acts as the "Run Inventory" (temp loot). +- In **Hub Mode**, the "Inventory" tab acts as the "Stash" (permanent items). + +--- + +## **4. Prompt for Coding Agent** + +"Create `src/ui/components/CharacterSheet.js` as a LitElement. + +1. **Layout:** Use CSS Grid to create the 3-column layout (Stats, Paper Doll, Tabs). +2. **Props:** Accept a `unit` object. Watch for changes to re-render stats. +3. **Stats Column:** Implement a helper `_renderStat(label, value, breakdown)` that creates a hoverable div with a tooltip. +4. **Paper Doll:** Render 4 button slots. If slot is empty, show a ghost icon. If full, show the Item Icon. +5. **Tabs:** Implement simple switching logic. + +- _Inventory Tab:_ Render a grid of `item-card` elements. +- _Skills Tab:_ Embed ``. + +6. **Events:** Dispatch `equip-item` events when dragging/clicking inventory items." + +## **5. Integration Strategy** + +**Context:** The Character Sheet is a modal that sits above all other UI (CombatHUD, Hub, TeamBuilder). It should be mounted to the #ui-layer when triggered and removed when closed. + +**Trigger Points:** + +- **Combat:** Clicking the Unit Portrait in CombatHUD dispatches open-character-sheet. +- **Hub:** Clicking a unit card in the Barracks dispatches open-character-sheet. +- **Input:** Pressing 'C' (configured in InputManager) triggers it for the active unit. diff --git a/specs/Inventory.spec.md b/specs/Inventory.spec.md new file mode 100644 index 0000000..a83e4f6 --- /dev/null +++ b/specs/Inventory.spec.md @@ -0,0 +1,156 @@ +# **Inventory System Specification** + +This document defines the architecture for item management, covering individual Explorer loadouts and the shared Party/Hub storage. + +## **1. System Overview** + +The Inventory system operates in two distinct contexts: + +1. **Run Context (The Expedition):** + - **Unit Loadout:** Active gear affecting stats. Locked during combat (usually), editable during rest. + - **Run Stash (The Bag):** Temporary storage for loot found during the run. Infinite (or high) capacity. + - **Rule:** If the squad wipes, the _Run Stash_ is lost. Only equipped gear might be recovered (depending on difficulty settings). +2. **Hub Context (The Armory):** + - **Master Stash:** Persistent storage for all unequipped items. + - **Management:** Players move items between the Master Stash and Unit Loadouts to prepare for the next run. + - **Extraction:** Upon successful run completion, _Run Stash_ contents are merged into _Master Stash_. + +## **2. Visual Description (UI)** + +### **A. Unit Loadout (The Paper Doll)** + +- **Visual:** A silhouette or 3D model of the character. +- **Slots:** + - **Primary Hand:** Weapon (Sword, Staff, Wrench). + - **Off-Hand:** Shield, Focus, Tool, or 2H (occupies both). + - **Body:** Armor (Plate, Robes, Vest). + - **Accessory/Relic:** Stat boosters or Passive enablers. + - **Belt (2 Slots):** Consumables (Potions, Grenades) usable in combat via Bonus Action. +- **Interaction:** Drag-and-drop from Stash to Slot. Invalid slots highlight Red. Valid slots highlight Green. + +### **B. The Stash (Grid View)** + +- **Visual:** A grid of item tiles on the right side of the screen. +- **Filters:** Tabs for [All] [Weapons] [Armor] [Utility] [Consumables]. +- **Sorting:** By Rarity (Color Coded border) or Tier. +- **Context Menu:** Right-click an item to "Equip to Active Unit" or "Salvage/Sell". + +## **3. TypeScript Interfaces (Data Model)** + +```typescript +// src/types/Inventory.ts + +export type ItemType = + | "WEAPON" + | "ARMOR" + | "RELIC" + | "UTILITY" + | "CONSUMABLE" + | "MATERIAL"; +export type Rarity = "COMMON" | "UNCOMMON" | "RARE" | "ANCIENT"; +export type SlotType = "MAIN_HAND" | "OFF_HAND" | "BODY" | "ACCESSORY" | "BELT"; + +/** + * A specific instance of an item. + * Allows for RNG stats or durability in the future. + */ +export interface ItemInstance { + uid: string; // Unique Instance ID (e.g. "ITEM_12345_A") + defId: string; // Reference to static registry (e.g. "ITEM_RUSTY_BLADE") + isNew: boolean; // For UI "New!" badges + quantity: number; // For stackables (Potions/Materials) +} + +/** + * The inventory of a single character. + */ +export interface UnitLoadout { + mainHand: ItemInstance | null; + offHand: ItemInstance | null; + body: ItemInstance | null; + accessory: ItemInstance | null; + belt: [ItemInstance | null, ItemInstance | null]; // Fixed 2 slots +} + +/** + * The shared storage (Run Bag or Hub Stash). + */ +export interface InventoryStorage { + id: string; // "RUN_LOOT" or "HUB_VAULT" + items: ItemInstance[]; // Unordered list + currency: { + aetherShards: number; + ancientCores: number; + }; +} + +/** + * Data payload for moving items. + */ +export interface TransferRequest { + itemUid: string; + sourceContainer: "STASH" | "UNIT_LOADOUT"; + targetContainer: "STASH" | "UNIT_LOADOUT"; + targetSlot?: SlotType; // If moving to Unit + slotIndex?: number; // For Belt slots (0 or 1) + unitId?: string; // Which unit owns the loadout +} +``` + +## **4. Logic & Rules** + +### **A. Equipping Items** + +1. **Validation:** + - Check Item.requirements (Class Lock, Min Stats) against Unit.baseStats. + - Check Slot Compatibility (Can't put Armor in Weapon slot). + - _Two-Handed Logic:_ If equipping a 2H weapon, unequip Off-Hand automatically. +2. **Swapping:** + - If target slot is occupied, move the existing item to Stash (or Swap if source was another slot). +3. **Stat Recalculation:** + - Trigger unit.recalculateStats() immediately. + +### **B. Stacking** + +- **Equipment:** Non-stackable. Each Sword is a unique instance. +- **Consumables/Materials:** Stackable up to 99. +- **Logic:** When adding a Potion to Stash, check if defId exists. If yes, quantity++. If no, create new entry. + +### **C. The Extraction (End of Run)** + +```js +function finalizeRun(runInventory, hubInventory) { + // 1. Transfer Currency + hubInventory.currency.shards += runInventory.currency.shards; + + // 2. Transfer Items + for (let item of runInventory.items) { + hubInventory.addItem(item); + } + + // 3. Clear Run Inventory + runInventory.clear(); +} +``` + +## **5. Conditions of Acceptance (CoA)** + +**CoA 1: Class Restrictions** + +- Attempting to equip a "Tinker Only" item on a "Vanguard" must fail. +- The UI should visually dim incompatible items when a unit is selected. + +**CoA 2: Stat Updates** + +- Equipping a `+5 Attack` sword must immediately update the displayed Attack stat in the Character Sheet. +- Unequipping it must revert the stat. + +**CoA 3: Belt Logic** + +- Using a Consumable in combat (via `ActionSystem`) must reduce its quantity. +- If quantity reaches 0, the item reference is removed from the Belt slot. + +**CoA 4: Persistence** + +- Saving the game must serialize the `InventoryStorage` array correctly. +- Loading the game must restore specific item instances (not just generic definitions). diff --git a/specs/Skill_Tree.spec.md b/specs/Skill_Tree.spec.md new file mode 100644 index 0000000..04b17b3 --- /dev/null +++ b/specs/Skill_Tree.spec.md @@ -0,0 +1,109 @@ +# **Skill Tree UI Specification** + +This document defines the technical implementation for the SkillTreeUI component. This component renders the interactive progression tree for a specific Explorer. + +## **1. Visual Architecture** + +**Style:** "Voxel-Web". We will use **CSS 3D Transforms** to render the nodes as rotating cubes, keeping the UI lightweight but consistent with the game's aesthetic. + +### **A. The Tree Container (Scroll View)** + +- **Layout:** A vertical flex container. +- **Tiers:** Each "Rank" (Novice, Apprentice, etc.) is a horizontal row (Flexbox). +- **Connections:** An overlay sits behind the nodes to draw connecting lines (cables). + +### **B. The Node (CSS Voxel)** + +- **Structure:** A div with preserve-3d containing 6 faces. +- **Animation:** + - _Locked:_ Static grey cube. + - _Available:_ Slowly bobbing, pulsing color. + - _Unlocked:_ Rotating slowly, emitting a glow (box-shadow). +- **Content:** An icon ( or FontAwesome) is mapped to the Front face. + +### **C. The Inspector (Footer)** + +- A slide-up panel showing details for the _selected_ node. +- Contains the "Unlock" button. + +## **2. TypeScript Interfaces (Data Model)** + +// src/types/SkillTreeUI.ts + +export interface SkillTreeProps { + /** The Unit object (source of state) \*/ + unit: Explorer; + /** The Tree Definition (source of layout) \*/ + treeDef: SkillTreeDefinition; +} + +export interface SkillNodeState { + id: string; + def: SkillNodeDefinition; + status: 'LOCKED' | 'AVAILABLE' | 'UNLOCKED'; + /\*_ Calculated position for drawing lines _/ + domRect?: DOMRect; +} + +export interface SkillTreeEvents { + /\*_ Dispatched when user attempts to spend SP _/ + 'unlock-request': { + nodeId: string; + cost: number; + }; +} + +**3. Interaction Logic** + +### **A. Node Status Calculation** + +The UI must determine the state of every node on render: + +1. **UNLOCKED:** `unit.classMastery.unlockedNodes.includes(node.id)` +2. **AVAILABLE:** Not unlocked AND `parent` is Unlocked AND `unit.level >= node.req`. +3. **LOCKED:** Everything else. + +### **B. Connection Drawing** + +Since nodes are DOM elements, we need a `ResizeObserver` to track their positions. + +- **Logic:** Calculate center `(x, y)` of Parent Node and Child Node relative to the Container. +- **Drawing:** Draw a `` or `` in the SVG layer with a "Circuit Board" style (90-degree bends). +- **Styling:** + - If Child is Unlocked: Line is **Bright Blue/Gold** (Neon). + - If Child is Available: Line is **Dim**. + - If Child is Locked: Line is **Dark Grey**. + +--- + +## **4. Conditions of Acceptance (CoA)** + +**CoA 1: Dynamic Rendering** + +- The Tree must handle variable depths (Tier 1 to Tier 5). +- Nodes must visibly update state immediately when `unit` prop changes (e.g., after unlocking). + +**CoA 2: Validation Feedback** + +- Clicking a "LOCKED" node should show the inspector but disable the button with a reason (e.g., "Requires: Shield Bash"). +- Clicking an "AVAILABLE" node with 0 SP should show "Insufficient Points". + +**CoA 3: Responsive Lines** + +- If the window resizes, the SVG connecting lines must redraw to connect the centers of the cubes accurately. + +**CoA 4: Scroll Position** + +- On open, the view should automatically scroll to center on the _highest tier_ that has an "Available" node, so the player sees their next step. + +--- + +## **5. Prompt for Coding Agent** + +"Create `src/ui/components/SkillTreeUI.js` as a LitElement. + +1. **CSS 3D:** Implement a `.voxel-node` class using `transform-style: preserve-3d` to create a cube. Use keyframes for rotation. +2. **Layout:** Render the tree tiers using `flex-direction: column-reverse` (Tier 1 at bottom). +3. **SVG Lines:** Implement a `_updateConnections()` method that uses `getBoundingClientRect()` to draw lines between nodes in an absolute-positioned ``. Call this on resize and first render. +4. **Interactivity:** Clicking a node selects it. Show details in a fixed footer. +5. **Logic:** Calculate `LOCKED/AVAILABLE/UNLOCKED` state based on `this.unit.unlockedNodes`." diff --git a/src/assets/data/missions/mission-schema.md b/src/assets/data/missions/mission-schema.md index fc77512..1c93bcb 100644 --- a/src/assets/data/missions/mission-schema.md +++ b/src/assets/data/missions/mission-schema.md @@ -10,6 +10,7 @@ A Mission file is a JSON object with the following top-level keys: - **biome**: Instructions for the Procedural Generator. - **deployment**: Constraints on who can go on the mission. - **narrative**: Hooks for Intro/Outro and scripted events. +- **enemy_spawns**: Specific enemy types and counts to spawn at mission start. - **objectives**: Win/Loss conditions. - **modifiers**: Global rules (e.g., "Fog of War", "High Gravity"). - **rewards**: What the player gets for success. @@ -62,6 +63,16 @@ This example utilizes every capability of the system. } ] }, + "enemy_spawns": [ + { + "enemy_def_id": "ENEMY_BOSS_ARTILLERY", + "count": 1 + }, + { + "enemy_def_id": "ENEMY_SHARDBORN_SENTINEL", + "count": 3 + } + ], "objectives": { "primary": [ { @@ -130,6 +141,15 @@ This example utilizes every capability of the system. - **forced_units**: The TeamBuilder UI must check this array and auto-fill slots with these units (locking them so they can't be removed). - **banned_classes**: The UI must disable these cards in the Roster. +- **suggested_units**: (Optional) Array of class/unit IDs recommended for this mission. Useful for tutorials to guide player selection. The UI should highlight or recommend these units. +- **tutorial_hint**: (Optional) Text to display as a tutorial overlay during the deployment phase. Should point to UI elements (e.g., "Drag units from the bench to the Green Zone."). + +### **Enemy Spawns** + +- **enemy_spawns**: Array of enemy spawn definitions. Each entry specifies an enemy definition ID and count. + - **enemy_def_id**: The enemy unit definition ID (e.g., 'ENEMY_SHARDBORN_SENTINEL'). Must match an enemy definition in the UnitRegistry. + - **count**: Number of this enemy type to spawn at mission start. + - The GameLoop's `finalizeDeployment()` method should read this array and spawn the specified enemies in the enemy spawn zone. ### **Objectives Types** diff --git a/src/assets/data/missions/mission.d.ts b/src/assets/data/missions/mission.d.ts index e511dc1..7dab702 100644 --- a/src/assets/data/missions/mission.d.ts +++ b/src/assets/data/missions/mission.d.ts @@ -18,6 +18,8 @@ export interface Mission { deployment?: DeploymentConstraints; /** Hooks for Narrative sequences and scripts */ narrative?: MissionNarrative; + /** Enemy units to spawn at mission start */ + enemy_spawns?: EnemySpawn[]; /** Win/Loss conditions */ objectives: MissionObjectives; /** Global rules or stat changes */ @@ -78,6 +80,19 @@ export interface DeploymentConstraints { forced_units?: string[]; /** IDs of classes that cannot be selected */ banned_classes?: string[]; + /** IDs of classes/units suggested for this mission (for tutorials) */ + suggested_units?: string[]; + /** Tutorial hint text to display during deployment phase */ + tutorial_hint?: string; +} + +// --- ENEMY SPAWNS --- + +export interface EnemySpawn { + /** Enemy definition ID (e.g., 'ENEMY_SHARDBORN_SENTINEL') */ + enemy_def_id: string; + /** Number of this enemy type to spawn */ + count: number; } // --- NARRATIVE & SCRIPTS --- diff --git a/src/assets/data/missions/mission_tutorial_01.description.md b/src/assets/data/missions/mission_tutorial_01.description.md new file mode 100644 index 0000000..c7ed03f --- /dev/null +++ b/src/assets/data/missions/mission_tutorial_01.description.md @@ -0,0 +1,75 @@ +Here is the complete breakdown of Mission: Tutorial 01 ("Protocol: First Descent"). + +This flow combines the Mission Config, the Narrative Script, and the Gameplay Objectives into one cohesive experience. + +1. Mission Overview + Context: The player has just arrived in the Hub City (The neutral zone near the Spire). + +Patron: Director Vorn of the Cogwork Concord (The Technocracy). He is using this mission to test if your squad is competent enough to hire. + +Setting: The Rusting Wastes. A controlled, smaller map (Fixed Seed 12345) ensuring a fair first fight. + +Objective: Eliminate 2 Shardborn Sentinels. + +Rewards: Unlocks the Tinker Class (Vorn's signature class) and basic currency. + +2. The Playthrough Script + Phase 1: The Hook (Cinematic) + Trigger: Player clicks "New Descent" -> "Start Mission". + +Visuals: The screen dims. The Dialogue Overlay slides up. + +Dialogue (Director Vorn): + +Slide 1: "Explorer. You made it. Good. My sensors are bleeding red in Sector 4." + +Slide 2: "Standard Shardborn signature. Mindless, aggressive, and unfortunately, standing on top of my excavation site." + +Slide 3: "I need the perimeter cleared. Don't disappoint me." + +System Action: The Narrative Manager triggers START_DEPLOYMENT_PHASE. The HUD appears. + +Phase 2: Deployment (Tutorial) +Visuals: The map loads. A bright Green Grid highlights the spawn zone. + +Tutorial Overlay: A pop-up points to the Team Bench. + +Text: "Drag units from the bench to the Green Zone." + +Action: Player places a Vanguard and an Aether Weaver. + +Action: Player clicks "INITIATE COMBAT". + +Phase 3: The Skirmish (Gameplay) +Turn 1 (Player): + +The player moves the Vanguard forward. + +System Event: The game detects the player ended a turn exposed. + +Mid-Mission Trigger: Vorn interrupts briefly (Narrative Overlay). + +Vorn: "Careful! You're exposed. End your move behind High Walls (Full Cover) or Debris (Half Cover) to survive." + +Turn 1 (Enemy): + +The Corrupted Sentinel charges but hits the Vanguard's shield (reduced damage due to cover). + +Turn 2 (Player): + +The player uses the Aether Weaver to cast Fireball. + +The Sentinel dies. Objective Counter: 1/2. + +Phase 4: Victory (Resolution) +Action: Player kills the second enemy. + +Visuals: "VICTORY" banner flashes. + +Outro Cinematic (Dialogue Overlay): + +Director Vorn: "Efficient. Brutal. I like it." + +Director Vorn: "Here's your payment. And take these schematics—you'll need an engineer if you want to survive the deeper levels." + +Rewards: The Tinker class card is added to the Roster. diff --git a/src/assets/data/missions/mission_tutorial_01.json b/src/assets/data/missions/mission_tutorial_01.json index b01517c..2495743 100644 --- a/src/assets/data/missions/mission_tutorial_01.json +++ b/src/assets/data/missions/mission_tutorial_01.json @@ -21,16 +21,34 @@ "room_count": 4 } }, + "deployment": { + "suggested_units": ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"], + "tutorial_hint": "Drag units from the bench to the Green Zone." + }, "narrative": { "intro_sequence": "NARRATIVE_TUTORIAL_INTRO", - "outro_success": "NARRATIVE_TUTORIAL_SUCCESS" + "outro_success": "NARRATIVE_TUTORIAL_SUCCESS", + "scripted_events": [ + { + "trigger": "ON_TURN_START", + "turn_index": 2, + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_TUTORIAL_COVER_TIP" + } + ] }, + "enemy_spawns": [ + { + "enemy_def_id": "ENEMY_SHARDBORN_SENTINEL", + "count": 2 + } + ], "objectives": { "primary": [ { "id": "OBJ_ELIMINATE_ENEMIES", "type": "ELIMINATE_ALL", - "description": "Eliminate 2 enemies", + "description": "Eliminate 2 Shardborn Sentinels", "target_count": 2 } ] @@ -40,7 +58,8 @@ "xp": 100, "currency": { "aether_shards": 50 - } + }, + "unlocks": ["CLASS_TINKER"] } } } diff --git a/src/assets/data/skill_trees/template_standard_30.json b/src/assets/data/skill_trees/template_standard_30.json new file mode 100644 index 0000000..b03f7ff --- /dev/null +++ b/src/assets/data/skill_trees/template_standard_30.json @@ -0,0 +1,222 @@ +{ + "id": "TEMPLATE_STANDARD_30", + "nodes": { + "NODE_T1_1": { + "tier": 1, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T2_1", "NODE_T2_2", "NODE_T2_3"], + "req": 1, + "cost": 1 + }, + "NODE_T2_1": { + "tier": 2, + "type": "SLOT_STAT_SECONDARY", + "children": ["NODE_T3_1", "NODE_T3_2"], + "req": 2, + "cost": 1 + }, + "NODE_T2_2": { + "tier": 2, + "type": "SLOT_SKILL_ACTIVE_1", + "children": ["NODE_T3_3", "NODE_T3_4"], + "req": 2, + "cost": 1 + }, + "NODE_T2_3": { + "tier": 2, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T3_5", "NODE_T3_6"], + "req": 2, + "cost": 1 + }, + "NODE_T3_1": { + "tier": 3, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T4_1", "NODE_T4_2"], + "req": 3, + "cost": 1 + }, + "NODE_T3_2": { + "tier": 3, + "type": "SLOT_STAT_SECONDARY", + "children": ["NODE_T4_3"], + "req": 3, + "cost": 1 + }, + "NODE_T3_3": { + "tier": 3, + "type": "SLOT_SKILL_ACTIVE_2", + "children": ["NODE_T4_4", "NODE_T4_5"], + "req": 3, + "cost": 1 + }, + "NODE_T3_4": { + "tier": 3, + "type": "SLOT_SKILL_PASSIVE_1", + "children": ["NODE_T4_6"], + "req": 3, + "cost": 2 + }, + "NODE_T3_5": { + "tier": 3, + "type": "SLOT_STAT_SECONDARY", + "children": ["NODE_T4_7"], + "req": 3, + "cost": 1 + }, + "NODE_T3_6": { + "tier": 3, + "type": "SLOT_SKILL_ACTIVE_1", + "children": ["NODE_T4_8", "NODE_T4_9"], + "req": 3, + "cost": 1 + }, + "NODE_T4_1": { + "tier": 4, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T5_1", "NODE_T5_2"], + "req": 4, + "cost": 2 + }, + "NODE_T4_2": { + "tier": 4, + "type": "SLOT_STAT_SECONDARY", + "children": ["NODE_T5_3"], + "req": 4, + "cost": 2 + }, + "NODE_T4_3": { + "tier": 4, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T5_4"], + "req": 4, + "cost": 2 + }, + "NODE_T4_4": { + "tier": 4, + "type": "SLOT_SKILL_ACTIVE_3", + "children": ["NODE_T5_5", "NODE_T5_6"], + "req": 4, + "cost": 2 + }, + "NODE_T4_5": { + "tier": 4, + "type": "SLOT_SKILL_ACTIVE_4", + "children": ["NODE_T5_7"], + "req": 4, + "cost": 2 + }, + "NODE_T4_6": { + "tier": 4, + "type": "SLOT_SKILL_PASSIVE_2", + "children": ["NODE_T5_8"], + "req": 4, + "cost": 2 + }, + "NODE_T4_7": { + "tier": 4, + "type": "SLOT_STAT_PRIMARY", + "children": ["NODE_T5_9"], + "req": 4, + "cost": 2 + }, + "NODE_T4_8": { + "tier": 4, + "type": "SLOT_SKILL_PASSIVE_3", + "children": ["NODE_T5_10", "NODE_T5_11"], + "req": 4, + "cost": 2 + }, + "NODE_T4_9": { + "tier": 4, + "type": "SLOT_STAT_SECONDARY", + "children": ["NODE_T5_12"], + "req": 4, + "cost": 2 + }, + "NODE_T5_1": { + "tier": 5, + "type": "SLOT_STAT_PRIMARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_2": { + "tier": 5, + "type": "SLOT_STAT_SECONDARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_3": { + "tier": 5, + "type": "SLOT_STAT_PRIMARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_4": { + "tier": 5, + "type": "SLOT_STAT_SECONDARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_5": { + "tier": 5, + "type": "SLOT_SKILL_ACTIVE_3", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_6": { + "tier": 5, + "type": "SLOT_SKILL_ACTIVE_4", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_7": { + "tier": 5, + "type": "SLOT_SKILL_PASSIVE_2", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_8": { + "tier": 5, + "type": "SLOT_SKILL_PASSIVE_4", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_9": { + "tier": 5, + "type": "SLOT_STAT_SECONDARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_10": { + "tier": 5, + "type": "SLOT_SKILL_ACTIVE_1", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_11": { + "tier": 5, + "type": "SLOT_STAT_PRIMARY", + "children": [], + "req": 5, + "cost": 3 + }, + "NODE_T5_12": { + "tier": 5, + "type": "SLOT_SKILL_PASSIVE_3", + "children": [], + "req": 5, + "cost": 3 + } + } +} diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 35a2f48..a380592 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -20,6 +20,9 @@ import { TurnSystem } from "../systems/TurnSystem.js"; import { MovementSystem } from "../systems/MovementSystem.js"; import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js"; import { skillRegistry } from "../managers/SkillRegistry.js"; +import { InventoryManager } from "../managers/InventoryManager.js"; +import { InventoryContainer } from "../models/InventoryContainer.js"; +import { itemRegistry } from "../managers/ItemRegistry.js"; // Import class definitions import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" }; @@ -36,6 +39,13 @@ export class GameLoop { constructor() { /** @type {boolean} */ this.isRunning = false; + + /** @type {Object|null} Cached skill tree template */ + this._skillTreeTemplate = null; + /** @type {number | null} */ + this.animationFrameId = null; + /** @type {boolean} */ + this.isPaused = false; // 1. Core Systems /** @type {THREE.Scene} */ @@ -63,6 +73,14 @@ export class GameLoop { this.movementSystem = null; /** @type {SkillTargetingSystem | null} */ this.skillTargetingSystem = null; + + // Inventory System + /** @type {InventoryManager | null} */ + this.inventoryManager = null; + + // AbortController for cleaning up event listeners + /** @type {AbortController | null} */ + this.turnSystemAbortController = null; /** @type {Map} */ this.unitMeshes = new Map(); @@ -137,6 +155,13 @@ export class GameLoop { this.movementSystem = new MovementSystem(); // SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready + // --- INITIALIZE INVENTORY SYSTEM --- + // Create stashes (InventoryManager will be initialized in startLevel after itemRegistry loads) + const runStash = new InventoryContainer("RUN_LOOT"); + const hubStash = new InventoryContainer("HUB_VAULT"); + // Initialize InventoryManager with itemRegistry (will load items in startLevel) + this.inventoryManager = new InventoryManager(itemRegistry, runStash, hubStash); + // --- SETUP INPUT MANAGER --- this.inputManager = new InputManager( this.camera, @@ -283,6 +308,67 @@ export class GameLoop { this.inputManager.setValidator(validator); } + if (code === "KeyC") { + // Open character sheet for active unit + this.openCharacterSheet(); + } + } + + /** + * Opens the character sheet for the currently active unit. + */ + openCharacterSheet() { + if (!this.turnSystem) return; + + const activeUnit = this.turnSystem.getActiveUnit(); + if (!activeUnit || activeUnit.team !== "PLAYER") { + // If no active unit or not player unit, try to get first player unit + if (this.unitManager) { + const playerUnits = this.unitManager.getAllUnits().filter((u) => u.team === "PLAYER"); + if (playerUnits.length > 0) { + this._dispatchOpenCharacterSheet(playerUnits[0]); + } + } + return; + } + + this._dispatchOpenCharacterSheet(activeUnit); + } + + /** + * Dispatches open-character-sheet event for a unit. + * @param {Unit|string} unitOrId - Unit object or unit ID + * @private + */ + _dispatchOpenCharacterSheet(unitOrId) { + // Get full unit object if ID was provided + let unit = unitOrId; + if (typeof unitOrId === "string" && this.unitManager) { + unit = this.unitManager.getUnitById(unitOrId); + } + + if (!unit) { + console.warn("Cannot open character sheet: unit not found"); + return; + } + + // Get inventory from runData or empty array + const inventory = this.runData?.inventory || []; + + // Determine if read-only (enemy turn or restricted) + const activeUnit = this.turnSystem?.getActiveUnit(); + const isReadOnly = this.combatState === "TARGETING_SKILL" || + (activeUnit && activeUnit.team !== "PLAYER"); + + window.dispatchEvent( + new CustomEvent("open-character-sheet", { + detail: { + unit: unit, + readOnly: isReadOnly, + inventory: inventory, + }, + }) + ); } /** @@ -383,7 +469,11 @@ export class GameLoop { ); // Update combat state and movement highlights - this.updateCombatState(); + this.updateCombatState().catch(console.error); + + // NOTE: Do NOT auto-end turn when AP reaches 0 after movement. + // The player should explicitly click "End Turn" to end their turn. + // Even if the unit has no AP left, they may want to use skills or wait. } } @@ -518,7 +608,7 @@ export class GameLoop { } // Update combat state - this.updateCombatState(); + this.updateCombatState().catch(console.error); } /** @@ -583,9 +673,11 @@ export class GameLoop { /** * Starts a level with the given run data. * @param {RunData} runData - Run data containing mission and squad info + * @param {Object} [options] - Optional configuration + * @param {boolean} [options.startAnimation=true] - Whether to start the animation loop * @returns {Promise} */ - async startLevel(runData) { + async startLevel(runData, options = {}) { console.log("GameLoop: Generating Level..."); this.runData = runData; this.isRunning = true; @@ -668,13 +760,16 @@ export class GameLoop { }; this.unitManager = new UnitManager(unitRegistry); + // Store classRegistry reference for accessing class definitions later + this.classRegistry = classRegistry; // WIRING: Connect Systems to Data this.movementSystem.setContext(this.grid, this.unitManager); this.turnSystem.setContext(this.unitManager); // Load skills and initialize SkillTargetingSystem - if (skillRegistry.skills.size === 0) { + // Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts + if (options.startAnimation !== false && skillRegistry.skills.size === 0) { await skillRegistry.loadAll(); } this.skillTargetingSystem = new SkillTargetingSystem( @@ -683,17 +778,20 @@ export class GameLoop { skillRegistry ); + // Load items for InventoryManager + if (options.startAnimation !== false && itemRegistry.items.size === 0) { + await itemRegistry.loadAll(); + } + // WIRING: Listen for Turn Changes (to update UI/Input state) - this.turnSystem.addEventListener("turn-start", (e) => - this._onTurnStart(e.detail) - ); - this.turnSystem.addEventListener("turn-end", (e) => - this._onTurnEnd(e.detail) - ); - this.turnSystem.addEventListener("combat-start", () => - this._onCombatStart() - ); - this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd()); + // Create new AbortController for this level - when aborted, listeners are automatically removed + this.turnSystemAbortController = new AbortController(); + const signal = this.turnSystemAbortController.signal; + + this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal }); + this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal }); + this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal }); + this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal }); this.highlightZones(); @@ -721,7 +819,10 @@ export class GameLoop { this.inputManager.setValidator(this.validateDeploymentCursor.bind(this)); - this.animate(); + // Only start animation loop if explicitly requested (default true for normal usage) + if (options.startAnimation !== false) { + this.animate(); + } } /** @@ -779,11 +880,17 @@ export class GameLoop { return existingUnit; } else { // CREATE logic - const unit = this.unitManager.createUnit( - unitDef.classId || unitDef.id, - "PLAYER" - ); + const classId = unitDef.classId || unitDef.id; + const unit = this.unitManager.createUnit(classId, "PLAYER"); + + if (!unit) { + console.error(`Failed to create unit for class: ${classId}`); + return null; + } + + // Set character name and class name from unitDef if (unitDef.name) unit.name = unitDef.name; + if (unitDef.className) unit.className = unitDef.className; // Preserve portrait/image from unitDef for UI display if (unitDef.image) { @@ -797,6 +904,24 @@ export class GameLoop { : "/" + unitDef.portrait; } + // Initialize starting equipment for Explorers + if (unit.type === "EXPLORER" && this.inventoryManager) { + // Get class definition from the registry + let classDef = null; + if (this.unitManager.registry) { + classDef = typeof this.unitManager.registry.get === "function" + ? this.unitManager.registry.get(classId) + : this.unitManager.registry[classId]; + } + + if (classDef && typeof unit.initializeStartingEquipment === "function") { + unit.initializeStartingEquipment( + this.inventoryManager.itemRegistry, + classDef + ); + } + } + // Ensure unit starts with full health // Explorer constructor might set health to 0 if classDef is missing base_stats if (unit.currentHealth <= 0) { @@ -823,38 +948,74 @@ export class GameLoop { this.gameStateManager.currentState !== "STATE_DEPLOYMENT" ) return; - const enemyCount = 2; - let attempts = 0; - const maxAttempts = this.enemySpawnZone.length * 2; // Try up to 2x the zone size - for (let i = 0; i < enemyCount && attempts < maxAttempts; attempts++) { - const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length); - const spot = this.enemySpawnZone[spotIndex]; + // Get enemy spawns from mission definition + const missionDef = this.missionManager?.getActiveMission(); + const enemySpawns = missionDef?.enemy_spawns || []; - if (!spot) continue; - - // Check if position is walkable (not just unoccupied) - // Find the correct walkable Y for this position - const walkableY = this.movementSystem?.findWalkableY( - spot.x, - spot.z, - spot.y - ); - if (walkableY === null) continue; - - const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; - - // Check if position is not occupied and is walkable (not solid) - if ( - !this.grid.isOccupied(walkablePos) && - !this.grid.isSolid(walkablePos) - ) { - const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); - this.grid.placeUnit(enemy, walkablePos); - this.createUnitMesh(enemy, walkablePos); - this.enemySpawnZone.splice(spotIndex, 1); - i++; // Only increment if we successfully placed an enemy + // If no enemy_spawns defined, fall back to default behavior + if (enemySpawns.length === 0) { + console.warn("No enemy_spawns defined in mission, using default"); + const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + if (enemy && this.enemySpawnZone.length > 0) { + const spot = this.enemySpawnZone[0]; + const walkableY = this.movementSystem?.findWalkableY( + spot.x, + spot.z, + spot.y + ); + if (walkableY !== null) { + const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; + if (!this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos)) { + this.grid.placeUnit(enemy, walkablePos); + this.createUnitMesh(enemy, walkablePos); + } + } } + } else { + // Spawn enemies according to mission definition + let totalSpawned = 0; + const availableSpots = [...this.enemySpawnZone]; // Copy to avoid mutating original + + for (const spawnDef of enemySpawns) { + const { enemy_def_id, count } = spawnDef; + let attempts = 0; + const maxAttempts = availableSpots.length * 2; + + for (let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++) { + const spotIndex = Math.floor(Math.random() * availableSpots.length); + const spot = availableSpots[spotIndex]; + + if (!spot) continue; + + // Check if position is walkable (not just unoccupied) + const walkableY = this.movementSystem?.findWalkableY( + spot.x, + spot.z, + spot.y + ); + if (walkableY === null) continue; + + const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; + + // Check if position is not occupied and is walkable (not solid) + if ( + !this.grid.isOccupied(walkablePos) && + !this.grid.isSolid(walkablePos) + ) { + const enemy = this.unitManager.createUnit(enemy_def_id, "ENEMY"); + if (enemy) { + this.grid.placeUnit(enemy, walkablePos); + this.createUnitMesh(enemy, walkablePos); + availableSpots.splice(spotIndex, 1); + totalSpawned++; + i++; // Only increment if we successfully placed an enemy + } + } + } + } + + console.log(`Spawned ${totalSpawned} enemies from mission definition`); } // Switch to standard movement validator for the game @@ -874,7 +1035,7 @@ export class GameLoop { this.turnSystem.startCombat(allUnits); // Update combat state immediately so UI shows combat HUD - this.updateCombatState(); + this.updateCombatState().catch(console.error); console.log("Combat Started!"); } @@ -915,7 +1076,26 @@ export class GameLoop { * Clears all movement highlight meshes from the scene. */ clearMovementHighlights() { - this.movementHighlights.forEach((mesh) => this.scene.remove(mesh)); + this.movementHighlights.forEach((mesh) => { + this.scene.remove(mesh); + // Dispose geometry and material to free memory + if (mesh.geometry) { + // For LineSegments, geometry might be EdgesGeometry which wraps another geometry + // Dispose the geometry itself + mesh.geometry.dispose(); + } + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((mat) => { + if (mat.map) mat.map.dispose(); + mat.dispose(); + }); + } else { + if (mesh.material.map) mesh.material.map.dispose(); + mesh.material.dispose(); + } + } + }); this.movementHighlights.clear(); } @@ -1039,9 +1219,52 @@ export class GameLoop { */ createUnitMesh(unit, pos) { const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6); - let color = 0xcccccc; - if (unit.id.includes("VANGUARD")) color = 0xff3333; - else if (unit.team === "ENEMY") color = 0x550000; + + // Class-based color mapping for player units + const CLASS_COLORS = { + CLASS_VANGUARD: 0xff3333, // Red - Tank + CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical + CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth + CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support + CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter + CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive + CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support + CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver) + CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic + CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic + }; + + let color = 0xcccccc; // Default gray + + if (unit.team === "ENEMY") { + color = 0x550000; // Dark red for enemies + } else if (unit.team === "PLAYER") { + // Get class ID from activeClassId (Explorer units) or extract from unit.id + let classId = unit.activeClassId; + + // If no activeClassId, try to extract from unit.id (format: "CLASS_VANGUARD_0") + if (!classId && unit.id.includes("CLASS_")) { + const parts = unit.id.split("_"); + if (parts.length >= 2) { + classId = parts[0] + "_" + parts[1]; + } + } + + // Look up color by class ID + if (classId && CLASS_COLORS[classId]) { + color = CLASS_COLORS[classId]; + } else { + // Fallback: check if unit.id contains any class name + for (const className of Object.keys(CLASS_COLORS)) { + const classShortName = className.replace("CLASS_", ""); + if (unit.id.includes(classShortName)) { + color = CLASS_COLORS[className]; + break; + } + } + } + } + const material = new THREE.MeshStandardMaterial({ color: color }); const mesh = new THREE.Mesh(geometry, material); // Floor surface is at pos.y - 0.5 (floor block at pos.y-1, top at pos.y-0.5) @@ -1053,34 +1276,147 @@ export class GameLoop { /** * Highlights spawn zones with visual indicators. + * Uses multi-layer glow outline style similar to movement highlights. */ highlightZones() { // Clear any existing spawn zone highlights this.clearSpawnZoneHighlights(); - const highlightMatPlayer = new THREE.MeshBasicMaterial({ - color: 0x00ff00, + // Player zone colors (green) - multi-layer glow + const playerOuterGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x006600, transparent: true, opacity: 0.3, }); - const highlightMatEnemy = new THREE.MeshBasicMaterial({ - color: 0xff0000, + + const playerMidGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x008800, + transparent: true, + opacity: 0.5, + }); + + const playerHighlightMaterial = new THREE.LineBasicMaterial({ + color: 0x00ff00, // Bright green + transparent: true, + opacity: 1.0, + }); + + const playerThickMaterial = new THREE.LineBasicMaterial({ + color: 0x00cc00, + transparent: true, + opacity: 0.8, + }); + + // Enemy zone colors (red) - multi-layer glow + const enemyOuterGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x660000, transparent: true, opacity: 0.3, }); - const geo = new THREE.PlaneGeometry(1, 1); - geo.rotateX(-Math.PI / 2); + + const enemyMidGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x880000, + transparent: true, + opacity: 0.5, + }); + + const enemyHighlightMaterial = new THREE.LineBasicMaterial({ + color: 0xff0000, // Bright red + transparent: true, + opacity: 1.0, + }); + + const enemyThickMaterial = new THREE.LineBasicMaterial({ + color: 0xcc0000, + transparent: true, + opacity: 0.8, + }); + + // Create base plane geometry for the tile + const baseGeometry = new THREE.PlaneGeometry(1, 1); + baseGeometry.rotateX(-Math.PI / 2); + + // Helper function to create multi-layer highlights for a position + const createHighlights = (pos, materials) => { + const { outerGlow, midGlow, highlight, thick } = materials; + + // Find walkable Y level (similar to movement highlights) + let walkableY = pos.y; + if (this.grid && this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { + for (let checkY = pos.y; checkY >= 0; checkY--) { + if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { + walkableY = checkY; + break; + } + } + } + + const floorSurfaceY = walkableY - 0.5; + + // Outer glow (largest, most transparent) + const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); + outerGlowGeometry.rotateX(-Math.PI / 2); + const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); + const outerGlowLines = new THREE.LineSegments( + outerGlowEdges, + outerGlow + ); + outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); + this.scene.add(outerGlowLines); + this.spawnZoneHighlights.add(outerGlowLines); + + // Mid glow (medium size) + const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); + midGlowGeometry.rotateX(-Math.PI / 2); + const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); + const midGlowLines = new THREE.LineSegments( + midGlowEdges, + midGlow + ); + midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); + this.scene.add(midGlowLines); + this.spawnZoneHighlights.add(midGlowLines); + + // Thick inner outline (slightly larger than base for thickness) + const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); + thickGeometry.rotateX(-Math.PI / 2); + const thickEdges = new THREE.EdgesGeometry(thickGeometry); + const thickLines = new THREE.LineSegments(thickEdges, thick); + thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z); + this.scene.add(thickLines); + this.spawnZoneHighlights.add(thickLines); + + // Main bright outline (exact size, brightest) + const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); + const lineSegments = new THREE.LineSegments( + edgesGeometry, + highlight + ); + lineSegments.position.set(pos.x, floorSurfaceY, pos.z); + this.scene.add(lineSegments); + this.spawnZoneHighlights.add(lineSegments); + }; + + // Create highlights for player spawn zone (green) + const playerMaterials = { + outerGlow: playerOuterGlowMaterial, + midGlow: playerMidGlowMaterial, + highlight: playerHighlightMaterial, + thick: playerThickMaterial, + }; this.playerSpawnZone.forEach((pos) => { - const mesh = new THREE.Mesh(geo, highlightMatPlayer); - mesh.position.set(pos.x, pos.y + 0.05, pos.z); - this.scene.add(mesh); - this.spawnZoneHighlights.add(mesh); + createHighlights(pos, playerMaterials); }); + + // Create highlights for enemy spawn zone (red) + const enemyMaterials = { + outerGlow: enemyOuterGlowMaterial, + midGlow: enemyMidGlowMaterial, + highlight: enemyHighlightMaterial, + thick: enemyThickMaterial, + }; this.enemySpawnZone.forEach((pos) => { - const mesh = new THREE.Mesh(geo, highlightMatEnemy); - mesh.position.set(pos.x, pos.y + 0.05, pos.z); - this.scene.add(mesh); - this.spawnZoneHighlights.add(mesh); + createHighlights(pos, enemyMaterials); }); } @@ -1088,7 +1424,20 @@ export class GameLoop { * Clears all spawn zone highlight meshes from the scene. */ clearSpawnZoneHighlights() { - this.spawnZoneHighlights.forEach((mesh) => this.scene.remove(mesh)); + this.spawnZoneHighlights.forEach((mesh) => { + this.scene.remove(mesh); + // Dispose geometry and material to free memory + if (mesh.geometry) { + mesh.geometry.dispose(); + } + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((mat) => mat.dispose()); + } else { + mesh.material.dispose(); + } + } + }); this.spawnZoneHighlights.clear(); } @@ -1146,11 +1495,56 @@ export class GameLoop { this.renderer.render(this.scene, this.camera); } + /** + * Pauses the game loop (temporarily stops animation). + * Can be resumed with resume(). + */ + pause() { + this.isPaused = true; + this.isRunning = false; + } + + /** + * Resumes the game loop after being paused. + */ + resume() { + if (this.isPaused) { + this.isPaused = false; + this.isRunning = true; + this.animate(); + } + } + /** * Stops the game loop and cleans up resources. */ stop() { this.isRunning = false; + this.isPaused = false; + + // Abort turn system event listeners (automatically removes them via signal) + if (this.turnSystemAbortController) { + this.turnSystemAbortController.abort(); + this.turnSystemAbortController = null; + } + + // Reset turn system state BEFORE ending combat to prevent event cascades + if (this.turnSystem) { + // End combat first to stop any ongoing turn advancement + if (this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END") { + try { + this.turnSystem.endCombat(); + } catch (e) { + // Ignore errors + } + } + + // Then reset + if (typeof this.turnSystem.reset === "function") { + this.turnSystem.reset(); + } + } + if (this.inputManager && typeof this.inputManager.detach === "function") { this.inputManager.detach(); } @@ -1162,7 +1556,7 @@ export class GameLoop { * Called when combat starts or when combat state changes (turn changes, etc.) * Uses TurnSystem to get the spec-compliant CombatState, then enriches it for UI. */ - updateCombatState() { + async updateCombatState() { if (!this.gameStateManager || !this.turnSystem) { return; } @@ -1194,7 +1588,7 @@ export class GameLoop { description: effect.description || effect.name || "Status Effect", })); - // Build skills (placeholder for now - will be populated from unit's actions/skill tree) + // Build skills from unit's actions const skills = (activeUnit.actions || []).map((action, index) => ({ id: action.id || `skill_${index}`, name: action.name || "Unknown Skill", @@ -1206,7 +1600,85 @@ export class GameLoop { (action.cooldown || 0) === 0, })); - // If no skills from actions, provide a default attack skill + // Add unlocked skill tree skills for Explorer units + if ( + (activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") && + activeUnit.activeClassId && + activeUnit.classMastery && + this.classRegistry + ) { + const mastery = activeUnit.classMastery[activeUnit.activeClassId]; + if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) { + try { + // Get class definition + const classDef = this.classRegistry.get(activeUnit.activeClassId); + if (classDef && classDef.skillTreeData) { + // Generate skill tree (similar to index.js) + // We'll need to import SkillTreeFactory dynamically or store it + // For now, let's try to get the skill tree from the skill registry + const { SkillTreeFactory } = await import( + "../factories/SkillTreeFactory.js" + ); + + // Load skill tree template (use cache if available) + let template = this._skillTreeTemplate; + if (!template) { + const templateResponse = await fetch( + "assets/data/skill_trees/template_standard_30.json" + ); + if (templateResponse.ok) { + template = await templateResponse.json(); + this._skillTreeTemplate = template; // Cache it + } + } + + if (template) { + const templateRegistry = { [template.id]: template }; + + // Convert skillRegistry Map to object for SkillTreeFactory + const skillMap = Object.fromEntries(skillRegistry.skills); + + // Create factory and generate tree + const factory = new SkillTreeFactory(templateRegistry, skillMap); + const skillTree = factory.createTree(classDef); + + // Add unlocked ACTIVE_SKILL nodes to skills array + for (const nodeId of mastery.unlockedNodes) { + const nodeDef = skillTree.nodes?.[nodeId]; + if (nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data) { + const skillData = nodeDef.data; + const skillId = skillData.id || nodeId; + + // Get full skill definition from registry if available + const fullSkill = skillRegistry.skills.get(skillId); + + // Add skill to skills array (avoid duplicates) + if (!skills.find((s) => s.id === skillId)) { + // Get costAP and cooldown from full skill definition + const costAP = fullSkill?.costs?.ap || skillData.costAP || 3; + const cooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; + + skills.push({ + id: skillId, + name: skillData.name || fullSkill?.name || "Unknown Skill", + icon: skillData.icon || fullSkill?.icon || "⚔", + costAP: costAP, + cooldown: cooldown, + isAvailable: + activeUnit.currentAP >= costAP && cooldown === 0, + }); + } + } + } + } + } + } catch (error) { + console.warn("Failed to load skill tree for combat HUD:", error); + } + } + } + + // If no skills from actions or skill tree, provide a default attack skill if (skills.length === 0) { skills.push({ id: "attack", @@ -1350,7 +1822,7 @@ export class GameLoop { this.turnSystem.endTurn(activeUnit); // Update combat state (TurnSystem will have advanced to next unit) - this.updateCombatState(); + this.updateCombatState().catch(console.error); // If the next unit is an enemy, trigger AI turn const nextUnit = this.turnSystem.getActiveUnit(); diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 2b75c8f..0296c07 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -198,18 +198,48 @@ class GameStateManagerClass { */ async handleEmbark(e) { // Handle Draft Mode (New Recruits) + let squadManifest = e.detail.squad; if (e.detail.mode === "DRAFT") { - e.detail.squad.forEach((unit) => { - if (unit.isNew) { - this.rosterManager.recruitUnit(unit); - } - }); + // Update squad manifest with IDs from recruited units + squadManifest = await Promise.all( + e.detail.squad.map(async (unit) => { + if (unit.isNew) { + const recruitedUnit = await this.rosterManager.recruitUnit(unit); + if (recruitedUnit) { + // Return the recruited unit with its generated ID + return recruitedUnit; + } + } else if (!unit.id) { + // For existing units without IDs, look them up in the roster + const rosterUnit = this.rosterManager.roster.find( + (r) => r.classId === unit.classId && r.name === unit.name + ); + if (rosterUnit) { + return { ...unit, id: rosterUnit.id }; + } + } + return unit; + }) + ); this._saveRoster(); + } else { + // For non-draft mode, ensure all units have IDs from roster + squadManifest = e.detail.squad.map((unit) => { + if (!unit.id) { + const rosterUnit = this.rosterManager.roster.find( + (r) => r.classId === unit.classId && r.name === unit.name + ); + if (rosterUnit) { + return { ...unit, id: rosterUnit.id }; + } + } + return unit; + }); } // We must transition to deployment before initializing the run so that the game loop gets set. this.transitionTo(GameStateManagerClass.STATES.DEPLOYMENT); // Will transition to DEPLOYMENT after run is initialized - await this._initializeRun(e.detail.squad); + await this._initializeRun(squadManifest); } // --- INTERNAL HELPERS --- @@ -249,11 +279,35 @@ class GameStateManagerClass { squad: squadManifest, objectives: missionDef.objectives, // Pass objectives for UI display world_state: {}, + // Include inventory data from run stash + inventory: this.gameLoop.inventoryManager + ? { + runStash: { + id: this.gameLoop.inventoryManager.runStash.id, + items: this.gameLoop.inventoryManager.runStash.getAllItems(), + currency: { + aetherShards: + this.gameLoop.inventoryManager.runStash.currency + ?.aetherShards || 0, + ancientCores: + this.gameLoop.inventoryManager.runStash.currency + ?.ancientCores || 0, + }, + }, + } + : undefined, }; // 4. Save & Start await this.persistence.saveRun(this.activeRunData); + // Notify UI that run data (including squad) has been updated + window.dispatchEvent( + new CustomEvent("run-data-updated", { + detail: { runData: this.activeRunData }, + }) + ); + // Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc) this.gameLoop.missionManager = this.missionManager; // Give GameLoop a reference to GameStateManager so it can notify about state changes diff --git a/src/factories/SkillTreeFactory.js b/src/factories/SkillTreeFactory.js index 80d2beb..f972266 100644 --- a/src/factories/SkillTreeFactory.js +++ b/src/factories/SkillTreeFactory.js @@ -69,17 +69,86 @@ export class SkillTreeFactory { node.type = "ACTIVE_SKILL"; // Map tier/slot to specific index in the config array // Example: Slot 1 is the 0th skill - node.data = this.getSkillData(config.active_skills[0]); + if (config.active_skills && config.active_skills[0]) { + node.data = this.getSkillData(config.active_skills[0]); + } else { + node.data = { id: "UNKNOWN", name: "Unknown Skill" }; + } break; case "SLOT_SKILL_ACTIVE_2": node.type = "ACTIVE_SKILL"; - node.data = this.getSkillData(config.active_skills[1]); + if (config.active_skills && config.active_skills[1]) { + node.data = this.getSkillData(config.active_skills[1]); + } else { + node.data = { id: "UNKNOWN", name: "Unknown Skill" }; + } + break; + + case "SLOT_SKILL_ACTIVE_3": + node.type = "ACTIVE_SKILL"; + if (config.active_skills && config.active_skills[2]) { + node.data = this.getSkillData(config.active_skills[2]); + } else { + node.data = { id: "UNKNOWN", name: "Unknown Skill" }; + } + break; + + case "SLOT_SKILL_ACTIVE_4": + node.type = "ACTIVE_SKILL"; + if (config.active_skills && config.active_skills[3]) { + node.data = this.getSkillData(config.active_skills[3]); + } else { + node.data = { id: "UNKNOWN", name: "Unknown Skill" }; + } break; case "SLOT_SKILL_PASSIVE_1": node.type = "PASSIVE_ABILITY"; - node.data = { effect_id: config.passive_skills[0] }; + if (config.passive_skills && config.passive_skills[0]) { + node.data = { + effect_id: config.passive_skills[0], + name: config.passive_skills[0], + }; + } else { + node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" }; + } + break; + + case "SLOT_SKILL_PASSIVE_2": + node.type = "PASSIVE_ABILITY"; + if (config.passive_skills && config.passive_skills[1]) { + node.data = { + effect_id: config.passive_skills[1], + name: config.passive_skills[1], + }; + } else { + node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" }; + } + break; + + case "SLOT_SKILL_PASSIVE_3": + node.type = "PASSIVE_ABILITY"; + if (config.passive_skills && config.passive_skills[2]) { + node.data = { + effect_id: config.passive_skills[2], + name: config.passive_skills[2], + }; + } else { + node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" }; + } + break; + + case "SLOT_SKILL_PASSIVE_4": + node.type = "PASSIVE_ABILITY"; + if (config.passive_skills && config.passive_skills[3]) { + node.data = { + effect_id: config.passive_skills[3], + name: config.passive_skills[3], + }; + } else { + node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" }; + } break; // ... Add cases for other slots (ULTIMATE, etc) diff --git a/src/index.html b/src/index.html index acc246b..81329a9 100644 --- a/src/index.html +++ b/src/index.html @@ -341,6 +341,9 @@ + +
+