Add mission management and narrative systems. Introduce MissionManager for handling mission states, objectives, and narrative triggers. Implement mission JSON schema and integrate with GameLoop for mission initiation and gameplay flow. Add RosterManager for unit management and deployment. Enhance UI with DeploymentHUD and DialogueOverlay for mission deployment and narrative presentation. Include tests for MissionManager and NarrativeManager functionalities.

This commit is contained in:
Matthew Mone 2025-12-20 21:04:44 -08:00
parent 0faef9d178
commit aab681132e
15 changed files with 1687 additions and 44 deletions

83
dependency-graph.md Normal file
View file

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

View file

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

201
src/assets/data/missions/mission.d.ts vendored Normal file
View file

@ -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<string, number>;
}

View file

@ -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"
}
}

View file

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

123
src/assets/data/narrative/narrative.d.ts vendored Normal file
View file

@ -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;
}

View file

@ -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"
}
]
}

View file

@ -6,6 +6,7 @@ import { UnitManager } from "../managers/UnitManager.js";
import { CaveGenerator } from "../generation/CaveGenerator.js"; import { CaveGenerator } from "../generation/CaveGenerator.js";
import { RuinGenerator } from "../generation/RuinGenerator.js"; import { RuinGenerator } from "../generation/RuinGenerator.js";
import { InputManager } from "./InputManager.js"; import { InputManager } from "./InputManager.js";
import { MissionManager } from "../systems/MissionManager.js";
export class GameLoop { export class GameLoop {
constructor() { constructor() {
@ -32,6 +33,13 @@ export class GameLoop {
this.lastMoveTime = 0; this.lastMoveTime = 0;
this.moveCooldown = 120; // ms between cursor moves this.moveCooldown = 120; // ms between cursor moves
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
this.missionManager = new MissionManager(this); // Init Mission Manager
// Deployment State
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
} }
init(container) { init(container) {
@ -89,8 +97,6 @@ export class GameLoop {
/** /**
* Validation Logic for Standard Movement. * Validation Logic for Standard Movement.
* Checks for valid ground, headroom, and bounds.
* Returns modified position (climbing/dropping) or false (invalid).
*/ */
validateCursorMove(x, y, z) { validateCursorMove(x, y, z) {
if (!this.grid) return true; // Allow if grid not ready 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; if (!this.grid.isValidBounds({ x, y: 0, z })) return false;
// 2. Scan Column for Surface (Climb/Drop Logic) // 2. Scan Column for Surface (Climb/Drop Logic)
// Look 2 units up and 2 units down from current Y
let bestY = null; let bestY = null;
// Check Current Level
if (this.isWalkable(x, y, z)) bestY = y; if (this.isWalkable(x, y, z)) bestY = y;
// Check Climb (y+1)
else if (this.isWalkable(x, y + 1, z)) bestY = 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 - 1, z)) bestY = y - 1;
else if (this.isWalkable(x, y - 2, z)) bestY = y - 2; else if (this.isWalkable(x, y - 2, z)) bestY = y - 2;
@ -114,12 +116,11 @@ export class GameLoop {
return { x, y: bestY, z }; return { x, y: bestY, z };
} }
return false; // No valid footing found return false;
} }
/** /**
* Validation Logic for Deployment Phase. * Validation Logic for Deployment Phase.
* Restricts cursor to the Player Spawn Zone.
*/ */
validateDeploymentCursor(x, y, z) { validateDeploymentCursor(x, y, z) {
if (!this.grid || this.playerSpawnZone.length === 0) return false; if (!this.grid || this.playerSpawnZone.length === 0) return false;
@ -135,24 +136,13 @@ export class GameLoop {
return false; // Cursor cannot leave the spawn zone return false; // Cursor cannot leave the spawn zone
} }
/**
* Helper: Checks if a specific tile is valid to stand on.
*/
isWalkable(x, y, z) { isWalkable(x, y, z) {
// Must be Air
if (this.grid.getCell(x, y, z) !== 0) return false; 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; 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; if (this.grid.getCell(x, y + 1, z) !== 0) return false;
return true; return true;
} }
/**
* Validation Logic for Interaction / Targeting.
* Allows selecting Walls, Enemies, or Empty Space (within bounds).
*/
validateInteractionTarget(x, y, z) { validateInteractionTarget(x, y, z) {
if (!this.grid) return true; if (!this.grid) return true;
return this.grid.isValidBounds({ x, y, z }); return this.grid.isValidBounds({ x, y, z });
@ -169,7 +159,6 @@ export class GameLoop {
if (code === "Space" || code === "Enter") { if (code === "Space" || code === "Enter") {
this.triggerSelection(); this.triggerSelection();
} }
// Toggle Mode for Debug (e.g. Tab)
if (code === "Tab") { if (code === "Tab") {
this.selectionMode = this.selectionMode =
this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT"; this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT";
@ -179,18 +168,58 @@ export class GameLoop {
: this.validateInteractionTarget.bind(this); : this.validateInteractionTarget.bind(this);
this.inputManager.setValidator(validator); 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() { triggerSelection() {
const cursor = this.inputManager.getCursorPosition(); const cursor = this.inputManager.getCursorPosition();
console.log("Action at:", cursor); console.log("Action at:", cursor);
if (this.phase === "DEPLOYMENT") { if (this.phase === "DEPLOYMENT") {
// TODO: Check if selecting a deployed unit to move it, or a tile to deploy to const selIndex = this.deploymentState.selectedUnitIndex;
// This requires state from the UI (which unit is selected in roster)
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) { async startLevel(runData) {
@ -200,6 +229,12 @@ export class GameLoop {
this.phase = "DEPLOYMENT"; this.phase = "DEPLOYMENT";
this.clearUnitMeshes(); this.clearUnitMeshes();
// Reset Deployment State
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
this.grid = new VoxelGrid(20, 10, 20); this.grid = new VoxelGrid(20, 10, 20);
const generator = new RuinGenerator(this.grid, runData.seed); const generator = new RuinGenerator(this.grid, runData.seed);
generator.generate(); generator.generate();
@ -235,39 +270,91 @@ export class GameLoop {
this.unitManager = new UnitManager(mockRegistry); this.unitManager = new UnitManager(mockRegistry);
this.highlightZones(); this.highlightZones();
// Snap Cursor to Player Start
if (this.playerSpawnZone.length > 0) { 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]; const start = this.playerSpawnZone[0];
// Ensure y is correct (on top of floor)
this.inputManager.setCursor(start.x, start.y, start.z); 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.inputManager.setValidator(this.validateDeploymentCursor.bind(this));
this.animate(); this.animate();
} }
deployUnit(unitDef, targetTile) { deployUnit(unitDef, targetTile, existingUnit = null) {
if (this.phase !== "DEPLOYMENT") return null; if (this.phase !== "DEPLOYMENT") return null;
// Re-validate using the zone logic (Double check)
const isValid = this.validateDeploymentCursor( const isValid = this.validateDeploymentCursor(
targetTile.x, targetTile.x,
targetTile.y, targetTile.y,
targetTile.z targetTile.z
); );
if (!isValid || this.grid.isOccupied(targetTile)) return null; // 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( const unit = this.unitManager.createUnit(
unitDef.classId || unitDef.id, unitDef.classId || unitDef.id,
"PLAYER" "PLAYER"
); );
if (unitDef.name) unit.name = unitDef.name; if (unitDef.name) unit.name = unitDef.name;
this.grid.placeUnit(unit, targetTile); this.grid.placeUnit(unit, targetTile);
this.createUnitMesh(unit, targetTile); this.createUnitMesh(unit, targetTile);
console.log(
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
);
return unit; return unit;
} }
}
finalizeDeployment() { finalizeDeployment() {
if (this.phase !== "DEPLOYMENT") return; if (this.phase !== "DEPLOYMENT") return;
@ -286,6 +373,8 @@ export class GameLoop {
// Switch to standard movement validator for the game // Switch to standard movement validator for the game
this.inputManager.setValidator(this.validateCursorMove.bind(this)); this.inputManager.setValidator(this.validateCursorMove.bind(this));
console.log("Combat Started!");
} }
clearUnitMeshes() { clearUnitMeshes() {
@ -334,11 +423,9 @@ export class GameLoop {
if (!this.isRunning) return; if (!this.isRunning) return;
requestAnimationFrame(this.animate); requestAnimationFrame(this.animate);
// 1. Update Managers
if (this.inputManager) this.inputManager.update(); if (this.inputManager) this.inputManager.update();
if (this.controls) this.controls.update(); if (this.controls) this.controls.update();
// 2. Handle Continuous Input (Keyboard polling)
const now = Date.now(); const now = Date.now();
if (now - this.lastMoveTime > this.moveCooldown) { if (now - this.lastMoveTime > this.moveCooldown) {
let dx = 0; let dx = 0;
@ -370,14 +457,11 @@ export class GameLoop {
const newX = currentPos.x + dx; const newX = currentPos.x + dx;
const newZ = currentPos.z + dz; 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.inputManager.setCursor(newX, currentPos.y, newZ);
this.lastMoveTime = now; this.lastMoveTime = now;
} }
} }
// 3. Render
const time = Date.now() * 0.002; const time = Date.now() * 0.002;
this.unitMeshes.forEach((mesh) => { this.unitMeshes.forEach((mesh) => {
mesh.position.y += Math.sin(time) * 0.002; mesh.position.y += Math.sin(time) * 0.002;

View file

@ -56,8 +56,9 @@ window.addEventListener("save-check-complete", (e) => {
btnNewRun.addEventListener("click", async () => { btnNewRun.addEventListener("click", async () => {
teamBuilder.addEventListener("embark", async (e) => { teamBuilder.addEventListener("embark", async (e) => {
gameStateManager.handleEmbark(e); gameStateManager.handleEmbark(e);
gameViewport.squad = teamBuilder.squad;
}); });
gameStateManager.startNewGame(); gameStateManager.startMission("MISSION_TUTORIAL_01");
}); });
btnContinue.addEventListener("click", async () => { btnContinue.addEventListener("click", async () => {

View file

@ -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");
}
}

223
src/ui/deployment-hud.js Normal file
View file

@ -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`
<div class="header">
<h2>MISSION DEPLOYMENT</h2>
<div class="status-bar">
Squad Size: ${deployedCount} / ${this.maxUnits}
</div>
<div style="font-size: 0.8rem; margin-top: 5px; color: #ccc;">
Select a unit below, then click a green tile to place.
</div>
</div>
<div class="action-panel">
<button
class="start-btn"
?disabled="${!canStart}"
@click="${this._handleStartBattle}"
>
START MISSION
</button>
</div>
<div class="bench-container">
${this.roster.map((unit) => {
const isDeployed = this.deployedIds.includes(unit.id);
const isSelected = this.selectedId === unit.id;
return html`
<div
class="unit-card ${isDeployed ? "deployed" : ""} ${isSelected
? "selected"
: ""}"
@click="${() => this._selectUnit(unit)}"
>
<div class="unit-icon">${unit.icon || "🛡️"}</div>
<div class="unit-name">${unit.name}</div>
<div class="unit-class">${unit.className || "Unknown"}</div>
${isDeployed
? html`<div style="font-size:0.7rem; color:#00ff00;">
DEPLOYED
</div>`
: ""}
</div>
`;
})}
</div>
`;
}
_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);

221
src/ui/dialogue-overlay.js Normal file
View file

@ -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`
<div
class="dialogue-box ${this.activeNode.type === "TUTORIAL"
? "type-tutorial"
: ""}"
@click="${() => !this.activeNode.choices && narrativeManager.next()}"
>
${this.activeNode.portrait
? html`
<div class="portrait">
<img
src="${this.activeNode.portrait}"
alt="${this.activeNode.speaker}"
/>
</div>
`
: ""}
<div class="content">
<div class="speaker">${this.activeNode.speaker}</div>
<div class="text">${this.activeNode.text}</div>
${this.activeNode.choices
? html`
<div class="choices">
${this.activeNode.choices.map(
(choice, index) => html`
<button
@click="${(e) => {
e.stopPropagation();
narrativeManager.makeChoice(index);
}}"
>
${choice.text}
</button>
`
)}
</div>
`
: html`<div class="next-indicator">
Press SPACE to continue...
</div>`}
</div>
</div>
`;
}
}
customElements.define("dialogue-overlay", DialogueOverlay);

View file

@ -1,7 +1,10 @@
import { LitElement, html, css } from "lit"; import { LitElement, html, css } from "lit";
import { gameStateManager } from "../core/GameStateManager.js"; import { gameStateManager } from "../core/GameStateManager.js";
import { RosterManager } from "../managers/RosterManager.js";
import { GameLoop } from "../core/GameLoop.js"; import { GameLoop } from "../core/GameLoop.js";
import "./deployment-hud.js";
export class GameViewport extends LitElement { export class GameViewport extends LitElement {
static styles = css` static styles = css`
:host { :host {
@ -16,8 +19,20 @@ export class GameViewport extends LitElement {
} }
`; `;
static get properties() {
return {
squad: { type: Array },
};
}
constructor() { constructor() {
super(); super();
this.squad = [];
}
#handleUnitSelected(event) {
const index = event.detail.index;
gameStateManager.gameLoop.selectDeploymentUnit(index);
} }
async firstUpdated() { async firstUpdated() {
@ -28,7 +43,11 @@ export class GameViewport extends LitElement {
} }
render() { render() {
return html`<div id="canvas-container"></div>`; return html`<div id="canvas-container"></div>
<deployment-hud
.roster=${this.squad}
@unit-selected=${this.#handleUnitSelected}
></deployment-hud>`;
} }
} }

View file

@ -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",
},
],
};
}
}

View file

@ -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();