aether-shards/src/core/GameLoop.js

597 lines
18 KiB
JavaScript

/**
* @typedef {import("./types.js").RunData} RunData
* @typedef {import("../grid/types.js").Position} Position
* @typedef {import("../units/Unit.js").Unit} Unit
*/
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<string, THREE.Mesh>} */
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<number, Unit> }} */
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
/** @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<void>}
*/
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<void>}
*/
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<Index, UnitInstance>
};
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");
}
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();
}
}