From da55cafc8f231840f4ce33c3ac48c6386464c642 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Sun, 21 Dec 2025 21:20:33 -0800 Subject: [PATCH] Enhance game state management and UI integration for combat and deployment phases. Introduce CombatHUD and update DeploymentHUD to reflect current game state. Refactor GameLoop and GameStateManager to manage state transitions more effectively. Implement asset copying for JSON files in the build process. Add tests for new functionalities and ensure proper state handling during gameplay. --- build.js | 30 ++++++++- src/core/GameLoop.js | 25 +++++-- src/core/GameStateManager.js | 69 ++++++++++++++------ src/index.js | 16 +++-- src/managers/MissionManager.js | 64 ++++++++++++------ src/ui/combat-hud.js | 101 +++++++++++++++++++++++++++++ src/ui/deployment-hud.js | 14 ++++ src/ui/dialogue-overlay.js | 2 +- src/ui/game-viewport.js | 13 +++- test/core/GameLoop.test.js | 6 ++ test/core/GameStateManager.test.js | 35 ++++++---- 11 files changed, 310 insertions(+), 65 deletions(-) create mode 100644 src/ui/combat-hud.js diff --git a/build.js b/build.js index 1d6dbc3..04965bd 100644 --- a/build.js +++ b/build.js @@ -6,8 +6,9 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Image file extensions to copy +// File extensions to copy const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]; +const DATA_EXTENSIONS = [".json"]; // Recursively copy image files from src to dist function copyImages(srcDir, distDir) { @@ -33,6 +34,30 @@ function copyImages(srcDir, distDir) { } } +// Recursively copy data files (JSON, markdown, TypeScript definitions) from assets to dist +function copyAssets(srcDir, distDir) { + const entries = readdirSync(srcDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(srcDir, entry.name); + const distPath = join(distDir, entry.name); + + if (entry.isDirectory()) { + mkdirSync(distPath, { recursive: true }); + copyAssets(srcPath, distPath); + } else if (entry.isFile()) { + const lastDot = entry.name.lastIndexOf("."); + if (lastDot !== -1) { + const ext = entry.name.toLowerCase().substring(lastDot); + if (DATA_EXTENSIONS.includes(ext)) { + mkdirSync(distDir, { recursive: true }); + copyFileSync(srcPath, distPath); + } + } + } + } +} + // Ensure dist directory exists mkdirSync("dist", { recursive: true }); @@ -53,4 +78,7 @@ copyFileSync("src/index.html", "dist/index.html"); // Copy images copyImages("src", "dist"); +// Copy assets (JSON, markdown, TypeScript definitions) +copyAssets("src/assets", "dist/assets"); + console.log("Build complete!"); diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 2da10ca..c1859ac 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -11,7 +11,6 @@ import { MissionManager } from "../managers/MissionManager.js"; export class GameLoop { constructor() { this.isRunning = false; - this.phase = "INIT"; // 1. Core Systems this.scene = new THREE.Scene(); @@ -184,7 +183,10 @@ export class GameLoop { const cursor = this.inputManager.getCursorPosition(); console.log("Action at:", cursor); - if (this.phase === "DEPLOYMENT") { + if ( + this.gameStateManager && + this.gameStateManager.currentState === "STATE_DEPLOYMENT" + ) { const selIndex = this.deploymentState.selectedUnitIndex; if (selIndex !== -1) { @@ -227,7 +229,6 @@ export class GameLoop { console.log("GameLoop: Generating Level..."); this.runData = runData; this.isRunning = true; - this.phase = "DEPLOYMENT"; this.clearUnitMeshes(); // Reset Deployment State @@ -299,7 +300,11 @@ export class GameLoop { } deployUnit(unitDef, targetTile, existingUnit = null) { - if (this.phase !== "DEPLOYMENT") return null; + if ( + !this.gameStateManager || + this.gameStateManager.currentState !== "STATE_DEPLOYMENT" + ) + return null; const isValid = this.validateDeploymentCursor( targetTile.x, @@ -358,7 +363,11 @@ export class GameLoop { } finalizeDeployment() { - if (this.phase !== "DEPLOYMENT") return; + 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); @@ -370,11 +379,15 @@ export class GameLoop { this.enemySpawnZone.splice(spotIndex, 1); } } - this.phase = "ACTIVE"; // 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!"); } diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 0e89d28..c71243e 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -8,7 +8,8 @@ class GameStateManagerClass { INIT: "STATE_INIT", MAIN_MENU: "STATE_MAIN_MENU", TEAM_BUILDER: "STATE_TEAM_BUILDER", - GAME_RUN: "STATE_GAME_RUN", + DEPLOYMENT: "STATE_DEPLOYMENT", + COMBAT: "STATE_COMBAT", }; constructor() { @@ -40,6 +41,18 @@ class GameStateManagerClass { this.#gameLoopInitialized.resolve(); } + reset() { + // Reset singleton state for testing + this.currentState = GameStateManagerClass.STATES.INIT; + this.gameLoop = null; + this.activeRunData = null; + this.rosterManager = new RosterManager(); + this.missionManager = new MissionManager(); + // Reset promise resolvers + this.#gameLoopInitialized = Promise.withResolvers(); + this.#rosterLoaded = Promise.withResolvers(); + } + async init() { console.log("System: Initializing State Manager..."); await this.persistence.init(); @@ -58,9 +71,14 @@ class GameStateManagerClass { } async transitionTo(newState, payload = null) { - console.log(`State Transition: ${this.currentState} -> ${newState}`); const oldState = this.currentState; - this.currentState = newState; + const stateChanged = oldState !== newState; + + // Only update state and run switch logic if state actually changed + if (stateChanged) { + console.log(`State Transition: ${oldState} -> ${newState}`); + this.currentState = newState; + } window.dispatchEvent( new CustomEvent("gamestate-changed", { @@ -68,19 +86,19 @@ class GameStateManagerClass { }) ); - switch (newState) { - case GameStateManagerClass.STATES.MAIN_MENU: - if (this.gameLoop) this.gameLoop.stop(); - await this._checkSaveGame(); - break; + // Only run state-specific logic if state actually changed + if (stateChanged) { + switch (newState) { + case GameStateManagerClass.STATES.MAIN_MENU: + if (this.gameLoop) this.gameLoop.stop(); + await this._checkSaveGame(); + break; - case GameStateManagerClass.STATES.GAME_RUN: - if (!this.activeRunData && payload) { - await this._initializeRun(payload); - } else { - await this._resumeRun(); - } - break; + case GameStateManagerClass.STATES.DEPLOYMENT: + case GameStateManagerClass.STATES.COMBAT: + // These states are handled by GameLoop, no special logic needed here + break; + } } } @@ -92,11 +110,12 @@ class GameStateManagerClass { const save = await this.persistence.loadRun(); if (save) { this.activeRunData = save; - this.transitionTo(GameStateManagerClass.STATES.GAME_RUN); + // Will transition to DEPLOYMENT after run is initialized + await this._resumeRun(); } } - handleEmbark(e) { + async handleEmbark(e) { // Handle Draft Mode (New Recruits) if (e.detail.mode === "DRAFT") { e.detail.squad.forEach((unit) => { @@ -106,7 +125,8 @@ class GameStateManagerClass { }); this._saveRoster(); } - this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad); + // Will transition to DEPLOYMENT after run is initialized + await this._initializeRun(e.detail.squad); } // --- INTERNAL HELPERS --- @@ -147,7 +167,11 @@ class GameStateManagerClass { // Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc) this.gameLoop.missionManager = this.missionManager; - this.gameLoop.startLevel(this.activeRunData); + // Give GameLoop a reference to GameStateManager so it can notify about state changes + this.gameLoop.gameStateManager = this; + await this.gameLoop.startLevel(this.activeRunData); + // Transition to deployment state after level is initialized + this.transitionTo(GameStateManagerClass.STATES.DEPLOYMENT); } async _resumeRun() { @@ -155,8 +179,13 @@ class GameStateManagerClass { if (this.activeRunData) { // Re-hook the mission manager this.gameLoop.missionManager = this.missionManager; + // Give GameLoop a reference to GameStateManager so it can notify about state changes + this.gameLoop.gameStateManager = this; // TODO: Ideally we reload the mission state from the save file here - this.gameLoop.startLevel(this.activeRunData); + await this.gameLoop.startLevel(this.activeRunData); + // Transition to appropriate state based on save data + // For now, always go to deployment + this.transitionTo(GameStateManagerClass.STATES.DEPLOYMENT); } } diff --git a/src/index.js b/src/index.js index cc3e7e2..5bbfff6 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,8 @@ window.addEventListener("gamestate-changed", async (e) => { case "STATE_TEAM_BUILDER": loadingMessage.textContent = "INITIALIZING TEAM BUILDER..."; break; - case "STATE_GAME_RUN": + case "STATE_DEPLOYMENT": + case "STATE_COMBAT": loadingMessage.textContent = "INITIALIZING GAME ENGINE..."; break; } @@ -37,7 +38,8 @@ window.addEventListener("gamestate-changed", async (e) => { await import("./ui/team-builder.js"); teamBuilder.toggleAttribute("hidden", false); break; - case "STATE_GAME_RUN": + case "STATE_DEPLOYMENT": + case "STATE_COMBAT": await import("./ui/game-viewport.js"); gameViewport.toggleAttribute("hidden", false); break; @@ -53,11 +55,13 @@ window.addEventListener("save-check-complete", (e) => { } }); +// Set up embark listener once (not inside button click) +teamBuilder.addEventListener("embark", async (e) => { + await gameStateManager.handleEmbark(e); + gameViewport.squad = teamBuilder.squad; +}); + btnNewRun.addEventListener("click", async () => { - teamBuilder.addEventListener("embark", async (e) => { - gameStateManager.handleEmbark(e); - gameViewport.squad = teamBuilder.squad; - }); gameStateManager.startNewGame(); }); diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index cd34cd2..a4a46ca 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -77,31 +77,57 @@ export class MissionManager { return Promise.resolve(); } - return new Promise((resolve) => { + return new Promise(async (resolve) => { const introId = this.currentMissionDef.narrative.intro_sequence; - // Mock loader: In real app, fetch the JSON from assets/data/narrative/ - // For prototype, we'll assume narrativeManager can handle the ID or we pass a mock. - // const narrativeData = await fetch(`assets/data/narrative/${introId}.json`).then(r => r.json()); - - // We'll simulate the event listener logic - const onEnd = () => { - narrativeManager.removeEventListener('narrative-end', onEnd); - resolve(); - }; - narrativeManager.addEventListener('narrative-end', onEnd); + // Map narrative ID to filename + // NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json + const narrativeFileName = this._mapNarrativeIdToFileName(introId); - // Trigger the manager (Assuming it has a loader, or we modify it to accept ID) - // For this snippet, we assume startSequence accepts data. - // In a full implementation, you'd load the JSON here. - console.log(`Playing Narrative Intro: ${introId}`); - // narrativeManager.startSequence(loadedJson); - - // Fallback for prototype if no JSON loader: - setTimeout(onEnd, 100); // Instant resolve for now to prevent hanging + try { + // Load the narrative JSON file + const response = await fetch(`assets/data/narrative/${narrativeFileName}.json`); + if (!response.ok) { + console.error(`Failed to load narrative: ${narrativeFileName}`); + resolve(); + return; + } + + const narrativeData = await response.json(); + + // Set up listener for narrative end + const onEnd = () => { + narrativeManager.removeEventListener('narrative-end', onEnd); + resolve(); + }; + narrativeManager.addEventListener('narrative-end', onEnd); + + // Start the narrative sequence + console.log(`Playing Narrative Intro: ${introId}`); + narrativeManager.startSequence(narrativeData); + } catch (error) { + console.error(`Error loading narrative ${narrativeFileName}:`, error); + resolve(); // Resolve anyway to not block game start + } }); } + /** + * Maps narrative sequence ID to filename. + * @param {string} narrativeId - The narrative ID from mission config + * @returns {string} The filename (without .json extension) + */ + _mapNarrativeIdToFileName(narrativeId) { + // Convert NARRATIVE_TUTORIAL_INTRO -> tutorial_intro + // Remove NARRATIVE_ prefix and convert to lowercase with underscores + const mapping = { + 'NARRATIVE_TUTORIAL_INTRO': 'tutorial_intro', + 'NARRATIVE_TUTORIAL_SUCCESS': 'tutorial_success' + }; + + return mapping[narrativeId] || narrativeId.toLowerCase().replace('NARRATIVE_', ''); + } + // --- GAMEPLAY LOGIC (Objectives) --- /** diff --git a/src/ui/combat-hud.js b/src/ui/combat-hud.js new file mode 100644 index 0000000..028dbb9 --- /dev/null +++ b/src/ui/combat-hud.js @@ -0,0 +1,101 @@ +import { LitElement, html, css } from "lit"; + +export class CombatHUD extends LitElement { + static get styles() { + return css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + font-family: "Courier New", monospace; + color: white; + } + + .header { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + border: 2px solid #ff0000; + padding: 15px 30px; + text-align: center; + pointer-events: auto; + } + + .status-bar { + margin-top: 5px; + font-size: 1.2rem; + color: #ff6666; + } + + .turn-indicator { + position: absolute; + top: 100px; + left: 30px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #ff0000; + padding: 10px 20px; + font-size: 1rem; + } + + .instructions { + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + border: 1px solid #555; + padding: 10px 20px; + font-size: 0.9rem; + color: #ccc; + text-align: center; + } + `; + } + + static get properties() { + return { + currentState: { type: String }, + currentTurn: { type: String }, + }; + } + + constructor() { + super(); + this.currentState = null; + this.currentTurn = "PLAYER"; + window.addEventListener("gamestate-changed", (e) => { + this.currentState = e.detail.newState; + }); + } + + render() { + // Only show during COMBAT state + if (this.currentState !== "STATE_COMBAT") { + return html``; + } + + return html` +
+

