2025-12-19 23:07:36 +00:00
|
|
|
import * as THREE from "three";
|
2025-12-19 23:08:54 +00:00
|
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
2025-12-19 23:07:36 +00:00
|
|
|
import { VoxelGrid } from "../grid/VoxelGrid.js";
|
|
|
|
|
import { VoxelManager } from "../grid/VoxelManager.js";
|
2025-12-19 23:35:29 +00:00
|
|
|
import { UnitManager } from "../managers/UnitManager.js";
|
2025-12-19 23:07:36 +00:00
|
|
|
import { CaveGenerator } from "../generation/CaveGenerator.js";
|
|
|
|
|
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
2025-12-20 04:58:16 +00:00
|
|
|
import { InputManager } from "./InputManager.js";
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
export class GameLoop {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.isRunning = false;
|
2025-12-20 04:58:16 +00:00
|
|
|
this.phase = "INIT";
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
// 1. Core Systems
|
|
|
|
|
this.scene = new THREE.Scene();
|
|
|
|
|
this.camera = null;
|
|
|
|
|
this.renderer = null;
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls = null;
|
2025-12-20 04:58:16 +00:00
|
|
|
this.inputManager = null;
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
this.grid = null;
|
|
|
|
|
this.voxelManager = null;
|
|
|
|
|
this.unitManager = null;
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
this.unitMeshes = new Map();
|
2025-12-19 23:07:36 +00:00
|
|
|
this.runData = null;
|
2025-12-20 00:02:42 +00:00
|
|
|
this.playerSpawnZone = [];
|
|
|
|
|
this.enemySpawnZone = [];
|
2025-12-20 04:58:16 +00:00
|
|
|
|
|
|
|
|
// Input Logic State
|
|
|
|
|
this.lastMoveTime = 0;
|
|
|
|
|
this.moveCooldown = 120; // ms between cursor moves
|
|
|
|
|
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2025-12-20 04:58:16 +00:00
|
|
|
this.renderer.setClearColor(0x111111);
|
2025-12-19 23:07:36 +00:00
|
|
|
container.appendChild(this.renderer.domElement);
|
|
|
|
|
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
2025-12-20 00:02:42 +00:00
|
|
|
this.controls.enableDamping = true;
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls.dampingFactor = 0.05;
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// --- 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));
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-20 04:58:16 +00:00
|
|
|
* Validation Logic for Standard Movement.
|
|
|
|
|
* Checks for valid ground, headroom, and bounds.
|
|
|
|
|
* Returns modified position (climbing/dropping) or false (invalid).
|
2025-12-19 23:07:36 +00:00
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
async startLevel(runData) {
|
2025-12-20 00:02:42 +00:00
|
|
|
console.log("GameLoop: Generating Level...");
|
2025-12-19 23:07:36 +00:00
|
|
|
this.runData = runData;
|
|
|
|
|
this.isRunning = true;
|
2025-12-20 00:02:42 +00:00
|
|
|
this.phase = "DEPLOYMENT";
|
|
|
|
|
this.clearUnitMeshes();
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
this.grid = new VoxelGrid(20, 10, 20);
|
|
|
|
|
const generator = new RuinGenerator(this.grid, runData.seed);
|
|
|
|
|
generator.generate();
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
if (generator.generatedAssets.spawnZones) {
|
|
|
|
|
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
|
|
|
|
|
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.playerSpawnZone.length === 0)
|
2025-12-20 00:02:42 +00:00
|
|
|
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.enemySpawnZone.length === 0)
|
2025-12-20 00:02:42 +00:00
|
|
|
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
this.voxelManager = new VoxelManager(this.grid, this.scene);
|
2025-12-19 23:07:36 +00:00
|
|
|
this.voxelManager.updateMaterials(generator.generatedAssets);
|
|
|
|
|
this.voxelManager.update();
|
2025-12-19 23:08:54 +00:00
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.controls) this.voxelManager.focusCamera(this.controls);
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-19 23:35:29 +00:00
|
|
|
const mockRegistry = {
|
|
|
|
|
get: (id) => {
|
2025-12-20 00:02:42 +00:00
|
|
|
if (id.startsWith("CLASS_"))
|
|
|
|
|
return { type: "EXPLORER", name: id, stats: { hp: 100 } };
|
2025-12-19 23:35:29 +00:00
|
|
|
return {
|
2025-12-20 00:02:42 +00:00
|
|
|
type: "ENEMY",
|
|
|
|
|
name: "Enemy",
|
|
|
|
|
stats: { hp: 50 },
|
|
|
|
|
ai_archetype: "BRUISER",
|
2025-12-19 23:35:29 +00:00
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
this.unitManager = new UnitManager(mockRegistry);
|
2025-12-20 00:02:42 +00:00
|
|
|
this.highlightZones();
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// 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));
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
this.animate();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
deployUnit(unitDef, targetTile) {
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.phase !== "DEPLOYMENT") return null;
|
2025-12-20 00:02:42 +00:00
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// Re-validate using the zone logic (Double check)
|
|
|
|
|
const isValid = this.validateDeploymentCursor(
|
|
|
|
|
targetTile.x,
|
|
|
|
|
targetTile.y,
|
|
|
|
|
targetTile.z
|
2025-12-20 00:02:42 +00:00
|
|
|
);
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (!isValid || this.grid.isOccupied(targetTile)) return null;
|
2025-12-20 00:02:42 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-19 23:35:29 +00:00
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
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);
|
2025-12-19 23:35:29 +00:00
|
|
|
}
|
2025-12-20 00:02:42 +00:00
|
|
|
}
|
|
|
|
|
this.phase = "ACTIVE";
|
2025-12-20 04:58:16 +00:00
|
|
|
|
|
|
|
|
// Switch to standard movement validator for the game
|
|
|
|
|
this.inputManager.setValidator(this.validateCursorMove.bind(this));
|
2025-12-20 00:02:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-12-20 04:58:16 +00:00
|
|
|
else if (unit.team === "ENEMY") color = 0x550000;
|
2025-12-20 00:02:42 +00:00
|
|
|
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);
|
2025-12-20 04:58:16 +00:00
|
|
|
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
2025-12-20 00:02:42 +00:00
|
|
|
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);
|
2025-12-19 23:35:29 +00:00
|
|
|
});
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
animate() {
|
|
|
|
|
if (!this.isRunning) return;
|
|
|
|
|
requestAnimationFrame(this.animate);
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
2025-12-19 23:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// 3. Render
|
2025-12-20 00:02:42 +00:00
|
|
|
const time = Date.now() * 0.002;
|
|
|
|
|
this.unitMeshes.forEach((mesh) => {
|
|
|
|
|
mesh.position.y += Math.sin(time) * 0.002;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stop() {
|
|
|
|
|
this.isRunning = false;
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.inputManager) this.inputManager.detach();
|
2025-12-19 23:08:54 +00:00
|
|
|
if (this.controls) this.controls.dispose();
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
}
|