diff --git a/dependency-graph.md b/dependency-graph.md new file mode 100644 index 0000000..e1654a8 --- /dev/null +++ b/dependency-graph.md @@ -0,0 +1,83 @@ +```mermaid +graph TD + %% --- LAYERS --- + subgraph UI_Layer [UI Presentation] + DOM[index.html] + HUD[Deployment/Combat HUDs] + Builder[Team Builder] + Dialogue[Dialogue Overlay] + end + + subgraph App_Layer [Application Control] + GSM[GameStateManager] + Persist[Persistence - IndexedDB] + Input[InputManager] + end + + subgraph Engine_Layer [The Game Loop] + Loop[GameLoop] + Mission[MissionManager] + Narrative[NarrativeManager] + end + + subgraph Sim_Layer [Simulation & Logic] + Grid[VoxelGrid] + UnitMgr[UnitManager] + Path[Pathfinding A*] + AI[AIController] + Effects[EffectProcessor] + Stats[StatSystem] + end + + subgraph Gen_Layer [Procedural Generation] + MapGen[Map Generators] + TexGen[Texture Generators] + end + + subgraph Visual_Layer [Rendering] + VoxMgr[VoxelManager] + ThreeJS[Three.js Scene] + end + + %% --- CONNECTIONS --- + + %% Application Flow + DOM --> GSM + GSM -->|Set Loop| Loop + GSM <-->|Save/Load| Persist + + %% Input Flow + Input -->|Events| Loop + Input -->|Raycast| ThreeJS + + %% Game Loop Control + Loop -->|Update| UnitMgr + Loop -->|Render| VoxMgr + Loop -->|Logic| AI + Loop -->|Logic| Mission + + %% Mission & Narrative + Mission -->|Triggers| Narrative + Narrative -->|Events| Dialogue + Mission -->|Config| MapGen + + %% Generation Flow + MapGen -->|Fills| Grid + MapGen -->|Uses| TexGen + MapGen -->|Assets| VoxMgr + + %% Simulation Interdependencies + UnitMgr -->|Queries| Grid + AI -->|Queries| UnitMgr + AI -->|Calculates| Path + Path -->|Queries| Grid + + %% Combat & Effects + AI -->|Action| Effects + Effects -->|Modify| UnitMgr + Effects -->|Modify| Grid + + %% Rendering + VoxMgr -->|Reads| Grid + VoxMgr -->|Updates| ThreeJS +``` diff --git a/src/assets/data/missions/mission-schema.md b/src/assets/data/missions/mission-schema.md new file mode 100644 index 0000000..fc77512 --- /dev/null +++ b/src/assets/data/missions/mission-schema.md @@ -0,0 +1,158 @@ +# **Mission JSON Schema Reference** + +This document defines the data structure for Game Missions. It covers configuration for World Generation, Narrative logic, Objectives, and Rewards. + +## **1\. The Structure (Overview)** + +A Mission file is a JSON object with the following top-level keys: + +- **config**: Meta-data (ID, Title, Difficulty). +- **biome**: Instructions for the Procedural Generator. +- **deployment**: Constraints on who can go on the mission. +- **narrative**: Hooks for Intro/Outro and scripted events. +- **objectives**: Win/Loss conditions. +- **modifiers**: Global rules (e.g., "Fog of War", "High Gravity"). +- **rewards**: What the player gets for success. + +## **2\. Complete Example (The "Kitchen Sink" Mission)** + +This example utilizes every capability of the system. + +```js +{ + "id": "MISSION_ACT1_FINAL", + "type": "STORY", + "config": { + "title": "Operation: Broken Sky", + "description": "The Iron Legion demands we silence the Shardborn Artillery. Expect heavy resistance.", + "difficulty_tier": 3, + "recommended_level": 5 + }, + "biome": { + "type": "BIOME_RUSTING_WASTES", + "generator_config": { + "seed_type": "RANDOM", + "size": { "x": 30, "y": 10, "z": 30 }, + "room_count": 8, + "density": "HIGH" + }, + "hazards": ["HAZARD_ACID_POOLS", "HAZARD_ELECTRICITY"] + }, + "deployment": { + "squad_size_limit": 4, + "forced_units": ["UNIT_HERO_VANGUARD"], + "banned_classes": ["CLASS_SCAVENGER"] + }, + "narrative": { + "intro_sequence": "NARRATIVE_ACT1_FINAL_INTRO", + "outro_success": "NARRATIVE_ACT1_FINAL_WIN", + "outro_failure": "NARRATIVE_ACT1_FINAL_LOSE", + "scripted_events": [ + { + "trigger": "ON_TURN_START", + "turn_index": 3, + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_MID_BATTLE_TAUNT" + }, + { + "trigger": "ON_UNIT_DEATH", + "target_tag": "ENEMY_BOSS", + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_BOSS_PHASE_2" + } + ] + }, + "objectives": { + "primary": [ + { + "id": "OBJ_KILL_BOSS", + "type": "ELIMINATE_UNIT", + "target_def_id": "ENEMY_BOSS_ARTILLERY", + "description": "Destroy the Shardborn Artillery Construct." + } + ], + "secondary": [ + { + "id": "OBJ_TIME_LIMIT", + "type": "COMPLETE_BEFORE_TURN", + "turn_limit": 10, + "description": "Finish within 10 Turns." + }, + { + "id": "OBJ_NO_DEATHS", + "type": "SQUAD_SURVIVAL", + "min_alive": 4, + "description": "Ensure entire squad survives." + } + ], + "failure_conditions": [ + { "type": "SQUAD_WIPE" }, + { "type": "VIP_DEATH", "target_tag": "VIP_ESCORT" } + ] + }, + "modifiers": [ + { + "type": "GLOBAL_EFFECT", + "effect_id": "EFFECT_ACID_RAIN", + "description": "All units take 5 damage at start of turn." + }, + { + "type": "STAT_MODIFIER", + "target_team": "ENEMY", + "stat": "attack", + "value": 1.2 + } + ], + "rewards": { + "guaranteed": { + "xp": 500, + "currency": { "aether_shards": 200, "ancient_cores": 1 }, + "items": ["ITEM_ELITE_BLAST_PLATE"], + "unlocks": ["CLASS_SAPPER"] + }, + "conditional": [ + { + "objective_id": "OBJ_TIME_LIMIT", + "reward": { "currency": { "aether_shards": 100 } } + } + ], + "faction_reputation": { + "IRON_LEGION": 50, + "COGWORK_CONCORD": -10 + } + } +} +``` + +## **3\. Field Definitions & Logic Requirements** + +### **Deployment Constraints** + +- **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. + +### **Objectives Types** + +The MissionManager needs logic to handle these specific types: + +| Type | Data Required | Logic | +| :----------------- | :--------------- | :------------------------------------------------------ | +| **ELIMINATE_ALL** | None | Monitor unitManager. If enemies.length \=== 0, Success. | +| **ELIMINATE_UNIT** | target_def_id | Monitor ON_DEATH events. If victim ID matches, Success. | +| **INTERACT** | target_object_id | Monitor ON_INTERACT. If object matches, Success. | +| **REACH_ZONE** | zone_coords | Monitor ON_MOVE. If unit ends turn in zone, Success. | +| **SURVIVE** | turn_count | Monitor ON_TURN_END. If count reached, Success. | +| **ESCORT** | vip_id | If VIP dies, Immediate Failure. | + +### **Scripted Events (Triggers)** + +The GameLoop needs an Event Bus listener that checks these triggers every time an action happens. + +- **ON_TURN_START**: Checks turnSystem.currentTurn. +- **ON_UNIT_SPAWN**: Useful for ambush events. +- **ON_HEALTH_PERCENT**: "Boss enters Phase 2 at 50% HP". + +### **Rewards** + +- **unlocks**: Must call MetaProgression.unlockClass(id). +- **faction_reputation**: Must update the persistent UserProfile faction standing. diff --git a/src/assets/data/missions/mission.d.ts b/src/assets/data/missions/mission.d.ts new file mode 100644 index 0000000..e511dc1 --- /dev/null +++ b/src/assets/data/missions/mission.d.ts @@ -0,0 +1,201 @@ +/** + * Mission.ts + * TypeScript definitions for the Mission JSON Schema. + */ + +// --- ROOT STRUCTURE --- + +export interface Mission { + /** Unique Mission ID (e.g., 'MISSION_ACT1_FINAL') */ + id: string; + /** Type of mission context */ + type: MissionType; + /** Meta-data about difficulty and display */ + config: MissionConfig; + /** Instructions for Procedural Generation */ + biome: MissionBiome; + /** Constraints on squad selection */ + deployment?: DeploymentConstraints; + /** Hooks for Narrative sequences and scripts */ + narrative?: MissionNarrative; + /** Win/Loss conditions */ + objectives: MissionObjectives; + /** Global rules or stat changes */ + modifiers?: MissionModifier[]; + /** Payouts for success */ + rewards: MissionRewards; +} + +export type MissionType = "STORY" | "SIDE_QUEST" | "PROCEDURAL" | "TUTORIAL"; + +// --- CONFIGURATION --- + +export interface MissionConfig { + title: string; + description: string; + /** 1-5 Scale */ + difficulty_tier: number; + /** Suggested level for Explorers */ + recommended_level?: number; + /** Path to icon image */ + icon?: string; +} + +// --- BIOME / WORLD GEN --- + +export type BiomeType = + | "BIOME_FUNGAL_CAVES" + | "BIOME_RUSTING_WASTES" + | "BIOME_CRYSTAL_SPIRES" + | "BIOME_VOID_SEEP" + | "BIOME_CONTESTED_FRONTIER"; + +export type SeedType = "RANDOM" | "FIXED"; + +export interface MissionBiome { + type: BiomeType; + generator_config: { + seed_type: SeedType; + /** If FIXED, use this seed */ + seed?: number; + /** Dimensions of the grid */ + size: { x: number; y: number; z: number }; + /** Number of rooms for Ruin generators */ + room_count?: number; + /** General density modifier */ + density?: "LOW" | "MEDIUM" | "HIGH"; + }; + /** List of environmental hazards to enable (e.g., 'HAZARD_ACID_POOLS') */ + hazards?: string[]; +} + +// --- DEPLOYMENT --- + +export interface DeploymentConstraints { + /** Max units allowed (Default: 4) */ + squad_size_limit?: number; + /** IDs of units that MUST be included */ + forced_units?: string[]; + /** IDs of classes that cannot be selected */ + banned_classes?: string[]; +} + +// --- NARRATIVE & SCRIPTS --- + +export interface MissionNarrative { + /** Narrative ID to play before deployment */ + intro_sequence?: string; + /** Narrative ID to play upon victory */ + outro_success?: string; + /** Narrative ID to play upon defeat */ + outro_failure?: string; + /** Triggers that fire during gameplay */ + scripted_events?: ScriptedEvent[]; +} + +export interface ScriptedEvent { + trigger: EventTriggerType; + /** Specific turn number if trigger is ON_TURN_START */ + turn_index?: number; + /** Tag or ID if trigger is ON_UNIT_DEATH */ + target_tag?: string; + /** The action to perform */ + action: "PLAY_SEQUENCE" | "SPAWN_REINFORCEMENTS" | "MODIFY_OBJECTIVE"; + /** ID of the narrative or wave definition */ + sequence_id?: string; + wave_id?: string; +} + +export type EventTriggerType = + | "ON_TURN_START" + | "ON_UNIT_DEATH" + | "ON_UNIT_SPAWN" + | "ON_HEALTH_PERCENT"; + +// --- OBJECTIVES --- + +export type ObjectiveType = + | "ELIMINATE_ALL" + | "ELIMINATE_UNIT" + | "INTERACT" + | "REACH_ZONE" + | "SURVIVE" + | "ESCORT" + | "COMPLETE_BEFORE_TURN" + | "SQUAD_SURVIVAL"; + +export interface ObjectiveDefinition { + id: string; + type: ObjectiveType; + description: string; + /** For ELIMINATE_UNIT or ESCORT */ + target_def_id?: string; + /** For INTERACT */ + target_object_id?: string; + /** For REACH_ZONE */ + zone_coords?: { x: number; y: number; z: number }; + /** For SURVIVE or COMPLETE_BEFORE_TURN */ + turn_limit?: number; + /** For ELIMINATE_ALL or SQUAD_SURVIVAL */ + target_count?: number; + min_alive?: number; +} + +export type FailureType = "SQUAD_WIPE" | "VIP_DEATH" | "TURN_LIMIT_EXCEEDED"; + +export interface FailureCondition { + type: FailureType; + target_tag?: string; +} + +export interface MissionObjectives { + /** All must be completed to win */ + primary: ObjectiveDefinition[]; + /** Optional bonus goals */ + secondary?: ObjectiveDefinition[]; + /** Explicit lose conditions */ + failure_conditions?: FailureCondition[]; +} + +// --- MODIFIERS --- + +export type ModifierType = "GLOBAL_EFFECT" | "STAT_MODIFIER"; + +export interface MissionModifier { + type: ModifierType; + description?: string; + /** For GLOBAL_EFFECT */ + effect_id?: string; + /** For STAT_MODIFIER */ + target_team?: "PLAYER" | "ENEMY"; + stat?: string; + value?: number; +} + +// --- REWARDS --- + +export interface RewardBundle { + xp?: number; + currency?: { + aether_shards?: number; + ancient_cores?: number; + }; + /** List of Item IDs */ + items?: string[]; + /** List of Class IDs to unlock */ + unlocks?: string[]; + /** List of Unit IDs or Class IDs to immediately join the roster */ + recruits?: string[]; +} + +export interface ConditionalReward { + objective_id: string; + reward: RewardBundle; +} + +export interface MissionRewards { + guaranteed: RewardBundle; + conditional?: ConditionalReward[]; + /** Key: Faction ID, Value: Reputation Change */ + faction_reputation?: Record; +} diff --git a/src/assets/data/missions/mission_tutorial_01.json b/src/assets/data/missions/mission_tutorial_01.json new file mode 100644 index 0000000..c470a88 --- /dev/null +++ b/src/assets/data/missions/mission_tutorial_01.json @@ -0,0 +1,23 @@ +{ + "id": "MISSION_TUTORIAL_01", + "title": "Protocol: First Descent", + "description": "Establish a foothold in the Rusting Wastes and secure the perimeter.", + "biome_config": { + "type": "RUINS", + "seed_type": "FIXED", + "seed": 12345 + }, + "narrative_intro": "NARRATIVE_TUTORIAL_INTRO", + "narrative_outro": "NARRATIVE_TUTORIAL_SUCCESS", + "objectives": [ + { + "type": "ELIMINATE_ENEMIES", + "target_count": 2 + } + ], + "rewards": { + "xp": 100, + "currency": 50, + "unlock_class": "CLASS_TINKER" + } +} diff --git a/src/assets/data/narrative/narrative-schema.md b/src/assets/data/narrative/narrative-schema.md new file mode 100644 index 0000000..5480425 --- /dev/null +++ b/src/assets/data/narrative/narrative-schema.md @@ -0,0 +1,189 @@ +# Mission JSON Schema Reference + +This document defines the data structure for Game Missions. It covers configuration for World Generation, Narrative logic, Objectives, and Rewards. + +## 1. The Structure (Overview) + +A Mission file is a JSON object with the following top-level keys: + +- **`config`**: Meta-data (ID, Title, Difficulty). +- **`biome`**: Instructions for the Procedural Generator. +- **`deployment`**: Constraints on who can go on the mission. +- **`narrative`**: Hooks for Intro/Outro and scripted events. +- **`objectives`**: Win/Loss conditions. +- **`modifiers`**: Global rules (e.g., "Fog of War", "High Gravity"). +- **`rewards`**: What the player gets for success. + +## 2. Complete Example (The "Kitchen Sink" Mission) + +This example utilizes every capability of the system. + +```json +{ + "id": "MISSION_ACT1_FINAL", + "type": "STORY", + "config": { + "title": "Operation: Broken Sky", + "description": "The Iron Legion demands we silence the Shardborn Artillery. Expect heavy resistance.", + "difficulty_tier": 3, + "recommended_level": 5 + }, + + "biome": { + "type": "BIOME_RUSTING_WASTES", + "generator_config": { + "seed_type": "RANDOM", + "size": { "x": 30, "y": 10, "z": 30 }, + "room_count": 8, + "density": "HIGH" + }, + "hazards": ["HAZARD_ACID_POOLS", "HAZARD_ELECTRICITY"] + }, + + "deployment": { + "squad_size_limit": 4, + "forced_units": ["UNIT_HERO_VANGUARD"], + "banned_classes": ["CLASS_SCAVENGER"] + }, + + "narrative": { + "intro_sequence": "NARRATIVE_ACT1_FINAL_INTRO", + "outro_success": "NARRATIVE_ACT1_FINAL_WIN", + "outro_failure": "NARRATIVE_ACT1_FINAL_LOSE", + + "scripted_events": [ + { + "trigger": "ON_TURN_START", + "turn_index": 3, + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_MID_BATTLE_TAUNT" + }, + { + "trigger": "ON_UNIT_DEATH", + "target_tag": "ENEMY_BOSS", + "action": "PLAY_SEQUENCE", + "sequence_id": "NARRATIVE_BOSS_PHASE_2" + } + ] + }, + + "objectives": { + "primary": [ + { + "id": "OBJ_KILL_BOSS", + "type": "ELIMINATE_UNIT", + "target_def_id": "ENEMY_BOSS_ARTILLERY", + "description": "Destroy the Shardborn Artillery Construct." + } + ], + "secondary": [ + { + "id": "OBJ_TIME_LIMIT", + "type": "COMPLETE_BEFORE_TURN", + "turn_limit": 10, + "description": "Finish within 10 Turns." + }, + { + "id": "OBJ_NO_DEATHS", + "type": "SQUAD_SURVIVAL", + "min_alive": 4, + "description": "Ensure entire squad survives." + } + ], + "failure_conditions": [ + { "type": "SQUAD_WIPE" }, + { "type": "VIP_DEATH", "target_tag": "VIP_ESCORT" } + ] + }, + + "modifiers": [ + { + "type": "GLOBAL_EFFECT", + "effect_id": "EFFECT_ACID_RAIN", + "description": "All units take 5 damage at start of turn." + }, + { + "type": "STAT_MODIFIER", + "target_team": "ENEMY", + "stat": "attack", + "value": 1.2 + } + ], + + "rewards": { + "guaranteed": { + "xp": 500, + "currency": { "aether_shards": 200, "ancient_cores": 1 }, + "items": ["ITEM_ELITE_BLAST_PLATE"], + "unlocks": ["CLASS_SAPPER"] + }, + "conditional": [ + { + "objective_id": "OBJ_TIME_LIMIT", + "reward": { "currency": { "aether_shards": 100 } } + } + ], + "faction_reputation": { + "IRON_LEGION": 50, + "COGWORK_CONCORD": -10 + } + } +} +``` + +## 3. Node Types & Logic + +### Common Fields (All Nodes) + +- **`id`**: (Required) Unique string identifier within this sequence. +- **`type`**: `DIALOGUE` | `CHOICE` | `TUTORIAL` | `ACTION`. +- **`trigger`**: (Optional) An action payload sent to `GameLoop` upon entering this node. +- **`next`**: (Optional) ID of the next node. If `"END"` or missing, the sequence closes. + +### Type: `DIALOGUE` + +Standard conversation. + +- **`speaker`**: Name displayed above text. +- **`portrait`**: Path to image asset. +- **`text`**: The body text. +- **`voice_clip`**: (Optional) Path to audio file. + +### Type: `CHOICE` + +Presents buttons to the user. + +- **`choices`**: Array of options. + - **`text`**: Button label. + - **`next`**: Target Node ID. + - **`condition`**: (Optional) Logic check. If false, choice is hidden or disabled. + - `{ "type": "HAS_CLASS", "value": "ID" }` + - `{ "type": "HAS_ITEM", "value": "ID" }` + - `{ "type": "STAT_CHECK", "stat": "tech", "value": 5 }` + +### Type: `TUTORIAL` + +Overlays instructions on the UI. + +- **`highlight_selector`**: CSS Selector string (e.g., `#btn-end-turn`) to visually highlight/pulse. +- **`block_input`**: (Boolean) If true, prevents clicking anything except the highlighted element. + +### Type: `ACTION` + +Invisible node used purely for logic/triggers. It executes its `trigger` and immediately jumps to `next`. + +- _Use Case:_ Granting rewards or changing game state without showing a text box. + +--- + +## 4. Trigger Definitions + +These payloads are dispatched via the `narrative-trigger` event to the `GameLoop` or `MissionManager`. + +| Trigger Type | Params | Description | +| :--------------------- | :------------------ | :-------------------------------------------------------------- | +| **`SPAWN_WAVE`** | `wave_id` | Spawns a specific set of enemies defined in the Mission config. | +| **`START_DEPLOYMENT`** | None | Transitions GameLoop to DEPLOYMENT phase. | +| **`GIVE_ITEM`** | `item_id` | Adds item to squad inventory. | +| **`MODIFY_RELATION`** | `faction`, `amount` | Updates meta-progression reputation. | +| **`PLAY_SFX`** | `file_path` | Plays a one-shot sound. | diff --git a/src/assets/data/narrative/narrative.d.ts b/src/assets/data/narrative/narrative.d.ts new file mode 100644 index 0000000..add662a --- /dev/null +++ b/src/assets/data/narrative/narrative.d.ts @@ -0,0 +1,123 @@ +/** + * Narrative.ts + * TypeScript definitions for the Narrative JSON Schema. + */ + +// --- ROOT STRUCTURE --- + +export interface NarrativeSequence { + /** Unique Sequence ID (e.g., 'NARRATIVE_TUTORIAL_INTRO') */ + id: string; + /** Ordered list of narrative nodes */ + nodes: NarrativeNode[]; +} + +// --- NODE TYPES --- + +export type NarrativeNodeType = "DIALOGUE" | "CHOICE" | "TUTORIAL" | "ACTION"; + +/** Union type of all possible node configurations */ +export type NarrativeNode = + | DialogueNode + | ChoiceNode + | TutorialNode + | ActionNode; + +/** Common fields shared by all nodes */ +interface BaseNode { + /** Unique string identifier within this sequence */ + id: string; + type: NarrativeNodeType; + /** Optional side-effect triggered when entering this node */ + trigger?: NarrativeTrigger; + /** ID of the next node. If 'END' or undefined, sequence finishes. */ + next?: string; +} + +/** * Standard conversation node. + * Displays text, a speaker name, and an optional portrait. + */ +export interface DialogueNode extends BaseNode { + type: "DIALOGUE"; + speaker: string; + text: string; + /** Path to image asset */ + portrait?: string; + /** Path to audio file */ + voice_clip?: string; +} + +/** * Branching path node. + * Presents a list of buttons to the user. + */ +export interface ChoiceNode extends BaseNode { + type: "CHOICE"; + speaker: string; + text: string; + choices: ChoiceOption[]; +} + +/** * Tutorial overlay node. + * Highlights UI elements and optionally blocks input. + */ +export interface TutorialNode extends BaseNode { + type: "TUTORIAL"; + speaker: string; + text: string; + /** CSS Selector string to visually highlight/pulse (e.g., '#btn-end-turn') */ + highlight_selector?: string; + /** Explicit screen coordinates if selector is insufficient */ + highlight_rect?: { x: number; y: number; w: number; h: number }; + /** If true, prevents clicking anything except the highlighted element */ + block_input?: boolean; +} + +/** * Invisible logic node. + * Used purely to execute a trigger and immediately jump to next. + */ +export interface ActionNode extends BaseNode { + type: "ACTION"; + // Action nodes usually rely heavily on the 'trigger' field in BaseNode +} + +// --- SUPPORTING TYPES --- + +export interface ChoiceOption { + text: string; + /** Target Node ID */ + next: string; + /** Optional side-effect specific to this choice */ + trigger?: NarrativeTrigger; + /** Logic check. If false, choice is hidden or disabled */ + condition?: NarrativeCondition; +} + +export type TriggerType = + | "SPAWN_WAVE" + | "START_DEPLOYMENT" + | "GIVE_ITEM" + | "MODIFY_RELATION" + | "PLAY_SFX" + | "GIVE_AP" + | "APPLY_BUFF"; + +export interface NarrativeTrigger { + type: TriggerType; + // Dynamic params based on type + wave_id?: string; + item_id?: string; + faction?: string; + amount?: number; + file_path?: string; + stat?: string; + value?: number; + [key: string]: any; +} + +export type ConditionType = "HAS_CLASS" | "HAS_ITEM" | "STAT_CHECK"; + +export interface NarrativeCondition { + type: ConditionType; + value?: string | number; + stat?: string; +} diff --git a/src/assets/data/narrative/tutorial_intro.json b/src/assets/data/narrative/tutorial_intro.json new file mode 100644 index 0000000..a3bc979 --- /dev/null +++ b/src/assets/data/narrative/tutorial_intro.json @@ -0,0 +1,31 @@ +{ + "id": "TUTORIAL_INTRO", + "nodes": [ + { + "id": "1", + "speaker": "Director Vorn", + "portrait": "assets/images/portraits/tinker.png", + "text": "Explorer! Good timing. The scanners are picking up a massive energy spike in this sector.", + "type": "DIALOGUE", + "next": "2" + }, + { + "id": "2", + "speaker": "Director Vorn", + "portrait": "assets/images/portraits/tinker.png", + "text": "We need to secure a foothold before the Shardborn swarm us. Deploy your squad in the green zone.", + "type": "DIALOGUE", + "next": "3" + }, + { + "id": "3", + "speaker": "System", + "portrait": null, + "text": "Click on a valid tile to place your units.", + "type": "TUTORIAL", + "highlightElement": "#canvas-container", + "next": "END", + "trigger": "START_DEPLOYMENT_PHASE" + } + ] +} diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 9413b6c..d8a7796 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -6,6 +6,7 @@ import { UnitManager } from "../managers/UnitManager.js"; import { CaveGenerator } from "../generation/CaveGenerator.js"; import { RuinGenerator } from "../generation/RuinGenerator.js"; import { InputManager } from "./InputManager.js"; +import { MissionManager } from "../systems/MissionManager.js"; export class GameLoop { constructor() { @@ -32,6 +33,13 @@ export class GameLoop { this.lastMoveTime = 0; this.moveCooldown = 120; // ms between cursor moves this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING + this.missionManager = new MissionManager(this); // Init Mission Manager + + // Deployment State + this.deploymentState = { + selectedUnitIndex: -1, + deployedUnits: new Map(), // Map + }; } init(container) { @@ -89,8 +97,6 @@ export class GameLoop { /** * Validation Logic for Standard Movement. - * Checks for valid ground, headroom, and bounds. - * Returns modified position (climbing/dropping) or false (invalid). */ validateCursorMove(x, y, z) { if (!this.grid) return true; // Allow if grid not ready @@ -99,14 +105,10 @@ export class GameLoop { if (!this.grid.isValidBounds({ x, y: 0, z })) return false; // 2. Scan Column for Surface (Climb/Drop Logic) - // Look 2 units up and 2 units down from current Y let bestY = null; - // Check Current Level if (this.isWalkable(x, y, z)) bestY = y; - // Check Climb (y+1) else if (this.isWalkable(x, y + 1, z)) bestY = y + 1; - // Check Drop (y-1, y-2) else if (this.isWalkable(x, y - 1, z)) bestY = y - 1; else if (this.isWalkable(x, y - 2, z)) bestY = y - 2; @@ -114,12 +116,11 @@ export class GameLoop { return { x, y: bestY, z }; } - return false; // No valid footing found + return false; } /** * Validation Logic for Deployment Phase. - * Restricts cursor to the Player Spawn Zone. */ validateDeploymentCursor(x, y, z) { if (!this.grid || this.playerSpawnZone.length === 0) return false; @@ -135,24 +136,13 @@ export class GameLoop { return false; // Cursor cannot leave the spawn zone } - /** - * Helper: Checks if a specific tile is valid to stand on. - */ isWalkable(x, y, z) { - // Must be Air if (this.grid.getCell(x, y, z) !== 0) return false; - // Must have Solid Floor below if (this.grid.getCell(x, y - 1, z) === 0) return false; - // Must have Headroom (Air above) if (this.grid.getCell(x, y + 1, z) !== 0) return false; - return true; } - /** - * Validation Logic for Interaction / Targeting. - * Allows selecting Walls, Enemies, or Empty Space (within bounds). - */ validateInteractionTarget(x, y, z) { if (!this.grid) return true; return this.grid.isValidBounds({ x, y, z }); @@ -169,7 +159,6 @@ export class GameLoop { if (code === "Space" || code === "Enter") { this.triggerSelection(); } - // Toggle Mode for Debug (e.g. Tab) if (code === "Tab") { this.selectionMode = this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT"; @@ -179,20 +168,60 @@ export class GameLoop { : this.validateInteractionTarget.bind(this); this.inputManager.setValidator(validator); - console.log(`Switched to ${this.selectionMode} mode`); } } + /** + * Called by UI when a unit is clicked in the Roster. + */ + selectDeploymentUnit(index) { + this.deploymentState.selectedUnitIndex = index; + console.log(`Deployment: Selected Unit Index ${index}`); + } + triggerSelection() { const cursor = this.inputManager.getCursorPosition(); console.log("Action at:", cursor); if (this.phase === "DEPLOYMENT") { - // TODO: Check if selecting a deployed unit to move it, or a tile to deploy to - // This requires state from the UI (which unit is selected in roster) + const selIndex = this.deploymentState.selectedUnitIndex; + + if (selIndex !== -1) { + // Attempt to deploy OR move the selected unit + const unitDef = this.runData.squad[selIndex]; + const existingUnit = this.deploymentState.deployedUnits.get(selIndex); + + const resultUnit = this.deployUnit(unitDef, cursor, existingUnit); + + if (resultUnit) { + // Track it + this.deploymentState.deployedUnits.set(selIndex, resultUnit); + + // Notify UI + window.dispatchEvent( + new CustomEvent("deployment-update", { + detail: { + deployedIndices: Array.from( + this.deploymentState.deployedUnits.keys() + ), + }, + }) + ); + } + } else { + console.log("No unit selected."); + } } } + async startMission(missionId) { + const mission = await fetch( + `assets/data/missions/${missionId.toLowerCase()}.json` + ); + const missionData = await mission.json(); + this.missionManager.startMission(missionData); + } + async startLevel(runData) { console.log("GameLoop: Generating Level..."); this.runData = runData; @@ -200,6 +229,12 @@ export class GameLoop { this.phase = "DEPLOYMENT"; this.clearUnitMeshes(); + // Reset Deployment State + this.deploymentState = { + selectedUnitIndex: -1, + deployedUnits: new Map(), // Map + }; + this.grid = new VoxelGrid(20, 10, 20); const generator = new RuinGenerator(this.grid, runData.seed); generator.generate(); @@ -235,38 +270,90 @@ export class GameLoop { this.unitManager = new UnitManager(mockRegistry); this.highlightZones(); - // Snap Cursor to Player Start if (this.playerSpawnZone.length > 0) { + let sumX = 0, + sumY = 0, + sumZ = 0; + for (const spot of this.playerSpawnZone) { + sumX += spot.x; + sumY += spot.y; + sumZ += spot.z; + } + const centerX = sumX / this.playerSpawnZone.length; + const centerY = sumY / this.playerSpawnZone.length; + const centerZ = sumZ / this.playerSpawnZone.length; + const start = this.playerSpawnZone[0]; - // Ensure y is correct (on top of floor) this.inputManager.setCursor(start.x, start.y, start.z); + + if (this.controls) { + this.controls.target.set(centerX, centerY, centerZ); + this.controls.update(); + } } - // Set Strict Validator for Deployment this.inputManager.setValidator(this.validateDeploymentCursor.bind(this)); this.animate(); } - deployUnit(unitDef, targetTile) { + deployUnit(unitDef, targetTile, existingUnit = null) { if (this.phase !== "DEPLOYMENT") return null; - // Re-validate using the zone logic (Double check) const isValid = this.validateDeploymentCursor( targetTile.x, targetTile.y, targetTile.z ); - if (!isValid || this.grid.isOccupied(targetTile)) return null; - const unit = this.unitManager.createUnit( - unitDef.classId || unitDef.id, - "PLAYER" - ); - if (unitDef.name) unit.name = unitDef.name; - this.grid.placeUnit(unit, targetTile); - this.createUnitMesh(unit, targetTile); - return unit; + // Check collision + if (!isValid) { + console.warn("Invalid spawn zone"); + return null; + } + + // If tile occupied... + if (this.grid.isOccupied(targetTile)) { + // If occupied by SELF (clicking same spot), that's valid, just do nothing + if ( + existingUnit && + existingUnit.position.x === targetTile.x && + existingUnit.position.z === targetTile.z + ) { + return existingUnit; + } + console.warn("Tile occupied"); + return null; + } + + if (existingUnit) { + // MOVE logic + this.grid.moveUnit(existingUnit, targetTile, { force: true }); // Force to bypass standard move checks if any + // Update Mesh + const mesh = this.unitMeshes.get(existingUnit.id); + if (mesh) { + mesh.position.set(targetTile.x, targetTile.y + 0.6, targetTile.z); + } + console.log( + `Moved ${existingUnit.name} to ${targetTile.x},${targetTile.y},${targetTile.z}` + ); + return existingUnit; + } else { + // CREATE logic + const unit = this.unitManager.createUnit( + unitDef.classId || unitDef.id, + "PLAYER" + ); + if (unitDef.name) unit.name = unitDef.name; + + this.grid.placeUnit(unit, targetTile); + this.createUnitMesh(unit, targetTile); + + console.log( + `Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}` + ); + return unit; + } } finalizeDeployment() { @@ -286,6 +373,8 @@ export class GameLoop { // Switch to standard movement validator for the game this.inputManager.setValidator(this.validateCursorMove.bind(this)); + + console.log("Combat Started!"); } clearUnitMeshes() { @@ -334,11 +423,9 @@ export class GameLoop { if (!this.isRunning) return; requestAnimationFrame(this.animate); - // 1. Update Managers if (this.inputManager) this.inputManager.update(); if (this.controls) this.controls.update(); - // 2. Handle Continuous Input (Keyboard polling) const now = Date.now(); if (now - this.lastMoveTime > this.moveCooldown) { let dx = 0; @@ -370,14 +457,11 @@ export class GameLoop { const newX = currentPos.x + dx; const newZ = currentPos.z + dz; - // Pass desired coordinates to InputManager - // InputManager will call our validator (validateCursorMove/Deployment) to check logic this.inputManager.setCursor(newX, currentPos.y, newZ); this.lastMoveTime = now; } } - // 3. Render const time = Date.now() * 0.002; this.unitMeshes.forEach((mesh) => { mesh.position.y += Math.sin(time) * 0.002; diff --git a/src/index.js b/src/index.js index 5ed2916..8927f78 100644 --- a/src/index.js +++ b/src/index.js @@ -56,8 +56,9 @@ window.addEventListener("save-check-complete", (e) => { btnNewRun.addEventListener("click", async () => { teamBuilder.addEventListener("embark", async (e) => { gameStateManager.handleEmbark(e); + gameViewport.squad = teamBuilder.squad; }); - gameStateManager.startNewGame(); + gameStateManager.startMission("MISSION_TUTORIAL_01"); }); btnContinue.addEventListener("click", async () => { diff --git a/src/managers/RosterManager.js b/src/managers/RosterManager.js new file mode 100644 index 0000000..61985e5 --- /dev/null +++ b/src/managers/RosterManager.js @@ -0,0 +1,72 @@ +/** + * RosterManager.js + * Manages the persistent pool of Explorer units (The Barracks). + * Handles recruitment, death, and selection for missions. + */ +export class RosterManager { + constructor() { + this.roster = []; // List of active Explorer objects (Data only) + this.graveyard = []; // List of dead units + this.rosterLimit = 12; + } + + /** + * Initializes the roster from saved data. + */ + load(saveData) { + this.roster = saveData.roster || []; + this.graveyard = saveData.graveyard || []; + } + + /** + * Serializes for save file. + */ + save() { + return { + roster: this.roster, + graveyard: this.graveyard, + }; + } + + /** + * Adds a new unit to the roster. + * @param {Object} unitData - The unit definition (Class, Name, Stats) + */ + recruitUnit(unitData) { + if (this.roster.length >= this.rosterLimit) { + console.warn("Roster full. Cannot recruit."); + return false; + } + + const newUnit = { + id: `UNIT_${Date.now()}_${Math.floor(Math.random() * 1000)}`, + ...unitData, + status: "READY", // READY, INJURED, DEPLOYED + history: { missions: 0, kills: 0 }, + }; + + this.roster.push(newUnit); + return newUnit; + } + + /** + * Marks a unit as dead and moves them to the graveyard. + */ + handleUnitDeath(unitId) { + const index = this.roster.findIndex((u) => u.id === unitId); + if (index > -1) { + const unit = this.roster[index]; + unit.status = "DEAD"; + this.graveyard.push(unit); + this.roster.splice(index, 1); + } + } + + /** + * Returns units eligible for a mission. + * Filters out injured or dead units. + */ + getDeployableUnits() { + return this.roster.filter((u) => u.status === "READY"); + } +} diff --git a/src/ui/deployment-hud.js b/src/ui/deployment-hud.js new file mode 100644 index 0000000..45a1a49 --- /dev/null +++ b/src/ui/deployment-hud.js @@ -0,0 +1,223 @@ +import { LitElement, html, css } from "lit"; + +export class DeploymentHUD extends LitElement { + static get styles() { + return css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + font-family: "Courier New", monospace; + color: white; + } + + /* --- HEADER --- */ + .header { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + border: 2px solid #00ffff; + padding: 15px 30px; + text-align: center; + pointer-events: auto; + } + + .status-bar { + margin-top: 5px; + font-size: 1.2rem; + color: #00ff00; + } + + /* --- UNIT BENCH (Bottom) --- */ + .bench-container { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 15px; + background: rgba(0, 0, 0, 0.85); + padding: 15px; + border-top: 3px solid #555; + pointer-events: auto; + border-radius: 10px 10px 0 0; + max-width: 90%; + overflow-x: auto; + } + + .unit-card { + width: 100px; + height: 130px; + background: #222; + border: 2px solid #444; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.1s; + position: relative; + } + + .unit-card:hover { + background: #333; + transform: translateY(-5px); + } + + .unit-card.selected { + border-color: #00ffff; + box-shadow: 0 0 15px #00ffff; + } + + .unit-card.deployed { + border-color: #00ff00; + opacity: 0.5; + } + + .unit-icon { + font-size: 2rem; + margin-bottom: 5px; + } + .unit-name { + font-size: 0.8rem; + text-align: center; + font-weight: bold; + } + .unit-class { + font-size: 0.7rem; + color: #aaa; + } + + /* --- ACTION BUTTON --- */ + .action-panel { + position: absolute; + right: 30px; + bottom: 200px; /* Above bench */ + pointer-events: auto; + } + + .start-btn { + background: #008800; + color: white; + border: 2px solid #00ff00; + padding: 15px 40px; + font-size: 1.5rem; + font-family: inherit; + font-weight: bold; + text-transform: uppercase; + cursor: pointer; + box-shadow: 0 0 20px rgba(0, 255, 0, 0.3); + } + + .start-btn:disabled { + background: #333; + border-color: #555; + color: #777; + cursor: not-allowed; + box-shadow: none; + } + `; + } + + static get properties() { + return { + roster: { type: Array }, // List of all available units + deployedIds: { type: Array }, // List of IDs currently on the board + selectedId: { type: String }, // ID of unit currently being placed + maxUnits: { type: Number }, + }; + } + + constructor() { + super(); + this.roster = []; + this.deployedIds = []; + this.selectedId = null; + this.maxUnits = 4; + } + + render() { + const deployedCount = this.deployedIds.length; + const canStart = deployedCount > 0; // At least 1 unit required + + return html` +
+

