Enhance GameLoop with deployment phase management and spawn zone logic. Introduce unit deployment functionality and finalize deployment to spawn enemies. Update RuinGenerator to define valid spawn zones for player and enemy units. Add integration tests for GameLoop to verify initialization, deployment, and enemy spawning behaviors.

This commit is contained in:
Matthew Mone 2025-12-19 16:02:42 -08:00
parent 781aee81a7
commit 2d72fb9170
3 changed files with 346 additions and 80 deletions

View file

@ -5,11 +5,11 @@ 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 { TurnSystem } from '../systems/TurnSystem.js';
export class GameLoop {
constructor() {
this.isRunning = false;
this.phase = "INIT"; // INIT, DEPLOYMENT, ACTIVE, RESOLUTION
// 1. Core Systems
this.scene = new THREE.Scene();
@ -21,8 +21,13 @@ export class GameLoop {
this.voxelManager = null;
this.unitManager = null;
// Store visual meshes for units [unitId -> THREE.Mesh]
this.unitMeshes = new Map();
// 2. State
this.runData = null;
this.playerSpawnZone = [];
this.enemySpawnZone = [];
}
init(container) {
@ -43,12 +48,12 @@ export class GameLoop {
// Setup OrbitControls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // Smooth camera movement
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.screenSpacePanning = false;
this.controls.minDistance = 5;
this.controls.maxDistance = 100;
this.controls.maxPolarAngle = Math.PI / 2; // Prevent going below ground
this.controls.maxPolarAngle = Math.PI / 2;
// Lighting
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
@ -69,91 +74,195 @@ export class GameLoop {
/**
* Starts a Level based on Run Data (New or Loaded).
* Generates the map but does NOT spawn units immediately.
*/
async startLevel(runData) {
console.log("GameLoop: Starting Level...");
console.log("GameLoop: Generating Level...");
this.runData = runData;
this.isRunning = true;
this.phase = "DEPLOYMENT";
// Cleanup previous level
this.clearUnitMeshes();
// 1. Initialize Grid (20x10x20 for prototype)
this.grid = new VoxelGrid(20, 10, 20);
// 2. Generate World (Using saved seed)
// 2. Generate World
// TODO: Switch generator based on runData.biome_id
const generator = new RuinGenerator(this.grid, runData.seed);
generator.generate();
// 3. Initialize Visuals
this.voxelManager = new VoxelManager(this.grid, this.scene);
// 3. Extract Spawn Zones
if (generator.generatedAssets.spawnZones) {
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
}
// Apply textures generated by the biome logic
// Safety Fallback if generator provided no zones
if (this.playerSpawnZone.length === 0) {
console.warn("No Player Spawn Zone generated. Using default.");
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
}
if (this.enemySpawnZone.length === 0) {
console.warn("No Enemy Spawn Zone generated. Using default.");
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
}
// 4. Initialize Visuals
this.voxelManager = new VoxelManager(this.grid, this.scene);
this.voxelManager.updateMaterials(generator.generatedAssets);
this.voxelManager.update();
// Center camera using the focus target
if (this.controls) {
this.voxelManager.focusCamera(this.controls);
}
// 4. Initialize Units
// Mock Registry for Prototype so UnitManager doesn't crash on createUnit
// 5. Initialize Unit Manager (Empty)
const mockRegistry = {
get: (id) => {
if (id.startsWith("CLASS_"))
return { type: "EXPLORER", name: id, stats: { hp: 100 } };
return {
type: "EXPLORER",
name: id, // Fallback name
stats: { hp: 100, attack: 10, speed: 10 },
type: "ENEMY",
name: "Enemy",
stats: { hp: 50 },
ai_archetype: "BRUISER",
};
},
};
this.unitManager = new UnitManager(mockRegistry);
this.spawnSquad(runData.squad);
// Start Loop
// 6. Highlight Spawn Zones (Visual Debug)
this.highlightZones();
// Start Render Loop (Waiting for player input)
this.animate();
}
spawnSquad(squadManifest) {
if (!squadManifest || !this.unitManager) return;
/**
* Called by UI to place a unit during Deployment Phase.
*/
deployUnit(unitDef, targetTile) {
if (this.phase !== "DEPLOYMENT") {
console.warn("Cannot deploy unit outside Deployment phase.");
return null;
}
// Simple spawn logic: line them up starting at (2, 1, 2)
// In a full implementation, the Generator would provide 'StartPoints'
let spawnX = 2;
let spawnZ = 2;
const spawnY = 1; // Assuming flat floor at y=1 for Ruins
// Validate Tile
const isValid = this.playerSpawnZone.some(
(t) => t.x === targetTile.x && t.z === targetTile.z
);
if (!isValid) {
console.warn("Invalid spawn location.");
return null;
}
squadManifest.forEach((member) => {
if (!member) return;
if (this.grid.isOccupied(targetTile)) {
console.warn("Tile occupied.");
return null;
}
// Create Unit (this uses the registry to look up stats)
const unit = this.unitManager.createUnit(member.classId, "PLAYER");
// Create and Place
const unit = this.unitManager.createUnit(
unitDef.classId || unitDef.id,
"PLAYER"
);
if (unitDef.name) unit.name = unitDef.name;
// Override name if provided in manifest
if (member.name) unit.name = member.name;
this.grid.placeUnit(unit, targetTile);
this.createUnitMesh(unit, targetTile);
// Find a valid spot (basic collision check)
let placed = false;
// Try a few spots if the first is taken
for (let i = 0; i < 5; i++) {
const pos = { x: spawnX + i, y: spawnY, z: spawnZ };
console.log(
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
);
return unit;
}
// Ensure we don't spawn inside a wall or off the map
if (this.grid.isValidBounds(pos) && !this.grid.isSolid(pos)) {
this.grid.placeUnit(unit, pos);
console.log(
`Spawned ${unit.name} (${unit.id}) at ${pos.x},${pos.y},${pos.z}`
);
placed = true;
// Update X for next unit to be next to this one
spawnX = pos.x + 1;
break;
}
/**
* Called when player clicks "Start Battle".
* Spawns enemies and switches phase.
*/
finalizeDeployment() {
if (this.phase !== "DEPLOYMENT") return;
console.log("Finalizing Deployment. Spawning Enemies...");
// Simple Enemy Spawning Logic
// In a real game, this would read from a Level Design configuration
const enemyCount = 2;
for (let i = 0; i < enemyCount; i++) {
// Pick a random spot in enemy zone
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
const spot = this.enemySpawnZone[spotIndex];
// Ensure spot is valid/empty
if (spot && !this.grid.isOccupied(spot)) {
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
this.grid.placeUnit(enemy, spot);
this.createUnitMesh(enemy, spot);
// Remove spot from pool to avoid double spawn
this.enemySpawnZone.splice(spotIndex, 1);
}
}
if (!placed) {
console.warn(`Could not find spawn point for ${unit.name}`);
}
this.phase = "ACTIVE";
// TODO: Start Turn System here
}
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.id.includes("WEAVER")) color = 0x3333ff;
else if (unit.id.includes("SCAVENGER")) color = 0xffff33;
else if (unit.id.includes("TINKER")) color = 0xff9933;
else if (unit.id.includes("CUSTODIAN")) color = 0x33ff33;
else if (unit.team === "ENEMY") color = 0x550000; // Dark Red for enemies
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() {
// Visual debug for spawn zones (Green for Player, Red for Enemy)
// In a full implementation, this would use the VoxelManager's highlight system
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); // Slightly above floor
this.scene.add(mesh);
// Note: Should track these to clean up later
});
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);
});
}
@ -161,20 +270,20 @@ export class GameLoop {
if (!this.isRunning) return;
requestAnimationFrame(this.animate);
// Update Logic
// TWEEN.update();
if (this.controls) {
this.controls.update();
}
// 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;
// Cleanup Three.js resources if needed
if (this.controls) this.controls.dispose();
}
}

View file

@ -1,6 +1,7 @@
import { BaseGenerator } from "./BaseGenerator.js";
import { RustedFloorTextureGenerator } from "./textures/RustedFloorTextureGenerator.js";
// We can reuse the texture generators or create specific Ruin ones.
import { RustedWallTextureGenerator } from "./textures/RustedWallTextureGenerator.js";
import { RustedFloorTextureGenerator } from "./textures/RustedFloorTextureGenerator.js";
/**
* Generates structured rooms and corridors.
@ -11,25 +12,22 @@ export class RuinGenerator extends BaseGenerator {
constructor(grid, seed) {
super(grid, seed);
// Initialize Texture Generators
// (In a full game, we would replace these with Ruin-specific aesthetic generators)
// Use Rusted Floor for the Industrial aesthetic
this.floorGen = new RustedFloorTextureGenerator(seed);
this.wallGen = new RustedWallTextureGenerator(seed);
// Container for assets generated by this biome logic.
// We preload 10 variations of floors and walls.
// The VoxelManager will need to read this 'palette' to create materials.
this.generatedAssets = {
palette: {}, // Maps Voxel ID -> Texture Asset Config
palette: {},
// New: Explicitly track valid spawn locations for teams
spawnZones: {
player: [],
enemy: [],
},
};
this.preloadTextures();
}
/**
* Pre-generates texture variations so we don't regenerate them per voxel.
* Assigns them to specific Voxel IDs.
*/
preloadTextures() {
const VARIATIONS = 10;
const TEXTURE_SIZE = 128;
@ -37,7 +35,6 @@ export class RuinGenerator extends BaseGenerator {
// 1. Preload Wall Variations (IDs 100 - 109)
for (let i = 0; i < VARIATIONS; i++) {
const wallSeed = this.rng.next() * 5000 + i;
// Generate the complex map (Diffuse + Emissive + Normal etc.)
const tempWallGen = new RustedWallTextureGenerator(wallSeed);
this.generatedAssets.palette[100 + i] =
tempWallGen.generateWall(TEXTURE_SIZE);
@ -84,6 +81,13 @@ export class RuinGenerator extends BaseGenerator {
this.buildCorridor(prev, curr); // Additive building
}
// 3. Define Spawn Zones
// Player gets the first room, Enemy gets the last room
if (rooms.length > 0) {
this.markSpawnZone(rooms[0], "player");
this.markSpawnZone(rooms[rooms.length - 1], "enemy");
}
// 3. Apply Texture/Material Logic
this.applyTextures();
@ -93,6 +97,26 @@ export class RuinGenerator extends BaseGenerator {
this.scatterCover(10, 0.1);
}
/**
* Collects valid floor tiles from a room and adds them to a spawn zone.
*/
markSpawnZone(room, type) {
// Scan the room's interior (excluding walls)
// We assume y=1 is the walking surface
for (let x = room.x + 1; x < room.x + room.w - 1; x++) {
for (let z = room.z + 1; z < room.z + room.d - 1; z++) {
// Double check it's a valid floor (Solid at 0, Air at 1)
// Note: IDs haven't been textured yet, so floor is still ID 1
if (
this.grid.getCell(x, 0, z) !== 0 &&
this.grid.getCell(x, 1, z) === 0
) {
this.generatedAssets.spawnZones[type].push({ x, y: 1, z });
}
}
}
}
roomsOverlap(room, rooms) {
for (const r of rooms) {
if (
@ -126,11 +150,9 @@ export class RuinGenerator extends BaseGenerator {
x === r.x || x === r.x + r.w - 1 || z === r.z || z === r.z + r.d - 1;
if (isWall) {
// Build Wall stack - Placeholder ID 1
this.grid.setCell(x, r.y, z, 1); // Wall Base
this.grid.setCell(x, r.y + 1, z, 1); // Wall Top
} else {
// Ensure Interior is Air
this.grid.setCell(x, r.y, z, 0);
this.grid.setCell(x, r.y + 1, z, 0);
}
@ -155,17 +177,11 @@ export class RuinGenerator extends BaseGenerator {
}
buildPathPoint(x, y, z) {
// Build Floor - Placeholder ID 1
this.grid.setCell(x, y - 1, z, 1);
// Clear Path (Air)
this.grid.setCell(x, y, z, 0);
this.grid.setCell(x, y + 1, z, 0);
}
/**
* Iterates through the grid to identify Floor and Wall voxels,
* assigning randomized IDs from our preloaded palette.
*/
applyTextures() {
for (let x = 0; x < this.width; x++) {
for (let z = 0; z < this.depth; z++) {
@ -173,17 +189,11 @@ export class RuinGenerator extends BaseGenerator {
const current = this.grid.getCell(x, y, z);
const above = this.grid.getCell(x, y + 1, z);
// If it's currently a placeholder solid block (ID 1)
// Note: We check if it is NOT Air (0)
if (current !== 0) {
if (above === 0) {
// This is a Floor Surface
// Pick random ID from 200 to 209
const variant = this.rng.rangeInt(0, 9);
this.grid.setCell(x, y, z, 200 + variant);
} else {
// This is a Wall or internal block
// Pick random ID from 100 to 109
const variant = this.rng.rangeInt(0, 9);
this.grid.setCell(x, y, z, 100 + variant);
}

147
test/core/GameLoop.test.js Normal file
View file

@ -0,0 +1,147 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import * as THREE from "three";
import { GameLoop } from "../../src/core/GameLoop.js";
describe("Core: GameLoop (Integration)", function () {
// Increase timeout for WebGL/Shader compilation overhead
this.timeout(30000);
let gameLoop;
let container;
beforeEach(() => {
// Create a mounting point
container = document.createElement("div");
document.body.appendChild(container);
gameLoop = new GameLoop();
});
afterEach(() => {
gameLoop.stop();
if (container.parentNode) {
container.parentNode.removeChild(container);
}
// Cleanup Three.js resources if possible to avoid context loss limits
if (gameLoop.renderer) {
gameLoop.renderer.dispose();
gameLoop.renderer.forceContextLoss();
}
});
it("CoA 1: init() should setup Three.js scene, camera, and renderer", () => {
gameLoop.init(container);
expect(gameLoop.scene).to.be.instanceOf(THREE.Scene);
expect(gameLoop.camera).to.be.instanceOf(THREE.PerspectiveCamera);
expect(gameLoop.renderer).to.be.instanceOf(THREE.WebGLRenderer);
// Verify renderer is attached to DOM
expect(container.querySelector("canvas")).to.exist;
});
it("CoA 2: startLevel() should initialize grid, visuals, and generate world", async () => {
gameLoop.init(container);
const runData = {
seed: 12345,
depth: 1,
squad: [],
};
await gameLoop.startLevel(runData);
// Grid should be populated
expect(gameLoop.grid).to.exist;
// Check center of map (likely not empty for RuinGen) or at least check valid bounds
expect(gameLoop.grid.size.x).to.equal(20);
// VoxelManager should be initialized
expect(gameLoop.voxelManager).to.exist;
// Should have visual meshes
expect(gameLoop.scene.children.length).to.be.greaterThan(0);
});
it("CoA 3: Deployment Phase should separate zones and allow manual placement", async () => {
gameLoop.init(container);
const runData = {
seed: 12345, // Deterministic seed
depth: 1,
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
};
// startLevel should now prepare the map but NOT spawn units immediately
await gameLoop.startLevel(runData);
// 1. Verify Spawn Zones Generated
// The generator/loop should identify valid tiles for player start and enemy start
expect(gameLoop.playerSpawnZone).to.be.an("array").that.is.not.empty;
expect(gameLoop.enemySpawnZone).to.be.an("array").that.is.not.empty;
// 2. Verify Zone Separation
// Create copies to ensure we don't test against mutated arrays later
const pZone = [...gameLoop.playerSpawnZone];
const eZone = [...gameLoop.enemySpawnZone];
const overlap = pZone.some((pTile) =>
eZone.some((eTile) => eTile.x === pTile.x && eTile.z === pTile.z)
);
expect(overlap).to.be.false;
// 3. Test Manual Deployment (User Selection)
const unitDef = runData.squad[0];
const validTile = pZone[0]; // Pick first valid tile from player zone
// Expect a method to manually place a unit from the roster onto a specific tile
const unit = gameLoop.deployUnit(unitDef, validTile);
expect(unit).to.exist;
expect(unit.position.x).to.equal(validTile.x);
expect(unit.position.z).to.equal(validTile.z);
// Verify visual mesh created
const mesh = gameLoop.unitMeshes.get(unit.id);
expect(mesh).to.exist;
expect(mesh.position.x).to.equal(validTile.x);
// 4. Test Enemy Spawning (Finalize Deployment)
// This triggers the actual start of combat/AI
gameLoop.finalizeDeployment();
const enemies = gameLoop.unitManager.getUnitsByTeam("ENEMY");
expect(enemies.length).to.be.greaterThan(0);
// Verify enemies are in their zone
// Note: finalizeDeployment removes used spots from gameLoop.enemySpawnZone,
// so we check against our copy `eZone`.
const enemyPos = enemies[0].position;
const isInZone = eZone.some(
(t) => t.x === enemyPos.x && t.z === enemyPos.z
);
expect(
isInZone,
`Enemy spawned at ${enemyPos.x},${enemyPos.z} which is not in enemy zone`
).to.be.true;
});
it("CoA 4: stop() should halt animation loop", (done) => {
gameLoop.init(container);
gameLoop.isRunning = true;
// Spy on animate
const spy = sinon.spy(gameLoop, "animate");
gameLoop.stop();
// Wait a short duration to ensure loop doesn't fire
// Using setTimeout instead of requestAnimationFrame for reliability in headless env
setTimeout(() => {
expect(gameLoop.isRunning).to.be.false;
done();
}, 50);
});
});