COMBAT ACTIVE

+
Turn: ${this.currentTurn}
+
+ +
+
State: ${this.currentState}
+
+ +
+ Use WASD or Arrow Keys to move cursor | SPACE/ENTER to select +
+ `; + } +} + +customElements.define("combat-hud", CombatHUD); + diff --git a/src/ui/deployment-hud.js b/src/ui/deployment-hud.js index 1eab389..a230f6d 100644 --- a/src/ui/deployment-hud.js +++ b/src/ui/deployment-hud.js @@ -131,6 +131,7 @@ export class DeploymentHUD extends LitElement { deployedIds: { type: Array }, // List of IDs currently on the board selectedId: { type: String }, // ID of unit currently being placed maxUnits: { type: Number }, + currentState: { type: String }, // Current game state }; } @@ -140,12 +141,25 @@ export class DeploymentHUD extends LitElement { this.deployedIds = []; this.selectedId = null; this.maxUnits = 4; + this.currentState = null; window.addEventListener("deployment-update", (e) => { this.deployedIds = e.detail.deployedIndices; }); + window.addEventListener("gamestate-changed", (e) => { + this.currentState = e.detail.newState; + }); } render() { + // Hide the deployment HUD when not in deployment state + // Show by default (when currentState is null) since we start in deployment + if ( + this.currentState !== null && + this.currentState !== "STATE_DEPLOYMENT" + ) { + return html``; + } + const deployedCount = this.deployedIds.length; const canStart = deployedCount > 0; // At least 1 unit required diff --git a/src/ui/dialogue-overlay.js b/src/ui/dialogue-overlay.js index 3c7a1c5..86a57f2 100644 --- a/src/ui/dialogue-overlay.js +++ b/src/ui/dialogue-overlay.js @@ -1,5 +1,5 @@ import { LitElement, html, css } from "lit"; -import { narrativeManager } from "../../systems/NarrativeManager.js"; +import { narrativeManager } from "../managers/NarrativeManager.js"; export class DialogueOverlay extends LitElement { static get styles() { diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index 902b58b..300f396 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -3,6 +3,8 @@ import { gameStateManager } from "../core/GameStateManager.js"; import { GameLoop } from "../core/GameLoop.js"; import "./deployment-hud.js"; +import "./dialogue-overlay.js"; +import "./combat-hud.js"; export class GameViewport extends LitElement { static styles = css` @@ -37,6 +39,12 @@ export class GameViewport extends LitElement { gameStateManager.gameLoop.selectDeploymentUnit(index); } + #handleStartBattle() { + if (gameStateManager.gameLoop) { + gameStateManager.gameLoop.finalizeDeployment(); + } + } + async firstUpdated() { const container = this.shadowRoot.getElementById("canvas-container"); const loop = new GameLoop(); @@ -51,7 +59,10 @@ export class GameViewport extends LitElement { .squad=${this.squad} .deployedIds=${this.deployedIds} @unit-selected=${this.#handleUnitSelected} - >`; + @start-battle=${this.#handleStartBattle} + > + + `; } } diff --git a/test/core/GameLoop.test.js b/test/core/GameLoop.test.js index a0a1fe8..f968c6b 100644 --- a/test/core/GameLoop.test.js +++ b/test/core/GameLoop.test.js @@ -73,6 +73,12 @@ describe("Core: GameLoop (Integration)", function () { squad: [{ id: "u1", classId: "CLASS_VANGUARD" }], }; + // Mock gameStateManager for deployment phase + gameLoop.gameStateManager = { + currentState: "STATE_DEPLOYMENT", + transitionTo: sinon.stub(), + }; + // startLevel should now prepare the map but NOT spawn units immediately await gameLoop.startLevel(runData); diff --git a/test/core/GameStateManager.test.js b/test/core/GameStateManager.test.js index 4144d6b..6061ae7 100644 --- a/test/core/GameStateManager.test.js +++ b/test/core/GameStateManager.test.js @@ -19,6 +19,8 @@ describe("Core: GameStateManager (Singleton)", () => { init: sinon.stub().resolves(), saveRun: sinon.stub().resolves(), loadRun: sinon.stub().resolves(null), + loadRoster: sinon.stub().resolves(null), + saveRoster: sinon.stub().resolves(), }; // Inject Mock (replacing the real Persistence instance) gameStateManager.persistence = mockPersistence; @@ -29,6 +31,23 @@ describe("Core: GameStateManager (Singleton)", () => { startLevel: sinon.spy(), stop: sinon.spy(), }; + + // 4. Mock MissionManager + gameStateManager.missionManager = { + setupActiveMission: sinon.stub(), + getActiveMission: sinon.stub().returns({ + id: "MISSION_TUTORIAL_01", + config: { title: "Test Mission" }, + biome: { + generator_config: { + seed_type: "RANDOM", + seed: 12345, + }, + }, + objectives: [], + }), + playIntro: sinon.stub().resolves(), + }; }); it("CoA 1: Should initialize and transition to MAIN_MENU", async () => { @@ -60,20 +79,14 @@ describe("Core: GameStateManager (Singleton)", () => { const mockSquad = [{ id: "u1" }]; - // Handle Async Chain - let resolveEngineStart; - const engineStartPromise = new Promise((r) => { - resolveEngineStart = r; - }); - mockGameLoop.startLevel = sinon.stub().callsFake(() => { - resolveEngineStart(); - }); + // Mock startLevel to resolve immediately + mockGameLoop.startLevel = sinon.stub().resolves(); - gameStateManager.handleEmbark({ detail: { squad: mockSquad } }); - await engineStartPromise; + // Await the full async chain + await gameStateManager.handleEmbark({ detail: { squad: mockSquad } }); expect(gameStateManager.currentState).to.equal( - GameStateManager.STATES.GAME_RUN + GameStateManager.STATES.DEPLOYMENT ); expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData)) .to.be.true;