feat: introduce core game systems including input management with raycasting, voxel and mission managers, game loop, combat HUD, and unit assets.

This commit is contained in:
Matthew Mone 2026-01-02 20:43:28 -08:00
parent 964a12fa47
commit 2898701b46
7 changed files with 333 additions and 46 deletions

View file

@ -0,0 +1,19 @@
{
"id": "UNIT_MULE_BOT",
"name": "Mule Bot",
"type": "ALLY",
"base_stats": {
"health": 250,
"attack": 0,
"defense": 10,
"magic": 0,
"speed": 8,
"willpower": 5,
"movement": 4
},
"model": {
"mesh": "MULE_BOT_MESH",
"color": "0xAAAAAA"
},
"actions": []
}

View file

@ -1130,6 +1130,14 @@ export class GameLoop {
console.log("GameLoop: Generating Level...");
this.runData = runData;
this.isRunning = true;
// Replay controls initialization if needed (e.g. after stop())
if (!this.controls && this.camera && this.renderer) {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
}
this.clearUnitMeshes();
this.clearMovementHighlights();
this.clearSpawnZoneHighlights();
@ -1137,12 +1145,29 @@ export class GameLoop {
this.clearZoneMarkers();
this.clearRangeHighlights();
// Re-attach input listeners if they were detached
if (this.inputManager && typeof this.inputManager.attach === "function") {
this.inputManager.attach();
}
// Reset Deployment State
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
// Notify UI to clear deployment state
window.dispatchEvent(
new CustomEvent("deployment-update", {
detail: {
deployedIndices: [],
},
})
);
// Restart the animation loop if it was stopped
this.animate();
this.grid = new VoxelGrid(20, 10, 20);
const generator = new RuinGenerator(this.grid, runData.seed);
generator.generate();
@ -1159,19 +1184,7 @@ export class GameLoop {
// Dispose of old VoxelManager if it exists
if (this.voxelManager) {
// Clear all meshes from the old VoxelManager
this.voxelManager.meshes.forEach((mesh) => {
this.scene.remove(mesh);
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((mat) => mat.dispose());
} else {
mesh.material.dispose();
}
}
});
this.voxelManager.meshes.clear();
this.voxelManager.clear();
}
this.voxelManager = new VoxelManager(this.grid, this.scene);
@ -1187,24 +1200,33 @@ export class GameLoop {
const classRegistry = new Map();
// Lazy-load class definitions
const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] =
await Promise.all([
import("../assets/data/classes/vanguard.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/aether_weaver.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/scavenger.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/tinker.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/custodian.json", {
with: { type: "json" },
}).then((m) => m.default),
]);
const [
vanguardDef,
weaverDef,
scavengerDef,
tinkerDef,
custodianDef,
muleBotDef,
] = await Promise.all([
import("../assets/data/classes/vanguard.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/aether_weaver.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/scavenger.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/tinker.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/classes/custodian.json", {
with: { type: "json" },
}).then((m) => m.default),
import("../assets/data/units/mule_bot.json", {
with: { type: "json" },
}).then((m) => m.default),
]);
// Register all class definitions
const classDefs = [
@ -1213,6 +1235,7 @@ export class GameLoop {
scavengerDef,
tinkerDef,
custodianDef,
muleBotDef,
];
for (const classDef of classDefs) {
@ -1604,6 +1627,82 @@ export class GameLoop {
console.log(`Spawned ${totalSpawned} enemies from mission definition`);
}
// Spawn Escort/VIP Units defined in objectives
// Check primary and secondary objectives for ESCORT type
const allObjectives = [
...(missionDef.objectives?.primary || []),
...(missionDef.objectives?.secondary || []),
];
const escortObjectives = allObjectives.filter(
(obj) => obj.type === "ESCORT"
);
for (const obj of escortObjectives) {
if (obj.target_def_id) {
// Create the VIP unit
// We assign to PLAYER team so the user can control them (or we could use NEUTRAL/ALLY if AI controlled)
// For now, assuming player control is desired for "Supply Run"
const vipUnit = this.unitManager.createUnit(
obj.target_def_id,
"PLAYER"
);
if (vipUnit) {
// Place near player start
let spawnPos = { x: 2, y: 1, z: 2 }; // Fallback
if (this.playerSpawnZone.length > 0) {
// Use the last spot in the spawn zone or a dedicated spot
// Ideally we find a free spot near the spawn zone center
const spot = this.playerSpawnZone[0]; // Just use first spot for now, or find free one
// Find a free spot near the spawn zone
const startX = spot.x;
const startZ = spot.z;
// Spiral search for free spot
let found = false;
for (let r = 0; r < 5; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
const tx = startX + dx;
const tz = startZ + dz;
if (
this.grid.isValidBounds({ x: tx, y: spot.y, z: tz }) &&
!this.grid.isOccupied({ x: tx, y: spot.y, z: tz })
) {
const walkableY = this.movementSystem?.findWalkableY(
tx,
tz,
spot.y
);
if (walkableY !== null) {
spawnPos = { x: tx, y: walkableY, z: tz };
found = true;
}
}
if (found) break;
}
if (found) break;
}
if (found) break;
}
}
this.grid.placeUnit(vipUnit, spawnPos);
this.createUnitMesh(vipUnit, spawnPos);
console.log(
`Spawned Escort VIP: ${vipUnit.name} at ${spawnPos.x},${spawnPos.y},${spawnPos.z}`
);
} else {
console.error(
`Failed to create Escort VIP unit: ${obj.target_def_id}`
);
}
}
}
// Spawn mission objects
const missionObjects = missionDef?.mission_objects || [];
for (const objDef of missionObjects) {
@ -2166,6 +2265,17 @@ export class GameLoop {
}
}
}
}
// Custom Override from Unit Definition (e.g. Mule Bot)
if (unit.voxelModelID && unit.voxelModelID.color) {
// Handle both string hex "0xAAAAAA" and number
if (typeof unit.voxelModelID.color === "string") {
color = parseInt(unit.voxelModelID.color.replace("0x", ""), 16);
} else {
color = unit.voxelModelID.color;
}
}
const material = new THREE.MeshStandardMaterial({ color: color });
const mesh = new THREE.Mesh(geometry, material);
@ -2720,6 +2830,11 @@ export class GameLoop {
this.clearMissionObjects();
this.clearRangeHighlights();
// Force a final render to clear the screen
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
// Clear unit manager
if (this.unitManager) {
// UnitManager doesn't have a clear method, but we can reset it by clearing units
@ -2729,6 +2844,11 @@ export class GameLoop {
});
}
// Clear VoxelManager (map meshes and materials)
if (this.voxelManager) {
this.voxelManager.clear();
}
// Reset deployment state
this.deploymentState = {
selectedUnitIndex: -1,
@ -2738,7 +2858,10 @@ export class GameLoop {
if (this.inputManager && typeof this.inputManager.detach === "function") {
this.inputManager.detach();
}
if (this.controls) this.controls.dispose();
if (this.controls) {
this.controls.dispose();
this.controls = null;
}
}
/**

View file

@ -76,6 +76,11 @@ export class InputManager extends EventTarget {
window.addEventListener("keyup", this.onKeyUp);
window.addEventListener("gamepadconnected", this.onGamepadConnected);
window.addEventListener("gamepaddisconnected", this.onGamepadDisconnected);
// Re-add cursor to scene if missing and scene is available
if (this.cursor && this.scene && !this.cursor.parent) {
this.scene.add(this.cursor);
}
}
detach() {

View file

@ -753,4 +753,44 @@ export class VoxelManager {
this.clearRangeHighlights();
this.clearReticle();
}
/**
* Clears all meshes and disposes of geometry/materials.
*/
clear() {
// Clear instanced meshes
this.meshes.forEach((mesh) => {
this.scene.remove(mesh);
if (mesh.geometry) mesh.geometry.dispose();
// Materials are shared, handled below
});
this.meshes.clear();
// Clear highlights
this.clearHighlights();
// Dispose of materials
Object.values(this.materials).forEach((material) => {
if (Array.isArray(material)) {
material.forEach((m) => {
if (m.map) m.map.dispose();
if (m.emissiveMap) m.emissiveMap.dispose();
if (m.normalMap) m.normalMap.dispose();
if (m.roughnessMap) m.roughnessMap.dispose();
if (m.bumpMap) m.bumpMap.dispose();
m.dispose();
});
} else {
if (material.map) material.map.dispose();
if (material.emissiveMap) material.emissiveMap.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
if (material.bumpMap) material.bumpMap.dispose();
material.dispose();
}
});
// Clear references
this.materials = {};
}
}

