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"; export class GameLoop { constructor() { this.isRunning = false; this.phase = "INIT"; // 1. Core Systems this.scene = new THREE.Scene(); this.camera = null; this.renderer = null; this.controls = null; this.inputManager = null; this.grid = null; this.voxelManager = null; this.unitManager = null; this.unitMeshes = new Map(); this.runData = null; this.playerSpawnZone = []; this.enemySpawnZone = []; // Input Logic State this.lastMoveTime = 0; this.moveCooldown = 120; // ms between cursor moves this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING } 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. * Checks for valid ground, headroom, and bounds. * Returns modified position (climbing/dropping) or false (invalid). */ 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) // Look 2 units up and 2 units down from current Y let bestY = null; // Check Current Level if (this.isWalkable(x, y, z)) bestY = y; // Check Climb (y+1) else if (this.isWalkable(x, y + 1, z)) bestY = y + 1; // Check Drop (y-1, y-2) 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; // No valid footing found } /** * Validation Logic for Deployment Phase. * Restricts cursor to the Player Spawn Zone. */ 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 } /** * Helper: Checks if a specific tile is valid to stand on. */ isWalkable(x, y, z) { // Must be Air if (this.grid.getCell(x, y, z) !== 0) return false; // Must have Solid Floor below if (this.grid.getCell(x, y - 1, z) === 0) return false; // Must have Headroom (Air above) if (this.grid.getCell(x, y + 1, z) !== 0) return false; return true; } /** * Validation Logic for Interaction / Targeting. * Allows selecting Walls, Enemies, or Empty Space (within bounds). */ validateInteractionTarget(x, y, z) { if (!this.grid) return true; return this.grid.isValidBounds({ x, y, z }); } handleButtonInput(detail) { if (detail.buttonIndex === 0) { // A / Cross this.triggerSelection(); } } handleKeyInput(code) { if (code === "Space" || code === "Enter") { this.triggerSelection(); } // Toggle Mode for Debug (e.g. Tab) 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); console.log(`Switched to ${this.selectionMode} mode`); } } triggerSelection() { const cursor = this.inputManager.getCursorPosition(); console.log("Action at:", cursor); if (this.phase === "DEPLOYMENT") { // TODO: Check if selecting a deployed unit to move it, or a tile to deploy to // This requires state from the UI (which unit is selected in roster) } } async startLevel(runData) { console.log("GameLoop: Generating Level..."); this.runData = runData; this.isRunning = true; this.phase = "DEPLOYMENT"; this.clearUnitMeshes(); 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(); // Snap Cursor to Player Start if (this.playerSpawnZone.length > 0) { const start = this.playerSpawnZone[0]; // Ensure y is correct (on top of floor) this.inputManager.setCursor(start.x, start.y, start.z); } // Set Strict Validator for Deployment this.inputManager.setValidator(this.validateDeploymentCursor.bind(this)); this.animate(); } deployUnit(unitDef, targetTile) { if (this.phase !== "DEPLOYMENT") return null; // Re-validate using the zone logic (Double check) const isValid = this.validateDeploymentCursor( targetTile.x, targetTile.y, targetTile.z ); if (!isValid || this.grid.isOccupied(targetTile)) return null; 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); return unit; } finalizeDeployment() { if (this.phase !== "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); } } this.phase = "ACTIVE"; // Switch to standard movement validator for the game this.inputManager.setValidator(this.validateCursorMove.bind(this)); } clearUnitMeshes() { this.unitMeshes.forEach((mesh) => this.scene.remove(mesh)); this.unitMeshes.clear(); } 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); } 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); }); } animate() { if (!this.isRunning) return; requestAnimationFrame(this.animate); // 1. Update Managers if (this.inputManager) this.inputManager.update(); if (this.controls) this.controls.update(); // 2. Handle Continuous Input (Keyboard polling) 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; // Pass desired coordinates to InputManager // InputManager will call our validator (validateCursorMove/Deployment) to check logic this.inputManager.setCursor(newX, currentPos.y, newZ); this.lastMoveTime = now; } } // 3. Render 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); } stop() { this.isRunning = false; if (this.inputManager) this.inputManager.detach(); if (this.controls) this.controls.dispose(); } }