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:
parent
0faef9d178
commit
aab681132e
15 changed files with 1687 additions and 44 deletions
83
dependency-graph.md
Normal file
83
dependency-graph.md
Normal 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
|
||||
```
|
||||
158
src/assets/data/missions/mission-schema.md
Normal file
158
src/assets/data/missions/mission-schema.md
Normal 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
201
src/assets/data/missions/mission.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
23
src/assets/data/missions/mission_tutorial_01.json
Normal file
23
src/assets/data/missions/mission_tutorial_01.json
Normal 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"
|
||||
}
|
||||
}
|
||||
189
src/assets/data/narrative/narrative-schema.md
Normal file
189
src/assets/data/narrative/narrative-schema.md
Normal 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
123
src/assets/data/narrative/narrative.d.ts
vendored
Normal 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;
|
||||
}
|
||||
31
src/assets/data/narrative/tutorial_intro.json
Normal file
31
src/assets/data/narrative/tutorial_intro.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<Index, UnitInstance>
|
||||
};
|
||||
}
|
||||
|
||||
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<Index, UnitInstance>
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
72
src/managers/RosterManager.js
Normal file
72
src/managers/RosterManager.js
Normal 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
223
src/ui/deployment-hud.js
Normal 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
221
src/ui/dialogue-overlay.js
Normal 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);
|
||||
|
|
@ -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`<div id="canvas-container"></div>`;
|
||||
return html`<div id="canvas-container"></div>
|
||||
<deployment-hud
|
||||
.roster=${this.squad}
|
||||
@unit-selected=${this.#handleUnitSelected}
|
||||
></deployment-hud>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
113
test/systems/MissionManager.js
Normal file
113
test/systems/MissionManager.js
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
102
test/systems/NarrativeManager.js
Normal file
102
test/systems/NarrativeManager.js
Normal 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();
|
||||
Loading…
Reference in a new issue