/** * @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"; /** * 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; /** @type {Map} */ this.unitMeshes = new Map(); /** @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; // --- 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."); } } } /** * 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(); // 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, stats: { hp: 100 } }; return { type: "ENEMY", name: "Enemy", stats: { hp: 50 }, ai_archetype: "BRUISER", }; }, }; this.unitManager = new UnitManager(mockRegistry); 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) { mesh.position.set(targetTile.x, targetTile.y + 0.6, 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; 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; for (let i = 0; i < enemyCount; i++) { const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length); const spot = this.enemySpawnZone[spotIndex]; if (spot && !this.grid.isOccupied(spot)) { const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); this.grid.placeUnit(enemy, spot); this.createUnitMesh(enemy, spot); this.enemySpawnZone.splice(spotIndex, 1); } } // Switch to standard movement validator for the game this.inputManager.setValidator(this.validateCursorMove.bind(this)); // Notify GameStateManager about state change if (this.gameStateManager) { this.gameStateManager.transitionTo("STATE_COMBAT"); } // Initialize combat state this.updateCombatState(); console.log("Combat Started!"); } /** * Clears all unit meshes from the scene. */ clearUnitMeshes() { this.unitMeshes.forEach((mesh) => this.scene.remove(mesh)); this.unitMeshes.clear(); } /** * 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); mesh.position.set(pos.x, pos.y + 0.6, pos.z); this.scene.add(mesh); this.unitMeshes.set(unit.id, mesh); } /** * Highlights spawn zones with visual indicators. */ highlightZones() { 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.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); }); } /** * 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) 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.) */ updateCombatState() { if (!this.gameStateManager || !this.grid || !this.unitManager) { return; } // Get all units from the grid const allUnits = Array.from(this.grid.unitMap.values()).filter( (unit) => unit.isAlive && unit.isAlive() ); if (allUnits.length === 0) { // No units, clear combat state this.gameStateManager.setCombatState(null); return; } // Build turn queue sorted by initiative (chargeMeter) const turnQueue = allUnits .map((unit) => { // Get portrait path (placeholder for now) const 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, }; }) .sort((a, b) => b.initiative - a.initiative); // Sort by initiative descending // Get active unit (first in queue) const activeUnitId = turnQueue.length > 0 ? turnQueue[0].unitId : null; const activeUnit = allUnits.find((u) => u.id === activeUnitId); // Build active unit status if we have an active unit let unitStatus = null; if (activeUnit) { // Get max AP (default to 10 for now, can be derived from stats later) const maxAP = 10; // 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 combat state const combatState = { activeUnit: unitStatus, turnQueue: turnQueue, targetingMode: false, // Will be set when player selects a skill roundNumber: 1, // TODO: Track actual round number }; // Update GameStateManager this.gameStateManager.setCombatState(combatState); } }