diff --git a/src/assets/data/missions/mission_tutorial_01.json b/src/assets/data/missions/mission_tutorial_01.json index c470a88..b01517c 100644 --- a/src/assets/data/missions/mission_tutorial_01.json +++ b/src/assets/data/missions/mission_tutorial_01.json @@ -1,23 +1,46 @@ { "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 + "type": "TUTORIAL", + "config": { + "title": "Protocol: First Descent", + "description": "Establish a foothold in the Rusting Wastes and secure the perimeter.", + "difficulty_tier": 1, + "recommended_level": 1 }, - "narrative_intro": "NARRATIVE_TUTORIAL_INTRO", - "narrative_outro": "NARRATIVE_TUTORIAL_SUCCESS", - "objectives": [ - { - "type": "ELIMINATE_ENEMIES", - "target_count": 2 + "biome": { + "type": "BIOME_RUSTING_WASTES", + "generator_config": { + "seed_type": "FIXED", + "seed": 12345, + "size": { + "x": 20, + "y": 5, + "z": 10 + }, + "density": "LOW", + "room_count": 4 } - ], + }, + "narrative": { + "intro_sequence": "NARRATIVE_TUTORIAL_INTRO", + "outro_success": "NARRATIVE_TUTORIAL_SUCCESS" + }, + "objectives": { + "primary": [ + { + "id": "OBJ_ELIMINATE_ENEMIES", + "type": "ELIMINATE_ALL", + "description": "Eliminate 2 enemies", + "target_count": 2 + } + ] + }, "rewards": { - "xp": 100, - "currency": 50, - "unlock_class": "CLASS_TINKER" + "guaranteed": { + "xp": 100, + "currency": { + "aether_shards": 50 + } + } } } diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index d8a7796..2da10ca 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -6,7 +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"; +import { MissionManager } from "../managers/MissionManager.js"; export class GameLoop { constructor() { @@ -173,6 +173,7 @@ export class GameLoop { /** * Called by UI when a unit is clicked in the Roster. + * @param {number} index - The index of the unit in the squad to select. */ selectDeploymentUnit(index) { this.deploymentState.selectedUnitIndex = index; diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 3affd89..0e89d28 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -1,4 +1,7 @@ import { Persistence } from "./Persistence.js"; +import { RosterManager } from "../managers/RosterManager.js"; +import { MissionManager } from "../managers/MissionManager.js"; +import { narrativeManager } from "../managers/NarrativeManager.js"; class GameStateManagerClass { static STATES = { @@ -13,28 +16,44 @@ class GameStateManagerClass { this.gameLoop = null; this.persistence = new Persistence(); this.activeRunData = null; - this.gameLoopSet = Promise.withResolvers(); + + // Integrate Core Managers + this.rosterManager = new RosterManager(); + this.missionManager = new MissionManager(); + this.narrativeManager = narrativeManager; // Track the singleton instance this.handleEmbark = this.handleEmbark.bind(this); } - /** - * For Testing: Resets the manager to a clean state. - */ - reset() { - this.currentState = GameStateManagerClass.STATES.INIT; - this.gameLoop = null; - this.activeRunData = null; + #gameLoopInitialized = Promise.withResolvers(); + get gameLoopInitialized() { + return this.#gameLoopInitialized.promise; + } + + #rosterLoaded = Promise.withResolvers(); + get rosterLoaded() { + return this.#rosterLoaded.promise; } setGameLoop(loop) { this.gameLoop = loop; - this.gameLoopSet.resolve(loop); + this.#gameLoopInitialized.resolve(); } async init() { console.log("System: Initializing State Manager..."); await this.persistence.init(); + + // 1. Load Roster + const savedRoster = await this.persistence.loadRoster(); + if (savedRoster) { + this.rosterManager.load(savedRoster); + this.#rosterLoaded.resolve(this.rosterManager.roster); + } + + // 2. Load Campaign Progress + // (In future: this.missionManager.load(savedCampaignData)) + this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); } @@ -78,28 +97,65 @@ class GameStateManagerClass { } handleEmbark(e) { + // Handle Draft Mode (New Recruits) + if (e.detail.mode === "DRAFT") { + e.detail.squad.forEach((unit) => { + if (unit.isNew) { + this.rosterManager.recruitUnit(unit); + } + }); + this._saveRoster(); + } this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad); } // --- INTERNAL HELPERS --- async _initializeRun(squadManifest) { - await this.gameLoopSet.promise; + await this.gameLoopInitialized; + // 1. Mission Logic: Setup + // This resets objectives and prepares the logic for the new run + this.missionManager.setupActiveMission(); + const missionDef = this.missionManager.getActiveMission(); + + console.log(`Initializing Run for Mission: ${missionDef.config.title}`); + + // 2. Mission Logic: Narrative Intro + // If the mission has an intro, play it now. + // The game loop won't start until this promise resolves (or we could start it paused). + // This relies on the MissionManager internally calling narrativeManager.startSequence() + await this.missionManager.playIntro(); + + // 3. Build Run Data this.activeRunData = { - seed: Math.floor(Math.random() * 999999), + id: `RUN_${Date.now()}`, + missionId: missionDef.id, + seed: + missionDef.biome.generator_config.seed_type === "FIXED" + ? missionDef.biome.generator_config.seed + : Math.floor(Math.random() * 999999), depth: 1, + biome: missionDef.biome, // Pass biome config to GameLoop squad: squadManifest, + objectives: missionDef.objectives, // Pass objectives for UI display world_state: {}, }; + // 4. Save & Start await this.persistence.saveRun(this.activeRunData); + + // Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc) + this.gameLoop.missionManager = this.missionManager; this.gameLoop.startLevel(this.activeRunData); } async _resumeRun() { - await this.gameLoopSet.promise; + await this.gameLoopInitialized; if (this.activeRunData) { + // Re-hook the mission manager + this.gameLoop.missionManager = this.missionManager; + // TODO: Ideally we reload the mission state from the save file here this.gameLoop.startLevel(this.activeRunData); } } @@ -110,10 +166,13 @@ class GameStateManagerClass { new CustomEvent("save-check-complete", { detail: { hasSave: !!save } }) ); } + + async _saveRoster() { + const data = this.rosterManager.save(); + await this.persistence.saveRoster(data); + } } // Export the Singleton Instance export const gameStateManager = new GameStateManagerClass(); - -// Export Class ref for constants/testing export const GameStateManager = GameStateManagerClass; diff --git a/src/core/Persistence.js b/src/core/Persistence.js index 0fcc6a8..902062b 100644 --- a/src/core/Persistence.js +++ b/src/core/Persistence.js @@ -1,10 +1,12 @@ /** * Persistence.js * Handles asynchronous saving and loading using IndexedDB. + * Manages both Active Runs and Persistent Roster data. */ const DB_NAME = "AetherShardsDB"; -const STORE_NAME = "Runs"; -const VERSION = 1; +const RUN_STORE = "Runs"; +const ROSTER_STORE = "Roster"; +const VERSION = 2; // Bumped version to add Roster store export class Persistence { constructor() { @@ -19,8 +21,15 @@ export class Persistence { request.onupgradeneeded = (e) => { const db = e.target.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: "id" }); + + // Create Runs Store if missing + if (!db.objectStoreNames.contains(RUN_STORE)) { + db.createObjectStore(RUN_STORE, { keyPath: "id" }); + } + + // Create Roster Store if missing + if (!db.objectStoreNames.contains(ROSTER_STORE)) { + db.createObjectStore(ROSTER_STORE, { keyPath: "id" }); } }; @@ -31,39 +40,64 @@ export class Persistence { }); } + // --- RUN DATA --- + async saveRun(runData) { if (!this.db) await this.init(); - return new Promise((resolve, reject) => { - const tx = this.db.transaction([STORE_NAME], "readwrite"); - const store = tx.objectStore(STORE_NAME); - // Always use ID 'active_run' for the single active session - runData.id = "active_run"; - const req = store.put(runData); + return this._put(RUN_STORE, { ...runData, id: "active_run" }); + } + async loadRun() { + if (!this.db) await this.init(); + return this._get(RUN_STORE, "active_run"); + } + + async clearRun() { + if (!this.db) await this.init(); + return this._delete(RUN_STORE, "active_run"); + } + + // --- ROSTER DATA --- + + async saveRoster(rosterData) { + if (!this.db) await this.init(); + // Wrap the raw data object in an ID for storage + return this._put(ROSTER_STORE, { id: "player_roster", data: rosterData }); + } + + async loadRoster() { + if (!this.db) await this.init(); + const result = await this._get(ROSTER_STORE, "player_roster"); + return result ? result.data : null; + } + + // --- INTERNAL HELPERS --- + + _put(storeName, item) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction([storeName], "readwrite"); + const store = tx.objectStore(storeName); + const req = store.put(item); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } - async loadRun() { - if (!this.db) await this.init(); + _get(storeName, key) { return new Promise((resolve, reject) => { - const tx = this.db.transaction([STORE_NAME], "readonly"); - const store = tx.objectStore(STORE_NAME); - const req = store.get("active_run"); - + const tx = this.db.transaction([storeName], "readonly"); + const store = tx.objectStore(storeName); + const req = store.get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } - async clearRun() { - if (!this.db) await this.init(); + _delete(storeName, key) { return new Promise((resolve, reject) => { - const tx = this.db.transaction([STORE_NAME], "readwrite"); - const store = tx.objectStore(STORE_NAME); - const req = store.delete("active_run"); - + const tx = this.db.transaction([storeName], "readwrite"); + const store = tx.objectStore(storeName); + const req = store.delete(key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); diff --git a/src/index.js b/src/index.js index 8927f78..cc3e7e2 100644 --- a/src/index.js +++ b/src/index.js @@ -58,7 +58,7 @@ btnNewRun.addEventListener("click", async () => { gameStateManager.handleEmbark(e); gameViewport.squad = teamBuilder.squad; }); - gameStateManager.startMission("MISSION_TUTORIAL_01"); + gameStateManager.startNewGame(); }); btnContinue.addEventListener("click", async () => { diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js new file mode 100644 index 0000000..cd34cd2 --- /dev/null +++ b/src/managers/MissionManager.js @@ -0,0 +1,158 @@ +import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' }; +import { narrativeManager } from './NarrativeManager.js'; + +/** + * MissionManager.js + * Manages campaign progression, mission selection, narrative triggers, and objective tracking. + */ +export class MissionManager { + constructor() { + // Campaign State + this.activeMissionId = null; + this.completedMissions = new Set(); + this.missionRegistry = new Map(); + + // Active Run State + this.currentMissionDef = null; + this.currentObjectives = []; + + // Register default missions + this.registerMission(tutorialMission); + } + + registerMission(missionDef) { + this.missionRegistry.set(missionDef.id, missionDef); + } + + // --- PERSISTENCE (Campaign) --- + + load(saveData) { + this.completedMissions = new Set(saveData.completedMissions || []); + // Default to Tutorial if history is empty + if (this.completedMissions.size === 0) { + this.activeMissionId = 'MISSION_TUTORIAL_01'; + } + } + + save() { + return { + completedMissions: Array.from(this.completedMissions) + }; + } + + // --- MISSION SETUP & NARRATIVE --- + + /** + * Gets the configuration for the currently selected mission. + */ + getActiveMission() { + if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01'); + return this.missionRegistry.get(this.activeMissionId); + } + + /** + * Prepares the manager for a new run. + * Resets objectives and prepares narrative hooks. + */ + setupActiveMission() { + const mission = this.getActiveMission(); + this.currentMissionDef = mission; + + // Hydrate objectives state + this.currentObjectives = mission.objectives.primary.map(obj => ({ + ...obj, + current: 0, + complete: false + })); + + console.log(`Mission Setup: ${mission.config.title} - Objectives:`, this.currentObjectives); + } + + /** + * Plays the intro narrative if one exists. + * Returns a Promise that resolves when the game should start. + */ + async playIntro() { + if (!this.currentMissionDef || !this.currentMissionDef.narrative || !this.currentMissionDef.narrative.intro_sequence) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const introId = this.currentMissionDef.narrative.intro_sequence; + + // Mock loader: In real app, fetch the JSON from assets/data/narrative/ + // For prototype, we'll assume narrativeManager can handle the ID or we pass a mock. + // const narrativeData = await fetch(`assets/data/narrative/${introId}.json`).then(r => r.json()); + + // We'll simulate the event listener logic + const onEnd = () => { + narrativeManager.removeEventListener('narrative-end', onEnd); + resolve(); + }; + narrativeManager.addEventListener('narrative-end', onEnd); + + // Trigger the manager (Assuming it has a loader, or we modify it to accept ID) + // For this snippet, we assume startSequence accepts data. + // In a full implementation, you'd load the JSON here. + console.log(`Playing Narrative Intro: ${introId}`); + // narrativeManager.startSequence(loadedJson); + + // Fallback for prototype if no JSON loader: + setTimeout(onEnd, 100); // Instant resolve for now to prevent hanging + }); + } + + // --- GAMEPLAY LOGIC (Objectives) --- + + /** + * Called by GameLoop whenever a relevant event occurs. + * @param {string} type - 'ENEMY_DEATH', 'TURN_END', etc. + * @param {Object} data - Context data + */ + onGameEvent(type, data) { + if (!this.currentObjectives.length) return; + + let statusChanged = false; + + this.currentObjectives.forEach(obj => { + if (obj.complete) return; + + // Logic for 'ELIMINATE_ALL' or 'ELIMINATE_UNIT' + if (type === 'ENEMY_DEATH') { + if (obj.type === 'ELIMINATE_ALL' || + (obj.type === 'ELIMINATE_UNIT' && data.unitId === obj.target_def_id)) { + + obj.current++; + if (obj.target_count && obj.current >= obj.target_count) { + obj.complete = true; + statusChanged = true; + } + } + } + }); + + if (statusChanged) { + this.checkVictory(); + } + } + + checkVictory() { + const allPrimaryComplete = this.currentObjectives.every(o => o.complete); + if (allPrimaryComplete) { + console.log("VICTORY! Mission Objectives Complete."); + this.completeActiveMission(); + // Dispatch event for GameLoop to handle Victory Screen + window.dispatchEvent(new CustomEvent('mission-victory', { detail: { missionId: this.activeMissionId }})); + } + } + + completeActiveMission() { + if (this.activeMissionId) { + this.completedMissions.add(this.activeMissionId); + // Simple campaign logic: If Tutorial done, unlock next (Placeholder) + if (this.activeMissionId === 'MISSION_TUTORIAL_01') { + // this.activeMissionId = 'MISSION_ACT1_01'; + } + } + } +} \ No newline at end of file diff --git a/src/managers/NarrativeManager.js b/src/managers/NarrativeManager.js new file mode 100644 index 0000000..1781a13 --- /dev/null +++ b/src/managers/NarrativeManager.js @@ -0,0 +1,143 @@ +/** + * NarrativeManager.js + * Manages the flow of story events, dialogue, and tutorials. + * Extends EventTarget to broadcast UI updates to the DialogueOverlay. + */ +export class NarrativeManager extends EventTarget { + constructor() { + super(); + this.currentSequence = null; + this.currentNode = null; + this.history = new Set(); // Track IDs of played sequences + } + + /** + * Loads and starts a narrative sequence. + * @param {Object} sequenceData - The JSON object of the conversation (from assets/data/narrative/). + */ + startSequence(sequenceData) { + if (!sequenceData || !sequenceData.nodes) { + console.error("NarrativeManager: Invalid sequence data", sequenceData); + return; + } + + console.log(`NarrativeManager: Starting Sequence '${sequenceData.id}'`); + this.currentSequence = sequenceData; + this.history.add(sequenceData.id); + + // Find first node (usually index 0 or has explicit start flag, here we use index 0) + this.currentNode = sequenceData.nodes[0]; + + // Process entry triggers for the first node + this._processNodeTriggers(this.currentNode); + + this.broadcastUpdate(); + } + + /** + * Advances to the next node in the linear sequence. + * If the current node has choices, this method should strictly be blocked by the UI, + * but we include a check here for safety. + */ + next() { + if (!this.currentNode) return; + if (this.currentNode.type === "CHOICE") { + console.warn( + "NarrativeManager: Cannot call next() on a CHOICE node. User must select option." + ); + return; + } + + const nextId = this.currentNode.next; + this._advanceToNode(nextId); + } + + /** + * Handles player choice selection from a branching node. + * @param {number} choiceIndex - The index of the chosen option in the `choices` array. + */ + makeChoice(choiceIndex) { + if (!this.currentNode || !this.currentNode.choices) return; + + const choice = this.currentNode.choices[choiceIndex]; + if (!choice) return; + + // Process Choice-specific triggers (e.g., immediate reputation gain) + if (choice.trigger) { + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: choice.trigger }, + }) + ); + } + + this._advanceToNode(choice.next); + } + + /** + * Internal helper to handle transition logic. + */ + _advanceToNode(nextId) { + if (!nextId || nextId === "END") { + this.endSequence(); + return; + } + + const nextNode = this.currentSequence.nodes.find((n) => n.id === nextId); + + if (!nextNode) { + console.error( + `NarrativeManager: Node '${nextId}' not found in sequence.` + ); + this.endSequence(); + return; + } + + this.currentNode = nextNode; + this._processNodeTriggers(this.currentNode); + + // If it's an ACTION node (invisible), execute trigger and auto-advance + if (this.currentNode.type === "ACTION") { + // Triggers are already processed above, just move to next + // Use setTimeout to allow event loop to breathe if needed, or sync recursion + this._advanceToNode(this.currentNode.next); + } else { + this.broadcastUpdate(); + } + } + + _processNodeTriggers(node) { + if (node && node.trigger) { + console.log("NarrativeManager: Dispatching Trigger", node.trigger); + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: node.trigger }, + }) + ); + } + } + + endSequence() { + console.log("NarrativeManager: Sequence Ended"); + this.currentSequence = null; + this.currentNode = null; + this.dispatchEvent(new CustomEvent("narrative-end")); + } + + /** + * Sends the current node data to the UI (DialogueOverlay). + */ + broadcastUpdate() { + this.dispatchEvent( + new CustomEvent("narrative-update", { + detail: { + node: this.currentNode, + active: !!this.currentNode, + }, + }) + ); + } +} + +// Export singleton for global access +export const narrativeManager = new NarrativeManager(); diff --git a/src/ui/deployment-hud.js b/src/ui/deployment-hud.js index 45a1a49..1eab389 100644 --- a/src/ui/deployment-hud.js +++ b/src/ui/deployment-hud.js @@ -127,7 +127,7 @@ export class DeploymentHUD extends LitElement { static get properties() { return { - roster: { type: Array }, // List of all available units + squad: { 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 }, @@ -136,10 +136,13 @@ export class DeploymentHUD extends LitElement { constructor() { super(); - this.roster = []; + this.squad = []; this.deployedIds = []; this.selectedId = null; this.maxUnits = 4; + window.addEventListener("deployment-update", (e) => { + this.deployedIds = e.detail.deployedIndices; + }); } render() { @@ -168,7 +171,7 @@ export class DeploymentHUD extends LitElement {
${this.hoveredClass.role || 'Tier ' + this.hoveredClass.tier} Class
-${this.hoveredClass.description || 'A skilled explorer ready for the depths.'}
-Hover over a class or squad member to see details.
` - } + ${this._renderDetails()}Hover over a unit to see details.
`; + + // Handle data structure diffs between ClassDef and UnitInstance + const name = this.hoveredItem.name; + const role = this.hoveredItem.role || this.hoveredItem.classId; + const stats = this.hoveredItem.base_stats || this.hoveredItem.stats || {}; + + return html` +${role}
+${this.hoveredItem.description || 'Ready for deployment.'}
+