Enhance mission management and introduce new mission features
- Add support for regenerating procedural missions and generating specific mission types through the DebugCommands class. - Implement new methods in MissionManager for populating zone coordinates for REACH_ZONE objectives, improving mission complexity and tracking. - Update GameLoop to dispatch UNIT_MOVE events for better interaction tracking with mission objectives. - Introduce MissionReview component for reviewing completed missions, displaying rewards and narrative elements. - Enhance MissionBoard to support mission review functionality and improve UI for mission selection. - Add tests for new mission features and ensure integration with existing game systems.
This commit is contained in:
parent
63bfb7da31
commit
0f4210d5c4
15 changed files with 2003 additions and 124 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { gameStateManager } from "./GameStateManager.js";
|
||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
import { MissionGenerator } from "../systems/MissionGenerator.js";
|
||||
|
||||
/**
|
||||
* Debug command system for testing game mechanics.
|
||||
|
|
@ -506,6 +507,112 @@ export class DebugCommands {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MISSION COMMANDS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Regenerates procedural missions on the mission board.
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
regenerateMissions() {
|
||||
if (!this.gameStateManager?.missionManager) {
|
||||
return "Error: MissionManager not available";
|
||||
}
|
||||
|
||||
const missionManager = this.gameStateManager.missionManager;
|
||||
missionManager.refreshProceduralMissions(false);
|
||||
|
||||
const proceduralCount = Array.from(missionManager.missionRegistry.values())
|
||||
.filter((m) => m.type === "SIDE_QUEST" && m.id?.startsWith("SIDE_OP_"))
|
||||
.length;
|
||||
|
||||
return `Regenerated procedural missions. ${proceduralCount} missions available on board.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a specific mission type and adds it to the mission board.
|
||||
* @param {string} archetype - Mission archetype: "SKIRMISH", "SALVAGE", "ASSASSINATION", or "RECON"
|
||||
* @param {number} [tier=2] - Campaign tier (1-5), defaults to 2
|
||||
* @param {string} [biomeType] - Biome type ID (optional, will pick random if not specified)
|
||||
* @returns {string} Result message
|
||||
*/
|
||||
generateMission(archetype = "RECON", tier = 2, biomeType = null) {
|
||||
if (!this.gameStateManager?.missionManager) {
|
||||
return "Error: MissionManager not available";
|
||||
}
|
||||
|
||||
const validArchetypes = ["SKIRMISH", "SALVAGE", "ASSASSINATION", "RECON"];
|
||||
const upperArchetype = archetype.toUpperCase();
|
||||
|
||||
if (!validArchetypes.includes(upperArchetype)) {
|
||||
return `Error: Invalid archetype "${archetype}". Valid types: ${validArchetypes.join(", ")}`;
|
||||
}
|
||||
|
||||
if (tier < 1 || tier > 5) {
|
||||
return "Error: Tier must be between 1 and 5";
|
||||
}
|
||||
|
||||
const missionManager = this.gameStateManager.missionManager;
|
||||
|
||||
// Get unlocked regions and history
|
||||
const unlockedRegions = missionManager._getUnlockedRegions
|
||||
? missionManager._getUnlockedRegions()
|
||||
: ["BIOME_RUSTING_WASTES"];
|
||||
|
||||
const history = missionManager._getMissionHistory
|
||||
? missionManager._getMissionHistory()
|
||||
: [];
|
||||
|
||||
// Pick a biome if not specified
|
||||
if (!biomeType) {
|
||||
biomeType = unlockedRegions[Math.floor(Math.random() * unlockedRegions.length)];
|
||||
}
|
||||
|
||||
// Generate mission with specific archetype
|
||||
// We need to force the archetype - MissionGenerator.generateSideOp picks randomly
|
||||
// So we'll generate multiple times until we get the right type, or modify the approach
|
||||
let mission = null;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
|
||||
while (!mission && attempts < maxAttempts) {
|
||||
const candidate = MissionGenerator.generateSideOp(tier, unlockedRegions, history);
|
||||
// Check if this mission matches our desired archetype
|
||||
const primaryObj = candidate.objectives?.primary?.[0];
|
||||
if (primaryObj) {
|
||||
const archetypeMap = {
|
||||
SKIRMISH: "ELIMINATE_ALL",
|
||||
SALVAGE: "INTERACT",
|
||||
ASSASSINATION: "ELIMINATE_UNIT",
|
||||
RECON: "REACH_ZONE",
|
||||
};
|
||||
|
||||
if (primaryObj.type === archetypeMap[upperArchetype]) {
|
||||
mission = candidate;
|
||||
// Override biome if specified
|
||||
if (biomeType && candidate.biome) {
|
||||
mission.biome = {
|
||||
...candidate.biome,
|
||||
type: biomeType,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!mission) {
|
||||
return `Error: Failed to generate ${upperArchetype} mission after ${maxAttempts} attempts. Try again.`;
|
||||
}
|
||||
|
||||
// Register the mission
|
||||
missionManager.registerMission(mission);
|
||||
|
||||
return `Generated ${upperArchetype} mission: "${mission.config.title}" (Tier ${tier}, ${mission.biome?.type || biomeType}). Mission ID: ${mission.id}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITY COMMANDS
|
||||
// ============================================
|
||||
|
|
@ -561,6 +668,14 @@ export class DebugCommands {
|
|||
"%cMISSION & NARRATIVE:",
|
||||
"font-weight: bold; color: #2196F3;"
|
||||
);
|
||||
console.log(" %cregenerateMissions()%c - Regenerate all procedural missions on board", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %cgenerateMission(type, tier, biome)%c - Generate specific mission type", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" type: \"SKIRMISH\", \"SALVAGE\", \"ASSASSINATION\", \"RECON\"");
|
||||
console.log(" tier: 1-5 (default: 2)");
|
||||
console.log(" biome: optional biome ID (e.g., \"BIOME_RUSTING_WASTES\")");
|
||||
console.log(" Examples:");
|
||||
console.log(" %cgenerateMission(\"RECON\")%c - Generate RECON mission (for testing zone objectives)", "color: #4CAF50; font-family: monospace;", "color: inherit;");
|
||||
console.log(" %cgenerateMission(\"SKIRMISH\", 3)%c - Generate tier 3 SKIRMISH mission", "color: #4CAF50; font-family: monospace;", "color: inherit;");
|
||||
console.log(" %ctriggerVictory()%c - Trigger mission victory", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %ccompleteObjective(objectiveId)%c - Complete a specific objective", "color: #FF9800;", "color: inherit;");
|
||||
console.log(" %ctriggerNarrative(narrativeId)%c - Trigger narrative sequence", "color: #FF9800;", "color: inherit;");
|
||||
|
|
@ -587,6 +702,8 @@ export class DebugCommands {
|
|||
console.log(" %cdebugCommands.addXP(\"first\", 500)", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.setLevel(\"all\", 10)", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.addItem(\"ITEM_SWORD_T1\", 1, \"hub\")", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.generateMission(\"RECON\")", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.regenerateMissions()", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.killEnemy(\"all\")", "color: #4CAF50; font-family: monospace;");
|
||||
console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;");
|
||||
console.log("");
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export class GameLoop {
|
|||
/** @type {Map<string, Position>} */
|
||||
this.missionObjects = new Map(); // object_id -> position
|
||||
/** @type {Set<THREE.Mesh>} */
|
||||
this.zoneMarkers = new Set(); // Visual markers for REACH_ZONE objectives
|
||||
/** @type {Set<THREE.Mesh>} */
|
||||
this.movementHighlights = new Set();
|
||||
/** @type {Set<THREE.Mesh>} */
|
||||
this.spawnZoneHighlights = new Set();
|
||||
|
|
@ -107,6 +109,20 @@ export class GameLoop {
|
|||
/** @type {number} */
|
||||
this.lastMoveTime = 0;
|
||||
/** @type {number} */
|
||||
|
||||
// Camera Animation State
|
||||
/** @type {boolean} */
|
||||
this.isAnimatingCamera = false;
|
||||
/** @type {THREE.Vector3} */
|
||||
this.cameraAnimationStart = new THREE.Vector3();
|
||||
/** @type {THREE.Vector3} */
|
||||
this.cameraAnimationTarget = new THREE.Vector3();
|
||||
/** @type {THREE.Vector3 | null} */
|
||||
this.cameraAnimationOffset = null; // Camera offset to maintain during animation
|
||||
/** @type {number} */
|
||||
this.cameraAnimationStartTime = 0;
|
||||
/** @type {number} */
|
||||
this.cameraAnimationDuration = 500; // milliseconds
|
||||
this.moveCooldown = 120; // ms between cursor moves
|
||||
/** @type {"MOVEMENT" | "TARGETING"} */
|
||||
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
||||
|
|
@ -516,6 +532,21 @@ export class GameLoop {
|
|||
`Moved ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
|
||||
);
|
||||
|
||||
// Follow camera to the unit's new position
|
||||
// Only follow if this is the active unit (whose turn it is)
|
||||
const currentActiveUnit = this.turnSystem.getActiveUnit();
|
||||
if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) {
|
||||
this.centerCameraOnUnit(activeUnit);
|
||||
}
|
||||
|
||||
// Dispatch UNIT_MOVE event to MissionManager for objective tracking
|
||||
if (this.missionManager) {
|
||||
this.missionManager.onGameEvent("UNIT_MOVE", {
|
||||
unitId: activeUnit.id,
|
||||
position: activeUnit.position,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if unit moved to a mission object position (interaction)
|
||||
this.checkMissionObjectInteraction(activeUnit);
|
||||
|
||||
|
|
@ -818,6 +849,24 @@ export class GameLoop {
|
|||
console.log(
|
||||
`Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
|
||||
);
|
||||
|
||||
// Dispatch UNIT_MOVE event to MissionManager for objective tracking
|
||||
if (this.missionManager) {
|
||||
this.missionManager.onGameEvent("UNIT_MOVE", {
|
||||
unitId: activeUnit.id,
|
||||
position: activeUnit.position,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if unit teleported to a mission object position (interaction)
|
||||
this.checkMissionObjectInteraction(activeUnit);
|
||||
|
||||
// Follow camera to the unit's new position after teleport
|
||||
// Only follow if this is the active unit (whose turn it is)
|
||||
const currentActiveUnit = this.turnSystem.getActiveUnit();
|
||||
if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) {
|
||||
this.centerCameraOnUnit(activeUnit);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Teleport failed: ${result.error || "Unknown error"}`);
|
||||
}
|
||||
|
|
@ -1037,6 +1086,7 @@ export class GameLoop {
|
|||
this.clearMovementHighlights();
|
||||
this.clearSpawnZoneHighlights();
|
||||
this.clearMissionObjects();
|
||||
this.clearZoneMarkers();
|
||||
this.clearRangeHighlights();
|
||||
|
||||
// Reset Deployment State
|
||||
|
|
@ -1187,6 +1237,11 @@ export class GameLoop {
|
|||
this.turnSystemAbortController = new AbortController();
|
||||
const signal = this.turnSystemAbortController.signal;
|
||||
|
||||
// Set up callbacks for TurnSystem
|
||||
this.turnSystem.onUnitDeathCallback = (unit) => {
|
||||
this.handleUnitDeath(unit);
|
||||
};
|
||||
|
||||
this.turnSystem.addEventListener(
|
||||
"turn-start",
|
||||
(e) => this._onTurnStart(e.detail),
|
||||
|
|
@ -1563,7 +1618,12 @@ export class GameLoop {
|
|||
if (this.missionManager) {
|
||||
this.missionManager.setUnitManager(this.unitManager);
|
||||
this.missionManager.setTurnSystem(this.turnSystem);
|
||||
this.missionManager.setGridContext(this.grid, this.movementSystem);
|
||||
await this.missionManager.setupActiveMission();
|
||||
// Populate zone coordinates for REACH_ZONE objectives
|
||||
this.missionManager.populateZoneCoordinates();
|
||||
// Create visual markers for zones
|
||||
this.createZoneMarkers();
|
||||
}
|
||||
|
||||
// WIRING: Listen for mission events
|
||||
|
|
@ -1626,6 +1686,115 @@ export class GameLoop {
|
|||
this.missionObjects.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all zone marker meshes from the scene.
|
||||
*/
|
||||
clearZoneMarkers() {
|
||||
this.zoneMarkers.forEach((mesh) => {
|
||||
this.scene.remove(mesh);
|
||||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
if (mesh.material) {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((mat) => mat.dispose());
|
||||
} else {
|
||||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.zoneMarkers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates visual markers for REACH_ZONE objectives.
|
||||
* Called after zone coordinates are populated.
|
||||
*/
|
||||
createZoneMarkers() {
|
||||
if (!this.missionManager) return;
|
||||
|
||||
// Find all REACH_ZONE objectives
|
||||
const reachZoneObjectives = [
|
||||
...(this.missionManager.currentObjectives || []),
|
||||
...(this.missionManager.secondaryObjectives || []),
|
||||
].filter((obj) => obj.type === "REACH_ZONE" && obj.zone_coords && obj.zone_coords.length > 0);
|
||||
|
||||
for (const obj of reachZoneObjectives) {
|
||||
for (const coord of obj.zone_coords) {
|
||||
this.createZoneMarker(coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a visual marker for a single zone coordinate.
|
||||
* @param {Position} pos - Zone position
|
||||
*/
|
||||
createZoneMarker(pos) {
|
||||
// Create a glowing beacon/marker for the zone
|
||||
// Use a cone or cylinder with pulsing glow effect
|
||||
const geometry = new THREE.ConeGeometry(0.3, 1.2, 8);
|
||||
|
||||
// Cyan/blue color to indicate recon zones
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0x00ffff, // Cyan
|
||||
emissive: 0x004444, // Glow
|
||||
metalness: 0.5,
|
||||
roughness: 0.3,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(pos.x, pos.y + 0.6, pos.z);
|
||||
mesh.rotation.x = Math.PI; // Point upward
|
||||
|
||||
// Add a pulsing animation
|
||||
mesh.userData = {
|
||||
originalY: pos.y + 0.6,
|
||||
pulseSpeed: 0.02,
|
||||
pulseAmount: 0.1,
|
||||
time: Math.random() * Math.PI * 2, // Random phase
|
||||
};
|
||||
|
||||
this.scene.add(mesh);
|
||||
this.zoneMarkers.add(mesh);
|
||||
|
||||
// Add a glowing ring on the ground
|
||||
const ringGeometry = new THREE.RingGeometry(0.4, 0.5, 16);
|
||||
const ringMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x00ffff,
|
||||
emissive: 0x004444,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeometry, ringMaterial);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(pos.x, pos.y + 0.01, pos.z);
|
||||
ring.userData = { pulseSpeed: 0.02, time: Math.random() * Math.PI * 2 };
|
||||
this.scene.add(ring);
|
||||
this.zoneMarkers.add(ring);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates zone marker animations (pulsing effect).
|
||||
* Should be called in the animation loop.
|
||||
*/
|
||||
updateZoneMarkers() {
|
||||
this.zoneMarkers.forEach((mesh) => {
|
||||
if (mesh.userData && mesh.userData.originalY !== undefined) {
|
||||
mesh.userData.time += mesh.userData.pulseSpeed;
|
||||
const offset = Math.sin(mesh.userData.time) * mesh.userData.pulseAmount;
|
||||
mesh.position.y = mesh.userData.originalY + offset;
|
||||
|
||||
// Pulse emissive intensity
|
||||
if (mesh.material && mesh.material.emissive) {
|
||||
const intensity = 0.004444 + Math.sin(mesh.userData.time) * 0.002;
|
||||
mesh.material.emissive.setHex(Math.floor(intensity * 0xffffff));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all movement highlight meshes from the scene.
|
||||
*/
|
||||
|
|
@ -1766,6 +1935,66 @@ export class GameLoop {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers the camera on a unit's position.
|
||||
* Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans.
|
||||
* @param {Unit} unit - The unit to center the camera on
|
||||
*/
|
||||
centerCameraOnUnit(unit) {
|
||||
if (!unit || !unit.position || !this.controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unit mesh is positioned at (pos.x, pos.y + 0.1, pos.z)
|
||||
// Center the camera on the unit's position
|
||||
const targetX = unit.position.x;
|
||||
const targetY = unit.position.y + 0.1; // Match unit mesh height offset
|
||||
const targetZ = unit.position.z;
|
||||
|
||||
this.followCameraToPosition(targetX, targetY, targetZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the camera to follow a specific position.
|
||||
* Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans.
|
||||
* Maintains camera rotation by preserving the relative offset from target to camera.
|
||||
* @param {number} x - Target X coordinate
|
||||
* @param {number} y - Target Y coordinate
|
||||
* @param {number} z - Target Z coordinate
|
||||
* @private
|
||||
*/
|
||||
followCameraToPosition(x, y, z) {
|
||||
if (!this.controls || !this.camera) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the offset from current target to camera position
|
||||
// This preserves the camera's rotation/angle relative to the target
|
||||
const cameraOffset = new THREE.Vector3();
|
||||
cameraOffset.subVectors(this.camera.position, this.controls.target);
|
||||
|
||||
// Check for prefers-reduced-motion
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
// Jump instantly to target position, maintaining camera offset
|
||||
this.controls.target.set(x, y, z);
|
||||
this.camera.position.copy(this.controls.target).add(cameraOffset);
|
||||
this.controls.update();
|
||||
this.isAnimatingCamera = false;
|
||||
} else {
|
||||
// Start smooth animation
|
||||
this.cameraAnimationStart.copy(this.controls.target);
|
||||
this.cameraAnimationTarget.set(x, y, z);
|
||||
this.cameraAnimationStartTime = Date.now();
|
||||
this.isAnimatingCamera = true;
|
||||
// Store the offset to maintain during animation
|
||||
this.cameraAnimationOffset = cameraOffset;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a visual mesh for a unit.
|
||||
* @param {Unit} unit - The unit instance
|
||||
|
|
@ -2191,6 +2420,42 @@ export class GameLoop {
|
|||
requestAnimationFrame(this.animate);
|
||||
|
||||
if (this.inputManager) this.inputManager.update();
|
||||
|
||||
// Update zone marker animations
|
||||
this.updateZoneMarkers();
|
||||
|
||||
// Handle camera animation if active
|
||||
if (this.isAnimatingCamera && this.controls && this.camera) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.cameraAnimationStartTime;
|
||||
const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0);
|
||||
|
||||
// Ease-out cubic for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
// Interpolate between start and target
|
||||
this.controls.target.lerpVectors(
|
||||
this.cameraAnimationStart,
|
||||
this.cameraAnimationTarget,
|
||||
eased
|
||||
);
|
||||
|
||||
// Maintain camera's relative offset to preserve rotation
|
||||
if (this.cameraAnimationOffset) {
|
||||
this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset);
|
||||
}
|
||||
|
||||
// If animation is complete, snap to final position and stop
|
||||
if (progress >= 1.0) {
|
||||
this.controls.target.copy(this.cameraAnimationTarget);
|
||||
if (this.cameraAnimationOffset) {
|
||||
this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset);
|
||||
}
|
||||
this.isAnimatingCamera = false;
|
||||
this.cameraAnimationOffset = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.controls) this.controls.update();
|
||||
|
||||
const now = Date.now();
|
||||
|
|
@ -2627,6 +2892,26 @@ export class GameLoop {
|
|||
})
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
// Get mission objectives and turn limit from MissionManager
|
||||
let missionObjectives = null;
|
||||
let turnLimit = null;
|
||||
if (this.missionManager) {
|
||||
missionObjectives = {
|
||||
primary: this.missionManager.currentObjectives || [],
|
||||
secondary: this.missionManager.secondaryObjectives || [],
|
||||
};
|
||||
// Find turn limit from failure conditions
|
||||
const turnLimitCondition = (this.missionManager.failureConditions || []).find(
|
||||
(fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit
|
||||
);
|
||||
if (turnLimitCondition) {
|
||||
turnLimit = {
|
||||
limit: turnLimitCondition.turn_limit,
|
||||
current: this.missionManager.currentTurn || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build combat state (enriched for UI, but includes spec fields)
|
||||
const combatState = {
|
||||
// Spec-compliant fields
|
||||
|
|
@ -2642,6 +2927,8 @@ export class GameLoop {
|
|||
targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill
|
||||
activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state)
|
||||
roundNumber: turnSystemState.round, // Alias for UI
|
||||
missionObjectives, // Mission objectives for UI
|
||||
turnLimit, // Turn limit info for UI
|
||||
};
|
||||
|
||||
// Update GameStateManager
|
||||
|
|
@ -2676,6 +2963,7 @@ export class GameLoop {
|
|||
this.clearMovementHighlights();
|
||||
|
||||
// DELEGATE to TurnSystem
|
||||
// Note: Death from damage is handled in executeSkill, death from status effects is handled in startTurn
|
||||
this.turnSystem.endTurn(activeUnit);
|
||||
|
||||
// Update combat state (TurnSystem will have advanced to next unit)
|
||||
|
|
@ -2700,6 +2988,9 @@ export class GameLoop {
|
|||
*/
|
||||
_onTurnStart(detail) {
|
||||
const { unit } = detail;
|
||||
// Center camera on the active unit
|
||||
this.centerCameraOnUnit(unit);
|
||||
|
||||
// Update movement highlights if it's a player's turn
|
||||
if (unit.team === "PLAYER") {
|
||||
this.updateMovementHighlights(unit);
|
||||
|
|
@ -2969,19 +3260,55 @@ export class GameLoop {
|
|||
* @param {Unit} unit - The unit that died
|
||||
*/
|
||||
handleUnitDeath(unit) {
|
||||
if (!unit || !this.grid || !this.unitManager) return;
|
||||
if (!unit) {
|
||||
console.warn("[GameLoop] handleUnitDeath called with null/undefined unit");
|
||||
return;
|
||||
}
|
||||
if (!this.grid || !this.unitManager) {
|
||||
console.warn("[GameLoop] handleUnitDeath called but grid or unitManager not available");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[GameLoop] handleUnitDeath called for ${unit.name} (${unit.id})`);
|
||||
|
||||
// Remove unit from grid
|
||||
if (unit.position) {
|
||||
this.grid.removeUnit(unit.position);
|
||||
console.log(`[GameLoop] Removed ${unit.name} from grid at (${unit.position.x}, ${unit.position.y}, ${unit.position.z})`);
|
||||
} else {
|
||||
console.warn(`[GameLoop] ${unit.name} has no position to remove from grid`);
|
||||
}
|
||||
|
||||
// Remove unit from UnitManager
|
||||
this.unitManager.removeUnit(unit.id);
|
||||
// Dispatch death event to MissionManager BEFORE removing from UnitManager
|
||||
// This allows MissionManager to check if this was the last enemy
|
||||
if (this.missionManager) {
|
||||
const eventType = unit.team === "ENEMY" ? "ENEMY_DEATH" : "PLAYER_DEATH";
|
||||
// Get the definition ID - either from unit.defId or extract from instance ID
|
||||
// Instance IDs are like "ENEMY_ELITE_BREAKER_1", we want "ENEMY_ELITE_BREAKER"
|
||||
let unitDefId = unit.defId;
|
||||
if (!unitDefId && unit.id) {
|
||||
// Extract defId from instance ID by removing the trailing "_N" suffix
|
||||
const match = unit.id.match(/^(.+)_\d+$/);
|
||||
unitDefId = match ? match[1] : unit.id;
|
||||
}
|
||||
if (!unitDefId) {
|
||||
unitDefId = unit.id; // Fallback to instance ID
|
||||
}
|
||||
console.log(`[GameLoop] Dispatching ${eventType} event for ${unit.name} (defId: ${unitDefId}) BEFORE removing from UnitManager`);
|
||||
this.missionManager.onGameEvent(eventType, {
|
||||
unitId: unit.id,
|
||||
defId: unitDefId,
|
||||
team: unit.team,
|
||||
});
|
||||
} else {
|
||||
console.warn(`[GameLoop] MissionManager not available, cannot dispatch death event`);
|
||||
}
|
||||
|
||||
// Remove unit mesh from scene
|
||||
// Remove unit mesh from scene FIRST (before removing from UnitManager)
|
||||
// This ensures the visual removal happens
|
||||
const mesh = this.unitMeshes.get(unit.id);
|
||||
if (mesh) {
|
||||
console.log(`[GameLoop] Removing mesh for ${unit.name} from scene`);
|
||||
this.scene.remove(mesh);
|
||||
this.unitMeshes.delete(unit.id);
|
||||
// Dispose geometry and material
|
||||
|
|
@ -2997,20 +3324,12 @@ export class GameLoop {
|
|||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
console.log(`[GameLoop] Mesh removed and disposed for ${unit.name}`);
|
||||
} else {
|
||||
console.warn(`[GameLoop] No mesh found for ${unit.name} (${unit.id}) in unitMeshes map. Available meshes:`, Array.from(this.unitMeshes.keys()));
|
||||
}
|
||||
|
||||
// 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.`);
|
||||
console.log(`[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3037,6 +3356,11 @@ export class GameLoop {
|
|||
_handleMissionVictory(detail) {
|
||||
console.log("Mission Victory!", detail);
|
||||
|
||||
// End combat first to properly clean up turn system state
|
||||
if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") {
|
||||
this.turnSystem.endCombat();
|
||||
}
|
||||
|
||||
// Save Explorer progression back to roster
|
||||
this._saveExplorerProgression();
|
||||
|
||||
|
|
@ -3046,6 +3370,18 @@ export class GameLoop {
|
|||
// Stop the game loop
|
||||
this.stop();
|
||||
|
||||
// Calculate MissionResult for debrief
|
||||
const missionResult = this._calculateMissionResult(detail);
|
||||
|
||||
// Dispatch show-debrief event (before outro narrative)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-debrief", {
|
||||
detail: { result: missionResult },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Clear the active run from persistence since mission is complete
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.clearActiveRun();
|
||||
|
|
@ -3069,12 +3405,15 @@ export class GameLoop {
|
|||
handleNarrativeEnd
|
||||
);
|
||||
|
||||
// Small delay after narrative ends to let user see the final message
|
||||
setTimeout(() => {
|
||||
// Wait for debrief to close before transitioning
|
||||
// The debrief will dispatch debrief-closed when user clicks return
|
||||
const handleDebriefClosed = () => {
|
||||
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
window.addEventListener("debrief-closed", handleDebriefClosed);
|
||||
};
|
||||
|
||||
narrativeManager.addEventListener("narrative-end", handleNarrativeEnd);
|
||||
|
|
@ -3088,21 +3427,107 @@ export class GameLoop {
|
|||
"narrative-end",
|
||||
handleNarrativeEnd
|
||||
);
|
||||
const handleDebriefClosed = () => {
|
||||
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
};
|
||||
window.addEventListener("debrief-closed", handleDebriefClosed);
|
||||
}, 30000);
|
||||
} else {
|
||||
// No outro, transition immediately after a short delay
|
||||
console.log("GameLoop: No outro narrative, transitioning to hub");
|
||||
setTimeout(() => {
|
||||
// No outro, wait for debrief to close before transitioning
|
||||
console.log("GameLoop: No outro narrative, waiting for debrief to close");
|
||||
const handleDebriefClosed = () => {
|
||||
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||
if (this.gameStateManager) {
|
||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
window.addEventListener("debrief-closed", handleDebriefClosed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates MissionResult for the debrief screen.
|
||||
* @param {Object} detail - Victory event detail
|
||||
* @returns {Object} MissionResult object
|
||||
* @private
|
||||
*/
|
||||
_calculateMissionResult(detail) {
|
||||
const missionDef = this.gameStateManager?.missionManager?.currentMissionDef;
|
||||
const rewards = missionDef?.rewards || {};
|
||||
const guaranteed = rewards.guaranteed || {};
|
||||
|
||||
// Get currency (handle both snake_case and camelCase)
|
||||
const currency = {
|
||||
shards:
|
||||
guaranteed.currency?.aether_shards ||
|
||||
guaranteed.currency?.aetherShards ||
|
||||
0,
|
||||
cores:
|
||||
guaranteed.currency?.ancient_cores ||
|
||||
guaranteed.currency?.ancientCores ||
|
||||
0,
|
||||
};
|
||||
|
||||
// Get XP
|
||||
const xpEarned = guaranteed.xp || 0;
|
||||
|
||||
// Get loot items (convert item IDs to item instances)
|
||||
const loot = [];
|
||||
if (guaranteed.items && Array.isArray(guaranteed.items)) {
|
||||
guaranteed.items.forEach((itemDefId) => {
|
||||
loot.push({
|
||||
defId: itemDefId,
|
||||
name: itemDefId, // Will be resolved by item registry if available
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get reputation changes
|
||||
const reputationChanges = [];
|
||||
if (rewards.faction_reputation) {
|
||||
Object.entries(rewards.faction_reputation).forEach(([factionId, amount]) => {
|
||||
reputationChanges.push({ factionId, amount });
|
||||
});
|
||||
}
|
||||
|
||||
// Get squad status
|
||||
const squadUpdates = [];
|
||||
if (this.unitManager) {
|
||||
const playerUnits = Array.from(
|
||||
this.unitManager.activeUnits.values()
|
||||
).filter((u) => u.team === "PLAYER");
|
||||
|
||||
playerUnits.forEach((unit) => {
|
||||
const isDead = unit.currentHealth <= 0;
|
||||
const maxHealth = unit.maxHealth || unit.health || 100;
|
||||
const currentHealth = unit.currentHealth || 0;
|
||||
const damageTaken = Math.max(0, maxHealth - currentHealth);
|
||||
|
||||
squadUpdates.push({
|
||||
unitId: unit.id || unit.defId || "Unknown",
|
||||
isDead,
|
||||
leveledUp: false, // TODO: Track level ups
|
||||
damageTaken,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: "VICTORY",
|
||||
missionTitle: missionDef?.config?.title || "Mission",
|
||||
xpEarned,
|
||||
currency,
|
||||
loot,
|
||||
reputationChanges,
|
||||
squadUpdates,
|
||||
turnsTaken: this.turnSystem?.currentTurn || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves Explorer progression (classMastery, activeClassId) back to roster.
|
||||
* @private
|
||||
|
|
@ -3165,6 +3590,11 @@ export class GameLoop {
|
|||
_handleMissionFailure(detail) {
|
||||
console.log("Mission Failed!", detail);
|
||||
|
||||
// End combat first to properly clean up turn system state
|
||||
if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") {
|
||||
this.turnSystem.endCombat();
|
||||
}
|
||||
|
||||
// Save Explorer progression back to roster (even on failure, progression should persist)
|
||||
this._saveExplorerProgression();
|
||||
|
||||
|
|
|
|||
|
|
@ -263,6 +263,21 @@ export class VoxelGrid {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a unit from the grid at the specified position.
|
||||
* @param {Position} pos - Position to remove unit from
|
||||
* @returns {boolean} - True if a unit was removed
|
||||
*/
|
||||
removeUnit(pos) {
|
||||
if (!pos) return false;
|
||||
const key = this._key(pos);
|
||||
if (this.unitMap.has(key)) {
|
||||
this.unitMap.delete(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- HAZARDS ---
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ window.addEventListener("gamestate-changed", async (e) => {
|
|||
// Load HubScreen dynamically
|
||||
await import("./ui/screens/hub-screen.js");
|
||||
await import("./ui/components/mission-board.js");
|
||||
await import("./ui/components/mission-review.js");
|
||||
const hub = document.querySelector("hub-screen");
|
||||
if (hub) {
|
||||
hub.toggleAttribute("hidden", false);
|
||||
|
|
@ -369,6 +370,8 @@ if (typeof window !== "undefined") {
|
|||
"addCurrency",
|
||||
"killEnemy",
|
||||
"healUnit",
|
||||
"regenerateMissions",
|
||||
"generateMission",
|
||||
"triggerVictory",
|
||||
"completeObjective",
|
||||
"triggerNarrative",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ export class MissionManager {
|
|||
this.unitManager = null;
|
||||
/** @type {TurnSystem | null} */
|
||||
this.turnSystem = null;
|
||||
/** @type {import("../grid/VoxelGrid.js").VoxelGrid | null} */
|
||||
this.grid = null;
|
||||
/** @type {import("../systems/MovementSystem.js").MovementSystem | null} */
|
||||
this.movementSystem = null;
|
||||
/** @type {boolean} */
|
||||
this._hadEnemies = false; // Track if we had enemies for ELIMINATE_ALL check
|
||||
/** @type {number} */
|
||||
this.currentTurn = 0;
|
||||
|
||||
|
|
@ -101,6 +107,12 @@ export class MissionManager {
|
|||
*/
|
||||
registerMission(missionDef) {
|
||||
this.missionRegistry.set(missionDef.id, missionDef);
|
||||
// Dispatch event to notify UI components (like MissionBoard) that missions have been updated
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("missions-updated", {
|
||||
detail: { missionId: missionDef.id, action: "registered" },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -158,14 +170,22 @@ export class MissionManager {
|
|||
this.missionRegistry.delete(mission.id);
|
||||
});
|
||||
|
||||
// Register refreshed procedural missions
|
||||
// Register refreshed procedural missions (batch register to avoid multiple events)
|
||||
refreshedProcedural.forEach((mission) => {
|
||||
this.registerMission(mission);
|
||||
// Register without dispatching event for each mission
|
||||
this.missionRegistry.set(mission.id, mission);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Refreshed procedural missions: ${refreshedProcedural.length} available`
|
||||
);
|
||||
|
||||
// Dispatch single event after all missions are registered
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("missions-updated", {
|
||||
detail: { action: "refreshed", count: refreshedProcedural.length },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -277,6 +297,78 @@ export class MissionManager {
|
|||
this.turnSystem = turnSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the grid and movement system references for zone coordinate generation.
|
||||
* @param {import("../grid/VoxelGrid.js").VoxelGrid} grid - Voxel grid instance
|
||||
* @param {import("../systems/MovementSystem.js").MovementSystem} movementSystem - Movement system instance
|
||||
*/
|
||||
setGridContext(grid, movementSystem) {
|
||||
this.grid = grid;
|
||||
this.movementSystem = movementSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates zone coordinates for REACH_ZONE objectives that don't have them.
|
||||
* Generates random walkable positions on the map.
|
||||
*/
|
||||
populateZoneCoordinates() {
|
||||
if (!this.grid || !this.movementSystem) {
|
||||
console.warn(
|
||||
"Cannot populate zone coordinates: grid or movementSystem not set"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all REACH_ZONE objectives that need zone_coords
|
||||
const reachZoneObjectives = [
|
||||
...this.currentObjectives,
|
||||
...this.secondaryObjectives,
|
||||
].filter(
|
||||
(obj) =>
|
||||
obj.type === "REACH_ZONE" &&
|
||||
(!obj.zone_coords || obj.zone_coords.length === 0)
|
||||
);
|
||||
|
||||
for (const obj of reachZoneObjectives) {
|
||||
const targetCount = obj.target_count || 3;
|
||||
const zones = [];
|
||||
|
||||
// Generate random walkable positions
|
||||
const attempts = 100;
|
||||
for (let i = 0; i < attempts && zones.length < targetCount; i++) {
|
||||
const x = Math.floor(Math.random() * this.grid.size.x);
|
||||
const z = Math.floor(Math.random() * this.grid.size.z);
|
||||
const y = Math.floor(this.grid.size.y / 2); // Start from middle height
|
||||
|
||||
const walkableY = this.movementSystem.findWalkableY(x, z, y);
|
||||
if (
|
||||
walkableY !== null &&
|
||||
!this.grid.isOccupied({ x, y: walkableY, z })
|
||||
) {
|
||||
// Check if we already have a zone at this position (avoid duplicates)
|
||||
const isDuplicate = zones.some(
|
||||
(zone) => zone.x === x && zone.y === walkableY && zone.z === z
|
||||
);
|
||||
if (!isDuplicate) {
|
||||
zones.push({ x, y: walkableY, z });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (zones.length > 0) {
|
||||
obj.zone_coords = zones;
|
||||
console.log(
|
||||
`Populated ${zones.length} zone coordinates for objective ${obj.id}:`,
|
||||
zones
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`Failed to generate zone coordinates for objective ${obj.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the manager for a new run.
|
||||
* Resets objectives and prepares narrative hooks.
|
||||
|
|
@ -420,14 +512,27 @@ export class MissionManager {
|
|||
// Check for ELIMINATE_ALL objective completion (needs active check)
|
||||
// Check after enemy death or at turn end
|
||||
if (type === "ENEMY_DEATH") {
|
||||
console.log(
|
||||
`[MissionManager] ENEMY_DEATH event received, checking ELIMINATE_ALL objective`
|
||||
);
|
||||
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
||||
console.log(
|
||||
`[MissionManager] ELIMINATE_ALL check returned statusChanged: ${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) {
|
||||
console.log(
|
||||
`[MissionManager] Objective status changed, calling checkVictory()`
|
||||
);
|
||||
this.checkVictory();
|
||||
} else {
|
||||
console.log(
|
||||
`[MissionManager] No objective status change, not checking victory`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,15 +551,27 @@ export class MissionManager {
|
|||
|
||||
// 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
|
||||
) {
|
||||
// Check if the killed enemy matches the target (by defId, not unitId)
|
||||
// unitId is the instance ID, defId is the definition ID we're looking for
|
||||
if (data.defId === obj.target_def_id) {
|
||||
obj.current = (obj.current || 0) + 1;
|
||||
if (obj.target_count && obj.current >= obj.target_count) {
|
||||
const targetCount =
|
||||
obj.target_count !== undefined ? obj.target_count : 1; // Default to 1 if not specified
|
||||
if (obj.current >= targetCount) {
|
||||
obj.complete = true;
|
||||
statusChanged = true;
|
||||
console.log(
|
||||
`[MissionManager] ELIMINATE_UNIT objective completed! Killed ${obj.current}/${targetCount} of ${obj.target_def_id}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[MissionManager] ELIMINATE_UNIT progress: ${obj.current}/${targetCount} of ${obj.target_def_id}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[MissionManager] Enemy killed (${data.defId}) does not match target (${obj.target_def_id})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,17 +585,66 @@ export class MissionManager {
|
|||
|
||||
// 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(
|
||||
if (data.position && obj.zone_coords && obj.zone_coords.length > 0) {
|
||||
// Find which zone was reached (match X and Z, Y can vary slightly due to walkable level)
|
||||
const reachedZoneIndex = obj.zone_coords.findIndex(
|
||||
(coord) =>
|
||||
coord.x === data.position.x &&
|
||||
coord.y === data.position.y &&
|
||||
coord.z === data.position.z
|
||||
coord.z === data.position.z &&
|
||||
Math.abs(coord.y - data.position.y) <= 1 // Allow Y to vary by 1 level
|
||||
);
|
||||
if (reached) {
|
||||
if (reachedZoneIndex !== -1) {
|
||||
// Mark this zone as reached (remove it from the list)
|
||||
const reachedZone = obj.zone_coords[reachedZoneIndex];
|
||||
obj.zone_coords.splice(reachedZoneIndex, 1);
|
||||
obj.current = (obj.current || 0) + 1;
|
||||
|
||||
console.log(
|
||||
`[MissionManager] Zone reached at (${data.position.x}, ${
|
||||
data.position.y
|
||||
}, ${data.position.z})! Progress: ${obj.current}/${
|
||||
obj.target_count || obj.zone_coords.length + obj.current
|
||||
}`
|
||||
);
|
||||
|
||||
// Check if we've reached the target count, or if all zones are reached (no target_count)
|
||||
if (obj.target_count) {
|
||||
// Has target_count: complete when current >= target_count
|
||||
if (obj.current >= obj.target_count) {
|
||||
obj.complete = true;
|
||||
statusChanged = true;
|
||||
console.log(`[MissionManager] REACH_ZONE objective completed!`);
|
||||
} else {
|
||||
statusChanged = true; // Progress updated
|
||||
}
|
||||
} else {
|
||||
// No target_count: complete when all zones are reached
|
||||
if (obj.zone_coords.length === 0) {
|
||||
obj.complete = true;
|
||||
statusChanged = true;
|
||||
console.log(
|
||||
`[MissionManager] REACH_ZONE objective completed (all zones reached)!`
|
||||
);
|
||||
} else {
|
||||
statusChanged = true; // Progress updated
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Debug: log when we're checking but not matching
|
||||
console.log(
|
||||
`[MissionManager] Unit at (${data.position.x}, ${data.position.y}, ${data.position.z}), checking ${obj.zone_coords.length} zones:`,
|
||||
obj.zone_coords.map((c) => `(${c.x}, ${c.y}, ${c.z})`)
|
||||
);
|
||||
}
|
||||
} else if (obj.type === "REACH_ZONE") {
|
||||
console.warn(
|
||||
`[MissionManager] REACH_ZONE objective missing zone_coords or position data:`,
|
||||
{
|
||||
hasPosition: !!data.position,
|
||||
hasZoneCoords: !!obj.zone_coords,
|
||||
zoneCoordsLength: obj.zone_coords?.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -518,14 +684,44 @@ export class MissionManager {
|
|||
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);
|
||||
const allUnits = Array.from(this.unitManager.activeUnits.values());
|
||||
const allEnemies = allUnits.filter((u) => u.team === "ENEMY");
|
||||
const aliveEnemies = allEnemies.filter((u) => u.currentHealth > 0);
|
||||
|
||||
if (enemies.length === 0) {
|
||||
console.log(
|
||||
`[MissionManager] ELIMINATE_ALL check: ${aliveEnemies.length} alive enemies remaining (out of ${allEnemies.length} total enemies, ${allUnits.length} total units)`
|
||||
);
|
||||
|
||||
// If we have an ELIMINATE_ALL objective and no alive enemies, mission is complete
|
||||
// We check aliveEnemies.length === 0 because:
|
||||
// - If enemies exist but are all dead (health <= 0), aliveEnemies will be empty
|
||||
// - If all enemies were removed, aliveEnemies will also be empty
|
||||
// - We only want to complete if we actually had enemies (allEnemies.length > 0 at some point)
|
||||
// But since we're checking after an ENEMY_DEATH event, we know there was at least one enemy
|
||||
if (aliveEnemies.length === 0) {
|
||||
// Check if we had enemies at some point (either still in list dead, or were just removed)
|
||||
// If allEnemies.length > 0, we had enemies (some may be dead but still in list)
|
||||
// If allEnemies.length === 0 but we got an ENEMY_DEATH event, the last enemy was just removed
|
||||
if (allEnemies.length > 0 || this._hadEnemies) {
|
||||
console.log(
|
||||
"[MissionManager] ELIMINATE_ALL objective completed! (No alive enemies)"
|
||||
);
|
||||
obj.complete = true;
|
||||
statusChanged = true;
|
||||
this._hadEnemies = false; // Reset flag
|
||||
} else {
|
||||
console.log(
|
||||
"[MissionManager] ELIMINATE_ALL: No enemies found in unitManager (may not have spawned yet)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Track that we had enemies
|
||||
this._hadEnemies = true;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[MissionManager] checkEliminateAllObjective: unitManager not set"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -602,6 +798,15 @@ export class MissionManager {
|
|||
const allPrimaryComplete =
|
||||
this.currentObjectives.length > 0 &&
|
||||
this.currentObjectives.every((o) => o.complete);
|
||||
|
||||
console.log(
|
||||
`[MissionManager] checkVictory: ${this.currentObjectives.length} objectives, all complete: ${allPrimaryComplete}`,
|
||||
this.currentObjectives.map((o) => ({
|
||||
type: o.type,
|
||||
complete: o.complete,
|
||||
}))
|
||||
);
|
||||
|
||||
if (allPrimaryComplete) {
|
||||
console.log("VICTORY! Mission Objectives Complete.");
|
||||
this.completeActiveMission();
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export class UnitManager {
|
|||
unit = new Explorer(instanceId, def.name, defId, def);
|
||||
} else if (def.type === "ENEMY" || defId.startsWith("ENEMY_")) {
|
||||
unit = new Enemy(instanceId, def.name, def);
|
||||
// Store the definition ID so we can match it later for ELIMINATE_UNIT objectives
|
||||
unit.defId = defId;
|
||||
} else {
|
||||
// Generic/Structure
|
||||
unit = new Unit(instanceId, def.name, "STRUCTURE", def.model);
|
||||
|
|
|
|||
|
|
@ -236,6 +236,9 @@ export class MissionGenerator {
|
|||
// Generate biome config based on archetype
|
||||
const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
|
||||
|
||||
// Generate enemy spawns based on archetype (especially for ASSASSINATION)
|
||||
const enemySpawns = this.generateEnemySpawns(archetype, objectives, validTier);
|
||||
|
||||
// Calculate rewards
|
||||
const rewards = this.calculateRewards(validTier, archetype, biomeType);
|
||||
|
||||
|
|
@ -257,6 +260,7 @@ export class MissionGenerator {
|
|||
squad_size_limit: 4
|
||||
},
|
||||
objectives: objectives,
|
||||
enemy_spawns: enemySpawns,
|
||||
rewards: rewards,
|
||||
expiresIn: 3 // Expires in 3 campaign days
|
||||
};
|
||||
|
|
@ -309,6 +313,7 @@ export class MissionGenerator {
|
|||
id: "OBJ_HUNT",
|
||||
type: "ELIMINATE_UNIT",
|
||||
target_def_id: targetId,
|
||||
target_count: 1, // Explicitly set to 1 for single target elimination
|
||||
description: "A High-Value Target has been spotted. Eliminate them."
|
||||
}],
|
||||
failure_conditions: [{ type: "SQUAD_WIPE" }]
|
||||
|
|
@ -316,6 +321,8 @@ export class MissionGenerator {
|
|||
|
||||
case "RECON":
|
||||
// Generate 3 zone coordinates (simplified - actual zones would be set during mission generation)
|
||||
// Turn limit: 15 + (tier * 5) turns for RECON missions
|
||||
const turnLimit = 15 + (tier * 5);
|
||||
return {
|
||||
primary: [{
|
||||
id: "OBJ_RECON",
|
||||
|
|
@ -325,7 +332,7 @@ export class MissionGenerator {
|
|||
}],
|
||||
failure_conditions: [
|
||||
{ type: "SQUAD_WIPE" },
|
||||
{ type: "TURN_LIMIT_EXCEEDED" }
|
||||
{ type: "TURN_LIMIT_EXCEEDED", turn_limit: turnLimit }
|
||||
]
|
||||
};
|
||||
|
||||
|
|
@ -341,6 +348,90 @@ export class MissionGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates enemy spawns based on archetype and objectives
|
||||
* @param {string} archetype - Mission archetype
|
||||
* @param {Object} objectives - Generated objectives
|
||||
* @param {number} tier - Difficulty tier
|
||||
* @returns {Array<EnemySpawn>} Array of enemy spawn definitions
|
||||
*/
|
||||
static generateEnemySpawns(archetype, objectives, tier) {
|
||||
const spawns = [];
|
||||
|
||||
switch (archetype) {
|
||||
case "ASSASSINATION":
|
||||
// For ASSASSINATION, spawn the target enemy from the ELIMINATE_UNIT objective
|
||||
const eliminateUnitObj = objectives.primary?.find(
|
||||
(obj) => obj.type === "ELIMINATE_UNIT"
|
||||
);
|
||||
if (eliminateUnitObj?.target_def_id) {
|
||||
spawns.push({
|
||||
enemy_def_id: eliminateUnitObj.target_def_id,
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
// Also spawn some regular enemies for support
|
||||
const regularEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER",
|
||||
"ENEMY_CRYSTAL_SHARD"
|
||||
];
|
||||
const supportEnemy = this.randomChoice(regularEnemies);
|
||||
spawns.push({
|
||||
enemy_def_id: supportEnemy,
|
||||
count: Math.max(1, Math.floor(tier / 2)) // 1-2 support enemies based on tier
|
||||
});
|
||||
break;
|
||||
|
||||
case "SKIRMISH":
|
||||
// Skirmish missions spawn a mix of enemies
|
||||
const skirmishEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER",
|
||||
"ENEMY_CRYSTAL_SHARD"
|
||||
];
|
||||
const totalSkirmish = 3 + tier; // 4-8 enemies based on tier
|
||||
for (let i = 0; i < totalSkirmish; i++) {
|
||||
const enemyType = this.randomChoice(skirmishEnemies);
|
||||
const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType);
|
||||
if (existingSpawn) {
|
||||
existingSpawn.count++;
|
||||
} else {
|
||||
spawns.push({ enemy_def_id: enemyType, count: 1 });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "SALVAGE":
|
||||
case "RECON":
|
||||
// These missions have fewer enemies (lower density)
|
||||
const lightEnemies = [
|
||||
"ENEMY_SHARDBORN_SENTINEL",
|
||||
"ENEMY_GOBLIN_RAIDER"
|
||||
];
|
||||
const totalLight = Math.max(1, tier); // 1-5 enemies based on tier
|
||||
for (let i = 0; i < totalLight; i++) {
|
||||
const enemyType = this.randomChoice(lightEnemies);
|
||||
const existingSpawn = spawns.find((s) => s.enemy_def_id === enemyType);
|
||||
if (existingSpawn) {
|
||||
existingSpawn.count++;
|
||||
} else {
|
||||
spawns.push({ enemy_def_id: enemyType, count: 1 });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Default: spawn a few basic enemies
|
||||
spawns.push({
|
||||
enemy_def_id: "ENEMY_SHARDBORN_SENTINEL",
|
||||
count: Math.max(1, tier)
|
||||
});
|
||||
}
|
||||
|
||||
return spawns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates biome configuration based on archetype
|
||||
* @param {string} archetype - Mission archetype
|
||||
|
|
|
|||
|
|
@ -160,7 +160,11 @@ export class TurnSystem extends EventTarget {
|
|||
if (isStunned) {
|
||||
// Process hazards first, then status effects
|
||||
this.processEnvironmentalHazards(unit);
|
||||
this.processStatusEffects(unit);
|
||||
const unitDied = this.processStatusEffects(unit);
|
||||
// Check if unit died from status effects
|
||||
if (unitDied && this.onUnitDeathCallback) {
|
||||
this.onUnitDeathCallback(unit);
|
||||
}
|
||||
// Skip action phase, immediately end turn
|
||||
this.phase = "TURN_END";
|
||||
this.endTurn(unit);
|
||||
|
|
@ -172,7 +176,15 @@ export class TurnSystem extends EventTarget {
|
|||
this.processEnvironmentalHazards(unit);
|
||||
|
||||
// D. Status Effect Tick (The "Upkeep" Step)
|
||||
this.processStatusEffects(unit);
|
||||
const unitDied = this.processStatusEffects(unit);
|
||||
|
||||
// Check if unit died from status effects - if so, handle death and skip turn
|
||||
if (unitDied && this.onUnitDeathCallback) {
|
||||
this.onUnitDeathCallback(unit);
|
||||
// Skip turn for dead unit
|
||||
this.endTurn(unit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch turn-start event
|
||||
this.dispatchEvent(
|
||||
|
|
@ -257,11 +269,13 @@ export class TurnSystem extends EventTarget {
|
|||
/**
|
||||
* Processes status effects for a unit (DoT/HoT, duration decrement, expiration).
|
||||
* @param {Unit} unit - The unit to process status effects for
|
||||
* @returns {boolean} True if the unit died from status effects
|
||||
*/
|
||||
processStatusEffects(unit) {
|
||||
if (!unit.statusEffects || unit.statusEffects.length === 0) return;
|
||||
if (!unit.statusEffects || unit.statusEffects.length === 0) return false;
|
||||
|
||||
const effectsToRemove = [];
|
||||
const healthBefore = unit.currentHealth;
|
||||
|
||||
unit.statusEffects.forEach((effect, index) => {
|
||||
// Apply DoT/HoT if applicable
|
||||
|
|
@ -311,6 +325,9 @@ export class TurnSystem extends EventTarget {
|
|||
effectsToRemove.reverse().forEach((index) => {
|
||||
unit.statusEffects.splice(index, 1);
|
||||
});
|
||||
|
||||
// Return true if unit died from status effects
|
||||
return healthBefore > 0 && unit.currentHealth <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -386,6 +386,82 @@ export class CombatHUD extends LitElement {
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Objectives Panel */
|
||||
.objectives-panel {
|
||||
position: absolute;
|
||||
top: 130px;
|
||||
left: var(--spacing-xl);
|
||||
background: var(--color-bg-primary);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-md);
|
||||
min-width: 250px;
|
||||
max-width: 350px;
|
||||
pointer-events: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.objectives-panel h3 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-accent-gold);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-default);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.objective-item {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
padding: var(--spacing-xs);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-left: var(--border-width-thin) solid var(--color-border-default);
|
||||
padding-left: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.objective-item.complete {
|
||||
opacity: 0.6;
|
||||
border-left-color: var(--color-accent-green);
|
||||
}
|
||||
|
||||
.objective-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.objective-progress {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.turn-limit-indicator {
|
||||
position: absolute;
|
||||
top: 130px;
|
||||
right: var(--spacing-xl);
|
||||
background: var(--color-bg-primary);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.turn-limit-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.turn-limit-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-orange);
|
||||
}
|
||||
|
||||
.turn-limit-value.warning {
|
||||
color: var(--color-accent-red);
|
||||
}
|
||||
|
||||
/* Responsive Design - Mobile (< 768px) */
|
||||
@media (max-width: 767px) {
|
||||
.bottom-bar {
|
||||
|
|
@ -569,12 +645,23 @@ export class CombatHUD extends LitElement {
|
|||
return html``;
|
||||
}
|
||||
|
||||
const { activeUnit, enrichedQueue, turnQueue, roundNumber, round } =
|
||||
this.combatState;
|
||||
const {
|
||||
activeUnit,
|
||||
enrichedQueue,
|
||||
turnQueue,
|
||||
roundNumber,
|
||||
round,
|
||||
missionObjectives,
|
||||
turnLimit,
|
||||
} = this.combatState;
|
||||
// Use enrichedQueue if available (for UI), otherwise fall back to turnQueue
|
||||
const displayQueue = enrichedQueue || turnQueue || [];
|
||||
const threatLevel = this._getThreatLevel();
|
||||
|
||||
// Calculate turn limit warning (less than 25% remaining)
|
||||
const turnLimitWarning =
|
||||
turnLimit && turnLimit.current / turnLimit.limit > 0.75;
|
||||
|
||||
return html`
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
|
|
@ -605,6 +692,76 @@ export class CombatHUD extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Objectives Panel -->
|
||||
${missionObjectives &&
|
||||
(missionObjectives.primary?.length > 0 ||
|
||||
missionObjectives.secondary?.length > 0)
|
||||
? html`
|
||||
<div class="objectives-panel">
|
||||
<h3>OBJECTIVES</h3>
|
||||
${missionObjectives.primary?.map(
|
||||
(obj) => html`
|
||||
<div
|
||||
class="objective-item ${obj.complete ? "complete" : ""}"
|
||||
>
|
||||
<div class="objective-description">
|
||||
${obj.complete ? "✓ " : ""}${obj.description}
|
||||
</div>
|
||||
${obj.target_count && obj.current !== undefined
|
||||
? html`
|
||||
<div class="objective-progress">
|
||||
${obj.current}/${obj.target_count}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${obj.type === "REACH_ZONE" && obj.zone_coords
|
||||
? html`
|
||||
<div class="objective-progress">
|
||||
Zones: ${obj.zone_coords.length} target${obj.zone_coords.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
${missionObjectives.secondary?.length > 0
|
||||
? html`
|
||||
<h3 style="margin-top: var(--spacing-md);">
|
||||
SECONDARY
|
||||
</h3>
|
||||
${missionObjectives.secondary.map(
|
||||
(obj) => html`
|
||||
<div
|
||||
class="objective-item ${obj.complete
|
||||
? "complete"
|
||||
: ""}"
|
||||
>
|
||||
<div class="objective-description">
|
||||
${obj.complete ? "✓ " : ""}${obj.description}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!-- Turn Limit Indicator -->
|
||||
${turnLimit
|
||||
? html`
|
||||
<div class="turn-limit-indicator">
|
||||
<div class="turn-limit-label">TURN LIMIT</div>
|
||||
<div
|
||||
class="turn-limit-value ${turnLimitWarning ? "warning" : ""}"
|
||||
>
|
||||
${turnLimit.current}/${turnLimit.limit}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="bottom-bar">
|
||||
<!-- Unit Status (Bottom-Left) -->
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { gameStateManager } from '../../core/GameStateManager.js';
|
||||
import { theme, buttonStyles, cardStyles, gridStyles, badgeStyles } from '../styles/theme.js';
|
||||
import { theme, buttonStyles, cardStyles, gridStyles, badgeStyles, tabStyles, overlayStyles } from '../styles/theme.js';
|
||||
|
||||
/**
|
||||
* MissionBoard.js
|
||||
|
|
@ -15,6 +15,8 @@ export class MissionBoard extends LitElement {
|
|||
cardStyles,
|
||||
gridStyles,
|
||||
badgeStyles,
|
||||
tabStyles,
|
||||
overlayStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
|
|
@ -70,6 +72,13 @@ export class MissionBoard extends LitElement {
|
|||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mission-card.completed:hover {
|
||||
border-color: var(--color-accent-green);
|
||||
box-shadow: var(--shadow-glow-green);
|
||||
transform: translateY(-2px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mission-card.locked {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -188,6 +197,34 @@ export class MissionBoard extends LitElement {
|
|||
padding: var(--spacing-2xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-cyan);
|
||||
margin: var(--spacing-xl) 0 var(--spacing-md) 0;
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.section-header:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-2xl);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
line-height: 1;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--color-accent-red);
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
|
@ -196,6 +233,8 @@ export class MissionBoard extends LitElement {
|
|||
return {
|
||||
missions: { type: Array },
|
||||
completedMissions: { type: Set },
|
||||
activeTab: { type: String },
|
||||
reviewMission: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -203,16 +242,46 @@ export class MissionBoard extends LitElement {
|
|||
super();
|
||||
this.missions = [];
|
||||
this.completedMissions = new Set();
|
||||
this.activeTab = 'active';
|
||||
this.reviewMission = null;
|
||||
this._isLoading = false; // Guard to prevent infinite loops
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadMissions();
|
||||
// Load missions and refresh procedural missions only on first open
|
||||
this._initialLoad();
|
||||
|
||||
// Listen for campaign data changes to refresh completed missions
|
||||
this._boundHandleCampaignChange = this._handleCampaignChange.bind(this);
|
||||
// Listen for mission updates (new missions added, missions refreshed)
|
||||
this._boundHandleMissionsUpdate = this._handleMissionsUpdate.bind(this);
|
||||
|
||||
window.addEventListener('campaign-data-changed', this._boundHandleCampaignChange);
|
||||
window.addEventListener('gamestate-changed', this._boundHandleCampaignChange);
|
||||
window.addEventListener('missions-updated', this._boundHandleMissionsUpdate);
|
||||
}
|
||||
|
||||
async _initialLoad() {
|
||||
// Guard to prevent multiple initial loads
|
||||
if (this._isLoading) {
|
||||
return;
|
||||
}
|
||||
this._isLoading = true;
|
||||
|
||||
try {
|
||||
// Ensure missions are loaded before accessing registry
|
||||
await gameStateManager.missionManager._ensureMissionsLoaded();
|
||||
// Refresh procedural missions if unlocked (to ensure board is populated on first open)
|
||||
// This only happens once when the board is first opened
|
||||
if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) {
|
||||
gameStateManager.missionManager.refreshProceduralMissions();
|
||||
}
|
||||
// Then load the missions directly (bypass guard since we're already in a guarded context)
|
||||
await this._loadMissionsInternal();
|
||||
} finally {
|
||||
this._isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -221,6 +290,9 @@ export class MissionBoard extends LitElement {
|
|||
window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange);
|
||||
window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange);
|
||||
}
|
||||
if (this._boundHandleMissionsUpdate) {
|
||||
window.removeEventListener('missions-updated', this._boundHandleMissionsUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
_handleCampaignChange() {
|
||||
|
|
@ -228,13 +300,34 @@ export class MissionBoard extends LitElement {
|
|||
this._loadMissions();
|
||||
}
|
||||
|
||||
_handleMissionsUpdate() {
|
||||
// Reload missions when new missions are added or missions are refreshed
|
||||
// Use guard to prevent infinite loops
|
||||
if (!this._isLoading) {
|
||||
this._loadMissions();
|
||||
}
|
||||
}
|
||||
|
||||
async _loadMissions() {
|
||||
// Guard to prevent infinite loops
|
||||
if (this._isLoading) {
|
||||
return;
|
||||
}
|
||||
this._isLoading = true;
|
||||
|
||||
try {
|
||||
await this._loadMissionsInternal();
|
||||
} finally {
|
||||
this._isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _loadMissionsInternal() {
|
||||
// Internal method that actually loads missions (no guard check)
|
||||
// Ensure missions are loaded before accessing registry
|
||||
await gameStateManager.missionManager._ensureMissionsLoaded();
|
||||
// Refresh procedural missions if unlocked (to ensure board is populated)
|
||||
if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) {
|
||||
gameStateManager.missionManager.refreshProceduralMissions();
|
||||
}
|
||||
// Don't automatically refresh procedural missions here - that causes infinite loops
|
||||
// Missions should be refreshed when the board is first opened, not on every update
|
||||
// Get all registered missions from MissionManager
|
||||
const missionRegistry = gameStateManager.missionManager.missionRegistry;
|
||||
this.missions = Array.from(missionRegistry.values());
|
||||
|
|
@ -300,6 +393,19 @@ export class MissionBoard extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
_reviewMission(mission) {
|
||||
this.reviewMission = mission;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_closeReview(e) {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
this.reviewMission = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_formatRewards(rewards) {
|
||||
const rewardItems = [];
|
||||
|
||||
|
|
@ -336,33 +442,34 @@ export class MissionBoard extends LitElement {
|
|||
return 'Unknown';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.missions.length === 0) {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="btn btn-close" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<p>No missions available at this time.</p>
|
||||
</div>
|
||||
`;
|
||||
_getActiveMissions() {
|
||||
return this.missions.filter(mission => {
|
||||
if (!this._shouldShowMission(mission)) {
|
||||
return false;
|
||||
}
|
||||
return !this._isMissionCompleted(mission.id);
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
_getCompletedMissions() {
|
||||
return this.missions.filter(mission => {
|
||||
if (!this._shouldShowMission(mission)) {
|
||||
return false;
|
||||
}
|
||||
return this._isMissionCompleted(mission.id);
|
||||
});
|
||||
}
|
||||
|
||||
<div class="missions-grid">
|
||||
${this.missions
|
||||
.filter(mission => this._shouldShowMission(mission))
|
||||
.map((mission) => {
|
||||
_getCompletedMissionsByType() {
|
||||
const completed = this._getCompletedMissions();
|
||||
return {
|
||||
story: completed.filter(m => m.type === 'STORY'),
|
||||
sideQuest: completed.filter(m => m.type === 'SIDE_QUEST'),
|
||||
other: completed.filter(m => m.type !== 'STORY' && m.type !== 'SIDE_QUEST'),
|
||||
};
|
||||
}
|
||||
|
||||
_renderMissionCard(mission) {
|
||||
const isCompleted = this._isMissionCompleted(mission.id);
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
const rewards = this._formatRewards(mission.rewards?.guaranteed || mission.rewards || {});
|
||||
|
|
@ -370,7 +477,13 @@ export class MissionBoard extends LitElement {
|
|||
return html`
|
||||
<div
|
||||
class="mission-card ${isCompleted ? 'completed' : ''} ${!isAvailable ? 'locked' : ''}"
|
||||
@click=${() => isAvailable && this._selectMission(mission)}
|
||||
@click=${() => {
|
||||
if (isCompleted) {
|
||||
this._reviewMission(mission);
|
||||
} else if (isAvailable) {
|
||||
this._selectMission(mission);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="mission-header">
|
||||
<h3 class="mission-title">${mission.config?.title || mission.id}</h3>
|
||||
|
|
@ -398,7 +511,11 @@ export class MissionBoard extends LitElement {
|
|||
<span class="difficulty">
|
||||
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
||||
</span>
|
||||
${isCompleted ? html`<span style="color: var(--color-accent-green);">✓ Completed</span>` : ''}
|
||||
${isCompleted ? html`
|
||||
<span style="color: var(--color-accent-green); cursor: pointer;" title="Click to review mission">
|
||||
✓ Completed - Click to Review
|
||||
</span>
|
||||
` : ''}
|
||||
${!isAvailable && !isCompleted ? html`
|
||||
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
|
||||
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
|
||||
|
|
@ -421,8 +538,111 @@ export class MissionBoard extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.missions.length === 0) {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<p>No missions available at this time.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const activeMissions = this._getActiveMissions();
|
||||
const completedByType = this._getCompletedMissionsByType();
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab-button ${this.activeTab === 'active' ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
this.activeTab = 'active';
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
class="tab-button ${this.activeTab === 'completed' ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
this.activeTab = 'completed';
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
Completed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
${this.activeTab === 'active' ? html`
|
||||
${activeMissions.length === 0 ? html`
|
||||
<div class="empty-state">
|
||||
<p>No active missions available at this time.</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="missions-grid">
|
||||
${activeMissions.map(mission => this._renderMissionCard(mission))}
|
||||
</div>
|
||||
`}
|
||||
` : html`
|
||||
${completedByType.story.length === 0 && completedByType.sideQuest.length === 0 && completedByType.other.length === 0 ? html`
|
||||
<div class="empty-state">
|
||||
<p>No completed missions yet.</p>
|
||||
</div>
|
||||
` : html`
|
||||
${completedByType.story.length > 0 ? html`
|
||||
<h3 class="section-header">Story Missions</h3>
|
||||
<div class="missions-grid">
|
||||
${completedByType.story.map(mission => this._renderMissionCard(mission))}
|
||||
</div>
|
||||
` : ''}
|
||||
${completedByType.sideQuest.length > 0 ? html`
|
||||
<h3 class="section-header">Side Quests</h3>
|
||||
<div class="missions-grid">
|
||||
${completedByType.sideQuest.map(mission => this._renderMissionCard(mission))}
|
||||
</div>
|
||||
` : ''}
|
||||
${completedByType.other.length > 0 ? html`
|
||||
<h3 class="section-header">Other Missions</h3>
|
||||
<div class="missions-grid">
|
||||
${completedByType.other.map(mission => this._renderMissionCard(mission))}
|
||||
</div>
|
||||
` : ''}
|
||||
`}
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this.reviewMission ? html`
|
||||
<div class="overlay-container active">
|
||||
<div class="overlay-backdrop" @click=${(e) => {
|
||||
e.stopPropagation();
|
||||
this._closeReview(e);
|
||||
}}></div>
|
||||
<div class="overlay-content" @click=${(e) => e.stopPropagation()}>
|
||||
<mission-review
|
||||
.mission=${this.reviewMission}
|
||||
@review-close=${(e) => {
|
||||
e.stopPropagation();
|
||||
this._closeReview(e);
|
||||
}}
|
||||
></mission-review>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
444
src/ui/components/mission-review.js
Normal file
444
src/ui/components/mission-review.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { LitElement, html, css } from 'lit';
|
||||
import { theme, buttonStyles, cardStyles, overlayStyles } from '../styles/theme.js';
|
||||
|
||||
/**
|
||||
* MissionReview.js
|
||||
* Component for reviewing completed missions, showing rewards and narrative.
|
||||
* @class
|
||||
*/
|
||||
export class MissionReview extends LitElement {
|
||||
static get styles() {
|
||||
return [
|
||||
theme,
|
||||
buttonStyles,
|
||||
cardStyles,
|
||||
overlayStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 900px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-bottom: var(--border-width-medium) solid var(--color-border-default);
|
||||
padding-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-2xl);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
line-height: 1;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--color-accent-red);
|
||||
}
|
||||
|
||||
.mission-info {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.mission-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-cyan);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mission-description {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-cyan);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.rewards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.reward-item {
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width-thin) solid var(--color-border-default);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.reward-icon {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.reward-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.reward-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.narrative-content {
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width-thin) solid var(--color-border-default);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--border-radius-md);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.narrative-node {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.narrative-node:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.narrative-speaker {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent-cyan);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.narrative-text {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.narrative-portrait {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--border-radius-md);
|
||||
border: var(--border-width-thin) solid var(--color-border-default);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-narrative {
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.narrative-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.narrative-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
mission: { type: Object },
|
||||
introNarrative: { type: Object },
|
||||
outroNarrative: { type: Object },
|
||||
loading: { type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.mission = null;
|
||||
this.introNarrative = null;
|
||||
this.outroNarrative = null;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.mission) {
|
||||
await this._loadNarratives();
|
||||
}
|
||||
}
|
||||
|
||||
async updated(changedProperties) {
|
||||
if (changedProperties.has('mission') && this.mission) {
|
||||
await this._loadNarratives();
|
||||
}
|
||||
}
|
||||
|
||||
_mapNarrativeIdToFileName(narrativeId) {
|
||||
// Convert NARRATIVE_STORY_03_INTRO -> narrative_story_03_intro
|
||||
// Remove NARRATIVE_ prefix and convert to lowercase
|
||||
const mapping = {
|
||||
NARRATIVE_TUTORIAL_INTRO: "tutorial_intro",
|
||||
NARRATIVE_TUTORIAL_SUCCESS: "tutorial_success",
|
||||
NARRATIVE_ACT1_FINAL_WIN: "act1_final_win",
|
||||
NARRATIVE_ACT1_FINAL_LOSE: "act1_final_lose",
|
||||
};
|
||||
|
||||
if (mapping[narrativeId]) {
|
||||
return mapping[narrativeId];
|
||||
}
|
||||
|
||||
// For unmapped IDs, convert NARRATIVE_XYZ -> narrative_xyz
|
||||
return narrativeId.toLowerCase().replace("narrative_", "");
|
||||
}
|
||||
|
||||
async _loadNarratives() {
|
||||
this.loading = true;
|
||||
this.introNarrative = null;
|
||||
this.outroNarrative = null;
|
||||
|
||||
const narrative = this.mission?.narrative;
|
||||
if (!narrative) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPromises = [];
|
||||
|
||||
if (narrative.intro_sequence) {
|
||||
loadPromises.push(
|
||||
this._loadNarrativeFile(narrative.intro_sequence).then(data => {
|
||||
this.introNarrative = data;
|
||||
}).catch(err => {
|
||||
console.error('Failed to load intro narrative:', err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (narrative.outro_success) {
|
||||
loadPromises.push(
|
||||
this._loadNarrativeFile(narrative.outro_success).then(data => {
|
||||
this.outroNarrative = data;
|
||||
}).catch(err => {
|
||||
console.error('Failed to load outro narrative:', err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
this.loading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
async _loadNarrativeFile(narrativeId) {
|
||||
const fileName = this._mapNarrativeIdToFileName(narrativeId);
|
||||
const response = await fetch(`assets/data/narrative/${fileName}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load narrative: ${fileName}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
_formatRewards(rewards) {
|
||||
const rewardItems = [];
|
||||
|
||||
if (rewards?.currency) {
|
||||
const shards = rewards.currency.aether_shards || rewards.currency.aetherShards || 0;
|
||||
const cores = rewards.currency.ancient_cores || rewards.currency.ancientCores || 0;
|
||||
|
||||
if (shards > 0) {
|
||||
rewardItems.push({
|
||||
icon: '💎',
|
||||
label: 'Aether Shards',
|
||||
value: shards
|
||||
});
|
||||
}
|
||||
if (cores > 0) {
|
||||
rewardItems.push({
|
||||
icon: '⚙️',
|
||||
label: 'Ancient Cores',
|
||||
value: cores
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rewards?.xp) {
|
||||
rewardItems.push({
|
||||
icon: '⭐',
|
||||
label: 'Experience',
|
||||
value: rewards.xp
|
||||
});
|
||||
}
|
||||
|
||||
if (rewards?.unlocks && Array.isArray(rewards.unlocks)) {
|
||||
rewards.unlocks.forEach(unlock => {
|
||||
rewardItems.push({
|
||||
icon: '🔓',
|
||||
label: 'Unlock',
|
||||
value: unlock
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return rewardItems;
|
||||
}
|
||||
|
||||
_renderNarrativeNodes(nodes) {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return html`<div class="empty-narrative">No narrative content available.</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${nodes.map(node => html`
|
||||
<div class="narrative-node">
|
||||
${node.portrait ? html`
|
||||
<img
|
||||
src="${node.portrait}"
|
||||
alt="${node.speaker || 'Speaker'}"
|
||||
class="narrative-portrait"
|
||||
@error=${(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
` : ''}
|
||||
${node.speaker ? html`
|
||||
<div class="narrative-speaker">${node.speaker}</div>
|
||||
` : ''}
|
||||
<div class="narrative-text">${node.text || ''}</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.mission) {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>Mission Review</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<p>No mission data available.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const rewards = this._formatRewards(this.mission.rewards?.guaranteed || this.mission.rewards || {});
|
||||
const config = this.mission.config || {};
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>Mission Review</h2>
|
||||
<button class="close-button" @click=${(e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('review-close', { bubbles: false, composed: true }));
|
||||
}}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mission-info">
|
||||
<div class="mission-title">${config.title || this.mission.id}</div>
|
||||
<div class="mission-description">${config.description || 'No description available.'}</div>
|
||||
</div>
|
||||
|
||||
${rewards.length > 0 ? html`
|
||||
<div class="section">
|
||||
<div class="section-title">Rewards</div>
|
||||
<div class="rewards-grid">
|
||||
${rewards.map(reward => html`
|
||||
<div class="reward-item">
|
||||
<span class="reward-icon">${reward.icon}</span>
|
||||
<div class="reward-text">
|
||||
<span class="reward-label">${reward.label}</span>
|
||||
<span class="reward-value">${reward.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.loading ? html`
|
||||
<div class="section">
|
||||
<div class="loading">Loading narrative content...</div>
|
||||
</div>
|
||||
` : html`
|
||||
${this.introNarrative || this.outroNarrative ? html`
|
||||
<div class="section">
|
||||
<div class="section-title">Narrative</div>
|
||||
<div class="narrative-content">
|
||||
${this.introNarrative ? html`
|
||||
<div class="narrative-section">
|
||||
<h4 style="color: var(--color-accent-cyan); margin-bottom: var(--spacing-md); font-size: var(--font-size-lg);">
|
||||
Mission Briefing
|
||||
</h4>
|
||||
${this._renderNarrativeNodes(this.introNarrative.nodes)}
|
||||
</div>
|
||||
` : ''}
|
||||
${this.outroNarrative ? html`
|
||||
<div class="narrative-section">
|
||||
<h4 style="color: var(--color-accent-cyan); margin-bottom: var(--spacing-md); font-size: var(--font-size-lg);">
|
||||
Mission Conclusion
|
||||
</h4>
|
||||
${this._renderNarrativeNodes(this.outroNarrative.nodes)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mission-review', MissionReview);
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ export class GameViewport extends LitElement {
|
|||
deployedIds: { type: Array },
|
||||
combatState: { type: Object },
|
||||
missionDef: { type: Object },
|
||||
debriefResult: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ export class GameViewport extends LitElement {
|
|||
this.deployedIds = [];
|
||||
this.combatState = null;
|
||||
this.missionDef = null;
|
||||
this.debriefResult = null;
|
||||
|
||||
// Set up event listeners early so we don't miss events
|
||||
this.#setupCombatStateUpdates();
|
||||
|
|
@ -126,12 +128,25 @@ export class GameViewport extends LitElement {
|
|||
|
||||
// Listen for mission end events to clear state
|
||||
window.addEventListener("mission-victory", () => {
|
||||
this.#clearState();
|
||||
// Don't clear state immediately - wait for debrief
|
||||
});
|
||||
window.addEventListener("mission-failure", () => {
|
||||
this.#clearState();
|
||||
});
|
||||
|
||||
// Listen for show-debrief event
|
||||
window.addEventListener("show-debrief", async (e) => {
|
||||
// Dynamically import MissionDebrief when needed
|
||||
await import("./screens/MissionDebrief.js");
|
||||
this.debriefResult = e.detail.result;
|
||||
});
|
||||
|
||||
// Listen for debrief-closed event
|
||||
window.addEventListener("debrief-closed", () => {
|
||||
this.debriefResult = null;
|
||||
this.#clearState();
|
||||
});
|
||||
|
||||
// Initial updates
|
||||
this.#updateCombatState();
|
||||
this.#updateSquad();
|
||||
|
|
@ -187,7 +202,17 @@ export class GameViewport extends LitElement {
|
|||
@skill-click=${this.#handleSkillClick}
|
||||
@movement-click=${this.#handleMovementClick}
|
||||
></combat-hud>
|
||||
<dialogue-overlay></dialogue-overlay>`;
|
||||
<dialogue-overlay></dialogue-overlay>
|
||||
${this.debriefResult
|
||||
? html`<mission-debrief
|
||||
.result=${this.debriefResult}
|
||||
@return-to-hub=${() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("debrief-closed", { bubbles: true, composed: true })
|
||||
);
|
||||
}}
|
||||
></mission-debrief>`
|
||||
: html``}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -460,12 +460,19 @@ export class MissionDebrief extends LitElement {
|
|||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
// Dispatch both events for compatibility
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("return-to-hub", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("debrief-closed", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
|||
|
|
@ -406,6 +406,71 @@ describe("Core: GameLoop - Combat Skill Targeting and Execution", function () {
|
|||
}
|
||||
});
|
||||
|
||||
it("should dispatch UNIT_MOVE event to MissionManager when teleporting", async () => {
|
||||
const skillId = "SKILL_TELEPORT";
|
||||
const skillDef = {
|
||||
id: skillId,
|
||||
name: "Phase Shift",
|
||||
costs: { ap: 2 },
|
||||
targeting: {
|
||||
range: 5,
|
||||
type: "EMPTY",
|
||||
line_of_sight: false,
|
||||
},
|
||||
effects: [{ type: "TELEPORT" }],
|
||||
};
|
||||
|
||||
skillRegistry.skills.set(skillId, skillDef);
|
||||
|
||||
if (!playerUnit.actions) {
|
||||
playerUnit.actions = [];
|
||||
}
|
||||
playerUnit.actions.push({
|
||||
id: skillId,
|
||||
name: "Phase Shift",
|
||||
costAP: 2,
|
||||
cooldown: 0,
|
||||
});
|
||||
|
||||
const originalPos = { ...playerUnit.position };
|
||||
const targetPos = {
|
||||
x: originalPos.x + 3,
|
||||
y: originalPos.y,
|
||||
z: originalPos.z + 3,
|
||||
};
|
||||
|
||||
// Ensure target position is valid and empty
|
||||
if (gameLoop.grid.isOccupied(targetPos)) {
|
||||
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
|
||||
if (unitAtPos) {
|
||||
gameLoop.grid.removeUnit(unitAtPos);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure target position is walkable
|
||||
gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor
|
||||
gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air
|
||||
gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air above
|
||||
|
||||
// Set up MissionManager spy
|
||||
const onGameEventSpy = sinon.spy();
|
||||
if (gameLoop.missionManager) {
|
||||
gameLoop.missionManager.onGameEvent = onGameEventSpy;
|
||||
}
|
||||
|
||||
await gameLoop.executeSkill(skillId, targetPos);
|
||||
|
||||
// Verify UNIT_MOVE event was dispatched
|
||||
expect(onGameEventSpy.called).to.be.true;
|
||||
const unitMoveCall = onGameEventSpy.getCalls().find(
|
||||
(call) => call.args[0] === "UNIT_MOVE"
|
||||
);
|
||||
expect(unitMoveCall).to.not.be.undefined;
|
||||
expect(unitMoveCall.args[1].unitId).to.equal(playerUnit.id);
|
||||
expect(unitMoveCall.args[1].position.x).to.equal(targetPos.x);
|
||||
expect(unitMoveCall.args[1].position.z).to.equal(targetPos.z);
|
||||
});
|
||||
|
||||
it("should deduct AP when executing TELEPORT skill", async () => {
|
||||
const skillId = "SKILL_TELEPORT";
|
||||
const skillDef = {
|
||||
|
|
|
|||
|
|
@ -403,6 +403,87 @@ describe("Manager: MissionManager", () => {
|
|||
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 19b: Should track progress for REACH_ZONE objective with multiple zones", async () => {
|
||||
await manager.setupActiveMission();
|
||||
manager.currentObjectives = [
|
||||
{
|
||||
type: "REACH_ZONE",
|
||||
target_count: 3,
|
||||
zone_coords: [
|
||||
{ x: 5, y: 0, z: 5 },
|
||||
{ x: 10, y: 0, z: 10 },
|
||||
{ x: 15, y: 0, z: 15 },
|
||||
],
|
||||
current: 0,
|
||||
complete: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Reach first zone
|
||||
manager.onGameEvent("UNIT_MOVE", {
|
||||
position: { x: 5, y: 0, z: 5 },
|
||||
});
|
||||
expect(manager.currentObjectives[0].current).to.equal(1);
|
||||
expect(manager.currentObjectives[0].complete).to.be.false;
|
||||
expect(manager.currentObjectives[0].zone_coords.length).to.equal(2);
|
||||
|
||||
// Reach second zone
|
||||
manager.onGameEvent("UNIT_MOVE", {
|
||||
position: { x: 10, y: 0, z: 10 },
|
||||
});
|
||||
expect(manager.currentObjectives[0].current).to.equal(2);
|
||||
expect(manager.currentObjectives[0].complete).to.be.false;
|
||||
expect(manager.currentObjectives[0].zone_coords.length).to.equal(1);
|
||||
|
||||
// Reach third zone - should complete
|
||||
manager.onGameEvent("UNIT_MOVE", {
|
||||
position: { x: 15, y: 0, z: 15 },
|
||||
});
|
||||
expect(manager.currentObjectives[0].current).to.equal(3);
|
||||
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||
expect(manager.currentObjectives[0].zone_coords.length).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 19c: Should handle Y-level variance when matching zones (for teleport)", async () => {
|
||||
await manager.setupActiveMission();
|
||||
manager.currentObjectives = [
|
||||
{
|
||||
type: "REACH_ZONE",
|
||||
zone_coords: [{ x: 5, y: 1, z: 5 }],
|
||||
complete: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Teleport might place unit at slightly different Y level
|
||||
manager.onGameEvent("UNIT_MOVE", {
|
||||
position: { x: 5, y: 0, z: 5 }, // Y differs by 1
|
||||
});
|
||||
|
||||
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 19d: Should complete REACH_ZONE objective when teleporting to target zone", async () => {
|
||||
await manager.setupActiveMission();
|
||||
manager.currentObjectives = [
|
||||
{
|
||||
type: "REACH_ZONE",
|
||||
target_count: 1,
|
||||
zone_coords: [{ x: 10, y: 1, z: 10 }],
|
||||
current: 0,
|
||||
complete: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate teleport to zone (UNIT_MOVE event with position matching zone)
|
||||
manager.onGameEvent("UNIT_MOVE", {
|
||||
unitId: "UNIT_TEST",
|
||||
position: { x: 10, y: 1, z: 10 },
|
||||
});
|
||||
|
||||
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||
expect(manager.currentObjectives[0].current).to.equal(1);
|
||||
});
|
||||
|
||||
it("CoA 20: Should complete INTERACT objective when unit interacts with target object", async () => {
|
||||
await manager.setupActiveMission();
|
||||
manager.currentObjectives = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue