diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index effad00..1742493 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -808,7 +808,8 @@ export class GameLoop { target: target, killedUnit: target, }); - // TODO: Handle unit death (remove from grid, trigger death effects, etc.) + // Handle unit death + this.handleUnitDeath(target); } // Process passive item effects for ON_DAMAGED trigger (on target) this.processPassiveItemEffects(target, "ON_DAMAGED", { @@ -1337,6 +1338,16 @@ export class GameLoop { const allUnits = this.unitManager.getAllUnits(); this.turnSystem.startCombat(allUnits); + // WIRING: Set up MissionManager references + if (this.missionManager) { + this.missionManager.setUnitManager(this.unitManager); + this.missionManager.setTurnSystem(this.turnSystem); + this.missionManager.setupActiveMission(); + } + + // WIRING: Listen for mission events + this._setupMissionEventListeners(); + // Update combat state immediately so UI shows combat HUD this.updateCombatState().catch(console.error); @@ -2234,6 +2245,13 @@ export class GameLoop { _onTurnEnd(detail) { // Clear movement highlights when turn ends this.clearMovementHighlights(); + + // Dispatch TURN_END event to MissionManager + if (this.missionManager && this.turnSystem) { + const currentTurn = this.turnSystem.round || 0; + this.missionManager.updateTurn(currentTurn); + this.missionManager.onGameEvent('TURN_END', { turn: currentTurn }); + } } /** @@ -2455,6 +2473,128 @@ export class GameLoop { if (context.didAttack === true) return false; } + return true; // All conditions passed + } + + /** + * Handles unit death: removes from grid, dispatches events, and updates MissionManager. + * @param {Unit} unit - The unit that died + */ + handleUnitDeath(unit) { + if (!unit || !this.grid || !this.unitManager) return; + + // Remove unit from grid + if (unit.position) { + this.grid.removeUnit(unit.position); + } + + // Remove unit from UnitManager + this.unitManager.removeUnit(unit.id); + + // Remove unit mesh from scene + const mesh = this.unitMeshes.get(unit.id); + if (mesh) { + this.scene.remove(mesh); + this.unitMeshes.delete(unit.id); + // Dispose geometry and material + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach(mat => { + if (mat.map) mat.map.dispose(); + mat.dispose(); + }); + } else { + if (mesh.material.map) mesh.material.map.dispose(); + mesh.material.dispose(); + } + } + } + + // Dispatch death event to MissionManager + if (this.missionManager) { + const eventType = unit.team === 'ENEMY' ? 'ENEMY_DEATH' : 'PLAYER_DEATH'; + const unitDefId = unit.defId || unit.id; + this.missionManager.onGameEvent(eventType, { + unitId: unit.id, + defId: unitDefId, + team: unit.team + }); + } + + console.log(`${unit.name} (${unit.team}) has been removed from combat.`); + } + + /** + * Sets up event listeners for mission victory and failure. + * @private + */ + _setupMissionEventListeners() { + // Listen for mission victory + window.addEventListener('mission-victory', (event) => { + this._handleMissionVictory(event.detail); + }); + + // Listen for mission failure + window.addEventListener('mission-failure', (event) => { + this._handleMissionFailure(event.detail); + }); + } + + /** + * Handles mission victory. + * @param {Object} detail - Victory event detail + * @private + */ + _handleMissionVictory(detail) { + console.log('Mission Victory!', detail); + + // Pause the game + this.isPaused = true; + + // Stop the game loop + this.stop(); + + // TODO: Show victory screen UI + // For now, just log and transition back to main menu after a delay + setTimeout(() => { + if (this.gameStateManager) { + this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + } + }, 3000); + } + + /** + * Handles mission failure. + * @param {Object} detail - Failure event detail + * @private + */ + _handleMissionFailure(detail) { + console.log('Mission Failed!', detail); + + // Pause the game + this.isPaused = true; + + // Stop the game loop + this.stop(); + + // TODO: Show failure screen UI + // For now, just log and transition back to main menu after a delay + setTimeout(() => { + if (this.gameStateManager) { + this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + } + }, 3000); + } + + /** + * Checks DID_NOT_ATTACK condition (for ON_TURN_END) + if (condition.type === "DID_NOT_ATTACK") { + // This would need to track if the unit attacked this turn + // For now, we'll assume it's tracked in context + if (context.didAttack === true) return false; + } + return true; } diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index cfbd704..fde101f 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -28,6 +28,16 @@ export class MissionManager { this.currentMissionDef = null; /** @type {Objective[]} */ this.currentObjectives = []; + /** @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; // Register default missions this.registerMission(tutorialMission); @@ -76,6 +86,22 @@ export class MissionManager { return this.missionRegistry.get(this.activeMissionId); } + /** + * 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; + } + /** * Prepares the manager for a new run. * Resets objectives and prepares narrative hooks. @@ -83,15 +109,32 @@ export class MissionManager { setupActiveMission() { const mission = this.getActiveMission(); this.currentMissionDef = mission; + this.currentTurn = 0; - // Hydrate objectives state - this.currentObjectives = mission.objectives.primary.map(obj => ({ + // Hydrate primary objectives state + this.currentObjectives = (mission.objectives.primary || []).map(obj => ({ ...obj, current: 0, complete: false })); - console.log(`Mission Setup: ${mission.config.title} - Objectives:`, this.currentObjectives); + // Hydrate secondary objectives state + this.secondaryObjectives = (mission.objectives.secondary || []).map(obj => ({ + ...obj, + current: 0, + complete: false + })); + + // 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); + } } /** @@ -148,7 +191,9 @@ export class MissionManager { // Remove NARRATIVE_ prefix and convert to lowercase with underscores const mapping = { 'NARRATIVE_TUTORIAL_INTRO': 'tutorial_intro', - 'NARRATIVE_TUTORIAL_SUCCESS': 'tutorial_success' + 'NARRATIVE_TUTORIAL_SUCCESS': 'tutorial_success', + 'NARRATIVE_ACT1_FINAL_WIN': 'act1_final_win', + 'NARRATIVE_ACT1_FINAL_LOSE': 'act1_final_lose' }; return mapping[narrativeId] || narrativeId.toLowerCase().replace('NARRATIVE_', ''); @@ -158,53 +203,365 @@ export class MissionManager { /** * Called by GameLoop whenever a relevant event occurs. - * @param {string} type - 'ENEMY_DEATH', 'TURN_END', etc. + * @param {string} type - 'ENEMY_DEATH', 'TURN_END', 'PLAYER_DEATH', etc. * @param {GameEventData} data - Context data */ onGameEvent(type, data) { - if (!this.currentObjectives.length) return; + if (!this.currentMissionDef) return; + // Check failure conditions first + this.checkFailureConditions(type, data); + + // Update objectives let statusChanged = false; - this.currentObjectives.forEach(obj => { + // 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 => { 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++; + // 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; if (obj.target_count && obj.current >= obj.target_count) { obj.complete = true; statusChanged = true; } } } + + // 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; + } + } + } }); - if (statusChanged) { - this.checkVictory(); + 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; + } + } } } + /** + * 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 + } + })); + } + checkVictory() { - const allPrimaryComplete = this.currentObjectives.every(o => o.complete); + const allPrimaryComplete = this.currentObjectives.length > 0 && + 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 }})); + window.dispatchEvent(new CustomEvent('mission-victory', { + detail: { + missionId: this.activeMissionId, + primaryObjectives: this.currentObjectives, + secondaryObjectives: this.secondaryObjectives + } + })); } } - 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'; - } + /** + * 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); } } + + /** + * 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; + } + 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); + } + } + }); + } + + // 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} + */ + 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; + } } \ No newline at end of file