View file

@ -29,6 +29,7 @@ export class MissionManager {
/** @type {Set<string>} */
this.unlockedMissions = new Set(); // Track unlocked missions
this.unlockedMissions.add("MISSION_ACT1_01"); // Default unlock
this.proceduralMissionsUnlocked = false; // Flag for procedural generation
/** @type {Map<string, MissionDefinition>} */
this.missionRegistry = new Map();
/** @type {Map<string, MissionDefinition>} */
@ -171,11 +172,10 @@ export class MissionManager {
/**
* Checks if procedural missions are unlocked.
* Procedural missions unlock after completing mission 3 (end of tutorial phase).
* @returns {boolean} True if procedural missions are unlocked
* @returns {boolean}
*/
areProceduralMissionsUnlocked() {
return this.completedMissions.has("MISSION_STORY_03");
return this.proceduralMissionsUnlocked;
}
/**
@ -742,11 +742,16 @@ export class MissionManager {
}
}
// SURVIVE: Check turn count
// SURVIVE: Check turn count
if (eventType === "TURN_END" && obj.type === "SURVIVE") {
if (obj.turn_count && this.currentTurn >= obj.turn_count) {
const limit = obj.turn_limit || obj.turn_count;
if (limit && this.currentTurn >= limit) {
obj.complete = true;
statusChanged = true;
console.log(
`[MissionManager] SURVIVE objective complete (Turn ${this.currentTurn} >= ${limit})`
);
}
}
@ -1141,6 +1146,11 @@ export class MissionManager {
// Procedural missions are now unlocked
// They will be generated when the mission board is accessed
console.log("Procedural missions unlocked!");
this.proceduralMissionsUnlocked = true;
// Persist the unlock
this.unlockClasses(["UNLOCK_PROCEDURAL_MISSIONS"]);
// Refresh board immediately
this.refreshProceduralMissions();
} else if (unlock.startsWith("MISSION_")) {
this.unlockMission(unlock);
}
@ -1295,6 +1305,8 @@ export class MissionManager {
for (const u of unlocks) {
if (u.startsWith("MISSION_")) {
this.unlockedMissions.add(u);
} else if (u === "UNLOCK_PROCEDURAL_MISSIONS") {
this.proceduralMissionsUnlocked = true;
}
}
} catch (e) {

View file

@ -405,7 +405,8 @@ export class CombatHUD extends LitElement {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--font-size-base);
color: var(--color-accent-gold);
border-bottom: var(--border-width-thin) solid var(--color-border-default);
border-bottom: var(--border-width-thin) solid
var(--color-border-default);
padding-bottom: var(--spacing-xs);
}
@ -413,7 +414,8 @@ export class CombatHUD extends LitElement {
margin-bottom: var(--spacing-sm);
padding: var(--spacing-xs);
background: rgba(0, 0, 0, 0.3);
border-left: var(--border-width-thin) solid var(--color-border-default);
border-left: var(--border-width-thin) solid
var(--color-border-default);
padding-left: var(--spacing-sm);
}
@ -520,6 +522,7 @@ export class CombatHUD extends LitElement {
this.combatState = null;
this.hidden = false;
this._missionVictoryHandler = this._handleMissionVictory.bind(this);
this._gameStateChangeHandler = this._handleGameStateChange.bind(this);
}
connectedCallback() {
@ -527,12 +530,27 @@ export class CombatHUD extends LitElement {
// Listen for mission victory to hide the combat HUD
window.addEventListener("mission-victory", this._missionVictoryHandler);
window.addEventListener("mission-failure", this._missionVictoryHandler);
// Listen for game state changes to reset visibility
window.addEventListener("gamestate-changed", this._gameStateChangeHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("mission-victory", this._missionVictoryHandler);
window.removeEventListener("mission-failure", this._missionVictoryHandler);
window.removeEventListener(
"gamestate-changed",
this._gameStateChangeHandler
);
}
_handleGameStateChange(e) {
// Only show Combat HUD when in actual combat state
if (e.detail.newState === "STATE_COMBAT") {
this.hidden = false;
} else {
this.hidden = true;
}
}
_handleMissionVictory() {
@ -701,9 +719,7 @@ export class CombatHUD extends LitElement {
<h3>OBJECTIVES</h3>
${missionObjectives.primary?.map(
(obj) => html`
<div
class="objective-item ${obj.complete ? "complete" : ""}"
>
<div class="objective-item ${obj.complete ? "complete" : ""}">
<div class="objective-description">
${obj.complete ? "✓ " : ""}${obj.description}
</div>
@ -717,7 +733,8 @@ export class CombatHUD extends LitElement {
${obj.type === "REACH_ZONE" && obj.zone_coords
? html`
<div class="objective-progress">
Zones: ${obj.zone_coords.length} target${obj.zone_coords.length !== 1 ? "s" : ""}
Zones: ${obj.zone_coords.length}
target${obj.zone_coords.length !== 1 ? "s" : ""}
</div>
`
: ""}
@ -726,9 +743,7 @@ export class CombatHUD extends LitElement {
)}
${missionObjectives.secondary?.length > 0
? html`
<h3 style="margin-top: var(--spacing-md);">
SECONDARY
</h3>
<h3 style="margin-top: var(--spacing-md);">SECONDARY</h3>
${missionObjectives.secondary.map(
(obj) => html`
<div

View file

@ -0,0 +1,73 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import { MissionManager } from "../../src/managers/MissionManager.js";
describe("Manager: MissionManager - SURVIVE Objective Bug", () => {
let manager;
let mockPersistence;
beforeEach(() => {
mockPersistence = {
loadUnlocks: sinon.stub().resolves([]),
saveUnlocks: sinon.stub().resolves(),
};
manager = new MissionManager(mockPersistence);
});
it("Should complete SURVIVE objective when turn limit is reached (using turn_limit property)", async () => {
// Mock a mission with SURVIVE objective using turn_limit (like in the JSON files)
manager.currentMissionDef = {
id: "MISSION_TEST",
objectives: {
primary: [
{
type: "SURVIVE",
turn_limit: 5, // Using turn_limit as found in JSON
current: 0,
complete: false,
},
],
},
rewards: { guaranteed: {} },
};
manager.activeMissionId = "MISSION_TEST";
manager.currentObjectives = manager.currentMissionDef.objectives.primary;
manager.currentTurn = 0;
// Simulate turns passing
manager.updateTurn(5);
manager.onGameEvent("TURN_END", { turn: 5 });
// Should be complete
expect(
manager.currentObjectives[0].complete,
"Objective should be complete after 5 turns"
).to.be.true;
});
it("Should unlock procedural missions when reward is granted", async () => {
// Setup
const unlockStr = "UNLOCK_PROCEDURAL_MISSIONS";
manager.currentMissionDef = {
rewards: {
guaranteed: {
unlocks: [unlockStr],
},
},
};
// Stub unlockClasses to verify it's called
manager.unlockClasses = sinon.stub();
// Stub refreshProceduralMissions
manager.refreshProceduralMissions = sinon.stub();
// Act
manager.distributeRewards();
// Assert
expect(manager.proceduralMissionsUnlocked).to.be.true;
expect(manager.unlockClasses.calledWith([unlockStr])).to.be.true;
expect(manager.refreshProceduralMissions.called).to.be.true;
});
});