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;