/** * @typedef {import("./types.js").RunData} RunData * @typedef {import("../grid/types.js").Position} Position * @typedef {import("../units/Unit.js").Unit} Unit * @typedef {import("../ui/combat-hud.d.ts").CombatState} CombatState * @typedef {import("../ui/combat-hud.d.ts").UnitStatus} UnitStatus * @typedef {import("../ui/combat-hud.d.ts").QueueEntry} QueueEntry */ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { VoxelGrid } from "../grid/VoxelGrid.js"; import { VoxelManager } from "../grid/VoxelManager.js"; import { UnitManager } from "../managers/UnitManager.js"; import { CaveGenerator } from "../generation/CaveGenerator.js"; import { RuinGenerator } from "../generation/RuinGenerator.js"; import { InputManager } from "./InputManager.js"; import { MissionManager } from "../managers/MissionManager.js"; import { TurnSystem } from "../systems/TurnSystem.js"; import { MovementSystem } from "../systems/MovementSystem.js"; import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js"; import { EffectProcessor } from "../systems/EffectProcessor.js"; import { SeededRandom } from "../utils/SeededRandom.js"; import { skillRegistry } from "../managers/SkillRegistry.js"; import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryContainer } from "../models/InventoryContainer.js"; import { itemRegistry } from "../managers/ItemRegistry.js"; import { narrativeManager } from "../managers/NarrativeManager.js"; // Class definitions will be lazy-loaded when startLevel is called /** * Main game loop managing rendering, input, and game state. * @class */ export class GameLoop { constructor() { /** @type {boolean} */ this.isRunning = false; /** @type {Object|null} Cached skill tree template */ this._skillTreeTemplate = null; /** @type {number | null} */ this.animationFrameId = null; /** @type {boolean} */ this.isPaused = false; // 1. Core Systems /** @type {THREE.Scene} */ this.scene = new THREE.Scene(); /** @type {THREE.PerspectiveCamera | null} */ this.camera = null; /** @type {THREE.WebGLRenderer | null} */ this.renderer = null; /** @type {OrbitControls | null} */ this.controls = null; /** @type {InputManager | null} */ this.inputManager = null; /** @type {VoxelGrid | null} */ this.grid = null; /** @type {VoxelManager | null} */ this.voxelManager = null; /** @type {UnitManager | null} */ this.unitManager = null; // Combat Logic Systems /** @type {TurnSystem | null} */ this.turnSystem = null; /** @type {MovementSystem | null} */ this.movementSystem = null; /** @type {SkillTargetingSystem | null} */ this.skillTargetingSystem = null; /** @type {EffectProcessor | null} */ this.effectProcessor = null; // Inventory System /** @type {InventoryManager | null} */ this.inventoryManager = null; // AbortController for cleaning up event listeners /** @type {AbortController | null} */ this.turnSystemAbortController = null; /** @type {Map} */ this.unitMeshes = new Map(); /** @type {Map} */ this.missionObjectMeshes = new Map(); // object_id -> mesh /** @type {Map} */ this.missionObjects = new Map(); // object_id -> position /** @type {Set} */ this.zoneMarkers = new Set(); // Visual markers for REACH_ZONE objectives /** @type {Set} */ this.movementHighlights = new Set(); /** @type {Set} */ this.spawnZoneHighlights = new Set(); /** @type {Set} */ this.rangeHighlights = new Set(); /** @type {Set} */ this.aoeReticle = new Set(); /** @type {RunData | null} */ this.runData = null; /** @type {Position[]} */ this.playerSpawnZone = []; /** @type {Position[]} */ this.enemySpawnZone = []; // Input Logic State /** @type {number} */ this.lastMoveTime = 0; /** @type {number} */ // Camera Animation State /** @type {boolean} */ this.isAnimatingCamera = false; /** @type {THREE.Vector3} */ this.cameraAnimationStart = new THREE.Vector3(); /** @type {THREE.Vector3} */ this.cameraAnimationTarget = new THREE.Vector3(); /** @type {THREE.Vector3 | null} */ this.cameraAnimationOffset = null; // Camera offset to maintain during animation /** @type {number} */ this.cameraAnimationStartTime = 0; /** @type {number} */ this.cameraAnimationDuration = 500; // milliseconds this.moveCooldown = 120; // ms between cursor moves /** @type {"MOVEMENT" | "TARGETING"} */ this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING /** @type {MissionManager} */ this.missionManager = new MissionManager(this); // Init Mission Manager // Deployment State /** @type {{ selectedUnitIndex: number; deployedUnits: Map }} */ this.deploymentState = { selectedUnitIndex: -1, deployedUnits: new Map(), // Map }; /** @type {import("./GameStateManager.js").GameStateManagerClass | null} */ this.gameStateManager = null; // Skill Targeting State /** @type {"IDLE" | "SELECTING_MOVE" | "TARGETING_SKILL" | "EXECUTING_SKILL"} */ this.combatState = "IDLE"; /** @type {string | null} */ this.activeSkillId = null; } /** * Initializes the game loop with Three.js setup. * @param {HTMLElement} container - DOM element to attach the renderer to */ init(container) { // Setup Three.js this.camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 1000 ); this.camera.position.set(20, 20, 20); this.camera.lookAt(0, 0, 0); this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setClearColor(0x111111); container.appendChild(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; // --- INSTANTIATE COMBAT SYSTEMS --- this.turnSystem = new TurnSystem(); this.movementSystem = new MovementSystem(); // SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready // --- INITIALIZE INVENTORY SYSTEM --- // Create stashes (InventoryManager will be initialized in startLevel after itemRegistry loads) const runStash = new InventoryContainer("RUN_LOOT"); const hubStash = new InventoryContainer("HUB_VAULT"); // Initialize InventoryManager with itemRegistry (will load items in startLevel) this.inventoryManager = new InventoryManager( itemRegistry, runStash, hubStash ); // --- SETUP INPUT MANAGER --- this.inputManager = new InputManager( this.camera, this.scene, this.renderer.domElement ); // Bind Buttons (Events) this.inputManager.addEventListener("gamepadbuttondown", (e) => this.handleButtonInput(e.detail) ); this.inputManager.addEventListener("keydown", (e) => this.handleKeyInput(e.detail) ); this.inputManager.addEventListener("hover", (e) => this.onCursorHover(e.detail.voxelPosition) ); // Default Validator: Movement Logic (Will be overridden in startLevel) this.inputManager.setValidator(this.validateCursorMove.bind(this)); const ambient = new THREE.AmbientLight(0xffffff, 0.6); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(10, 20, 10); this.scene.add(ambient); this.scene.add(dirLight); window.addEventListener("resize", () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); }); this.animate = this.animate.bind(this); } /** * Validation Logic for Standard Movement. * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate * @returns {false | Position} - False if invalid, or adjusted position object */ validateCursorMove(x, y, z) { if (!this.grid) return true; // Allow if grid not ready // 1. Basic Bounds Check if (!this.grid.isValidBounds({ x, y: 0, z })) return false; // 2. Scan Column for Surface (Climb/Drop Logic) let bestY = null; if (this.isWalkable(x, y, z)) bestY = y; else if (this.isWalkable(x, y + 1, z)) bestY = y + 1; else if (this.isWalkable(x, y - 1, z)) bestY = y - 1; else if (this.isWalkable(x, y - 2, z)) bestY = y - 2; if (bestY !== null) { return { x, y: bestY, z }; } return false; } /** * Validation Logic for Deployment Phase. * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate * @returns {false | Position} - False if invalid, or valid spawn position */ validateDeploymentCursor(x, y, z) { if (!this.grid || this.playerSpawnZone.length === 0) return false; // Check if the target X,Z is inside the spawn zone list const validSpot = this.playerSpawnZone.find((t) => t.x === x && t.z === z); if (validSpot) { // Snap Y to the valid floor height defined in the zone return { x: validSpot.x, y: validSpot.y, z: validSpot.z }; } return false; // Cursor cannot leave the spawn zone } /** * Checks if a position is walkable. * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate * @returns {boolean} - True if walkable */ isWalkable(x, y, z) { if (this.grid.getCell(x, y, z) !== 0) return false; if (this.grid.getCell(x, y - 1, z) === 0) return false; if (this.grid.getCell(x, y + 1, z) !== 0) return false; return true; } /** * Validates an interaction target position. * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate * @returns {boolean} - True if valid */ validateInteractionTarget(x, y, z) { if (!this.grid) return true; return this.grid.isValidBounds({ x, y, z }); } /** * Handles gamepad button input. * @param {{ buttonIndex: number; gamepadIndex: number }} detail - Button input detail */ handleButtonInput(detail) { if (detail.buttonIndex === 0) { // A / Cross this.triggerSelection(); } } /** * Handles keyboard input. * @param {string} code - Key code */ handleKeyInput(code) { if (code === "Space" || code === "Enter") { this.triggerSelection(); } if (code === "Escape" || code === "KeyB") { // Cancel skill targeting if (this.combatState === "TARGETING_SKILL") { this.cancelSkillTargeting(); } } if (code === "Tab") { this.selectionMode = this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT"; const validator = this.selectionMode === "MOVEMENT" ? this.validateCursorMove.bind(this) : this.validateInteractionTarget.bind(this); this.inputManager.setValidator(validator); } if (code === "KeyC") { // Open character sheet for active unit this.openCharacterSheet(); } if (code === "KeyM") { // Movement mode hotkey if (this.gameStateManager?.currentState === "STATE_COMBAT") { this.onMovementClicked(); } } // Number key hotkeys for skills (1-5) if ( this.gameStateManager?.currentState === "STATE_COMBAT" && this.turnSystem ) { const activeUnit = this.turnSystem.getActiveUnit(); if (activeUnit && activeUnit.team === "PLAYER") { const skills = activeUnit.actions || []; let skillIndex = -1; // Map key codes to skill indices (1-5) if (code === "Digit1" || code === "Numpad1") { skillIndex = 0; } else if (code === "Digit2" || code === "Numpad2") { skillIndex = 1; } else if (code === "Digit3" || code === "Numpad3") { skillIndex = 2; } else if (code === "Digit4" || code === "Numpad4") { skillIndex = 3; } else if (code === "Digit5" || code === "Numpad5") { skillIndex = 4; } if (skillIndex >= 0 && skillIndex < skills.length) { const skill = skills[skillIndex]; if (skill && skill.id) { this.onSkillClicked(skill.id); } } } } } /** * Opens the character sheet for the currently active unit. */ openCharacterSheet() { if (!this.turnSystem) return; const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") { // If no active unit or not player unit, try to get first player unit if (this.unitManager) { const playerUnits = this.unitManager .getAllUnits() .filter((u) => u.team === "PLAYER"); if (playerUnits.length > 0) { this._dispatchOpenCharacterSheet(playerUnits[0]); } } return; } this._dispatchOpenCharacterSheet(activeUnit); } /** * Dispatches open-character-sheet event for a unit. * @param {Unit|string} unitOrId - Unit object or unit ID * @private */ _dispatchOpenCharacterSheet(unitOrId) { // Get full unit object if ID was provided let unit = unitOrId; if (typeof unitOrId === "string" && this.unitManager) { unit = this.unitManager.getUnitById(unitOrId); } if (!unit) { console.warn("Cannot open character sheet: unit not found"); return; } // Get inventory from runData or empty array const inventory = this.runData?.inventory || []; // Determine if read-only (enemy turn or restricted) const activeUnit = this.turnSystem?.getActiveUnit(); const isReadOnly = this.combatState === "TARGETING_SKILL" || (activeUnit && activeUnit.team !== "PLAYER"); window.dispatchEvent( new CustomEvent("open-character-sheet", { detail: { unit: unit, readOnly: isReadOnly, inventory: inventory, }, }) ); } /** * Called by UI when a unit is clicked in the Roster. * @param {number} index - The index of the unit in the squad to select. */ selectDeploymentUnit(index) { this.deploymentState.selectedUnitIndex = index; console.log(`Deployment: Selected Unit Index ${index}`); } /** * Triggers selection action at cursor position. */ triggerSelection() { const cursor = this.inputManager.getCursorPosition(); console.log("Action at:", cursor); if ( this.gameStateManager && this.gameStateManager.currentState === "STATE_DEPLOYMENT" ) { const selIndex = this.deploymentState.selectedUnitIndex; if (selIndex !== -1) { // Attempt to deploy OR move the selected unit const unitDef = this.runData.squad[selIndex]; const existingUnit = this.deploymentState.deployedUnits.get(selIndex); const resultUnit = this.deployUnit(unitDef, cursor, existingUnit); if (resultUnit) { // Track it this.deploymentState.deployedUnits.set(selIndex, resultUnit); // Notify UI window.dispatchEvent( new CustomEvent("deployment-update", { detail: { deployedIndices: Array.from( this.deploymentState.deployedUnits.keys() ), }, }) ); } } else { console.log("No unit selected."); } } else if ( this.gameStateManager && this.gameStateManager.currentState === "STATE_COMBAT" ) { // Handle combat actions based on state if (this.combatState === "TARGETING_SKILL") { this.handleSkillTargeting(cursor); } else { // Default to movement this.handleCombatMovement(cursor); } } } /** * Handles movement in combat state. * Delegates to MovementSystem. * @param {Position} targetPos - Target position to move to */ async handleCombatMovement(targetPos) { if (!this.movementSystem || !this.turnSystem) return; const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") { console.log("Not a player's turn or unit not found"); return; } // DELEGATE to MovementSystem const success = await this.movementSystem.executeMove( activeUnit, targetPos ); if (success) { // Update unit mesh position const mesh = this.unitMeshes.get(activeUnit.id); if (mesh) { // Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1 mesh.position.set( activeUnit.position.x, activeUnit.position.y + 0.1, activeUnit.position.z ); } console.log( `Moved ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}` ); // Follow camera to the unit's new position // Only follow if this is the active unit (whose turn it is) const currentActiveUnit = this.turnSystem.getActiveUnit(); if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) { this.centerCameraOnUnit(activeUnit); } // Dispatch UNIT_MOVE event to MissionManager for objective tracking if (this.missionManager) { this.missionManager.onGameEvent("UNIT_MOVE", { unitId: activeUnit.id, position: activeUnit.position, }); } // Check if unit moved to a mission object position (interaction) this.checkMissionObjectInteraction(activeUnit); // Update combat state and movement highlights this.updateCombatState().catch(console.error); // NOTE: Do NOT auto-end turn when AP reaches 0 after movement. // The player should explicitly click "End Turn" to end their turn. // Even if the unit has no AP left, they may want to use skills or wait. } } /** * Handles skill click from CombatHUD. * Enters TARGETING_SKILL state and shows skill range. * @param {string} skillId - Skill ID */ onSkillClicked(skillId) { if (!this.turnSystem || !this.skillTargetingSystem) return; const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") return; // If clicking the same skill that's already active, cancel targeting if ( this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId ) { this.cancelSkillTargeting(); return; } // Find skill in unit's actions const skill = (activeUnit.actions || []).find((a) => a.id === skillId); if (!skill) { console.warn(`Skill ${skillId} not found in unit actions`); return; } // Validate unit has AP if (activeUnit.currentAP < (skill.costAP || 0)) { console.log("Insufficient AP"); return; } // Enter targeting mode this.combatState = "TARGETING_SKILL"; this.activeSkillId = skillId; // Clear movement highlights and show skill range (only valid targets) this.clearMovementHighlights(); const skillDef = this.skillTargetingSystem.getSkillDef(skillId); if (skillDef && this.voxelManager && this.skillTargetingSystem) { // Check if this is a teleport skill with unlimited range (range = -1) const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT"); const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity; let allTilesInRange = []; if (isTeleportSkill && hasUnlimitedRange) { // For teleport with unlimited range, scan all valid tiles in the grid // Range is limited only by line of sight if (this.grid && this.grid.size) { for (let x = 0; x < this.grid.size.x; x++) { for (let y = 0; y < this.grid.size.y; y++) { for (let z = 0; z < this.grid.size.z; z++) { if (this.grid.isValidBounds({ x, y, z })) { allTilesInRange.push({ x, y, z }); } } } } } } else { // For normal skills, get tiles within range for ( let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++ ) { for ( let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++ ) { for ( let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++ ) { const dist = Math.abs(x - activeUnit.position.x) + Math.abs(y - activeUnit.position.y) + Math.abs(z - activeUnit.position.z); if (dist <= skillDef.range) { // Check if position is valid bounds if (this.grid && this.grid.isValidBounds({ x, y, z })) { allTilesInRange.push({ x, y, z }); } } } } } } // Filter to only valid targets using validation and collect obstruction data const validTilesWithObstruction = []; allTilesInRange.forEach((tilePos) => { const validation = this.skillTargetingSystem.validateTarget( activeUnit, tilePos, skillId ); if (validation.valid) { validTilesWithObstruction.push({ pos: tilePos, obstruction: validation.obstruction || 0, }); } }); // Highlight only valid targets with obstruction-based dimming this.voxelManager.highlightTilesWithObstruction( validTilesWithObstruction, "RED_OUTLINE" ); } // Update combat state to refresh UI (show cancel button) this.updateCombatState().catch(console.error); console.log(`Entering targeting mode for skill: ${skillId}`); } /** * Handles skill targeting when in TARGETING_SKILL state. * Validates target and executes skill if valid. * @param {Position} targetPos - Target position */ handleSkillTargeting(targetPos) { if (!this.turnSystem || !this.skillTargetingSystem || !this.activeSkillId) { return; } const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") { return; } // Validate target const validation = this.skillTargetingSystem.validateTarget( activeUnit, targetPos, this.activeSkillId ); if (validation.valid) { this.executeSkill(this.activeSkillId, targetPos); } else { // Audio: Error Buzz console.log(`Invalid target: ${validation.reason}`); } } /** * Executes a skill at the target position. * Deducts costs, processes effects, and cleans up. * @param {string} skillId - Skill ID * @param {Position} targetPos - Target position */ async executeSkill(skillId, targetPos) { if (!this.turnSystem || !this.skillTargetingSystem) return; const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit) return; this.combatState = "EXECUTING_SKILL"; // 1. Deduct Costs (AP, Cooldown) const skill = (activeUnit.actions || []).find((a) => a.id === skillId); if (skill) { activeUnit.currentAP -= skill.costAP || 0; if (skill.cooldown !== undefined) { skill.cooldown = (skill.cooldown || 0) + 1; // Set cooldown } } // 2. Get Targets (Units in AoE) let targets = this.skillTargetingSystem.getUnitsInAoE( activeUnit.position, targetPos, skillId ); console.log( `AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}` ); if (targets.length > 0) { targets.forEach((t) => { console.log( ` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}` ); }); } // Fallback: If no targets found but there's a unit at the target position, include it // This handles cases where the AoE calculation might miss the exact target if (targets.length === 0 && this.grid) { const unitAtTarget = this.grid.getUnitAt(targetPos); if (unitAtTarget) { targets = [unitAtTarget]; console.log( `Fallback: Added unit at target position: ${unitAtTarget.name}` ); } } // 3. Process Effects using EffectProcessor const skillDef = this.skillTargetingSystem.getSkillDef(skillId); // Process ON_SKILL_CAST passive effects if (skillDef) { this.processPassiveItemEffects(activeUnit, "ON_SKILL_CAST", { skillId: skillId, skillDef: skillDef, }); } if (skillDef && skillDef.effects && this.effectProcessor) { for (const effect of skillDef.effects) { // Special handling for TELEPORT - teleports the source unit, not targets if (effect.type === "TELEPORT") { // Check line of sight - if obstructed, teleport has a chance to fail const losResult = this.skillTargetingSystem.hasLineOfSight( activeUnit.position, targetPos, skillDef.ignore_cover || false ); // Calculate failure chance based on obstruction level // Obstruction of 0 = 0% failure, obstruction of 1.0 = 100% failure const failureChance = losResult.obstruction || 0; if (Math.random() < failureChance) { console.warn( `${ activeUnit.name }'s teleport failed due to obstructed line of sight! (${Math.round( failureChance * 100 )}% obstruction)` ); // Teleport failed - unit stays in place, but AP was already deducted // Could optionally refund AP here, but for now we'll just log the failure continue; // Skip teleport execution } // Find walkable Y level for target position let walkableY = targetPos.y; if (this.movementSystem) { const foundY = this.movementSystem.findWalkableY( targetPos.x, targetPos.z, targetPos.y ); if (foundY !== null) { walkableY = foundY; } else { // No walkable position found - teleport fails console.warn( `${activeUnit.name}'s teleport failed: target position is not walkable` ); continue; // Skip teleport execution } } const teleportDestination = { x: targetPos.x, y: walkableY, z: targetPos.z, }; // Process teleport effect - source unit is teleported to destination const result = this.effectProcessor.process( effect, activeUnit, teleportDestination ); if (result.success && result.data) { // Update unit mesh position after teleport const mesh = this.unitMeshes.get(activeUnit.id); if (mesh) { // Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1 mesh.position.set( activeUnit.position.x, activeUnit.position.y + 0.1, activeUnit.position.z ); } console.log( `Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}` ); // Dispatch UNIT_MOVE event to MissionManager for objective tracking if (this.missionManager) { this.missionManager.onGameEvent("UNIT_MOVE", { unitId: activeUnit.id, position: activeUnit.position, }); } // Check if unit teleported to a mission object position (interaction) this.checkMissionObjectInteraction(activeUnit); // Follow camera to the unit's new position after teleport // Only follow if this is the active unit (whose turn it is) const currentActiveUnit = this.turnSystem.getActiveUnit(); if (currentActiveUnit && currentActiveUnit.id === activeUnit.id) { this.centerCameraOnUnit(activeUnit); } } else { console.warn(`Teleport failed: ${result.error || "Unknown error"}`); } continue; // Skip normal target processing for TELEPORT } // Process effect for all targets (for non-TELEPORT effects) for (const target of targets) { if (!target) continue; // Check if unit is alive if (typeof target.isAlive === "function" && !target.isAlive()) continue; if (target.currentHealth <= 0) continue; // Process ON_SKILL_HIT passive effects (before processing effect) this.processPassiveItemEffects(activeUnit, "ON_SKILL_HIT", { skillId: skillId, skillDef: skillDef, target: target, effect: effect, }); // Process effect through EffectProcessor const result = this.effectProcessor.process( effect, activeUnit, target ); if (result.success) { // Log success messages based on effect type if (result.data) { if (result.data.type === "DAMAGE") { console.log( `${activeUnit.name} dealt ${result.data.amount} damage to ${target.name} (${result.data.currentHP}/${target.maxHealth} HP)` ); if (result.data.currentHP <= 0) { console.log(`${target.name} has been defeated!`); // Process ON_KILL passive effects (on source) this.processPassiveItemEffects(activeUnit, "ON_KILL", { target: target, killedUnit: target, }); // Handle unit death this.handleUnitDeath(target); } // Process passive item effects for ON_DAMAGED trigger (on target) this.processPassiveItemEffects(target, "ON_DAMAGED", { source: activeUnit, damageAmount: result.data.amount, }); // Process passive item effects for ON_DAMAGE_DEALT trigger (on source) this.processPassiveItemEffects(activeUnit, "ON_DAMAGE_DEALT", { target: target, damageAmount: result.data.amount, }); } else if (result.data.type === "HEAL") { if (result.data.amount > 0) { console.log( `${activeUnit.name} healed ${target.name} for ${result.data.amount} HP (${result.data.currentHP}/${target.maxHealth} HP)` ); } // Process passive item effects for ON_HEAL_DEALT trigger (on source) this.processPassiveItemEffects(activeUnit, "ON_HEAL_DEALT", { target: target, healAmount: result.data.amount, }); } else if (result.data.type === "APPLY_STATUS") { console.log( `${activeUnit.name} applied ${result.data.statusId} to ${target.name} for ${result.data.duration} turns` ); } else if (result.data.type === "CHAIN_DAMAGE") { // Log chain damage results if (result.data.results && result.data.results.length > 0) { const primaryResult = result.data.results[0]; console.log( `${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)` ); if ( result.data.chainTargets && result.data.chainTargets.length > 0 ) { console.log( `Chain lightning bounced to ${result.data.chainTargets.length} additional targets` ); } } } } } else { // Log warnings for failed effects (but don't block other effects) if (result.error && result.error !== "Conditions not met") { console.warn(`Effect ${effect.type} failed: ${result.error}`); } } } } } console.log( `Executed skill ${skillId} at ${targetPos.x},${targetPos.y},${targetPos.z}, hit ${targets.length} targets` ); // 4. Cleanup this.combatState = "IDLE"; this.activeSkillId = null; // Clear skill highlights if (this.voxelManager) { this.voxelManager.clearHighlights(); } // Restore movement highlights if we have an active unit if (this.turnSystem) { const activeUnit = this.turnSystem.getActiveUnit(); if (activeUnit && activeUnit.team === "PLAYER") { this.updateMovementHighlights(activeUnit); } } // Update combat state this.updateCombatState().catch(console.error); } /** * Handles cursor hover to update AoE preview when targeting skills. * @param {THREE.Vector3} pos - Cursor position */ onCursorHover(pos) { if ( this.combatState === "TARGETING_SKILL" && this.activeSkillId && this.turnSystem && this.skillTargetingSystem && this.voxelManager ) { const activeUnit = this.turnSystem.getActiveUnit(); if (activeUnit) { const cursorPos = { x: pos.x, y: pos.y, z: pos.z }; const aoeTiles = this.skillTargetingSystem.getAoETiles( activeUnit.position, cursorPos, this.activeSkillId ); // Show AoE reticle this.voxelManager.showReticle(aoeTiles); } } } /** * Cancels skill targeting and returns to IDLE state. */ cancelSkillTargeting() { this.combatState = "IDLE"; this.activeSkillId = null; // Clear skill highlights if (this.voxelManager) { this.voxelManager.clearHighlights(); } // Restore movement highlights if we have an active unit if (this.turnSystem) { const activeUnit = this.turnSystem.getActiveUnit(); if (activeUnit && activeUnit.team === "PLAYER") { this.updateMovementHighlights(activeUnit); } } // Update combat state to refresh UI this.updateCombatState().catch(console.error); } /** * Handles movement button click from CombatHUD. * Returns to movement mode from skill targeting. */ onMovementClicked() { if (!this.turnSystem) return; const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit || activeUnit.team !== "PLAYER") return; // If we're in skill targeting mode, cancel it and return to movement if (this.combatState === "TARGETING_SKILL") { this.cancelSkillTargeting(); } else { // If already in movement mode, ensure movement highlights are shown this.updateMovementHighlights(activeUnit); // Update combat state to refresh UI this.updateCombatState().catch(console.error); } } /** * Starts a mission by ID. * @param {string} missionId - Mission identifier * @returns {Promise} */ async startMission(missionId) { const mission = await fetch( `assets/data/missions/${missionId.toLowerCase()}.json` ); const missionData = await mission.json(); this.missionManager.startMission(missionData); } /** * Starts a level with the given run data. * @param {RunData} runData - Run data containing mission and squad info * @param {Object} [options] - Optional configuration * @param {boolean} [options.startAnimation=true] - Whether to start the animation loop * @returns {Promise} */ async startLevel(runData, options = {}) { console.log("GameLoop: Generating Level..."); this.runData = runData; this.isRunning = true; this.clearUnitMeshes(); this.clearMovementHighlights(); this.clearSpawnZoneHighlights(); this.clearMissionObjects(); this.clearZoneMarkers(); this.clearRangeHighlights(); // Reset Deployment State this.deploymentState = { selectedUnitIndex: -1, deployedUnits: new Map(), // Map }; this.grid = new VoxelGrid(20, 10, 20); const generator = new RuinGenerator(this.grid, runData.seed); generator.generate(); if (generator.generatedAssets.spawnZones) { this.playerSpawnZone = generator.generatedAssets.spawnZones.player || []; this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || []; } if (this.playerSpawnZone.length === 0) this.playerSpawnZone.push({ x: 2, y: 1, z: 2 }); if (this.enemySpawnZone.length === 0) this.enemySpawnZone.push({ x: 18, y: 1, z: 18 }); // Dispose of old VoxelManager if it exists if (this.voxelManager) { // Clear all meshes from the old VoxelManager this.voxelManager.meshes.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.voxelManager.meshes.clear(); } this.voxelManager = new VoxelManager(this.grid, this.scene); this.voxelManager.updateMaterials(generator.generatedAssets); this.voxelManager.update(); // Set up highlight tracking sets this.voxelManager.setHighlightSets(this.rangeHighlights, this.aoeReticle); if (this.controls) this.voxelManager.focusCamera(this.controls); // Create a proper registry with actual class definitions const classRegistry = new Map(); // Lazy-load class definitions const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] = await Promise.all([ import("../assets/data/classes/vanguard.json", { with: { type: "json" }, }).then((m) => m.default), import("../assets/data/classes/aether_weaver.json", { with: { type: "json" }, }).then((m) => m.default), import("../assets/data/classes/scavenger.json", { with: { type: "json" }, }).then((m) => m.default), import("../assets/data/classes/tinker.json", { with: { type: "json" }, }).then((m) => m.default), import("../assets/data/classes/custodian.json", { with: { type: "json" }, }).then((m) => m.default), ]); // Register all class definitions const classDefs = [ vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef, ]; for (const classDef of classDefs) { if (classDef && classDef.id) { // Add type field for compatibility classRegistry.set(classDef.id, { ...classDef, type: "EXPLORER", }); } } // Create registry object with get method for UnitManager const unitRegistry = { get: (id) => { // Try to get from class registry first if (classRegistry.has(id)) { return classRegistry.get(id); } // Fallback for enemy units if (id.startsWith("ENEMY_")) { return { type: "ENEMY", name: "Enemy", stats: { health: 50, attack: 8, defense: 3, speed: 8 }, ai_archetype: "BRUISER", }; } console.warn(`Unit definition not found: ${id}`); return null; }, }; this.unitManager = new UnitManager(unitRegistry); // Store classRegistry reference for accessing class definitions later this.classRegistry = classRegistry; // WIRING: Connect Systems to Data this.movementSystem.setContext(this.grid, this.unitManager); this.turnSystem.setContext(this.unitManager); // Initialize EffectProcessor with grid, unitManager, and optional RNG (using seed from runData) this.effectProcessor = new EffectProcessor( this.grid, this.unitManager, runData.seed ? new SeededRandom(runData.seed) : null ); // Set hazard context for TurnSystem (for environmental hazard processing) this.turnSystem.setHazardContext(this.grid, this.effectProcessor); // Load skills and initialize SkillTargetingSystem // Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts if (options.startAnimation !== false && skillRegistry.skills.size === 0) { await skillRegistry.loadAll(); } this.skillTargetingSystem = new SkillTargetingSystem( this.grid, this.unitManager, skillRegistry ); // Load items for InventoryManager if (options.startAnimation !== false && itemRegistry.items.size === 0) { await itemRegistry.loadAll(); } // WIRING: Listen for Turn Changes (to update UI/Input state) // Create new AbortController for this level - when aborted, listeners are automatically removed this.turnSystemAbortController = new AbortController(); const signal = this.turnSystemAbortController.signal; // Set up callbacks for TurnSystem this.turnSystem.onUnitDeathCallback = (unit) => { this.handleUnitDeath(unit); }; this.turnSystem.addEventListener( "turn-start", (e) => this._onTurnStart(e.detail), { signal } ); this.turnSystem.addEventListener( "turn-end", (e) => this._onTurnEnd(e.detail), { signal } ); this.turnSystem.addEventListener( "combat-start", () => this._onCombatStart(), { signal } ); this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal, }); this.highlightZones(); if (this.playerSpawnZone.length > 0) { let sumX = 0, sumY = 0, sumZ = 0; for (const spot of this.playerSpawnZone) { sumX += spot.x; sumY += spot.y; sumZ += spot.z; } const centerX = sumX / this.playerSpawnZone.length; const centerY = sumY / this.playerSpawnZone.length; const centerZ = sumZ / this.playerSpawnZone.length; const start = this.playerSpawnZone[0]; this.inputManager.setCursor(start.x, start.y, start.z); if (this.controls) { this.controls.target.set(centerX, centerY, centerZ); this.controls.update(); } } this.inputManager.setValidator(this.validateDeploymentCursor.bind(this)); // Only start animation loop if explicitly requested (default true for normal usage) if (options.startAnimation !== false) { this.animate(); } } /** * Deploys or moves a unit to a target tile. * @param {import("./types.js").SquadMember} unitDef - Unit definition * @param {Position} targetTile - Target position * @param {Unit | null} [existingUnit] - Existing unit to move, or null to create new * @returns {Unit | null} - The deployed/moved unit, or null if failed */ deployUnit(unitDef, targetTile, existingUnit = null) { if ( !this.gameStateManager || this.gameStateManager.currentState !== "STATE_DEPLOYMENT" ) return null; const isValid = this.validateDeploymentCursor( targetTile.x, targetTile.y, targetTile.z ); // Check collision if (!isValid) { console.warn("Invalid spawn zone"); return null; } // If tile occupied... if (this.grid.isOccupied(targetTile)) { // If occupied by SELF (clicking same spot), that's valid, just do nothing if ( existingUnit && existingUnit.position.x === targetTile.x && existingUnit.position.z === targetTile.z ) { return existingUnit; } console.warn("Tile occupied"); return null; } if (existingUnit) { // MOVE logic this.grid.moveUnit(existingUnit, targetTile, { force: true }); // Force to bypass standard move checks if any // Update Mesh const mesh = this.unitMeshes.get(existingUnit.id); if (mesh) { // Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1 mesh.position.set(targetTile.x, targetTile.y + 0.1, targetTile.z); } console.log( `Moved ${existingUnit.name} to ${targetTile.x},${targetTile.y},${targetTile.z}` ); return existingUnit; } else { // CREATE logic const classId = unitDef.classId || unitDef.id; const unit = this.unitManager.createUnit(classId, "PLAYER"); if (!unit) { console.error(`Failed to create unit for class: ${classId}`); return null; } // Set character name and class name from unitDef if (unitDef.name) unit.name = unitDef.name; if (unitDef.className) unit.className = unitDef.className; // Restore progression data from roster for Explorers if (unit.type === "EXPLORER" && unitDef.id && this.gameStateManager) { const rosterUnit = this.gameStateManager.rosterManager.roster.find( (r) => r.id === unitDef.id ); if (rosterUnit) { // Store roster ID on unit for later saving unit.rosterId = unitDef.id; // Restore activeClassId first (needed for stat recalculation) if (rosterUnit.activeClassId) { unit.activeClassId = rosterUnit.activeClassId; } // Restore classMastery progression if (rosterUnit.classMastery) { unit.classMastery = JSON.parse( JSON.stringify(rosterUnit.classMastery) ); // Recalculate stats based on restored mastery and activeClassId if (unit.recalculateBaseStats && unit.activeClassId) { const classDef = typeof this.unitManager.registry.get === "function" ? this.unitManager.registry.get(unit.activeClassId) : this.unitManager.registry[unit.activeClassId]; if (classDef) { unit.recalculateBaseStats(classDef); } } } // Restore currentHealth from roster (preserve HP that was paid for) if ( rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null ) { // Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased) unit.currentHealth = Math.min( rosterUnit.currentHealth, unit.maxHealth || 100 ); console.log( `Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)` ); } } } // Preserve portrait/image from unitDef for UI display if (unitDef.image) { // Normalize path: ensure it starts with / if it doesn't already unit.portrait = unitDef.image.startsWith("/") ? unitDef.image : "/" + unitDef.image; } else if (unitDef.portrait) { unit.portrait = unitDef.portrait.startsWith("/") ? unitDef.portrait : "/" + unitDef.portrait; } // Initialize starting equipment for Explorers if (unit.type === "EXPLORER" && this.inventoryManager) { // Get class definition from the registry let classDef = null; if (this.unitManager.registry) { classDef = typeof this.unitManager.registry.get === "function" ? this.unitManager.registry.get(classId) : this.unitManager.registry[classId]; } if ( classDef && typeof unit.initializeStartingEquipment === "function" ) { unit.initializeStartingEquipment( this.inventoryManager.itemRegistry, classDef ); } } // Ensure unit has valid health values // Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster // This preserves HP that was paid for in the barracks if ( unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find( (r) => r.id === unit.rosterId )?.currentHealth) ) { // Only set to full if we didn't restore from roster (new unit or roster had no saved HP) unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100; } // Ensure maxHealth is set if (!unit.maxHealth) { unit.maxHealth = unit.baseStats?.health || 100; } this.grid.placeUnit(unit, targetTile); this.createUnitMesh(unit, targetTile); console.log( `Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}` ); return unit; } } /** * Finalizes deployment phase and starts combat. */ async finalizeDeployment() { if ( !this.gameStateManager || this.gameStateManager.currentState !== "STATE_DEPLOYMENT" ) return; // Get enemy spawns from mission definition const missionDef = await this.missionManager?.getActiveMission(); const enemySpawns = missionDef?.enemy_spawns || []; // If no enemy_spawns defined, fall back to default behavior if (enemySpawns.length === 0) { console.warn("No enemy_spawns defined in mission, using default"); const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); if (enemy && this.enemySpawnZone.length > 0) { const spot = this.enemySpawnZone[0]; const walkableY = this.movementSystem?.findWalkableY( spot.x, spot.z, spot.y ); if (walkableY !== null) { const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; if ( !this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos) ) { this.grid.placeUnit(enemy, walkablePos); this.createUnitMesh(enemy, walkablePos); } } } } else { // Spawn enemies according to mission definition let totalSpawned = 0; const availableSpots = [...this.enemySpawnZone]; // Copy to avoid mutating original for (const spawnDef of enemySpawns) { const { enemy_def_id, count } = spawnDef; let attempts = 0; const maxAttempts = availableSpots.length * 2; for ( let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++ ) { const spotIndex = Math.floor(Math.random() * availableSpots.length); const spot = availableSpots[spotIndex]; if (!spot) continue; // Check if position is walkable (not just unoccupied) const walkableY = this.movementSystem?.findWalkableY( spot.x, spot.z, spot.y ); if (walkableY === null) continue; const walkablePos = { x: spot.x, y: walkableY, z: spot.z }; // Check if position is not occupied and is walkable (not solid) if ( !this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos) ) { const enemy = this.unitManager.createUnit(enemy_def_id, "ENEMY"); if (enemy) { this.grid.placeUnit(enemy, walkablePos); this.createUnitMesh(enemy, walkablePos); availableSpots.splice(spotIndex, 1); totalSpawned++; i++; // Only increment if we successfully placed an enemy } } } } console.log(`Spawned ${totalSpawned} enemies from mission definition`); } // Spawn mission objects const missionObjects = missionDef?.mission_objects || []; for (const objDef of missionObjects) { const { object_id, position, placement_strategy } = objDef; if (!object_id) continue; let objPos = null; // If explicit position is provided, use it (for backwards compatibility) if (position) { const walkableY = this.movementSystem?.findWalkableY( position.x, position.z, position.y ); if (walkableY !== null) { objPos = { x: position.x, y: walkableY, z: position.z }; } } // Otherwise, use placement strategy else if (placement_strategy) { objPos = this.findObjectPlacement(placement_strategy); } if (!objPos) { console.warn( `Could not find valid position for object ${object_id} using ${placement_strategy || "explicit position"}` ); continue; } // Store object position this.missionObjects.set(object_id, objPos); // Create visual mesh for the object this.createMissionObjectMesh(object_id, objPos); } if (missionObjects.length > 0) { console.log(`Spawned ${missionObjects.length} mission objects`); } // Switch to standard movement validator for the game this.inputManager.setValidator(this.validateCursorMove.bind(this)); // Clear spawn zone highlights now that deployment is finished this.clearSpawnZoneHighlights(); // Notify GameStateManager about state change if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_COMBAT"); } // WIRING: Hand control to TurnSystem // Get units from UnitManager (which tracks all units including enemies just spawned) const allUnits = this.unitManager.getAllUnits(); this.turnSystem.startCombat(allUnits); // WIRING: Set up MissionManager references if (this.missionManager) { this.missionManager.setUnitManager(this.unitManager); this.missionManager.setTurnSystem(this.turnSystem); this.missionManager.setGridContext(this.grid, this.movementSystem); await this.missionManager.setupActiveMission(); // Populate zone coordinates for REACH_ZONE objectives this.missionManager.populateZoneCoordinates(); // Create visual markers for zones this.createZoneMarkers(); } // WIRING: Listen for mission events this._setupMissionEventListeners(); // Update combat state immediately so UI shows combat HUD this.updateCombatState().catch(console.error); console.log("Combat Started!"); } /** * Initializes all units for combat with starting AP and charge. */ initializeCombatUnits() { if (!this.grid) return; const allUnits = Array.from(this.grid.unitMap.values()); allUnits.forEach((unit) => { // Set starting AP (default to 10, can be derived from stats later) const maxAP = 10; // TODO: Derive from unit stats // All units start with full AP when combat begins unit.currentAP = maxAP; // Initialize charge meter based on speed stat (faster units start with more charge) // Charge meter ranges from 0-100, speed-based units get a head start const speed = unit.baseStats?.speed || 10; // Scale speed (typically 5-20) to charge (0-100) // Faster units start closer to 100, slower units start lower unit.chargeMeter = Math.min(100, Math.max(0, speed * 5)); // Rough scaling: 10 speed = 50 charge }); } /** * Clears all unit meshes from the scene. */ clearUnitMeshes() { this.unitMeshes.forEach((mesh) => this.scene.remove(mesh)); this.unitMeshes.clear(); } /** * Clears all mission object meshes from the scene. */ clearMissionObjects() { this.missionObjectMeshes.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.missionObjectMeshes.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. */ clearMovementHighlights() { this.movementHighlights.forEach((mesh) => { this.scene.remove(mesh); // Dispose geometry and material to free memory if (mesh.geometry) { // For LineSegments, geometry might be EdgesGeometry which wraps another geometry // Dispose the geometry itself mesh.geometry.dispose(); } if (mesh.material) { if (Array.isArray(mesh.material)) { mesh.material.forEach((mat) => { if (mat.map) mat.map.dispose(); mat.dispose(); }); } else { if (mesh.material.map) mesh.material.map.dispose(); mesh.material.dispose(); } } }); this.movementHighlights.clear(); } /** * Updates movement highlights for the active player unit. * Uses MovementSystem to get reachable tiles. * @param {Unit | null} activeUnit - The active unit, or null to clear highlights */ updateMovementHighlights(activeUnit) { // Clear existing highlights this.clearMovementHighlights(); // Only show highlights for player units in combat if ( !activeUnit || activeUnit.team !== "PLAYER" || !this.gameStateManager || this.gameStateManager.currentState !== "STATE_COMBAT" || !this.movementSystem ) { return; } // DELEGATE to MovementSystem const reachablePositions = this.movementSystem.getReachableTiles(activeUnit); // Create glowing blue outline materials with multiple layers for enhanced glow // Outer glow layers (fade outward, decreasing opacity) const outerGlowMaterial = new THREE.LineBasicMaterial({ color: 0x0066ff, transparent: true, opacity: 0.3, }); const midGlowMaterial = new THREE.LineBasicMaterial({ color: 0x0088ff, transparent: true, opacity: 0.5, }); // Inner bright outline (main glow - brightest) const highlightMaterial = new THREE.LineBasicMaterial({ color: 0x00ccff, // Very bright cyan-blue for maximum visibility transparent: true, opacity: 1.0, }); // Thick inner outline (for thickness simulation) const thickMaterial = new THREE.LineBasicMaterial({ color: 0x00aaff, transparent: true, opacity: 0.8, }); // Create base plane geometry for the tile const baseGeometry = new THREE.PlaneGeometry(1, 1); baseGeometry.rotateX(-Math.PI / 2); // Create highlight outlines for each reachable position reachablePositions.forEach((pos) => { // Get the correct floor surface height for this position const walkableY = this.movementSystem.findWalkableY(pos.x, pos.z, pos.y); if (walkableY === null) return; // Skip if no valid floor found // Floor surface is at the walkable Y coordinate (top of the floor block) // Adjust by -0.5 to account for voxel centering const floorSurfaceY = walkableY - 0.5; // Create multiple glow layers for enhanced visibility and fade effect // Outer glow (largest, most transparent) const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowLines = new THREE.LineSegments( outerGlowEdges, outerGlowMaterial ); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.movementHighlights.add(outerGlowLines); // Mid glow (medium size) const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowLines = new THREE.LineSegments( midGlowEdges, midGlowMaterial ); midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); this.scene.add(midGlowLines); this.movementHighlights.add(midGlowLines); // Thick inner outline (slightly larger than base for thickness) const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); thickGeometry.rotateX(-Math.PI / 2); const thickEdges = new THREE.EdgesGeometry(thickGeometry); const thickLines = new THREE.LineSegments(thickEdges, thickMaterial); thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z); this.scene.add(thickLines); this.movementHighlights.add(thickLines); // Main bright outline (exact size, brightest) const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); const lineSegments = new THREE.LineSegments( edgesGeometry, highlightMaterial ); // Position exactly on floor surface lineSegments.position.set(pos.x, floorSurfaceY, pos.z); this.scene.add(lineSegments); this.movementHighlights.add(lineSegments); }); } /** * Centers the camera on a unit's position. * Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans. * @param {Unit} unit - The unit to center the camera on */ centerCameraOnUnit(unit) { if (!unit || !unit.position || !this.controls) { return; } // Unit mesh is positioned at (pos.x, pos.y + 0.1, pos.z) // Center the camera on the unit's position const targetX = unit.position.x; const targetY = unit.position.y + 0.1; // Match unit mesh height offset const targetZ = unit.position.z; this.followCameraToPosition(targetX, targetY, targetZ); } /** * Moves the camera to follow a specific position. * Respects prefers-reduced-motion: jumps instantly if enabled, otherwise smoothly pans. * Maintains camera rotation by preserving the relative offset from target to camera. * @param {number} x - Target X coordinate * @param {number} y - Target Y coordinate * @param {number} z - Target Z coordinate * @private */ followCameraToPosition(x, y, z) { if (!this.controls || !this.camera) { return; } // Calculate the offset from current target to camera position // This preserves the camera's rotation/angle relative to the target const cameraOffset = new THREE.Vector3(); cameraOffset.subVectors(this.camera.position, this.controls.target); // Check for prefers-reduced-motion const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; if (prefersReducedMotion) { // Jump instantly to target position, maintaining camera offset this.controls.target.set(x, y, z); this.camera.position.copy(this.controls.target).add(cameraOffset); this.controls.update(); this.isAnimatingCamera = false; } else { // Start smooth animation this.cameraAnimationStart.copy(this.controls.target); this.cameraAnimationTarget.set(x, y, z); this.cameraAnimationStartTime = Date.now(); this.isAnimatingCamera = true; // Store the offset to maintain during animation this.cameraAnimationOffset = cameraOffset; } } /** * Creates a visual mesh for a unit. * @param {Unit} unit - The unit instance * @param {Position} pos - Position to place the mesh */ createUnitMesh(unit, pos) { const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6); // Class-based color mapping for player units const CLASS_COLORS = { CLASS_VANGUARD: 0xff3333, // Red - Tank CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver) CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic }; let color = 0xcccccc; // Default gray if (unit.team === "ENEMY") { color = 0x550000; // Dark red for enemies } else if (unit.team === "PLAYER") { // Get class ID from activeClassId (Explorer units) or extract from unit.id let classId = unit.activeClassId; // If no activeClassId, try to extract from unit.id (format: "CLASS_VANGUARD_0") if (!classId && unit.id.includes("CLASS_")) { const parts = unit.id.split("_"); if (parts.length >= 2) { classId = parts[0] + "_" + parts[1]; } } // Look up color by class ID if (classId && CLASS_COLORS[classId]) { color = CLASS_COLORS[classId]; } else { // Fallback: check if unit.id contains any class name for (const className of Object.keys(CLASS_COLORS)) { const classShortName = className.replace("CLASS_", ""); if (unit.id.includes(classShortName)) { color = CLASS_COLORS[className]; break; } } } } const material = new THREE.MeshStandardMaterial({ color: color }); const mesh = new THREE.Mesh(geometry, material); // Floor surface is at pos.y - 0.5 (floor block at pos.y-1, top at pos.y-0.5) // Unit should be 0.6 units above floor surface: (pos.y - 0.5) + 0.6 = pos.y + 0.1 mesh.position.set(pos.x, pos.y + 0.1, pos.z); this.scene.add(mesh); this.unitMeshes.set(unit.id, mesh); } /** * Finds a valid placement position for a mission object based on strategy. * @param {string} strategy - Placement strategy (e.g., "center_of_enemy_room", "center_of_player_room", "random_walkable") * @returns {Position | null} - Valid position or null if not found */ findObjectPlacement(strategy) { if (!this.grid || !this.movementSystem) return null; switch (strategy) { case "center_of_enemy_room": // Place in the center of the enemy spawn zone if (this.enemySpawnZone.length > 0) { // Find center of enemy spawn zone let sumX = 0, sumY = 0, sumZ = 0; for (const spot of this.enemySpawnZone) { sumX += spot.x; sumY += spot.y; sumZ += spot.z; } const centerX = Math.round(sumX / this.enemySpawnZone.length); const centerZ = Math.round(sumZ / this.enemySpawnZone.length); const avgY = Math.round(sumY / this.enemySpawnZone.length); // Find walkable position near center const walkableY = this.movementSystem.findWalkableY(centerX, centerZ, avgY); if (walkableY !== null) { return { x: centerX, y: walkableY, z: centerZ }; } } break; case "center_of_player_room": // Place in the center of the player spawn zone if (this.playerSpawnZone.length > 0) { let sumX = 0, sumY = 0, sumZ = 0; for (const spot of this.playerSpawnZone) { sumX += spot.x; sumY += spot.y; sumZ += spot.z; } const centerX = Math.round(sumX / this.playerSpawnZone.length); const centerZ = Math.round(sumZ / this.playerSpawnZone.length); const avgY = Math.round(sumY / this.playerSpawnZone.length); const walkableY = this.movementSystem.findWalkableY(centerX, centerZ, avgY); if (walkableY !== null) { return { x: centerX, y: walkableY, z: centerZ }; } } break; case "random_walkable": // Find a random walkable position in the grid const attempts = 50; for (let i = 0; i < attempts; 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 })) { return { x, y: walkableY, z }; } } break; case "middle_room": // Try to place between player and enemy spawn zones if (this.playerSpawnZone.length > 0 && this.enemySpawnZone.length > 0) { const playerCenter = { x: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.x, 0) / this.playerSpawnZone.length), z: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.z, 0) / this.playerSpawnZone.length), y: Math.round(this.playerSpawnZone.reduce((sum, s) => sum + s.y, 0) / this.playerSpawnZone.length) }; const enemyCenter = { x: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.x, 0) / this.enemySpawnZone.length), z: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.z, 0) / this.enemySpawnZone.length), y: Math.round(this.enemySpawnZone.reduce((sum, s) => sum + s.y, 0) / this.enemySpawnZone.length) }; const midX = Math.round((playerCenter.x + enemyCenter.x) / 2); const midZ = Math.round((playerCenter.z + enemyCenter.z) / 2); const midY = Math.round((playerCenter.y + enemyCenter.y) / 2); const walkableY = this.movementSystem.findWalkableY(midX, midZ, midY); if (walkableY !== null) { return { x: midX, y: walkableY, z: midZ }; } } break; default: console.warn(`Unknown placement strategy: ${strategy}`); return null; } // Fallback: try random_walkable if strategy failed if (strategy !== "random_walkable") { return this.findObjectPlacement("random_walkable"); } return null; } /** * Creates a visual mesh for a mission object (placeholder). * @param {string} objectId - Object ID (e.g., "OBJ_SIGNAL_RELAY") * @param {Position} pos - Position to place the object * @returns {THREE.Mesh} Created mesh */ createMissionObjectMesh(objectId, pos) { // Create a distinctive placeholder object (cylinder for objects vs boxes for units) const geometry = new THREE.CylinderGeometry(0.4, 0.4, 0.8, 8); // Use a bright color to make objects stand out (yellow/gold for interactable objects) const material = new THREE.MeshStandardMaterial({ color: 0xffaa00, // Orange/gold emissive: 0x442200, // Slight glow metalness: 0.3, roughness: 0.7 }); const mesh = new THREE.Mesh(geometry, material); // Position the object on the floor (same as units: pos.y + 0.1) mesh.position.set(pos.x, pos.y + 0.5, pos.z); // Add metadata for interaction detection mesh.userData = { objectId, originalY: pos.y + 0.5 }; // Add to scene this.scene.add(mesh); this.missionObjectMeshes.set(objectId, mesh); console.log(`Created mission object mesh for ${objectId} at ${pos.x},${pos.y},${pos.z}`); return mesh; } /** * Checks if a unit is at a mission object position and triggers interaction. * @param {Unit} unit - The unit to check */ checkMissionObjectInteraction(unit) { if (!unit || !this.missionObjects) return; const unitPos = unit.position; // Check each mission object to see if unit is at its position for (const [objectId, objPos] of this.missionObjects.entries()) { // Check if unit is at the same x, z position (Y can vary slightly) if ( Math.floor(unitPos.x) === Math.floor(objPos.x) && Math.floor(unitPos.z) === Math.floor(objPos.z) ) { console.log(`Unit ${unit.name} interacted with ${objectId}`); // Dispatch INTERACT event for MissionManager to handle if (this.missionManager) { this.missionManager.onGameEvent("INTERACT", { objectId: objectId, unitId: unit.id, position: unitPos }); } // Visual feedback: make object glow or change color const mesh = this.missionObjectMeshes.get(objectId); if (mesh && mesh.material) { mesh.material.emissive.setHex(0x884400); // Brighter glow on interaction } // Only interact with one object per move break; } } } /** * Highlights spawn zones with visual indicators. * Uses multi-layer glow outline style similar to movement highlights. */ highlightZones() { // Clear any existing spawn zone highlights this.clearSpawnZoneHighlights(); // Player zone colors (green) - multi-layer glow const playerOuterGlowMaterial = new THREE.LineBasicMaterial({ color: 0x006600, transparent: true, opacity: 0.3, }); const playerMidGlowMaterial = new THREE.LineBasicMaterial({ color: 0x008800, transparent: true, opacity: 0.5, }); const playerHighlightMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, // Bright green transparent: true, opacity: 1.0, }); const playerThickMaterial = new THREE.LineBasicMaterial({ color: 0x00cc00, transparent: true, opacity: 0.8, }); // Enemy zone colors (red) - multi-layer glow const enemyOuterGlowMaterial = new THREE.LineBasicMaterial({ color: 0x660000, transparent: true, opacity: 0.3, }); const enemyMidGlowMaterial = new THREE.LineBasicMaterial({ color: 0x880000, transparent: true, opacity: 0.5, }); const enemyHighlightMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, // Bright red transparent: true, opacity: 1.0, }); const enemyThickMaterial = new THREE.LineBasicMaterial({ color: 0xcc0000, transparent: true, opacity: 0.8, }); // Create base plane geometry for the tile const baseGeometry = new THREE.PlaneGeometry(1, 1); baseGeometry.rotateX(-Math.PI / 2); // Helper function to create multi-layer highlights for a position const createHighlights = (pos, materials) => { const { outerGlow, midGlow, highlight, thick } = materials; // Find walkable Y level (similar to movement highlights) let walkableY = pos.y; if (this.grid && this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { for (let checkY = pos.y; checkY >= 0; checkY--) { if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) { walkableY = checkY; break; } } } const floorSurfaceY = walkableY - 0.5; // Outer glow (largest, most transparent) const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15); outerGlowGeometry.rotateX(-Math.PI / 2); const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry); const outerGlowLines = new THREE.LineSegments(outerGlowEdges, outerGlow); outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z); this.scene.add(outerGlowLines); this.spawnZoneHighlights.add(outerGlowLines); // Mid glow (medium size) const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08); midGlowGeometry.rotateX(-Math.PI / 2); const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry); const midGlowLines = new THREE.LineSegments(midGlowEdges, midGlow); midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z); this.scene.add(midGlowLines); this.spawnZoneHighlights.add(midGlowLines); // Thick inner outline (slightly larger than base for thickness) const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02); thickGeometry.rotateX(-Math.PI / 2); const thickEdges = new THREE.EdgesGeometry(thickGeometry); const thickLines = new THREE.LineSegments(thickEdges, thick); thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z); this.scene.add(thickLines); this.spawnZoneHighlights.add(thickLines); // Main bright outline (exact size, brightest) const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); const lineSegments = new THREE.LineSegments(edgesGeometry, highlight); lineSegments.position.set(pos.x, floorSurfaceY, pos.z); this.scene.add(lineSegments); this.spawnZoneHighlights.add(lineSegments); }; // Create highlights for player spawn zone (green) const playerMaterials = { outerGlow: playerOuterGlowMaterial, midGlow: playerMidGlowMaterial, highlight: playerHighlightMaterial, thick: playerThickMaterial, }; this.playerSpawnZone.forEach((pos) => { createHighlights(pos, playerMaterials); }); // Create highlights for enemy spawn zone (red) const enemyMaterials = { outerGlow: enemyOuterGlowMaterial, midGlow: enemyMidGlowMaterial, highlight: enemyHighlightMaterial, thick: enemyThickMaterial, }; this.enemySpawnZone.forEach((pos) => { createHighlights(pos, enemyMaterials); }); } /** * Clears all spawn zone highlight meshes from the scene. */ clearSpawnZoneHighlights() { this.spawnZoneHighlights.forEach((mesh) => { this.scene.remove(mesh); // Dispose geometry and material to free memory 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.spawnZoneHighlights.clear(); } /** * Clears all range highlight meshes from the scene. */ clearRangeHighlights() { this.rangeHighlights.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.rangeHighlights.clear(); } /** * Main animation loop. */ animate() { if (!this.isRunning) return; requestAnimationFrame(this.animate); if (this.inputManager) this.inputManager.update(); // Update zone marker animations this.updateZoneMarkers(); // Handle camera animation if active if (this.isAnimatingCamera && this.controls && this.camera) { const now = Date.now(); const elapsed = now - this.cameraAnimationStartTime; const progress = Math.min(elapsed / this.cameraAnimationDuration, 1.0); // Ease-out cubic for smooth deceleration const eased = 1 - Math.pow(1 - progress, 3); // Interpolate between start and target this.controls.target.lerpVectors( this.cameraAnimationStart, this.cameraAnimationTarget, eased ); // Maintain camera's relative offset to preserve rotation if (this.cameraAnimationOffset) { this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset); } // If animation is complete, snap to final position and stop if (progress >= 1.0) { this.controls.target.copy(this.cameraAnimationTarget); if (this.cameraAnimationOffset) { this.camera.position.copy(this.controls.target).add(this.cameraAnimationOffset); } this.isAnimatingCamera = false; this.cameraAnimationOffset = null; } } if (this.controls) this.controls.update(); const now = Date.now(); if (now - this.lastMoveTime > this.moveCooldown) { let dx = 0; let dz = 0; if ( this.inputManager.isKeyPressed("KeyW") || this.inputManager.isKeyPressed("ArrowUp") ) dz = -1; if ( this.inputManager.isKeyPressed("KeyS") || this.inputManager.isKeyPressed("ArrowDown") ) dz = 1; if ( this.inputManager.isKeyPressed("KeyA") || this.inputManager.isKeyPressed("ArrowLeft") ) dx = -1; if ( this.inputManager.isKeyPressed("KeyD") || this.inputManager.isKeyPressed("ArrowRight") ) dx = 1; if (dx !== 0 || dz !== 0) { const currentPos = this.inputManager.getCursorPosition(); const newX = currentPos.x + dx; const newZ = currentPos.z + dz; this.inputManager.setCursor(newX, currentPos.y, newZ); this.lastMoveTime = now; } } const time = Date.now() * 0.002; this.unitMeshes.forEach((mesh) => { mesh.position.y += Math.sin(time) * 0.002; }); this.renderer.render(this.scene, this.camera); } /** * Pauses the game loop (temporarily stops animation). * Can be resumed with resume(). */ pause() { this.isPaused = true; this.isRunning = false; } /** * Resumes the game loop after being paused. */ resume() { if (this.isPaused) { this.isPaused = false; this.isRunning = true; this.animate(); } } /** * Stops the game loop and cleans up resources. */ stop() { this.isRunning = false; this.isPaused = false; // Abort turn system event listeners (automatically removes them via signal) if (this.turnSystemAbortController) { this.turnSystemAbortController.abort(); this.turnSystemAbortController = null; } // Reset turn system state BEFORE ending combat to prevent event cascades if (this.turnSystem) { // End combat first to stop any ongoing turn advancement if ( this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END" ) { try { this.turnSystem.endCombat(); } catch (e) { // Ignore errors } } // Then reset if (typeof this.turnSystem.reset === "function") { this.turnSystem.reset(); } } // Clear all visual elements from the scene this.clearUnitMeshes(); this.clearMovementHighlights(); this.clearSpawnZoneHighlights(); this.clearMissionObjects(); this.clearRangeHighlights(); // Clear unit manager if (this.unitManager) { // UnitManager doesn't have a clear method, but we can reset it by clearing units const allUnits = this.unitManager.getAllUnits(); allUnits.forEach((unit) => { this.unitManager.removeUnit(unit.id); }); } // Reset deployment state this.deploymentState = { selectedUnitIndex: -1, deployedUnits: new Map(), }; if (this.inputManager && typeof this.inputManager.detach === "function") { this.inputManager.detach(); } if (this.controls) this.controls.dispose(); } /** * Updates the combat state in GameStateManager. * Called when combat starts or when combat state changes (turn changes, etc.) * Uses TurnSystem to get the spec-compliant CombatState, then enriches it for UI. */ async updateCombatState() { if (!this.gameStateManager || !this.turnSystem) { return; } // Get spec-compliant combat state from TurnSystem const turnSystemState = this.turnSystem.getCombatState(); if (!turnSystemState.isActive) { // Combat not active, clear state this.gameStateManager.setCombatState(null); return; } // Get active unit for UI enrichment const activeUnit = this.turnSystem.getActiveUnit(); // Build active unit status if we have an active unit (for UI) let unitStatus = null; if (activeUnit) { // Calculate effective speed (including equipment and skill tree bonuses) let effectiveSpeed = activeUnit.baseStats?.speed || 10; // Add equipment bonuses if available if (activeUnit.loadout && this.inventoryManager) { const loadoutSlots = ["mainHand", "offHand", "body", "accessory"]; for (const slot of loadoutSlots) { const itemInstance = activeUnit.loadout[slot]; if (itemInstance) { const itemDef = this.inventoryManager.itemRegistry?.get( itemInstance.defId ); if (itemDef && itemDef.stats && itemDef.stats.speed) { effectiveSpeed += itemDef.stats.speed; } } } } // Calculate max AP using formula: 3 + floor(effectiveSpeed/5) // We'll add skill tree bonuses to speed below when we generate the skill tree let maxAP = 3 + Math.floor(effectiveSpeed / 5); // Convert status effects to status icons const statuses = (activeUnit.statusEffects || []).map((effect) => ({ id: effect.id || "unknown", icon: effect.icon || "❓", turnsRemaining: effect.duration || 0, description: effect.description || effect.name || "Status Effect", })); // Build skills from unit's actions const skills = (activeUnit.actions || []).map((action, index) => ({ id: action.id || `skill_${index}`, name: action.name || "Unknown Skill", icon: action.icon || "⚔", costAP: action.costAP || 0, cooldown: action.cooldown || 0, isAvailable: activeUnit.currentAP >= (action.costAP || 0) && (action.cooldown || 0) === 0, })); // Add unlocked skill tree skills for Explorer units if ( (activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") && activeUnit.activeClassId && activeUnit.classMastery && this.classRegistry ) { const mastery = activeUnit.classMastery[activeUnit.activeClassId]; if ( mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0 ) { try { // Get class definition const classDef = this.classRegistry.get(activeUnit.activeClassId); if (classDef && classDef.skillTreeData) { // Generate skill tree (similar to index.js) // We'll need to import SkillTreeFactory dynamically or store it // For now, let's try to get the skill tree from the skill registry const { SkillTreeFactory } = await import( "../factories/SkillTreeFactory.js" ); // Load skill tree template (use cache if available) let template = this._skillTreeTemplate; if (!template) { const templateResponse = await fetch( "assets/data/skill_trees/template_standard_30.json" ); if (templateResponse.ok) { template = await templateResponse.json(); this._skillTreeTemplate = template; // Cache it } } if (template) { const templateRegistry = { [template.id]: template }; // Convert skillRegistry Map to object for SkillTreeFactory const skillMap = Object.fromEntries(skillRegistry.skills); // Create factory and generate tree const factory = new SkillTreeFactory( templateRegistry, skillMap ); const skillTree = factory.createTree(classDef); // Add speed boosts from unlocked nodes to effective speed for (const nodeId of mastery.unlockedNodes) { const nodeDef = skillTree.nodes?.[nodeId]; if ( nodeDef && nodeDef.type === "STAT_BOOST" && nodeDef.data && nodeDef.data.stat === "speed" ) { effectiveSpeed += nodeDef.data.value || 0; } } // Recalculate maxAP with skill tree bonuses maxAP = 3 + Math.floor(effectiveSpeed / 5); // Add unlocked ACTIVE_SKILL nodes to skills array for (const nodeId of mastery.unlockedNodes) { const nodeDef = skillTree.nodes?.[nodeId]; if ( nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data ) { const skillData = nodeDef.data; const skillId = skillData.id || nodeId; // Get full skill definition from registry if available const fullSkill = skillRegistry.skills.get(skillId); // Add skill to skills array (avoid duplicates) if (!skills.find((s) => s.id === skillId)) { // Get costAP from full skill definition const costAP = fullSkill?.costs?.ap || skillData.costAP || 3; const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0; // Ensure skill exists in unit.actions for cooldown tracking if (!activeUnit.actions) { activeUnit.actions = []; } let existingAction = activeUnit.actions.find( (a) => a.id === skillId ); // If action doesn't exist, create it with cooldown 0 (ready to use immediately) if (!existingAction) { existingAction = { id: skillId, name: skillData.name || fullSkill?.name || "Unknown Skill", icon: skillData.icon || fullSkill?.icon || "⚔", costAP: costAP, cooldown: 0, // Newly unlocked skills start ready to use }; activeUnit.actions.push(existingAction); } // Use current cooldown from the action (which gets decremented by TurnSystem) const currentCooldown = existingAction.cooldown || 0; skills.push({ id: skillId, name: existingAction.name, icon: existingAction.icon, costAP: costAP, cooldown: currentCooldown, isAvailable: activeUnit.currentAP >= costAP && currentCooldown === 0, }); } } } } } } catch (error) { console.warn("Failed to load skill tree for combat HUD:", error); } } } // If no skills from actions or skill tree, provide a default attack skill if (skills.length === 0) { skills.push({ id: "attack", name: "Attack", icon: "⚔", costAP: 3, cooldown: 0, isAvailable: activeUnit.currentAP >= 3, }); } // Get portrait for active unit (same logic as enrichedQueue) let activePortrait = activeUnit.portrait || activeUnit.image; // If no portrait and it's a player unit, try to look up by classId if ( !activePortrait && activeUnit.team === "PLAYER" && activeUnit.activeClassId ) { const CLASS_PORTRAITS = { CLASS_VANGUARD: "/assets/images/portraits/vanguard.png", CLASS_WEAVER: "/assets/images/portraits/weaver.png", CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png", CLASS_TINKER: "/assets/images/portraits/tinker.png", CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png", }; activePortrait = CLASS_PORTRAITS[activeUnit.activeClassId]; } // Normalize path: ensure it starts with / if it doesn't already if (activePortrait && !activePortrait.startsWith("/")) { activePortrait = "/" + activePortrait; } // Fallback to default portraits if (!activePortrait) { activePortrait = activeUnit.team === "PLAYER" ? "/assets/images/portraits/default.png" : "/assets/images/portraits/enemy.png"; } unitStatus = { id: activeUnit.id, name: activeUnit.name, portrait: activePortrait, hp: { current: activeUnit.currentHealth, max: activeUnit.maxHealth, }, ap: { current: activeUnit.currentAP, max: maxAP, }, charge: activeUnit.chargeMeter || 0, statuses: statuses, skills: skills, }; } // Build enriched turn queue for UI (with portraits, etc.) const enrichedQueue = turnSystemState.turnQueue .map((unitId) => { const unit = this.unitManager?.activeUnits.get(unitId); if (!unit) return null; // Try to get portrait from unit property (portrait or image) let portrait = unit.portrait || unit.image; // If no portrait and it's a player unit, try to look up by classId if (!portrait && unit.team === "PLAYER" && unit.activeClassId) { // Map of class IDs to portrait paths (matching team-builder CLASS_METADATA) const CLASS_PORTRAITS = { CLASS_VANGUARD: "/assets/images/portraits/vanguard.png", CLASS_WEAVER: "/assets/images/portraits/weaver.png", CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png", CLASS_TINKER: "/assets/images/portraits/tinker.png", CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png", }; portrait = CLASS_PORTRAITS[unit.activeClassId]; } // Normalize path: ensure it starts with / if it doesn't already if (portrait && !portrait.startsWith("/")) { portrait = "/" + portrait; } // Fallback to default portraits if (!portrait) { portrait = unit.team === "PLAYER" ? "/assets/images/portraits/default.png" : "/assets/images/portraits/enemy.png"; } return { unitId: unit.id, portrait: portrait, team: unit.team || "ENEMY", initiative: unit.chargeMeter || 0, }; }) .filter((entry) => entry !== null); // Get mission objectives and turn limit from MissionManager let missionObjectives = null; let turnLimit = null; if (this.missionManager) { missionObjectives = { primary: this.missionManager.currentObjectives || [], secondary: this.missionManager.secondaryObjectives || [], }; // Find turn limit from failure conditions const turnLimitCondition = (this.missionManager.failureConditions || []).find( (fc) => fc.type === "TURN_LIMIT_EXCEEDED" && fc.turn_limit ); if (turnLimitCondition) { turnLimit = { limit: turnLimitCondition.turn_limit, current: this.missionManager.currentTurn || 0, }; } } // Build combat state (enriched for UI, but includes spec fields) const combatState = { // Spec-compliant fields isActive: turnSystemState.isActive, round: turnSystemState.round, turnQueue: turnSystemState.turnQueue, // string[] as per spec activeUnitId: turnSystemState.activeUnitId, // string as per spec phase: turnSystemState.phase, // UI-enriched fields (for backward compatibility) activeUnit: unitStatus, // Object for UI enrichedQueue: enrichedQueue, // Objects for UI display targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state) roundNumber: turnSystemState.round, // Alias for UI missionObjectives, // Mission objectives for UI turnLimit, // Turn limit info for UI }; // Update GameStateManager this.gameStateManager.setCombatState(combatState); } /** * Ends the current unit's turn and advances the turn queue. * Delegates to TurnSystem. */ endTurn() { if (!this.turnSystem) { return; } const activeUnit = this.turnSystem.getActiveUnit(); if (!activeUnit) { return; } // Clear any active skill targeting state and highlights if (this.combatState === "TARGETING_SKILL" || this.activeSkillId) { this.combatState = "IDLE"; this.activeSkillId = null; // Clear skill targeting highlights (range and AoE reticle) if (this.voxelManager) { this.voxelManager.clearHighlights(); } } // Clear movement highlights this.clearMovementHighlights(); // DELEGATE to TurnSystem // Note: Death from damage is handled in executeSkill, death from status effects is handled in startTurn this.turnSystem.endTurn(activeUnit); // Update combat state (TurnSystem will have advanced to next unit) this.updateCombatState().catch(console.error); // If the next unit is an enemy, trigger AI turn const nextUnit = this.turnSystem.getActiveUnit(); if (nextUnit && nextUnit.team === "ENEMY") { // TODO: Trigger AI turn console.log(`Enemy ${nextUnit.name}'s turn`); // For now, auto-end enemy turns after a delay setTimeout(() => { this.endTurn(); }, 1000); } } /** * Event handler for turn-start event from TurnSystem. * @param {{ unitId: string; unit: Unit }} detail - Turn start event detail * @private */ _onTurnStart(detail) { const { unit } = detail; // Center camera on the active unit this.centerCameraOnUnit(unit); // Update movement highlights if it's a player's turn if (unit.team === "PLAYER") { this.updateMovementHighlights(unit); } else { this.clearMovementHighlights(); } } /** * Event handler for turn-end event from TurnSystem. * @param {{ unitId: string; unit: Unit }} detail - Turn end event detail * @private */ _onTurnEnd(detail) { // Clear movement highlights when turn ends this.clearMovementHighlights(); // Dispatch TURN_END event to MissionManager if (this.missionManager && this.turnSystem) { const currentTurn = this.turnSystem.round || 0; this.missionManager.updateTurn(currentTurn); this.missionManager.onGameEvent("TURN_END", { turn: currentTurn }); } } /** * Event handler for combat-start event from TurnSystem. * @private */ _onCombatStart() { // Combat has started console.log("TurnSystem: Combat started"); } /** * Processes passive item effects for a unit when a trigger event occurs. * Integration Point 2: EventSystem for Passive Items * @param {Unit} unit - Unit whose items should be checked * @param {string} trigger - Trigger event type (e.g., "ON_DAMAGED", "ON_DAMAGE_DEALT", "ON_HEAL_DEALT") * @param {Object} context - Event context (source, target, damageAmount, etc.) * @private */ processPassiveItemEffects(unit, trigger, context = {}) { if ( !unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager ) { return; } // Get all equipped items from loadout const equippedItems = [ unit.loadout.mainHand, unit.loadout.offHand, unit.loadout.body, unit.loadout.accessory, ...(unit.loadout.belt || []), ].filter(Boolean); // Remove nulls // Process each equipped item's passive effects for (const itemInstance of equippedItems) { if (!itemInstance || !itemInstance.defId) continue; const itemDef = this.inventoryManager.itemRegistry.get( itemInstance.defId ); if (!itemDef || !itemDef.passives) continue; // Check each passive effect for (const passive of itemDef.passives) { if (passive.trigger !== trigger) continue; // Check conditions before processing if (!this._checkPassiveConditions(passive, context)) continue; // Check chance if present if (passive.chance !== undefined) { if (!this.rng || Math.random() > passive.chance) continue; } // Convert passive effect to EffectDefinition format const effectDef = this._convertPassiveToEffectDef(passive, context); if (!effectDef) continue; // Determine target based on passive action let target = context.target || context.source || unit; if ( passive.params && passive.params.target === "SOURCE" && context.source ) { target = context.source; } else if (passive.params && passive.params.target === "SELF") { target = unit; } // Process the effect through EffectProcessor const result = this.effectProcessor.process(effectDef, unit, target); if (result.success && result.data) { console.log( `Passive effect ${passive.id || "unknown"} triggered on ${ unit.name } (${trigger})` ); } } } } /** * Converts a passive effect definition to an EffectDefinition format. * @param {Object} passive - Passive effect definition * @param {Object} context - Event context * @returns {Object | null} - EffectDefinition or null if conversion not supported * @private */ _convertPassiveToEffectDef(passive, context) { if (!passive.action || !passive.params) return null; // Map passive actions to EffectProcessor effect types const actionMap = { DAMAGE: "DAMAGE", APPLY_STATUS: "APPLY_STATUS", HEAL: "HEAL", CHAIN_DAMAGE: "CHAIN_DAMAGE", GIVE_AP: "GIVE_AP", HEAL_SELF: "HEAL_SELF", APPLY_BUFF: "APPLY_BUFF", }; const effectType = actionMap[passive.action]; if (!effectType) return null; // Unsupported action const effectDef = { type: effectType, }; // Copy relevant params if (passive.params.power !== undefined) { effectDef.power = passive.params.power; } if (passive.params.element) { effectDef.element = passive.params.element; } if (passive.params.status_id) { effectDef.status_id = passive.params.status_id; } if (passive.params.duration !== undefined) { effectDef.duration = passive.params.duration; } if (passive.params.chance !== undefined) { effectDef.chance = passive.params.chance; } // CHAIN_DAMAGE specific params if (passive.params.bounces !== undefined) { effectDef.bounces = passive.params.bounces; } if (passive.params.chainRange !== undefined) { effectDef.chainRange = passive.params.chainRange; } if (passive.params.decay !== undefined) { effectDef.decay = passive.params.decay; } if (passive.params.synergy_trigger) { effectDef.synergy_trigger = passive.params.synergy_trigger; } if (passive.condition) { effectDef.condition = passive.condition; } // GIVE_AP specific params if (passive.params.amount !== undefined) { effectDef.amount = passive.params.amount; } // HEAL_SELF specific params if (passive.params.percentage !== undefined) { effectDef.percentage = passive.params.percentage; } if (context.healAmount !== undefined) { effectDef.healAmount = context.healAmount; } // APPLY_BUFF specific params if (passive.params.stat !== undefined) { effectDef.stat = passive.params.stat; } if (passive.params.value !== undefined) { effectDef.value = passive.params.value; } return effectDef; } /** * Checks if passive effect conditions are met. * @param {Object} passive - Passive effect definition * @param {Object} context - Event context * @returns {boolean} - True if conditions are met * @private */ _checkPassiveConditions(passive, context) { if (!passive.condition) return true; // No conditions means always execute const condition = passive.condition; // Check SKILL_TAG condition if (condition.type === "SKILL_TAG" && condition.value) { const skillDef = context.skillDef; if (!skillDef || !skillDef.tags) return false; if (!skillDef.tags.includes(condition.value)) return false; } // Check DAMAGE_TYPE condition if (condition.type === "DAMAGE_TYPE" && condition.value) { const effect = context.effect; if (!effect || effect.element !== condition.value) return false; } // Check TARGET_TAG condition if (condition.type === "TARGET_TAG" && condition.value) { const target = context.target; if (!target || !target.tags) return false; if (!target.tags.includes(condition.value)) return false; } // Check SOURCE_IS_ADJACENT condition if (condition.type === "SOURCE_IS_ADJACENT") { const source = context.source; const target = context.target || context.unit; if (!source || !target || !source.position || !target.position) return false; const dist = Math.abs(source.position.x - target.position.x) + Math.abs(source.position.y - target.position.y) + Math.abs(source.position.z - target.position.z); if (dist > 1) return false; // Not adjacent (Manhattan distance > 1) } // Check IS_ADJACENT condition if (condition.type === "IS_ADJACENT") { const source = context.source || context.unit; const target = context.target; if (!source || !target || !source.position || !target.position) return false; const dist = Math.abs(source.position.x - target.position.x) + Math.abs(source.position.y - target.position.y) + Math.abs(source.position.z - target.position.z); if (dist > 1) return false; // Not adjacent } // Check DID_NOT_ATTACK condition (for ON_TURN_END) if (condition.type === "DID_NOT_ATTACK") { // This would need to track if the unit attacked this turn // For now, we'll assume it's tracked in context if (context.didAttack === true) return false; } return true; // All conditions passed } /** * Handles unit death: removes from grid, dispatches events, and updates MissionManager. * @param {Unit} unit - The unit that died */ handleUnitDeath(unit) { if (!unit) { console.warn("[GameLoop] handleUnitDeath called with null/undefined unit"); return; } if (!this.grid || !this.unitManager) { console.warn("[GameLoop] handleUnitDeath called but grid or unitManager not available"); return; } console.log(`[GameLoop] handleUnitDeath called for ${unit.name} (${unit.id})`); // Remove unit from grid if (unit.position) { this.grid.removeUnit(unit.position); console.log(`[GameLoop] Removed ${unit.name} from grid at (${unit.position.x}, ${unit.position.y}, ${unit.position.z})`); } else { console.warn(`[GameLoop] ${unit.name} has no position to remove from grid`); } // Dispatch death event to MissionManager BEFORE removing from UnitManager // This allows MissionManager to check if this was the last enemy if (this.missionManager) { const eventType = unit.team === "ENEMY" ? "ENEMY_DEATH" : "PLAYER_DEATH"; // Get the definition ID - either from unit.defId or extract from instance ID // Instance IDs are like "ENEMY_ELITE_BREAKER_1", we want "ENEMY_ELITE_BREAKER" let unitDefId = unit.defId; if (!unitDefId && unit.id) { // Extract defId from instance ID by removing the trailing "_N" suffix const match = unit.id.match(/^(.+)_\d+$/); unitDefId = match ? match[1] : unit.id; } if (!unitDefId) { unitDefId = unit.id; // Fallback to instance ID } console.log(`[GameLoop] Dispatching ${eventType} event for ${unit.name} (defId: ${unitDefId}) BEFORE removing from UnitManager`); this.missionManager.onGameEvent(eventType, { unitId: unit.id, defId: unitDefId, team: unit.team, }); } else { console.warn(`[GameLoop] MissionManager not available, cannot dispatch death event`); } // Remove unit mesh from scene FIRST (before removing from UnitManager) // This ensures the visual removal happens const mesh = this.unitMeshes.get(unit.id); if (mesh) { console.log(`[GameLoop] Removing mesh for ${unit.name} from scene`); this.scene.remove(mesh); this.unitMeshes.delete(unit.id); // Dispose geometry and material if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { if (Array.isArray(mesh.material)) { mesh.material.forEach((mat) => { if (mat.map) mat.map.dispose(); mat.dispose(); }); } else { if (mesh.material.map) mesh.material.map.dispose(); mesh.material.dispose(); } } 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())); } console.log(`[GameLoop] ${unit.name} (${unit.team}) has been removed from combat.`); } /** * Sets up event listeners for mission victory and failure. * @private */ _setupMissionEventListeners() { // Listen for mission victory window.addEventListener("mission-victory", (event) => { this._handleMissionVictory(event.detail); }); // Listen for mission failure window.addEventListener("mission-failure", (event) => { this._handleMissionFailure(event.detail); }); } /** * Handles mission victory. * @param {Object} detail - Victory event detail * @private */ _handleMissionVictory(detail) { console.log("Mission Victory!", detail); // End combat first to properly clean up turn system state if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") { this.turnSystem.endCombat(); } // Save Explorer progression back to roster this._saveExplorerProgression(); // Pause the game this.isPaused = true; // Stop the game loop this.stop(); // Calculate MissionResult for debrief const missionResult = this._calculateMissionResult(detail); // Dispatch show-debrief event (before outro narrative) window.dispatchEvent( new CustomEvent("show-debrief", { detail: { result: missionResult }, bubbles: true, composed: true, }) ); // Clear the active run from persistence since mission is complete if (this.gameStateManager) { this.gameStateManager.clearActiveRun(); } // Wait for the outro narrative to complete before transitioning // The outro is played in MissionManager.completeActiveMission() // We'll listen for the narrative-end event to know when it's done const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative ?.outro_success; if (hasOutro) { console.log("GameLoop: Waiting for outro narrative to complete..."); const handleNarrativeEnd = () => { console.log( "GameLoop: Narrative end event received, transitioning to hub" ); narrativeManager.removeEventListener( "narrative-end", handleNarrativeEnd ); // Wait for debrief to close before transitioning // The debrief will dispatch debrief-closed when user clicks return const handleDebriefClosed = () => { window.removeEventListener("debrief-closed", handleDebriefClosed); if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }; window.addEventListener("debrief-closed", handleDebriefClosed); }; narrativeManager.addEventListener("narrative-end", handleNarrativeEnd); // Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway setTimeout(() => { console.warn( "GameLoop: Narrative end timeout - transitioning to hub anyway" ); narrativeManager.removeEventListener( "narrative-end", handleNarrativeEnd ); const handleDebriefClosed = () => { window.removeEventListener("debrief-closed", handleDebriefClosed); if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }; window.addEventListener("debrief-closed", handleDebriefClosed); }, 30000); } else { // No outro, wait for debrief to close before transitioning console.log("GameLoop: No outro narrative, waiting for debrief to close"); const handleDebriefClosed = () => { window.removeEventListener("debrief-closed", handleDebriefClosed); if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }; window.addEventListener("debrief-closed", handleDebriefClosed); } } /** * Calculates MissionResult for the debrief screen. * @param {Object} detail - Victory event detail * @returns {Object} MissionResult object * @private */ _calculateMissionResult(detail) { const missionDef = this.gameStateManager?.missionManager?.currentMissionDef; const rewards = missionDef?.rewards || {}; const guaranteed = rewards.guaranteed || {}; // Get currency (handle both snake_case and camelCase) const currency = { shards: guaranteed.currency?.aether_shards || guaranteed.currency?.aetherShards || 0, cores: guaranteed.currency?.ancient_cores || guaranteed.currency?.ancientCores || 0, }; // Get XP const xpEarned = guaranteed.xp || 0; // Get loot items (convert item IDs to item instances) const loot = []; if (guaranteed.items && Array.isArray(guaranteed.items)) { guaranteed.items.forEach((itemDefId) => { loot.push({ defId: itemDefId, name: itemDefId, // Will be resolved by item registry if available quantity: 1, }); }); } // Get reputation changes const reputationChanges = []; if (rewards.faction_reputation) { Object.entries(rewards.faction_reputation).forEach(([factionId, amount]) => { reputationChanges.push({ factionId, amount }); }); } // Get squad status const squadUpdates = []; if (this.unitManager) { const playerUnits = Array.from( this.unitManager.activeUnits.values() ).filter((u) => u.team === "PLAYER"); playerUnits.forEach((unit) => { const isDead = unit.currentHealth <= 0; const maxHealth = unit.maxHealth || unit.health || 100; const currentHealth = unit.currentHealth || 0; const damageTaken = Math.max(0, maxHealth - currentHealth); squadUpdates.push({ unitId: unit.id || unit.defId || "Unknown", isDead, leveledUp: false, // TODO: Track level ups damageTaken, }); }); } return { outcome: "VICTORY", missionTitle: missionDef?.config?.title || "Mission", xpEarned, currency, loot, reputationChanges, squadUpdates, turnsTaken: this.turnSystem?.currentTurn || 0, }; } /** * Saves Explorer progression (classMastery, activeClassId) back to roster. * @private */ _saveExplorerProgression() { if (!this.unitManager || !this.gameStateManager) return; const playerUnits = Array.from( this.unitManager.activeUnits.values() ).filter((u) => u.team === "PLAYER" && u.type === "EXPLORER"); for (const unit of playerUnits) { // Use rosterId if available, otherwise fall back to unit.id const rosterId = unit.rosterId || unit.id; if (!rosterId) continue; const rosterUnit = this.gameStateManager.rosterManager.roster.find( (r) => r.id === rosterId ); if (rosterUnit) { // Save classMastery progression if (unit.classMastery) { rosterUnit.classMastery = JSON.parse( JSON.stringify(unit.classMastery) ); } // Save activeClassId if (unit.activeClassId) { rosterUnit.activeClassId = unit.activeClassId; } // Save equipment/loadout if (unit.loadout) { rosterUnit.loadout = JSON.parse(JSON.stringify(unit.loadout)); } if (unit.equipment) { rosterUnit.equipment = JSON.parse(JSON.stringify(unit.equipment)); } // Save current health if (unit.currentHealth !== undefined) { rosterUnit.currentHealth = unit.currentHealth; } console.log( `Saved progression for ${unit.name} (roster ID: ${rosterId})` ); } } // Save roster to persistence if (this.gameStateManager.rosterManager) { this.gameStateManager._saveRoster(); } } /** * Handles mission failure. * @param {Object} detail - Failure event detail * @private */ _handleMissionFailure(detail) { console.log("Mission Failed!", detail); // End combat first to properly clean up turn system state if (this.turnSystem && this.turnSystem.phase !== "COMBAT_END") { this.turnSystem.endCombat(); } // Save Explorer progression back to roster (even on failure, progression should persist) this._saveExplorerProgression(); // Pause the game this.isPaused = true; // Stop the game loop this.stop(); // Clear the active run from persistence since mission is failed if (this.gameStateManager) { this.gameStateManager.clearActiveRun(); } // TODO: Show failure screen UI // For now, just log and transition back to main menu after a delay setTimeout(() => { if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_MAIN_MENU"); } }, 3000); } /** * Checks DID_NOT_ATTACK condition (for ON_TURN_END) if (condition.type === "DID_NOT_ATTACK") { // This would need to track if the unit attacked this turn // For now, we'll assume it's tracked in context if (context.didAttack === true) return false; } return true; } /** * Event handler for combat-end event from TurnSystem. * @private */ _onCombatEnd() { // Combat has ended console.log("TurnSystem: Combat ended"); this.clearMovementHighlights(); } }