/** * @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"; /** * Main game loop managing rendering, input, and game state. * @class */ export class GameLoop { constructor() { /** @type {boolean} */ this.isRunning = 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 {Map} */ this.unitMeshes = new Map(); /** @type {Set} */ this.movementHighlights = new Set(); /** @type {Set} */ this.spawnZoneHighlights = 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} */ 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; } /** * 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(); // --- 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) ); // 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 === "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); } } /** * 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 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}` ); // Update combat state and movement highlights this.updateCombatState(); } } /** * 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 * @returns {Promise} */ async startLevel(runData) { console.log("GameLoop: Generating Level..."); this.runData = runData; this.isRunning = true; this.clearUnitMeshes(); this.clearMovementHighlights(); this.clearSpawnZoneHighlights(); // 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 }); this.voxelManager = new VoxelManager(this.grid, this.scene); this.voxelManager.updateMaterials(generator.generatedAssets); this.voxelManager.update(); if (this.controls) this.voxelManager.focusCamera(this.controls); const mockRegistry = { get: (id) => { if (id.startsWith("CLASS_")) return { type: "EXPLORER", name: id, id: id, base_stats: { health: 100, attack: 10, defense: 5, speed: 10 }, growth_rates: {}, }; return { type: "ENEMY", name: "Enemy", stats: { health: 50, attack: 8, defense: 3, speed: 8 }, ai_archetype: "BRUISER", }; }, }; this.unitManager = new UnitManager(mockRegistry); // WIRING: Connect Systems to Data this.movementSystem.setContext(this.grid, this.unitManager); this.turnSystem.setContext(this.unitManager); // WIRING: Listen for Turn Changes (to update UI/Input state) this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail) ); this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail) ); this.turnSystem.addEventListener("combat-start", () => this._onCombatStart() ); this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd()); 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)); 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 unit = this.unitManager.createUnit( unitDef.classId || unitDef.id, "PLAYER" ); if (unitDef.name) unit.name = unitDef.name; // Ensure unit starts with full health // Explorer constructor might set health to 0 if classDef is missing base_stats if (unit.currentHealth <= 0) { unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100; 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. */ finalizeDeployment() { if ( !this.gameStateManager || this.gameStateManager.currentState !== "STATE_DEPLOYMENT" ) return; const enemyCount = 2; let attempts = 0; const maxAttempts = this.enemySpawnZone.length * 2; // Try up to 2x the zone size for (let i = 0; i < enemyCount && attempts < maxAttempts; attempts++) { const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length); const spot = this.enemySpawnZone[spotIndex]; if (!spot) continue; // Check if position is walkable (not just unoccupied) // Find the correct walkable Y for this position 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_DEFAULT", "ENEMY"); this.grid.placeUnit(enemy, walkablePos); this.createUnitMesh(enemy, walkablePos); this.enemySpawnZone.splice(spotIndex, 1); i++; // Only increment if we successfully placed an enemy } } // 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); // Update combat state immediately so UI shows combat HUD this.updateCombatState(); 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 movement highlight meshes from the scene. */ clearMovementHighlights() { this.movementHighlights.forEach((mesh) => this.scene.remove(mesh)); 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); }); } /** * 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); let color = 0xcccccc; if (unit.id.includes("VANGUARD")) color = 0xff3333; else if (unit.team === "ENEMY") color = 0x550000; 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); } /** * Highlights spawn zones with visual indicators. */ highlightZones() { // Clear any existing spawn zone highlights this.clearSpawnZoneHighlights(); const highlightMatPlayer = new THREE.MeshBasicMaterial({ color: 0x00ff00, transparent: true, opacity: 0.3, }); const highlightMatEnemy = new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.3, }); const geo = new THREE.PlaneGeometry(1, 1); geo.rotateX(-Math.PI / 2); this.playerSpawnZone.forEach((pos) => { const mesh = new THREE.Mesh(geo, highlightMatPlayer); mesh.position.set(pos.x, pos.y + 0.05, pos.z); this.scene.add(mesh); this.spawnZoneHighlights.add(mesh); }); this.enemySpawnZone.forEach((pos) => { const mesh = new THREE.Mesh(geo, highlightMatEnemy); mesh.position.set(pos.x, pos.y + 0.05, pos.z); this.scene.add(mesh); this.spawnZoneHighlights.add(mesh); }); } /** * Clears all spawn zone highlight meshes from the scene. */ clearSpawnZoneHighlights() { this.spawnZoneHighlights.forEach((mesh) => this.scene.remove(mesh)); this.spawnZoneHighlights.clear(); } /** * Main animation loop. */ animate() { if (!this.isRunning) return; requestAnimationFrame(this.animate); if (this.inputManager) this.inputManager.update(); 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); } /** * Stops the game loop and cleans up resources. */ stop() { this.isRunning = false; 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. */ 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 max AP using formula: 3 + floor(speed/5) const speed = activeUnit.baseStats?.speed || 10; const maxAP = 3 + Math.floor(speed / 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 (placeholder for now - will be populated from unit's actions/skill tree) 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, })); // If no skills from actions, provide a default attack skill if (skills.length === 0) { skills.push({ id: "attack", name: "Attack", icon: "⚔", costAP: 3, cooldown: 0, isAvailable: activeUnit.currentAP >= 3, }); } unitStatus = { id: activeUnit.id, name: activeUnit.name, portrait: activeUnit.team === "PLAYER" ? "/assets/images/portraits/default.png" : "/assets/images/portraits/enemy.png", 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; const portrait = unit.team === "PLAYER" ? "/assets/images/portraits/default.png" : "/assets/images/portraits/enemy.png"; return { unitId: unit.id, portrait: unit.portrait || portrait, team: unit.team || "ENEMY", initiative: unit.chargeMeter || 0, }; }) .filter((entry) => entry !== null); // 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: false, // Will be set when player selects a skill roundNumber: turnSystemState.round, // Alias 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; } // DELEGATE to TurnSystem this.turnSystem.endTurn(activeUnit); // Update combat state (TurnSystem will have advanced to next unit) this.updateCombatState(); // 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; // 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(); } /** * Event handler for combat-start event from TurnSystem. * @private */ _onCombatStart() { // Combat has started console.log("TurnSystem: Combat started"); } /** * Event handler for combat-end event from TurnSystem. * @private */ _onCombatEnd() { // Combat has ended console.log("TurnSystem: Combat ended"); this.clearMovementHighlights(); } }