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 @@ + +
+