Enhance GameLoop and MissionManager integration for unit death handling and mission events

Refactor the GameLoop to implement unit death handling through the new handleUnitDeath method, which removes units from the grid and dispatches relevant events to the MissionManager. Set up MissionManager references and event listeners for mission victory and failure. Update the MissionManager to manage secondary objectives and failure conditions, ensuring proper tracking of game events like unit deaths and turn ends. This integration improves gameplay dynamics and mission management.
This commit is contained in:
Matthew Mone 2025-12-30 20:56:41 -08:00
parent f04905044d
commit 06389903c5
2 changed files with 522 additions and 25 deletions

View file

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

View file

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