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:
parent
964a12fa47
commit
2898701b46
7 changed files with 333 additions and 46 deletions
19
src/assets/data/units/mule_bot.json
Normal file
19
src/assets/data/units/mule_bot.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -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,8 +1200,14 @@ export class GameLoop {
|
|||
const classRegistry = new Map();
|
||||
|
||||
// Lazy-load class definitions
|
||||
const [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef] =
|
||||
await Promise.all([
|
||||
const [
|
||||
vanguardDef,
|
||||
weaverDef,
|
||||
scavengerDef,
|
||||
tinkerDef,
|
||||
custodianDef,
|
||||
muleBotDef,
|
||||
] = await Promise.all([
|
||||
import("../assets/data/classes/vanguard.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default),
|
||||
|
|
@ -1204,6 +1223,9 @@ export class GameLoop {
|
|||
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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
test/managers/MissionManager_objectives.test.js
Normal file
73
test/managers/MissionManager_objectives.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue