2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("./types.js").MissionDefinition} MissionDefinition
|
|
|
|
|
* @typedef {import("./types.js").MissionSaveData} MissionSaveData
|
|
|
|
|
* @typedef {import("./types.js").Objective} Objective
|
|
|
|
|
* @typedef {import("./types.js").GameEventData} GameEventData
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-22 04:40:48 +00:00
|
|
|
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.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @class
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
export class MissionManager {
|
|
|
|
|
constructor() {
|
|
|
|
|
// Campaign State
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {string | null} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.activeMissionId = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Set<string>} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.completedMissions = new Set();
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Map<string, MissionDefinition>} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.missionRegistry = new Map();
|
|
|
|
|
|
|
|
|
|
// Active Run State
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {MissionDefinition | null} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.currentMissionDef = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Objective[]} */
|
2025-12-22 04:40:48 +00:00
|
|
|
this.currentObjectives = [];
|
2025-12-31 04:56:41 +00:00
|
|
|
/** @type {Objective[]} */
|
|
|
|
|
this.secondaryObjectives = [];
|
|
|
|
|
/** @type {Array<{type: string; [key: string]: unknown}>} */
|
|
|
|
|
this.failureConditions = [];
|
|
|
|
|
/** @type {UnitManager | null} */
|
|
|
|
|
this.unitManager = null;
|
|
|
|
|
/** @type {TurnSystem | null} */
|
|
|
|
|
this.turnSystem = null;
|
|
|
|
|
/** @type {number} */
|
|
|
|
|
this.currentTurn = 0;
|
2025-12-22 04:40:48 +00:00
|
|
|
|
|
|
|
|
// Register default missions
|
|
|
|
|
this.registerMission(tutorialMission);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Registers a mission definition.
|
|
|
|
|
* @param {MissionDefinition} missionDef - Mission definition to register
|
|
|
|
|
*/
|
2025-12-22 04:40:48 +00:00
|
|
|
registerMission(missionDef) {
|
|
|
|
|
this.missionRegistry.set(missionDef.id, missionDef);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- PERSISTENCE (Campaign) ---
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Loads campaign save data.
|
|
|
|
|
* @param {MissionSaveData} saveData - Save data to load
|
|
|
|
|
*/
|
2025-12-22 04:40:48 +00:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Saves campaign data.
|
|
|
|
|
* @returns {MissionSaveData} - Serialized campaign data
|
|
|
|
|
*/
|
2025-12-22 04:40:48 +00:00
|
|
|
save() {
|
|
|
|
|
return {
|
|
|
|
|
completedMissions: Array.from(this.completedMissions)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- MISSION SETUP & NARRATIVE ---
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gets the configuration for the currently selected mission.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @returns {MissionDefinition | undefined} - Active mission definition
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
getActiveMission() {
|
|
|
|
|
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
|
|
|
|
|
return this.missionRegistry.get(this.activeMissionId);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
/**
|
|
|
|
|
* Sets the unit manager reference for objective checking.
|
|
|
|
|
* @param {UnitManager} unitManager - Unit manager instance
|
|
|
|
|
*/
|
|
|
|
|
setUnitManager(unitManager) {
|
|
|
|
|
this.unitManager = unitManager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the turn system reference for turn-based objectives.
|
|
|
|
|
* @param {TurnSystem} turnSystem - Turn system instance
|
|
|
|
|
*/
|
|
|
|
|
setTurnSystem(turnSystem) {
|
|
|
|
|
this.turnSystem = turnSystem;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 04:40:48 +00:00
|
|
|
/**
|
|
|
|
|
* Prepares the manager for a new run.
|
|
|
|
|
* Resets objectives and prepares narrative hooks.
|
|
|
|
|
*/
|
|
|
|
|
setupActiveMission() {
|
|
|
|
|
const mission = this.getActiveMission();
|
|
|
|
|
this.currentMissionDef = mission;
|
2025-12-31 04:56:41 +00:00
|
|
|
this.currentTurn = 0;
|
2025-12-22 04:40:48 +00:00
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Hydrate primary objectives state
|
|
|
|
|
this.currentObjectives = (mission.objectives.primary || []).map(obj => ({
|
|
|
|
|
...obj,
|
|
|
|
|
current: 0,
|
|
|
|
|
complete: false
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Hydrate secondary objectives state
|
|
|
|
|
this.secondaryObjectives = (mission.objectives.secondary || []).map(obj => ({
|
2025-12-22 04:40:48 +00:00
|
|
|
...obj,
|
|
|
|
|
current: 0,
|
|
|
|
|
complete: false
|
|
|
|
|
}));
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Store failure conditions
|
|
|
|
|
this.failureConditions = mission.objectives.failure_conditions || [];
|
|
|
|
|
|
|
|
|
|
console.log(`Mission Setup: ${mission.config.title} - Primary Objectives:`, this.currentObjectives);
|
|
|
|
|
if (this.secondaryObjectives.length > 0) {
|
|
|
|
|
console.log('Secondary Objectives:', this.secondaryObjectives);
|
|
|
|
|
}
|
|
|
|
|
if (this.failureConditions.length > 0) {
|
|
|
|
|
console.log('Failure Conditions:', this.failureConditions);
|
|
|
|
|
}
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
return new Promise(async (resolve) => {
|
2025-12-22 04:40:48 +00:00
|
|
|
const introId = this.currentMissionDef.narrative.intro_sequence;
|
|
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
// Map narrative ID to filename
|
|
|
|
|
// NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json
|
|
|
|
|
const narrativeFileName = this._mapNarrativeIdToFileName(introId);
|
2025-12-22 04:40:48 +00:00
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
try {
|
|
|
|
|
// Load the narrative JSON file
|
|
|
|
|
const response = await fetch(`assets/data/narrative/${narrativeFileName}.json`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
console.error(`Failed to load narrative: ${narrativeFileName}`);
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const narrativeData = await response.json();
|
|
|
|
|
|
|
|
|
|
// Set up listener for narrative end
|
|
|
|
|
const onEnd = () => {
|
|
|
|
|
narrativeManager.removeEventListener('narrative-end', onEnd);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
narrativeManager.addEventListener('narrative-end', onEnd);
|
|
|
|
|
|
|
|
|
|
// Start the narrative sequence
|
|
|
|
|
console.log(`Playing Narrative Intro: ${introId}`);
|
|
|
|
|
narrativeManager.startSequence(narrativeData);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Error loading narrative ${narrativeFileName}:`, error);
|
|
|
|
|
resolve(); // Resolve anyway to not block game start
|
|
|
|
|
}
|
2025-12-22 04:40:48 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
/**
|
|
|
|
|
* Maps narrative sequence ID to filename.
|
|
|
|
|
* @param {string} narrativeId - The narrative ID from mission config
|
|
|
|
|
* @returns {string} The filename (without .json extension)
|
|
|
|
|
*/
|
|
|
|
|
_mapNarrativeIdToFileName(narrativeId) {
|
|
|
|
|
// Convert NARRATIVE_TUTORIAL_INTRO -> tutorial_intro
|
|
|
|
|
// Remove NARRATIVE_ prefix and convert to lowercase with underscores
|
|
|
|
|
const mapping = {
|
|
|
|
|
'NARRATIVE_TUTORIAL_INTRO': 'tutorial_intro',
|
2025-12-31 04:56:41 +00:00
|
|
|
'NARRATIVE_TUTORIAL_SUCCESS': 'tutorial_success',
|
|
|
|
|
'NARRATIVE_ACT1_FINAL_WIN': 'act1_final_win',
|
|
|
|
|
'NARRATIVE_ACT1_FINAL_LOSE': 'act1_final_lose'
|
2025-12-22 05:20:33 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return mapping[narrativeId] || narrativeId.toLowerCase().replace('NARRATIVE_', '');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 04:40:48 +00:00
|
|
|
// --- GAMEPLAY LOGIC (Objectives) ---
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Called by GameLoop whenever a relevant event occurs.
|
2025-12-31 04:56:41 +00:00
|
|
|
* @param {string} type - 'ENEMY_DEATH', 'TURN_END', 'PLAYER_DEATH', etc.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @param {GameEventData} data - Context data
|
2025-12-22 04:40:48 +00:00
|
|
|
*/
|
|
|
|
|
onGameEvent(type, data) {
|
2025-12-31 04:56:41 +00:00
|
|
|
if (!this.currentMissionDef) return;
|
|
|
|
|
|
|
|
|
|
// Check failure conditions first
|
|
|
|
|
this.checkFailureConditions(type, data);
|
2025-12-22 04:40:48 +00:00
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Update objectives
|
2025-12-22 04:40:48 +00:00
|
|
|
let statusChanged = false;
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Process primary objectives
|
|
|
|
|
statusChanged = this.updateObjectives(this.currentObjectives, type, data) || statusChanged;
|
|
|
|
|
|
|
|
|
|
// Process secondary objectives
|
|
|
|
|
this.updateObjectives(this.secondaryObjectives, type, data);
|
|
|
|
|
|
|
|
|
|
// Check for ELIMINATE_ALL objective completion (needs active check)
|
|
|
|
|
// Check after enemy death or at turn end
|
|
|
|
|
if (type === 'ENEMY_DEATH') {
|
|
|
|
|
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
|
|
|
|
} else if (type === 'TURN_END') {
|
|
|
|
|
// Also check on turn end in case all enemies died from status effects
|
|
|
|
|
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (statusChanged) {
|
|
|
|
|
this.checkVictory();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Updates objectives based on game events.
|
|
|
|
|
* @param {Objective[]} objectives - Objectives to update
|
|
|
|
|
* @param {string} eventType - Event type
|
|
|
|
|
* @param {GameEventData} data - Event data
|
|
|
|
|
* @returns {boolean} True if any objective status changed
|
|
|
|
|
*/
|
|
|
|
|
updateObjectives(objectives, eventType, data) {
|
|
|
|
|
let statusChanged = false;
|
|
|
|
|
|
|
|
|
|
objectives.forEach(obj => {
|
2025-12-22 04:40:48 +00:00
|
|
|
if (obj.complete) return;
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// ELIMINATE_UNIT: Track specific enemy deaths
|
|
|
|
|
if (eventType === 'ENEMY_DEATH' && obj.type === 'ELIMINATE_UNIT') {
|
|
|
|
|
if (data.unitId === obj.target_def_id || data.defId === obj.target_def_id) {
|
|
|
|
|
obj.current = (obj.current || 0) + 1;
|
2025-12-22 04:40:48 +00:00
|
|
|
if (obj.target_count && obj.current >= obj.target_count) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
|
|
|
|
|
// SURVIVE: Check turn count
|
|
|
|
|
if (eventType === 'TURN_END' && obj.type === 'SURVIVE') {
|
|
|
|
|
if (obj.turn_count && this.currentTurn >= obj.turn_count) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// REACH_ZONE: Check if unit reached target zone
|
|
|
|
|
if (eventType === 'UNIT_MOVE' && obj.type === 'REACH_ZONE') {
|
|
|
|
|
if (data.position && obj.zone_coords) {
|
|
|
|
|
const reached = obj.zone_coords.some(coord =>
|
|
|
|
|
coord.x === data.position.x &&
|
|
|
|
|
coord.y === data.position.y &&
|
|
|
|
|
coord.z === data.position.z
|
|
|
|
|
);
|
|
|
|
|
if (reached) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// INTERACT: Check if unit interacted with target object
|
|
|
|
|
if (eventType === 'INTERACT' && obj.type === 'INTERACT') {
|
|
|
|
|
if (data.objectId === obj.target_object_id) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SQUAD_SURVIVAL: Check if minimum units are alive
|
|
|
|
|
if (eventType === 'PLAYER_DEATH' && obj.type === 'SQUAD_SURVIVAL') {
|
|
|
|
|
if (this.unitManager) {
|
|
|
|
|
const playerUnits = Array.from(this.unitManager.activeUnits.values())
|
|
|
|
|
.filter(u => u.team === 'PLAYER' && u.currentHealth > 0);
|
|
|
|
|
if (obj.min_alive && playerUnits.length >= obj.min_alive) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-22 04:40:48 +00:00
|
|
|
});
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
return statusChanged;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks if ELIMINATE_ALL objective is complete.
|
|
|
|
|
* @returns {boolean} True if status changed
|
|
|
|
|
*/
|
|
|
|
|
checkEliminateAllObjective() {
|
|
|
|
|
let statusChanged = false;
|
|
|
|
|
|
|
|
|
|
[...this.currentObjectives, ...this.secondaryObjectives].forEach(obj => {
|
|
|
|
|
if (obj.complete || obj.type !== 'ELIMINATE_ALL') return;
|
|
|
|
|
|
|
|
|
|
if (this.unitManager) {
|
|
|
|
|
const enemies = Array.from(this.unitManager.activeUnits.values())
|
|
|
|
|
.filter(u => u.team === 'ENEMY' && u.currentHealth > 0);
|
|
|
|
|
|
|
|
|
|
if (enemies.length === 0) {
|
|
|
|
|
obj.complete = true;
|
|
|
|
|
statusChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return statusChanged;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks failure conditions and triggers failure if met.
|
|
|
|
|
* @param {string} eventType - Event type
|
|
|
|
|
* @param {GameEventData} data - Event data
|
|
|
|
|
*/
|
|
|
|
|
checkFailureConditions(eventType, data) {
|
|
|
|
|
if (!this.failureConditions.length) return;
|
|
|
|
|
|
|
|
|
|
for (const condition of this.failureConditions) {
|
|
|
|
|
// SQUAD_WIPE: All player units are dead
|
|
|
|
|
if (condition.type === 'SQUAD_WIPE') {
|
|
|
|
|
if (this.unitManager) {
|
|
|
|
|
const playerUnits = Array.from(this.unitManager.activeUnits.values())
|
|
|
|
|
.filter(u => u.team === 'PLAYER' && u.currentHealth > 0);
|
|
|
|
|
if (playerUnits.length === 0) {
|
|
|
|
|
this.triggerFailure('SQUAD_WIPE');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// VIP_DEATH: VIP unit died
|
|
|
|
|
if (condition.type === 'VIP_DEATH' && eventType === 'PLAYER_DEATH') {
|
|
|
|
|
if (data.unitId && condition.target_tag) {
|
|
|
|
|
const unit = this.unitManager?.getUnitById(data.unitId);
|
|
|
|
|
if (unit && unit.tags && unit.tags.includes(condition.target_tag)) {
|
|
|
|
|
this.triggerFailure('VIP_DEATH', { unitId: data.unitId });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TURN_LIMIT_EXCEEDED: Mission took too long
|
|
|
|
|
if (condition.type === 'TURN_LIMIT_EXCEEDED' && eventType === 'TURN_END') {
|
|
|
|
|
if (condition.turn_limit && this.currentTurn > condition.turn_limit) {
|
|
|
|
|
this.triggerFailure('TURN_LIMIT_EXCEEDED', { turn: this.currentTurn });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
/**
|
|
|
|
|
* Triggers mission failure.
|
|
|
|
|
* @param {string} reason - Failure reason
|
|
|
|
|
* @param {GameEventData} [data] - Additional failure data
|
|
|
|
|
*/
|
|
|
|
|
triggerFailure(reason, data = {}) {
|
|
|
|
|
console.log(`MISSION FAILED: ${reason}`, data);
|
|
|
|
|
window.dispatchEvent(new CustomEvent('mission-failure', {
|
|
|
|
|
detail: {
|
|
|
|
|
missionId: this.activeMissionId,
|
|
|
|
|
reason: reason,
|
|
|
|
|
...data
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 04:40:48 +00:00
|
|
|
checkVictory() {
|
2025-12-31 04:56:41 +00:00
|
|
|
const allPrimaryComplete = this.currentObjectives.length > 0 &&
|
|
|
|
|
this.currentObjectives.every(o => o.complete);
|
2025-12-22 04:40:48 +00:00
|
|
|
if (allPrimaryComplete) {
|
|
|
|
|
console.log("VICTORY! Mission Objectives Complete.");
|
|
|
|
|
this.completeActiveMission();
|
|
|
|
|
// Dispatch event for GameLoop to handle Victory Screen
|
2025-12-31 04:56:41 +00:00
|
|
|
window.dispatchEvent(new CustomEvent('mission-victory', {
|
|
|
|
|
detail: {
|
|
|
|
|
missionId: this.activeMissionId,
|
|
|
|
|
primaryObjectives: this.currentObjectives,
|
|
|
|
|
secondaryObjectives: this.secondaryObjectives
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Completes the active mission and distributes rewards.
|
|
|
|
|
*/
|
|
|
|
|
async completeActiveMission() {
|
|
|
|
|
if (!this.activeMissionId || !this.currentMissionDef) return;
|
|
|
|
|
|
|
|
|
|
// Mark mission as completed
|
|
|
|
|
this.completedMissions.add(this.activeMissionId);
|
|
|
|
|
|
|
|
|
|
// Distribute rewards
|
|
|
|
|
this.distributeRewards();
|
|
|
|
|
|
|
|
|
|
// Play outro narrative if available
|
|
|
|
|
if (this.currentMissionDef.narrative?.outro_success) {
|
|
|
|
|
await this.playOutro(this.currentMissionDef.narrative.outro_success);
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2025-12-31 21:52:59 +00:00
|
|
|
|
|
|
|
|
// Dispatch event to save campaign data
|
|
|
|
|
window.dispatchEvent(new CustomEvent('campaign-data-changed', {
|
|
|
|
|
detail: { missionCompleted: this.activeMissionId }
|
|
|
|
|
}));
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
/**
|
|
|
|
|
* Distributes mission rewards (XP, currency, items, unlocks).
|
|
|
|
|
*/
|
|
|
|
|
distributeRewards() {
|
|
|
|
|
if (!this.currentMissionDef || !this.currentMissionDef.rewards) return;
|
|
|
|
|
|
|
|
|
|
const rewards = this.currentMissionDef.rewards;
|
|
|
|
|
const rewardData = {
|
|
|
|
|
xp: 0,
|
|
|
|
|
currency: {},
|
|
|
|
|
items: [],
|
|
|
|
|
unlocks: [],
|
|
|
|
|
factionReputation: {}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Guaranteed rewards
|
|
|
|
|
if (rewards.guaranteed) {
|
|
|
|
|
if (rewards.guaranteed.xp) {
|
|
|
|
|
rewardData.xp += rewards.guaranteed.xp;
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
if (rewards.guaranteed.currency) {
|
|
|
|
|
Object.assign(rewardData.currency, rewards.guaranteed.currency);
|
|
|
|
|
}
|
|
|
|
|
if (rewards.guaranteed.items) {
|
|
|
|
|
rewardData.items.push(...rewards.guaranteed.items);
|
|
|
|
|
}
|
|
|
|
|
if (rewards.guaranteed.unlocks) {
|
|
|
|
|
rewardData.unlocks.push(...rewards.guaranteed.unlocks);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Conditional rewards (based on secondary objectives)
|
|
|
|
|
if (rewards.conditional) {
|
|
|
|
|
rewards.conditional.forEach(conditional => {
|
|
|
|
|
const objective = this.secondaryObjectives.find(obj => obj.id === conditional.objective_id);
|
|
|
|
|
if (objective && objective.complete && conditional.reward) {
|
|
|
|
|
if (conditional.reward.xp) {
|
|
|
|
|
rewardData.xp += conditional.reward.xp;
|
|
|
|
|
}
|
|
|
|
|
if (conditional.reward.currency) {
|
|
|
|
|
Object.assign(rewardData.currency, conditional.reward.currency);
|
|
|
|
|
}
|
|
|
|
|
if (conditional.reward.items) {
|
|
|
|
|
rewardData.items.push(...conditional.reward.items);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
|
|
|
|
|
// Faction reputation
|
|
|
|
|
if (rewards.faction_reputation) {
|
|
|
|
|
Object.assign(rewardData.factionReputation, rewards.faction_reputation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch reward event
|
|
|
|
|
window.dispatchEvent(new CustomEvent('mission-rewards', {
|
|
|
|
|
detail: rewardData
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Handle unlocks (store in localStorage)
|
|
|
|
|
if (rewardData.unlocks.length > 0) {
|
|
|
|
|
this.unlockClasses(rewardData.unlocks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('Mission Rewards Distributed:', rewardData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Unlocks classes and stores them in localStorage.
|
|
|
|
|
* @param {string[]} classIds - Array of class IDs to unlock
|
|
|
|
|
*/
|
|
|
|
|
unlockClasses(classIds) {
|
|
|
|
|
const storageKey = 'aether_shards_unlocks';
|
|
|
|
|
let unlocks = [];
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const stored = localStorage.getItem(storageKey);
|
|
|
|
|
if (stored) {
|
|
|
|
|
unlocks = JSON.parse(stored);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to load unlocks from storage:', e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add new unlocks
|
|
|
|
|
classIds.forEach(classId => {
|
|
|
|
|
if (!unlocks.includes(classId)) {
|
|
|
|
|
unlocks.push(classId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Save back to storage
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(storageKey, JSON.stringify(unlocks));
|
|
|
|
|
console.log('Unlocked classes:', classIds);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to save unlocks to storage:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plays the outro narrative if one exists.
|
|
|
|
|
* @param {string} outroId - Narrative sequence ID
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
|
|
|
|
async playOutro(outroId) {
|
|
|
|
|
return new Promise(async (resolve) => {
|
|
|
|
|
const narrativeFileName = this._mapNarrativeIdToFileName(outroId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`assets/data/narrative/${narrativeFileName}.json`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
console.error(`Failed to load outro narrative: ${narrativeFileName}`);
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const narrativeData = await response.json();
|
|
|
|
|
|
|
|
|
|
const onEnd = () => {
|
|
|
|
|
narrativeManager.removeEventListener('narrative-end', onEnd);
|
|
|
|
|
resolve();
|
|
|
|
|
};
|
|
|
|
|
narrativeManager.addEventListener('narrative-end', onEnd);
|
|
|
|
|
|
|
|
|
|
console.log(`Playing Narrative Outro: ${outroId}`);
|
|
|
|
|
narrativeManager.startSequence(narrativeData);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Error loading outro narrative ${narrativeFileName}:`, error);
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Updates the current turn count.
|
|
|
|
|
* @param {number} turn - Current turn number
|
|
|
|
|
*/
|
|
|
|
|
updateTurn(turn) {
|
|
|
|
|
this.currentTurn = turn;
|
2025-12-22 04:40:48 +00:00
|
|
|
}
|
|
|
|
|
}
|