MISSION DEPLOYMENT

+
+ Squad Size: ${deployedCount} / ${this.maxUnits} +
+
+ Select a unit below, then click a green tile to place. +
+
+ +
+ +
+ +
+ ${this.roster.map((unit) => { + const isDeployed = this.deployedIds.includes(unit.id); + const isSelected = this.selectedId === unit.id; + + return html` +
+
${unit.icon || "🛡️"}
+
${unit.name}
+
${unit.className || "Unknown"}
+ ${isDeployed + ? html`
+ DEPLOYED +
` + : ""} +
+ `; + })} +
+ `; + } + + _selectUnit(unit) { + if (this.deployedIds.includes(unit.id)) { + // If already deployed, maybe select it to move it? + // For now, let's just emit event to focus/recall it + this.dispatchEvent( + new CustomEvent("recall-unit", { detail: { unitId: unit.id } }) + ); + } else if (this.deployedIds.length < this.maxUnits) { + this.selectedId = unit.id; + // Tell GameLoop we want to place this unit next click + this.dispatchEvent( + new CustomEvent("select-unit-for-placement", { detail: { unit } }) + ); + } + } + + _handleStartBattle() { + this.dispatchEvent( + new CustomEvent("start-battle", { + bubbles: true, + composed: true, + }) + ); + } +} + +customElements.define("deployment-hud", DeploymentHUD); diff --git a/src/ui/dialogue-overlay.js b/src/ui/dialogue-overlay.js new file mode 100644 index 0000000..3c7a1c5 --- /dev/null +++ b/src/ui/dialogue-overlay.js @@ -0,0 +1,221 @@ +import { LitElement, html, css } from "lit"; +import { narrativeManager } from "../../systems/NarrativeManager.js"; + +export class DialogueOverlay extends LitElement { + static get styles() { + return css` + :host { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: 80%; + max-width: 800px; + z-index: 100; + pointer-events: auto; + font-family: "Courier New", monospace; + } + + .dialogue-box { + background: rgba(10, 10, 20, 0.95); + border: 2px solid #00ffff; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.2); + padding: 20px; + display: flex; + gap: 20px; + animation: slideUp 0.3s ease-out; + } + + .portrait { + width: 100px; + height: 100px; + background: #222; + border: 1px solid #555; + flex-shrink: 0; + } + + .portrait img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .content { + flex-grow: 1; + display: flex; + flex-direction: column; + } + + .speaker { + color: #00ffff; + font-weight: bold; + font-size: 1.2rem; + margin-bottom: 5px; + } + + .text { + color: white; + font-size: 1.1rem; + line-height: 1.5; + min-height: 3em; + } + + .choices { + margin-top: 15px; + display: flex; + gap: 10px; + } + + button { + background: #333; + color: white; + border: 1px solid #555; + padding: 8px 16px; + cursor: pointer; + font-family: inherit; + text-transform: uppercase; + } + + button:hover { + background: #444; + border-color: #00ffff; + } + + .next-indicator { + align-self: flex-end; + font-size: 0.8rem; + color: #888; + margin-top: 10px; + animation: blink 1s infinite; + } + + @keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes blink { + 50% { + opacity: 0; + } + } + + /* Tutorial Style Override */ + .type-tutorial { + border-color: #00ff00; + } + .type-tutorial .speaker { + color: #00ff00; + } + `; + } + + static get properties() { + return { + activeNode: { type: Object }, + isVisible: { type: Boolean }, + }; + } + + constructor() { + super(); + this.activeNode = null; + this.isVisible = false; + } + + connectedCallback() { + super.connectedCallback(); + // Subscribe to Manager Updates + narrativeManager.addEventListener( + "narrative-update", + this._onUpdate.bind(this) + ); + narrativeManager.addEventListener("narrative-end", this._onEnd.bind(this)); + + // Allow clicking/spacebar to advance + window.addEventListener("keydown", this._handleInput.bind(this)); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("keydown", this._handleInput.bind(this)); + } + + _onUpdate(e) { + this.activeNode = e.detail.node; + this.isVisible = e.detail.active; + } + + _onEnd() { + this.isVisible = false; + this.activeNode = null; + } + + _handleInput(e) { + if (!this.isVisible) return; + if (e.code === "Space" || e.code === "Enter") { + // Only advance if no choices + if (!this.activeNode.choices) { + narrativeManager.next(); + } + } + } + + render() { + if (!this.isVisible || !this.activeNode) return html``; + + return html` +
+ ${this.activeNode.portrait + ? html` +
+ ${this.activeNode.speaker} +
+ ` + : ""} + +
+
${this.activeNode.speaker}
+
${this.activeNode.text}
+ + ${this.activeNode.choices + ? html` +
+ ${this.activeNode.choices.map( + (choice, index) => html` + + ` + )} +
+ ` + : html`
+ Press SPACE to continue... +
`} +
+
+ `; + } +} + +customElements.define("dialogue-overlay", DialogueOverlay); diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index dc08c13..082516d 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -1,7 +1,10 @@ import { LitElement, html, css } from "lit"; import { gameStateManager } from "../core/GameStateManager.js"; +import { RosterManager } from "../managers/RosterManager.js"; import { GameLoop } from "../core/GameLoop.js"; +import "./deployment-hud.js"; + export class GameViewport extends LitElement { static styles = css` :host { @@ -16,8 +19,20 @@ export class GameViewport extends LitElement { } `; + static get properties() { + return { + squad: { type: Array }, + }; + } + constructor() { super(); + this.squad = []; + } + + #handleUnitSelected(event) { + const index = event.detail.index; + gameStateManager.gameLoop.selectDeploymentUnit(index); } async firstUpdated() { @@ -28,7 +43,11 @@ export class GameViewport extends LitElement { } render() { - return html`
`; + return html`
+ `; } } diff --git a/test/systems/MissionManager.js b/test/systems/MissionManager.js new file mode 100644 index 0000000..2d0d952 --- /dev/null +++ b/test/systems/MissionManager.js @@ -0,0 +1,113 @@ +import { narrativeManager } from "./NarrativeManager.js"; + +/** + * MissionManager.js + * Handles the state of the current mission, objectives, and narrative triggers. + */ +export class MissionManager { + constructor(gameLoop) { + this.gameLoop = gameLoop; + this.activeMission = null; + this.objectives = []; + } + + /** + * Loads a mission definition and starts the sequence. + * @param {Object} missionDef - The JSON definition. + */ + startMission(missionDef) { + console.log(`Mission Start: ${missionDef.title}`); + this.activeMission = missionDef; + this.objectives = missionDef.objectives.map((obj) => ({ + ...obj, + current: 0, + complete: false, + })); + + // 1. Check for Narrative Intro + if (this.activeMission.narrative_intro) { + this.gameLoop.setPhase("CINEMATIC"); + + // Load narrative data (Mocking fetch for prototype) + // In real app: const data = await fetch(`assets/data/narrative/${id}.json`) + const narrativeData = this._mockLoadNarrative( + this.activeMission.narrative_intro + ); + + // Hook into narrative end to start gameplay + const onEnd = () => { + narrativeManager.removeEventListener("narrative-end", onEnd); + this.beginGameplay(); + }; + narrativeManager.addEventListener("narrative-end", onEnd); + + // Start the show + narrativeManager.startSequence(narrativeData); + } else { + // No intro, jump straight to deployment + this.beginGameplay(); + } + } + + beginGameplay() { + console.log("Mission: Narrative complete. Deploying."); + // Trigger the GameLoop to generate the world based on Mission Biome Config + this.gameLoop.generateWorld(this.activeMission.biome_config); + this.gameLoop.setPhase("DEPLOYMENT"); + } + + /** + * Called whenever an event happens (Enemy death, Item pickup) + */ + onGameEvent(event) { + if (!this.activeMission) return; + + let changed = false; + + this.objectives.forEach((obj) => { + if (obj.complete) return; + + if (obj.type === "ELIMINATE_ENEMIES" && event.type === "ENEMY_DEATH") { + obj.current++; + if (obj.current >= obj.target_count) { + obj.complete = true; + changed = true; + console.log("Objective Complete: Eliminate Enemies"); + } + } + }); + + if (changed) { + this.checkMissionSuccess(); + } + } + + checkMissionSuccess() { + const allComplete = this.objectives.every((o) => o.complete); + if (allComplete) { + console.log("MISSION SUCCESS!"); + // Trigger Outro or End Level + if (this.activeMission.narrative_outro) { + // Play Outro... + } else { + this.gameLoop.endLevel(true); + } + } + } + + _mockLoadNarrative(id) { + // Placeholder: This would actually load the JSON file we defined earlier + return { + id: id, + nodes: [ + { + id: "1", + text: "Commander, we've arrived at the coordinates.", + speaker: "Vanguard", + type: "DIALOGUE", + next: "END", + }, + ], + }; + } +} diff --git a/test/systems/NarrativeManager.js b/test/systems/NarrativeManager.js new file mode 100644 index 0000000..a6076de --- /dev/null +++ b/test/systems/NarrativeManager.js @@ -0,0 +1,102 @@ +/** + * NarrativeManager.js + * Manages the flow of story events, dialogue, and tutorials. + * Extends EventTarget to broadcast UI updates. + */ +export class NarrativeManager extends EventTarget { + constructor() { + super(); + this.currentSequence = null; + this.currentNode = null; + this.history = new Set(); // Track played sequences IDs + } + + /** + * Loads and starts a narrative sequence. + * @param {Object} sequenceData - The JSON object of the conversation. + */ + startSequence(sequenceData) { + if (!sequenceData || !sequenceData.nodes) { + console.error("Invalid sequence data"); + return; + } + + console.log(`Starting Narrative: ${sequenceData.id}`); + this.currentSequence = sequenceData; + this.history.add(sequenceData.id); + + // Find first node (usually index 0 or id '1') + this.currentNode = sequenceData.nodes[0]; + this.broadcastUpdate(); + } + + /** + * Advances to the next node in the sequence. + */ + next() { + if (!this.currentNode) return; + + // 1. Handle Triggers (Side Effects) + if (this.currentNode.trigger) { + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: this.currentNode.trigger }, + }) + ); + } + + // 2. Find Next Node + const nextId = this.currentNode.next; + + if (nextId === "END" || !nextId) { + this.endSequence(); + } else { + this.currentNode = this.currentSequence.nodes.find( + (n) => n.id === nextId + ); + this.broadcastUpdate(); + } + } + + /** + * Handles player choice selection. + */ + makeChoice(choiceIndex) { + if (!this.currentNode.choices) return; + + const choice = this.currentNode.choices[choiceIndex]; + const nextId = choice.next; + + if (choice.trigger) { + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: choice.trigger }, + }) + ); + } + + this.currentNode = this.currentSequence.nodes.find((n) => n.id === nextId); + this.broadcastUpdate(); + } + + endSequence() { + console.log("Narrative Ended"); + this.currentSequence = null; + this.currentNode = null; + this.dispatchEvent(new CustomEvent("narrative-end")); + } + + broadcastUpdate() { + this.dispatchEvent( + new CustomEvent("narrative-update", { + detail: { + node: this.currentNode, + active: !!this.currentNode, + }, + }) + ); + } +} + +// Export singleton for global access +export const narrativeManager = new NarrativeManager();