From 2898701b4673bdeb98fd007e3ea36ceea4f299d0 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Fri, 2 Jan 2026 20:43:28 -0800 Subject: [PATCH] feat: introduce core game systems including input management with raycasting, voxel and mission managers, game loop, combat HUD, and unit assets. --- src/assets/data/units/mule_bot.json | 19 ++ src/core/GameLoop.js | 187 +++++++++++++++--- src/core/InputManager.js | 5 + src/grid/VoxelManager.js | 42 +++- src/managers/MissionManager.js | 20 +- src/ui/combat-hud.js | 33 +++- .../MissionManager_objectives.test.js | 73 +++++++ 7 files changed, 333 insertions(+), 46 deletions(-) create mode 100644 src/assets/data/units/mule_bot.json create mode 100644 test/managers/MissionManager_objectives.test.js diff --git a/src/assets/data/units/mule_bot.json b/src/assets/data/units/mule_bot.json new file mode 100644 index 0000000..2f44e85 --- /dev/null +++ b/src/assets/data/units/mule_bot.json @@ -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": [] +} diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 587cccd..5bb10af 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -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 }; + // 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; + } } /** diff --git a/src/core/InputManager.js b/src/core/InputManager.js index 708d6c3..2233263 100644 --- a/src/core/InputManager.js +++ b/src/core/InputManager.js @@ -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() { diff --git a/src/grid/VoxelManager.js b/src/grid/VoxelManager.js index 1a8173e..faa93a1 100644 --- a/src/grid/VoxelManager.js +++ b/src/grid/VoxelManager.js @@ -556,7 +556,7 @@ export class VoxelManager { // Success chance = 1 - obstruction, so opacity should reflect that const baseOpacity = 1.0; const dimmedOpacity = baseOpacity * (1 - obstruction * 0.7); // Dim up to 70% based on obstruction - + // Create materials with obstruction-based opacity const outerGlowMaterial = new THREE.LineBasicMaterial({ color: 0x660000, @@ -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 = {}; + } } diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index 60a4da2..e6e09a1 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -29,6 +29,7 @@ export class MissionManager { /** @type {Set} */ this.unlockedMissions = new Set(); // Track unlocked missions this.unlockedMissions.add("MISSION_ACT1_01"); // Default unlock + this.proceduralMissionsUnlocked = false; // Flag for procedural generation /** @type {Map} */ this.missionRegistry = new Map(); /** @type {Map} */ @@ -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) { diff --git a/src/ui/combat-hud.js b/src/ui/combat-hud.js index ba2b447..3978d05 100644 --- a/src/ui/combat-hud.js +++ b/src/ui/combat-hud.js @@ -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 {

OBJECTIVES

${missionObjectives.primary?.map( (obj) => html` -
+
${obj.complete ? "✓ " : ""}${obj.description}
@@ -717,7 +733,8 @@ export class CombatHUD extends LitElement { ${obj.type === "REACH_ZONE" && obj.zone_coords ? html`
- Zones: ${obj.zone_coords.length} target${obj.zone_coords.length !== 1 ? "s" : ""} + Zones: ${obj.zone_coords.length} + target${obj.zone_coords.length !== 1 ? "s" : ""}
` : ""} @@ -726,9 +743,7 @@ export class CombatHUD extends LitElement { )} ${missionObjectives.secondary?.length > 0 ? html` -

- SECONDARY -

+

SECONDARY

${missionObjectives.secondary.map( (obj) => html`
{ + 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; + }); +});