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 { gameStateManager } from "./GameStateManager.js";
|
||||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||||
|
import { MissionGenerator } from "../systems/MissionGenerator.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug command system for testing game mechanics.
|
* 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
|
// UTILITY COMMANDS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -561,6 +668,14 @@ export class DebugCommands {
|
||||||
"%cMISSION & NARRATIVE:",
|
"%cMISSION & NARRATIVE:",
|
||||||
"font-weight: bold; color: #2196F3;"
|
"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(" %ctriggerVictory()%c - Trigger mission victory", "color: #FF9800;", "color: inherit;");
|
||||||
console.log(" %ccompleteObjective(objectiveId)%c - Complete a specific objective", "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;");
|
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.addXP(\"first\", 500)", "color: #4CAF50; font-family: monospace;");
|
||||||
console.log(" %cdebugCommands.setLevel(\"all\", 10)", "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.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.killEnemy(\"all\")", "color: #4CAF50; font-family: monospace;");
|
||||||
console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;");
|
console.log(" %cdebugCommands.triggerVictory()", "color: #4CAF50; font-family: monospace;");
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export class GameLoop {
|
||||||
/** @type {Map<string, Position>} */
|
/** @type {Map<string, Position>} */
|
||||||
this.missionObjects = new Map(); // object_id -> position
|
this.missionObjects = new Map(); // object_id -> position
|
||||||
/** @type {Set<THREE.Mesh>} */
|
/** @type {Set<THREE.Mesh>} */
|
||||||
|
this.zoneMarkers = new Set(); // Visual markers for REACH_ZONE objectives
|
||||||
|
/** @type {Set<THREE.Mesh>} */
|
||||||
this.movementHighlights = new Set();
|
this.movementHighlights = new Set();
|
||||||
/** @type {Set<THREE.Mesh>} */
|
/** @type {Set<THREE.Mesh>} */
|
||||||
this.spawnZoneHighlights = new Set();
|
this.spawnZoneHighlights = new Set();
|
||||||
|
|
@ -107,6 +109,20 @@ export class GameLoop {
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
this.lastMoveTime = 0;
|
this.lastMoveTime = 0;
|
||||||
/** @type {number} */
|
/** @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
|
this.moveCooldown = 120; // ms between cursor moves
|
||||||
/** @type {"MOVEMENT" | "TARGETING"} */
|
/** @type {"MOVEMENT" | "TARGETING"} */
|
||||||
this.selectionMode = "MOVEMENT"; // 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}`
|
`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)
|
// Check if unit moved to a mission object position (interaction)
|
||||||
this.checkMissionObjectInteraction(activeUnit);
|
this.checkMissionObjectInteraction(activeUnit);
|
||||||
|
|
||||||
|
|
@ -818,6 +849,24 @@ export class GameLoop {
|
||||||
console.log(
|
console.log(
|
||||||
`Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
|
`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 {
|
} else {
|
||||||
console.warn(`Teleport failed: ${result.error || "Unknown error"}`);
|
console.warn(`Teleport failed: ${result.error || "Unknown error"}`);
|
||||||
}
|
}
|
||||||
|
|
@ -1037,6 +1086,7 @@ export class GameLoop {
|
||||||
this.clearMovementHighlights();
|
this.clearMovementHighlights();
|
||||||
this.clearSpawnZoneHighlights();
|
this.clearSpawnZoneHighlights();
|
||||||
this.clearMissionObjects();
|
this.clearMissionObjects();
|
||||||
|
this.clearZoneMarkers();
|
||||||
this.clearRangeHighlights();
|
this.clearRangeHighlights();
|
||||||
|
|
||||||
// Reset Deployment State
|
// Reset Deployment State
|
||||||
|
|
@ -1187,6 +1237,11 @@ export class GameLoop {
|
||||||
this.turnSystemAbortController = new AbortController();
|
this.turnSystemAbortController = new AbortController();
|
||||||
const signal = this.turnSystemAbortController.signal;
|
const signal = this.turnSystemAbortController.signal;
|
||||||
|
|
||||||
|
// Set up callbacks for TurnSystem
|
||||||
|
this.turnSystem.onUnitDeathCallback = (unit) => {
|
||||||
|
this.handleUnitDeath(unit);
|
||||||
|
};
|
||||||
|
|
||||||
this.turnSystem.addEventListener(
|
this.turnSystem.addEventListener(
|
||||||
"turn-start",
|
"turn-start",
|
||||||
(e) => this._onTurnStart(e.detail),
|
(e) => this._onTurnStart(e.detail),
|
||||||
|
|
@ -1563,7 +1618,12 @@ export class GameLoop {
|
||||||
if (this.missionManager) {
|
if (this.missionManager) {
|
||||||
this.missionManager.setUnitManager(this.unitManager);
|
this.missionManager.setUnitManager(this.unitManager);
|
||||||
this.missionManager.setTurnSystem(this.turnSystem);
|
this.missionManager.setTurnSystem(this.turnSystem);
|
||||||
|
this.missionManager.setGridContext(this.grid, this.movementSystem);
|
||||||
await this.missionManager.setupActiveMission();
|
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
|
// WIRING: Listen for mission events
|
||||||
|
|
@ -1626,6 +1686,115 @@ export class GameLoop {
|
||||||
this.missionObjects.clear();
|
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.
|
* 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.
|
* Creates a visual mesh for a unit.
|
||||||
* @param {Unit} unit - The unit instance
|
* @param {Unit} unit - The unit instance
|
||||||
|
|
@ -2191,6 +2420,42 @@ export class GameLoop {
|
||||||
requestAnimationFrame(this.animate);
|
requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
if (this.inputManager) this.inputManager.update();
|
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();
|
if (this.controls) this.controls.update();
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -2627,6 +2892,26 @@ export class GameLoop {
|
||||||
})
|
})
|
||||||
.filter((entry) => entry !== null);
|
.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)
|
// Build combat state (enriched for UI, but includes spec fields)
|
||||||
const combatState = {
|
const combatState = {
|
||||||
// Spec-compliant fields
|
// Spec-compliant fields
|
||||||
|
|
@ -2642,6 +2927,8 @@ export class GameLoop {
|
||||||
targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill
|
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)
|
activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state)
|
||||||
roundNumber: turnSystemState.round, // Alias for UI
|
roundNumber: turnSystemState.round, // Alias for UI
|
||||||
|
missionObjectives, // Mission objectives for UI
|
||||||
|
turnLimit, // Turn limit info for UI
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update GameStateManager
|
// Update GameStateManager
|
||||||
|
|
@ -2676,6 +2963,7 @@ export class GameLoop {
|
||||||
this.clearMovementHighlights();
|
this.clearMovementHighlights();
|
||||||
|
|
||||||
// DELEGATE to TurnSystem
|
// DELEGATE to TurnSystem
|
||||||
|
// Note: Death from damage is handled in executeSkill, death from status effects is handled in startTurn
|
||||||
this.turnSystem.endTurn(activeUnit);
|
this.turnSystem.endTurn(activeUnit);
|
||||||
|
|
||||||
// Update combat state (TurnSystem will have advanced to next unit)
|
// Update combat state (TurnSystem will have advanced to next unit)
|
||||||
|
|
@ -2700,6 +2988,9 @@ export class GameLoop {
|
||||||
*/
|
*/
|
||||||
_onTurnStart(detail) {
|
_onTurnStart(detail) {
|
||||||
const { unit } = detail;
|
const { unit } = detail;
|
||||||
|
// Center camera on the active unit
|
||||||
|
this.centerCameraOnUnit(unit);
|
||||||
|
|
||||||
// Update movement highlights if it's a player's turn
|
// Update movement highlights if it's a player's turn
|
||||||
if (unit.team === "PLAYER") {
|
if (unit.team === "PLAYER") {
|
||||||
this.updateMovementHighlights(unit);
|
this.updateMovementHighlights(unit);
|
||||||
|
|
@ -2969,19 +3260,55 @@ export class GameLoop {
|
||||||
* @param {Unit} unit - The unit that died
|
* @param {Unit} unit - The unit that died
|
||||||
*/
|
*/
|
||||||
handleUnitDeath(unit) {
|
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
|
// Remove unit from grid
|
||||||
if (unit.position) {
|
if (unit.position) {
|
||||||
this.grid.removeUnit(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
|
// Dispatch death event to MissionManager BEFORE removing from UnitManager
|
||||||
this.unitManager.removeUnit(unit.id);
|
// 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);
|
const mesh = this.unitMeshes.get(unit.id);
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
|
console.log(`[GameLoop] Removing mesh for ${unit.name} from scene`);
|
||||||
this.scene.remove(mesh);
|
this.scene.remove(mesh);
|
||||||
this.unitMeshes.delete(unit.id);
|
this.unitMeshes.delete(unit.id);
|
||||||
// Dispose geometry and material
|
// Dispose geometry and material
|
||||||
|
|
@ -2997,20 +3324,12 @@ export class GameLoop {
|
||||||
mesh.material.dispose();
|
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
|
console.log(`[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`);
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -3037,6 +3356,11 @@ export class GameLoop {
|
||||||
_handleMissionVictory(detail) {
|
_handleMissionVictory(detail) {
|
||||||
console.log("Mission Victory!", 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
|
// Save Explorer progression back to roster
|
||||||
this._saveExplorerProgression();
|
this._saveExplorerProgression();
|
||||||
|
|
||||||
|
|
@ -3046,6 +3370,18 @@ export class GameLoop {
|
||||||
// Stop the game loop
|
// Stop the game loop
|
||||||
this.stop();
|
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
|
// Clear the active run from persistence since mission is complete
|
||||||
if (this.gameStateManager) {
|
if (this.gameStateManager) {
|
||||||
this.gameStateManager.clearActiveRun();
|
this.gameStateManager.clearActiveRun();
|
||||||
|
|
@ -3069,12 +3405,15 @@ export class GameLoop {
|
||||||
handleNarrativeEnd
|
handleNarrativeEnd
|
||||||
);
|
);
|
||||||
|
|
||||||
// Small delay after narrative ends to let user see the final message
|
// Wait for debrief to close before transitioning
|
||||||
setTimeout(() => {
|
// The debrief will dispatch debrief-closed when user clicks return
|
||||||
|
const handleDebriefClosed = () => {
|
||||||
|
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||||
if (this.gameStateManager) {
|
if (this.gameStateManager) {
|
||||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||||
}
|
}
|
||||||
}, 500);
|
};
|
||||||
|
window.addEventListener("debrief-closed", handleDebriefClosed);
|
||||||
};
|
};
|
||||||
|
|
||||||
narrativeManager.addEventListener("narrative-end", handleNarrativeEnd);
|
narrativeManager.addEventListener("narrative-end", handleNarrativeEnd);
|
||||||
|
|
@ -3088,21 +3427,107 @@ export class GameLoop {
|
||||||
"narrative-end",
|
"narrative-end",
|
||||||
handleNarrativeEnd
|
handleNarrativeEnd
|
||||||
);
|
);
|
||||||
|
const handleDebriefClosed = () => {
|
||||||
|
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||||
if (this.gameStateManager) {
|
if (this.gameStateManager) {
|
||||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("debrief-closed", handleDebriefClosed);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
} else {
|
} else {
|
||||||
// No outro, transition immediately after a short delay
|
// No outro, wait for debrief to close before transitioning
|
||||||
console.log("GameLoop: No outro narrative, transitioning to hub");
|
console.log("GameLoop: No outro narrative, waiting for debrief to close");
|
||||||
setTimeout(() => {
|
const handleDebriefClosed = () => {
|
||||||
|
window.removeEventListener("debrief-closed", handleDebriefClosed);
|
||||||
if (this.gameStateManager) {
|
if (this.gameStateManager) {
|
||||||
this.gameStateManager.transitionTo("STATE_MAIN_MENU");
|
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.
|
* Saves Explorer progression (classMastery, activeClassId) back to roster.
|
||||||
* @private
|
* @private
|
||||||
|
|
@ -3165,6 +3590,11 @@ export class GameLoop {
|
||||||
_handleMissionFailure(detail) {
|
_handleMissionFailure(detail) {
|
||||||
console.log("Mission Failed!", 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)
|
// Save Explorer progression back to roster (even on failure, progression should persist)
|
||||||
this._saveExplorerProgression();
|
this._saveExplorerProgression();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,21 @@ export class VoxelGrid {
|
||||||
return true;
|
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 ---
|
// --- HAZARDS ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ window.addEventListener("gamestate-changed", async (e) => {
|
||||||
// Load HubScreen dynamically
|
// Load HubScreen dynamically
|
||||||
await import("./ui/screens/hub-screen.js");
|
await import("./ui/screens/hub-screen.js");
|
||||||
await import("./ui/components/mission-board.js");
|
await import("./ui/components/mission-board.js");
|
||||||
|
await import("./ui/components/mission-review.js");
|
||||||
const hub = document.querySelector("hub-screen");
|
const hub = document.querySelector("hub-screen");
|
||||||
if (hub) {
|
if (hub) {
|
||||||
hub.toggleAttribute("hidden", false);
|
hub.toggleAttribute("hidden", false);
|
||||||
|
|
@ -369,6 +370,8 @@ if (typeof window !== "undefined") {
|
||||||
"addCurrency",
|
"addCurrency",
|
||||||
"killEnemy",
|
"killEnemy",
|
||||||
"healUnit",
|
"healUnit",
|
||||||
|
"regenerateMissions",
|
||||||
|
"generateMission",
|
||||||
"triggerVictory",
|
"triggerVictory",
|
||||||
"completeObjective",
|
"completeObjective",
|
||||||
"triggerNarrative",
|
"triggerNarrative",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ export class MissionManager {
|
||||||
this.unitManager = null;
|
this.unitManager = null;
|
||||||
/** @type {TurnSystem | null} */
|
/** @type {TurnSystem | null} */
|
||||||
this.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} */
|
/** @type {number} */
|
||||||
this.currentTurn = 0;
|
this.currentTurn = 0;
|
||||||
|
|
||||||
|
|
@ -101,6 +107,12 @@ export class MissionManager {
|
||||||
*/
|
*/
|
||||||
registerMission(missionDef) {
|
registerMission(missionDef) {
|
||||||
this.missionRegistry.set(missionDef.id, 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);
|
this.missionRegistry.delete(mission.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register refreshed procedural missions
|
// Register refreshed procedural missions (batch register to avoid multiple events)
|
||||||
refreshedProcedural.forEach((mission) => {
|
refreshedProcedural.forEach((mission) => {
|
||||||
this.registerMission(mission);
|
// Register without dispatching event for each mission
|
||||||
|
this.missionRegistry.set(mission.id, mission);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Refreshed procedural missions: ${refreshedProcedural.length} available`
|
`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;
|
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.
|
* Prepares the manager for a new run.
|
||||||
* Resets objectives and prepares narrative hooks.
|
* Resets objectives and prepares narrative hooks.
|
||||||
|
|
@ -420,14 +512,27 @@ export class MissionManager {
|
||||||
// Check for ELIMINATE_ALL objective completion (needs active check)
|
// Check for ELIMINATE_ALL objective completion (needs active check)
|
||||||
// Check after enemy death or at turn end
|
// Check after enemy death or at turn end
|
||||||
if (type === "ENEMY_DEATH") {
|
if (type === "ENEMY_DEATH") {
|
||||||
|
console.log(
|
||||||
|
`[MissionManager] ENEMY_DEATH event received, checking ELIMINATE_ALL objective`
|
||||||
|
);
|
||||||
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
||||||
|
console.log(
|
||||||
|
`[MissionManager] ELIMINATE_ALL check returned statusChanged: ${statusChanged}`
|
||||||
|
);
|
||||||
} else if (type === "TURN_END") {
|
} else if (type === "TURN_END") {
|
||||||
// Also check on turn end in case all enemies died from status effects
|
// Also check on turn end in case all enemies died from status effects
|
||||||
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
statusChanged = this.checkEliminateAllObjective() || statusChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusChanged) {
|
if (statusChanged) {
|
||||||
|
console.log(
|
||||||
|
`[MissionManager] Objective status changed, calling checkVictory()`
|
||||||
|
);
|
||||||
this.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
|
// ELIMINATE_UNIT: Track specific enemy deaths
|
||||||
if (eventType === "ENEMY_DEATH" && obj.type === "ELIMINATE_UNIT") {
|
if (eventType === "ENEMY_DEATH" && obj.type === "ELIMINATE_UNIT") {
|
||||||
if (
|
// Check if the killed enemy matches the target (by defId, not unitId)
|
||||||
data.unitId === obj.target_def_id ||
|
// unitId is the instance ID, defId is the definition ID we're looking for
|
||||||
data.defId === obj.target_def_id
|
if (data.defId === obj.target_def_id) {
|
||||||
) {
|
|
||||||
obj.current = (obj.current || 0) + 1;
|
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;
|
obj.complete = true;
|
||||||
statusChanged = 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
|
// REACH_ZONE: Check if unit reached target zone
|
||||||
if (eventType === "UNIT_MOVE" && obj.type === "REACH_ZONE") {
|
if (eventType === "UNIT_MOVE" && obj.type === "REACH_ZONE") {
|
||||||
if (data.position && obj.zone_coords) {
|
if (data.position && obj.zone_coords && obj.zone_coords.length > 0) {
|
||||||
const reached = obj.zone_coords.some(
|
// 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) =>
|
||||||
coord.x === data.position.x &&
|
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;
|
obj.complete = true;
|
||||||
statusChanged = 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 (obj.complete || obj.type !== "ELIMINATE_ALL") return;
|
||||||
|
|
||||||
if (this.unitManager) {
|
if (this.unitManager) {
|
||||||
const enemies = Array.from(
|
const allUnits = Array.from(this.unitManager.activeUnits.values());
|
||||||
this.unitManager.activeUnits.values()
|
const allEnemies = allUnits.filter((u) => u.team === "ENEMY");
|
||||||
).filter((u) => u.team === "ENEMY" && u.currentHealth > 0);
|
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;
|
obj.complete = true;
|
||||||
statusChanged = 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 =
|
const allPrimaryComplete =
|
||||||
this.currentObjectives.length > 0 &&
|
this.currentObjectives.length > 0 &&
|
||||||
this.currentObjectives.every((o) => o.complete);
|
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) {
|
if (allPrimaryComplete) {
|
||||||
console.log("VICTORY! Mission Objectives Complete.");
|
console.log("VICTORY! Mission Objectives Complete.");
|
||||||
this.completeActiveMission();
|
this.completeActiveMission();
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ export class UnitManager {
|
||||||
unit = new Explorer(instanceId, def.name, defId, def);
|
unit = new Explorer(instanceId, def.name, defId, def);
|
||||||
} else if (def.type === "ENEMY" || defId.startsWith("ENEMY_")) {
|
} else if (def.type === "ENEMY" || defId.startsWith("ENEMY_")) {
|
||||||
unit = new Enemy(instanceId, def.name, def);
|
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 {
|
} else {
|
||||||
// Generic/Structure
|
// Generic/Structure
|
||||||
unit = new Unit(instanceId, def.name, "STRUCTURE", def.model);
|
unit = new Unit(instanceId, def.name, "STRUCTURE", def.model);
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,9 @@ export class MissionGenerator {
|
||||||
// Generate biome config based on archetype
|
// Generate biome config based on archetype
|
||||||
const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
|
const biomeConfig = this.generateBiomeConfig(archetype, biomeType);
|
||||||
|
|
||||||
|
// Generate enemy spawns based on archetype (especially for ASSASSINATION)
|
||||||
|
const enemySpawns = this.generateEnemySpawns(archetype, objectives, validTier);
|
||||||
|
|
||||||
// Calculate rewards
|
// Calculate rewards
|
||||||
const rewards = this.calculateRewards(validTier, archetype, biomeType);
|
const rewards = this.calculateRewards(validTier, archetype, biomeType);
|
||||||
|
|
||||||
|
|
@ -257,6 +260,7 @@ export class MissionGenerator {
|
||||||
squad_size_limit: 4
|
squad_size_limit: 4
|
||||||
},
|
},
|
||||||
objectives: objectives,
|
objectives: objectives,
|
||||||
|
enemy_spawns: enemySpawns,
|
||||||
rewards: rewards,
|
rewards: rewards,
|
||||||
expiresIn: 3 // Expires in 3 campaign days
|
expiresIn: 3 // Expires in 3 campaign days
|
||||||
};
|
};
|
||||||
|
|
@ -309,6 +313,7 @@ export class MissionGenerator {
|
||||||
id: "OBJ_HUNT",
|
id: "OBJ_HUNT",
|
||||||
type: "ELIMINATE_UNIT",
|
type: "ELIMINATE_UNIT",
|
||||||
target_def_id: targetId,
|
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."
|
description: "A High-Value Target has been spotted. Eliminate them."
|
||||||
}],
|
}],
|
||||||
failure_conditions: [{ type: "SQUAD_WIPE" }]
|
failure_conditions: [{ type: "SQUAD_WIPE" }]
|
||||||
|
|
@ -316,6 +321,8 @@ export class MissionGenerator {
|
||||||
|
|
||||||
case "RECON":
|
case "RECON":
|
||||||
// Generate 3 zone coordinates (simplified - actual zones would be set during mission generation)
|
// 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 {
|
return {
|
||||||
primary: [{
|
primary: [{
|
||||||
id: "OBJ_RECON",
|
id: "OBJ_RECON",
|
||||||
|
|
@ -325,7 +332,7 @@ export class MissionGenerator {
|
||||||
}],
|
}],
|
||||||
failure_conditions: [
|
failure_conditions: [
|
||||||
{ type: "SQUAD_WIPE" },
|
{ 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
|
* Generates biome configuration based on archetype
|
||||||
* @param {string} archetype - Mission archetype
|
* @param {string} archetype - Mission archetype
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,11 @@ export class TurnSystem extends EventTarget {
|
||||||
if (isStunned) {
|
if (isStunned) {
|
||||||
// Process hazards first, then status effects
|
// Process hazards first, then status effects
|
||||||
this.processEnvironmentalHazards(unit);
|
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
|
// Skip action phase, immediately end turn
|
||||||
this.phase = "TURN_END";
|
this.phase = "TURN_END";
|
||||||
this.endTurn(unit);
|
this.endTurn(unit);
|
||||||
|
|
@ -172,7 +176,15 @@ export class TurnSystem extends EventTarget {
|
||||||
this.processEnvironmentalHazards(unit);
|
this.processEnvironmentalHazards(unit);
|
||||||
|
|
||||||
// D. Status Effect Tick (The "Upkeep" Step)
|
// 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
|
// Dispatch turn-start event
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
|
|
@ -257,11 +269,13 @@ export class TurnSystem extends EventTarget {
|
||||||
/**
|
/**
|
||||||
* Processes status effects for a unit (DoT/HoT, duration decrement, expiration).
|
* Processes status effects for a unit (DoT/HoT, duration decrement, expiration).
|
||||||
* @param {Unit} unit - The unit to process status effects for
|
* @param {Unit} unit - The unit to process status effects for
|
||||||
|
* @returns {boolean} True if the unit died from status effects
|
||||||
*/
|
*/
|
||||||
processStatusEffects(unit) {
|
processStatusEffects(unit) {
|
||||||
if (!unit.statusEffects || unit.statusEffects.length === 0) return;
|
if (!unit.statusEffects || unit.statusEffects.length === 0) return false;
|
||||||
|
|
||||||
const effectsToRemove = [];
|
const effectsToRemove = [];
|
||||||
|
const healthBefore = unit.currentHealth;
|
||||||
|
|
||||||
unit.statusEffects.forEach((effect, index) => {
|
unit.statusEffects.forEach((effect, index) => {
|
||||||
// Apply DoT/HoT if applicable
|
// Apply DoT/HoT if applicable
|
||||||
|
|
@ -311,6 +325,9 @@ export class TurnSystem extends EventTarget {
|
||||||
effectsToRemove.reverse().forEach((index) => {
|
effectsToRemove.reverse().forEach((index) => {
|
||||||
unit.statusEffects.splice(index, 1);
|
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);
|
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) */
|
/* Responsive Design - Mobile (< 768px) */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
|
|
@ -569,12 +645,23 @@ export class CombatHUD extends LitElement {
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeUnit, enrichedQueue, turnQueue, roundNumber, round } =
|
const {
|
||||||
this.combatState;
|
activeUnit,
|
||||||
|
enrichedQueue,
|
||||||
|
turnQueue,
|
||||||
|
roundNumber,
|
||||||
|
round,
|
||||||
|
missionObjectives,
|
||||||
|
turnLimit,
|
||||||
|
} = this.combatState;
|
||||||
// Use enrichedQueue if available (for UI), otherwise fall back to turnQueue
|
// Use enrichedQueue if available (for UI), otherwise fall back to turnQueue
|
||||||
const displayQueue = enrichedQueue || turnQueue || [];
|
const displayQueue = enrichedQueue || turnQueue || [];
|
||||||
const threatLevel = this._getThreatLevel();
|
const threatLevel = this._getThreatLevel();
|
||||||
|
|
||||||
|
// Calculate turn limit warning (less than 25% remaining)
|
||||||
|
const turnLimitWarning =
|
||||||
|
turnLimit && turnLimit.current / turnLimit.limit > 0.75;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<!-- Top Bar -->
|
<!-- Top Bar -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
|
|
@ -605,6 +692,76 @@ export class CombatHUD extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Bottom Bar -->
|
||||||
<div class="bottom-bar">
|
<div class="bottom-bar">
|
||||||
<!-- Unit Status (Bottom-Left) -->
|
<!-- Unit Status (Bottom-Left) -->
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { gameStateManager } from '../../core/GameStateManager.js';
|
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
|
* MissionBoard.js
|
||||||
|
|
@ -15,6 +15,8 @@ export class MissionBoard extends LitElement {
|
||||||
cardStyles,
|
cardStyles,
|
||||||
gridStyles,
|
gridStyles,
|
||||||
badgeStyles,
|
badgeStyles,
|
||||||
|
tabStyles,
|
||||||
|
overlayStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -70,6 +72,13 @@ export class MissionBoard extends LitElement {
|
||||||
opacity: 0.7;
|
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 {
|
.mission-card.locked {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -188,6 +197,34 @@ export class MissionBoard extends LitElement {
|
||||||
padding: var(--spacing-2xl);
|
padding: var(--spacing-2xl);
|
||||||
color: var(--color-text-muted);
|
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 {
|
return {
|
||||||
missions: { type: Array },
|
missions: { type: Array },
|
||||||
completedMissions: { type: Set },
|
completedMissions: { type: Set },
|
||||||
|
activeTab: { type: String },
|
||||||
|
reviewMission: { type: Object },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,16 +242,46 @@ export class MissionBoard extends LitElement {
|
||||||
super();
|
super();
|
||||||
this.missions = [];
|
this.missions = [];
|
||||||
this.completedMissions = new Set();
|
this.completedMissions = new Set();
|
||||||
|
this.activeTab = 'active';
|
||||||
|
this.reviewMission = null;
|
||||||
|
this._isLoading = false; // Guard to prevent infinite loops
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.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
|
// Listen for campaign data changes to refresh completed missions
|
||||||
this._boundHandleCampaignChange = this._handleCampaignChange.bind(this);
|
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('campaign-data-changed', this._boundHandleCampaignChange);
|
||||||
window.addEventListener('gamestate-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() {
|
disconnectedCallback() {
|
||||||
|
|
@ -221,6 +290,9 @@ export class MissionBoard extends LitElement {
|
||||||
window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange);
|
window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange);
|
||||||
window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange);
|
window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange);
|
||||||
}
|
}
|
||||||
|
if (this._boundHandleMissionsUpdate) {
|
||||||
|
window.removeEventListener('missions-updated', this._boundHandleMissionsUpdate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleCampaignChange() {
|
_handleCampaignChange() {
|
||||||
|
|
@ -228,13 +300,34 @@ export class MissionBoard extends LitElement {
|
||||||
this._loadMissions();
|
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() {
|
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
|
// Ensure missions are loaded before accessing registry
|
||||||
await gameStateManager.missionManager._ensureMissionsLoaded();
|
await gameStateManager.missionManager._ensureMissionsLoaded();
|
||||||
// Refresh procedural missions if unlocked (to ensure board is populated)
|
// Don't automatically refresh procedural missions here - that causes infinite loops
|
||||||
if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) {
|
// Missions should be refreshed when the board is first opened, not on every update
|
||||||
gameStateManager.missionManager.refreshProceduralMissions();
|
|
||||||
}
|
|
||||||
// Get all registered missions from MissionManager
|
// Get all registered missions from MissionManager
|
||||||
const missionRegistry = gameStateManager.missionManager.missionRegistry;
|
const missionRegistry = gameStateManager.missionManager.missionRegistry;
|
||||||
this.missions = Array.from(missionRegistry.values());
|
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) {
|
_formatRewards(rewards) {
|
||||||
const rewardItems = [];
|
const rewardItems = [];
|
||||||
|
|
||||||
|
|
@ -336,33 +442,34 @@ export class MissionBoard extends LitElement {
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
_getActiveMissions() {
|
||||||
if (this.missions.length === 0) {
|
return this.missions.filter(mission => {
|
||||||
return html`
|
if (!this._shouldShowMission(mission)) {
|
||||||
<div class="header">
|
return false;
|
||||||
<h2>MISSION BOARD</h2>
|
}
|
||||||
<button class="btn btn-close" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
return !this._isMissionCompleted(mission.id);
|
||||||
×
|
});
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No missions available at this time.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
_getCompletedMissions() {
|
||||||
<div class="header">
|
return this.missions.filter(mission => {
|
||||||
<h2>MISSION BOARD</h2>
|
if (!this._shouldShowMission(mission)) {
|
||||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
return false;
|
||||||
×
|
}
|
||||||
</button>
|
return this._isMissionCompleted(mission.id);
|
||||||
</div>
|
});
|
||||||
|
}
|
||||||
|
|
||||||
<div class="missions-grid">
|
_getCompletedMissionsByType() {
|
||||||
${this.missions
|
const completed = this._getCompletedMissions();
|
||||||
.filter(mission => this._shouldShowMission(mission))
|
return {
|
||||||
.map((mission) => {
|
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 isCompleted = this._isMissionCompleted(mission.id);
|
||||||
const isAvailable = this._isMissionAvailable(mission);
|
const isAvailable = this._isMissionAvailable(mission);
|
||||||
const rewards = this._formatRewards(mission.rewards?.guaranteed || mission.rewards || {});
|
const rewards = this._formatRewards(mission.rewards?.guaranteed || mission.rewards || {});
|
||||||
|
|
@ -370,7 +477,13 @@ export class MissionBoard extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="mission-card ${isCompleted ? 'completed' : ''} ${!isAvailable ? 'locked' : ''}"
|
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">
|
<div class="mission-header">
|
||||||
<h3 class="mission-title">${mission.config?.title || mission.id}</h3>
|
<h3 class="mission-title">${mission.config?.title || mission.id}</h3>
|
||||||
|
|
@ -398,7 +511,11 @@ export class MissionBoard extends LitElement {
|
||||||
<span class="difficulty">
|
<span class="difficulty">
|
||||||
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
||||||
</span>
|
</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`
|
${!isAvailable && !isCompleted ? html`
|
||||||
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
|
<span style="color: var(--color-text-muted); font-size: var(--font-size-xs);">
|
||||||
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
|
🔒 Requires: ${mission.config?.prerequisites?.map(id => {
|
||||||
|
|
@ -421,8 +538,111 @@ export class MissionBoard extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<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 },
|
deployedIds: { type: Array },
|
||||||
combatState: { type: Object },
|
combatState: { type: Object },
|
||||||
missionDef: { type: Object },
|
missionDef: { type: Object },
|
||||||
|
debriefResult: { type: Object },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ export class GameViewport extends LitElement {
|
||||||
this.deployedIds = [];
|
this.deployedIds = [];
|
||||||
this.combatState = null;
|
this.combatState = null;
|
||||||
this.missionDef = null;
|
this.missionDef = null;
|
||||||
|
this.debriefResult = null;
|
||||||
|
|
||||||
// Set up event listeners early so we don't miss events
|
// Set up event listeners early so we don't miss events
|
||||||
this.#setupCombatStateUpdates();
|
this.#setupCombatStateUpdates();
|
||||||
|
|
@ -126,12 +128,25 @@ export class GameViewport extends LitElement {
|
||||||
|
|
||||||
// Listen for mission end events to clear state
|
// Listen for mission end events to clear state
|
||||||
window.addEventListener("mission-victory", () => {
|
window.addEventListener("mission-victory", () => {
|
||||||
this.#clearState();
|
// Don't clear state immediately - wait for debrief
|
||||||
});
|
});
|
||||||
window.addEventListener("mission-failure", () => {
|
window.addEventListener("mission-failure", () => {
|
||||||
this.#clearState();
|
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
|
// Initial updates
|
||||||
this.#updateCombatState();
|
this.#updateCombatState();
|
||||||
this.#updateSquad();
|
this.#updateSquad();
|
||||||
|
|
@ -187,7 +202,17 @@ export class GameViewport extends LitElement {
|
||||||
@skill-click=${this.#handleSkillClick}
|
@skill-click=${this.#handleSkillClick}
|
||||||
@movement-click=${this.#handleMovementClick}
|
@movement-click=${this.#handleMovementClick}
|
||||||
></combat-hud>
|
></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) {
|
if (dialog) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
|
// Dispatch both events for compatibility
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("return-to-hub", {
|
new CustomEvent("return-to-hub", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("debrief-closed", {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 () => {
|
it("should deduct AP when executing TELEPORT skill", async () => {
|
||||||
const skillId = "SKILL_TELEPORT";
|
const skillId = "SKILL_TELEPORT";
|
||||||
const skillDef = {
|
const skillDef = {
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,87 @@ describe("Manager: MissionManager", () => {
|
||||||
expect(manager.currentObjectives[0].complete).to.be.true;
|
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 () => {
|
it("CoA 20: Should complete INTERACT objective when unit interacts with target object", async () => {
|
||||||
await manager.setupActiveMission();
|
await manager.setupActiveMission();
|
||||||
manager.currentObjectives = [
|
manager.currentObjectives = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue