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.

This commit is contained in:
Matthew Mone 2025-12-21 21:20:33 -08:00
parent 5797d9ac68
commit da55cafc8f
11 changed files with 310 additions and 65 deletions

View file

@ -6,8 +6,9 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// Image file extensions to copy // File extensions to copy
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]; const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
const DATA_EXTENSIONS = [".json"];
// Recursively copy image files from src to dist // Recursively copy image files from src to dist
function copyImages(srcDir, distDir) { 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 // Ensure dist directory exists
mkdirSync("dist", { recursive: true }); mkdirSync("dist", { recursive: true });
@ -53,4 +78,7 @@ copyFileSync("src/index.html", "dist/index.html");
// Copy images // Copy images
copyImages("src", "dist"); copyImages("src", "dist");
// Copy assets (JSON, markdown, TypeScript definitions)
copyAssets("src/assets", "dist/assets");
console.log("Build complete!"); console.log("Build complete!");

View file

@ -11,7 +11,6 @@ import { MissionManager } from "../managers/MissionManager.js";
export class GameLoop { export class GameLoop {
constructor() { constructor() {
this.isRunning = false; this.isRunning = false;
this.phase = "INIT";
// 1. Core Systems // 1. Core Systems
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
@ -184,7 +183,10 @@ export class GameLoop {
const cursor = this.inputManager.getCursorPosition(); const cursor = this.inputManager.getCursorPosition();
console.log("Action at:", cursor); console.log("Action at:", cursor);
if (this.phase === "DEPLOYMENT") { if (
this.gameStateManager &&
this.gameStateManager.currentState === "STATE_DEPLOYMENT"
) {
const selIndex = this.deploymentState.selectedUnitIndex; const selIndex = this.deploymentState.selectedUnitIndex;
if (selIndex !== -1) { if (selIndex !== -1) {
@ -227,7 +229,6 @@ export class GameLoop {
console.log("GameLoop: Generating Level..."); console.log("GameLoop: Generating Level...");
this.runData = runData; this.runData = runData;
this.isRunning = true; this.isRunning = true;
this.phase = "DEPLOYMENT";
this.clearUnitMeshes(); this.clearUnitMeshes();
// Reset Deployment State // Reset Deployment State
@ -299,7 +300,11 @@ export class GameLoop {
} }
deployUnit(unitDef, targetTile, existingUnit = null) { 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( const isValid = this.validateDeploymentCursor(
targetTile.x, targetTile.x,
@ -358,7 +363,11 @@ export class GameLoop {
} }
finalizeDeployment() { finalizeDeployment() {
if (this.phase !== "DEPLOYMENT") return; if (
!this.gameStateManager ||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
)
return;
const enemyCount = 2; const enemyCount = 2;
for (let i = 0; i < enemyCount; i++) { for (let i = 0; i < enemyCount; i++) {
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length); const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
@ -370,11 +379,15 @@ export class GameLoop {
this.enemySpawnZone.splice(spotIndex, 1); this.enemySpawnZone.splice(spotIndex, 1);
} }
} }
this.phase = "ACTIVE";
// Switch to standard movement validator for the game // Switch to standard movement validator for the game
this.inputManager.setValidator(this.validateCursorMove.bind(this)); this.inputManager.setValidator(this.validateCursorMove.bind(this));
// Notify GameStateManager about state change
if (this.gameStateManager) {
this.gameStateManager.transitionTo("STATE_COMBAT");
}
console.log("Combat Started!"); console.log("Combat Started!");
} }

View file

@ -8,7 +8,8 @@ class GameStateManagerClass {
INIT: "STATE_INIT", INIT: "STATE_INIT",
MAIN_MENU: "STATE_MAIN_MENU", MAIN_MENU: "STATE_MAIN_MENU",
TEAM_BUILDER: "STATE_TEAM_BUILDER", TEAM_BUILDER: "STATE_TEAM_BUILDER",
GAME_RUN: "STATE_GAME_RUN", DEPLOYMENT: "STATE_DEPLOYMENT",
COMBAT: "STATE_COMBAT",
}; };
constructor() { constructor() {
@ -40,6 +41,18 @@ class GameStateManagerClass {
this.#gameLoopInitialized.resolve(); 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() { async init() {
console.log("System: Initializing State Manager..."); console.log("System: Initializing State Manager...");
await this.persistence.init(); await this.persistence.init();
@ -58,9 +71,14 @@ class GameStateManagerClass {
} }
async transitionTo(newState, payload = null) { async transitionTo(newState, payload = null) {
console.log(`State Transition: ${this.currentState} -> ${newState}`);
const oldState = this.currentState; const oldState = this.currentState;
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; this.currentState = newState;
}
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("gamestate-changed", { new CustomEvent("gamestate-changed", {
@ -68,21 +86,21 @@ class GameStateManagerClass {
}) })
); );
// Only run state-specific logic if state actually changed
if (stateChanged) {
switch (newState) { switch (newState) {
case GameStateManagerClass.STATES.MAIN_MENU: case GameStateManagerClass.STATES.MAIN_MENU:
if (this.gameLoop) this.gameLoop.stop(); if (this.gameLoop) this.gameLoop.stop();
await this._checkSaveGame(); await this._checkSaveGame();
break; break;
case GameStateManagerClass.STATES.GAME_RUN: case GameStateManagerClass.STATES.DEPLOYMENT:
if (!this.activeRunData && payload) { case GameStateManagerClass.STATES.COMBAT:
await this._initializeRun(payload); // These states are handled by GameLoop, no special logic needed here
} else {
await this._resumeRun();
}
break; break;
} }
} }
}
startNewGame() { startNewGame() {
this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER); this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER);
@ -92,11 +110,12 @@ class GameStateManagerClass {
const save = await this.persistence.loadRun(); const save = await this.persistence.loadRun();
if (save) { if (save) {
this.activeRunData = 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) // Handle Draft Mode (New Recruits)
if (e.detail.mode === "DRAFT") { if (e.detail.mode === "DRAFT") {
e.detail.squad.forEach((unit) => { e.detail.squad.forEach((unit) => {
@ -106,7 +125,8 @@ class GameStateManagerClass {
}); });
this._saveRoster(); 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 --- // --- INTERNAL HELPERS ---
@ -147,7 +167,11 @@ class GameStateManagerClass {
// Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc) // Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc)
this.gameLoop.missionManager = this.missionManager; 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() { async _resumeRun() {
@ -155,8 +179,13 @@ class GameStateManagerClass {
if (this.activeRunData) { if (this.activeRunData) {
// Re-hook the mission manager // Re-hook the mission manager
this.gameLoop.missionManager = this.missionManager; 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 // 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);
} }
} }

View file

@ -20,7 +20,8 @@ window.addEventListener("gamestate-changed", async (e) => {
case "STATE_TEAM_BUILDER": case "STATE_TEAM_BUILDER":
loadingMessage.textContent = "INITIALIZING TEAM BUILDER..."; loadingMessage.textContent = "INITIALIZING TEAM BUILDER...";
break; break;
case "STATE_GAME_RUN": case "STATE_DEPLOYMENT":
case "STATE_COMBAT":
loadingMessage.textContent = "INITIALIZING GAME ENGINE..."; loadingMessage.textContent = "INITIALIZING GAME ENGINE...";
break; break;
} }
@ -37,7 +38,8 @@ window.addEventListener("gamestate-changed", async (e) => {
await import("./ui/team-builder.js"); await import("./ui/team-builder.js");
teamBuilder.toggleAttribute("hidden", false); teamBuilder.toggleAttribute("hidden", false);
break; break;
case "STATE_GAME_RUN": case "STATE_DEPLOYMENT":
case "STATE_COMBAT":
await import("./ui/game-viewport.js"); await import("./ui/game-viewport.js");
gameViewport.toggleAttribute("hidden", false); gameViewport.toggleAttribute("hidden", false);
break; break;
@ -53,11 +55,13 @@ window.addEventListener("save-check-complete", (e) => {
} }
}); });
btnNewRun.addEventListener("click", async () => { // Set up embark listener once (not inside button click)
teamBuilder.addEventListener("embark", async (e) => { teamBuilder.addEventListener("embark", async (e) => {
gameStateManager.handleEmbark(e); await gameStateManager.handleEmbark(e);
gameViewport.squad = teamBuilder.squad; gameViewport.squad = teamBuilder.squad;
}); });
btnNewRun.addEventListener("click", async () => {
gameStateManager.startNewGame(); gameStateManager.startNewGame();
}); });

View file

@ -77,31 +77,57 @@ export class MissionManager {
return Promise.resolve(); return Promise.resolve();
} }
return new Promise((resolve) => { return new Promise(async (resolve) => {
const introId = this.currentMissionDef.narrative.intro_sequence; const introId = this.currentMissionDef.narrative.intro_sequence;
// Mock loader: In real app, fetch the JSON from assets/data/narrative/ // Map narrative ID to filename
// For prototype, we'll assume narrativeManager can handle the ID or we pass a mock. // NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.json
// const narrativeData = await fetch(`assets/data/narrative/${introId}.json`).then(r => r.json()); const narrativeFileName = this._mapNarrativeIdToFileName(introId);
// We'll simulate the event listener logic 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 = () => { const onEnd = () => {
narrativeManager.removeEventListener('narrative-end', onEnd); narrativeManager.removeEventListener('narrative-end', onEnd);
resolve(); resolve();
}; };
narrativeManager.addEventListener('narrative-end', onEnd); narrativeManager.addEventListener('narrative-end', onEnd);
// Trigger the manager (Assuming it has a loader, or we modify it to accept ID) // Start the narrative sequence
// For this snippet, we assume startSequence accepts data.
// In a full implementation, you'd load the JSON here.
console.log(`Playing Narrative Intro: ${introId}`); console.log(`Playing Narrative Intro: ${introId}`);
// narrativeManager.startSequence(loadedJson); narrativeManager.startSequence(narrativeData);
} catch (error) {
// Fallback for prototype if no JSON loader: console.error(`Error loading narrative ${narrativeFileName}:`, error);
setTimeout(onEnd, 100); // Instant resolve for now to prevent hanging 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) --- // --- GAMEPLAY LOGIC (Objectives) ---
/** /**

101
src/ui/combat-hud.js Normal file
View file

@ -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`
<div class="header">
<h2>COMBAT ACTIVE</h2>
<div class="status-bar">Turn: ${this.currentTurn}</div>
</div>
<div class="turn-indicator">
<div>State: ${this.currentState}</div>
</div>
<div class="instructions">
Use WASD or Arrow Keys to move cursor | SPACE/ENTER to select
</div>
`;
}
}
customElements.define("combat-hud", CombatHUD);

View file

@ -131,6 +131,7 @@ export class DeploymentHUD extends LitElement {
deployedIds: { type: Array }, // List of IDs currently on the board deployedIds: { type: Array }, // List of IDs currently on the board
selectedId: { type: String }, // ID of unit currently being placed selectedId: { type: String }, // ID of unit currently being placed
maxUnits: { type: Number }, maxUnits: { type: Number },
currentState: { type: String }, // Current game state
}; };
} }
@ -140,12 +141,25 @@ export class DeploymentHUD extends LitElement {
this.deployedIds = []; this.deployedIds = [];
this.selectedId = null; this.selectedId = null;
this.maxUnits = 4; this.maxUnits = 4;
this.currentState = null;
window.addEventListener("deployment-update", (e) => { window.addEventListener("deployment-update", (e) => {
this.deployedIds = e.detail.deployedIndices; this.deployedIds = e.detail.deployedIndices;
}); });
window.addEventListener("gamestate-changed", (e) => {
this.currentState = e.detail.newState;
});
} }
render() { 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 deployedCount = this.deployedIds.length;
const canStart = deployedCount > 0; // At least 1 unit required const canStart = deployedCount > 0; // At least 1 unit required

View file

@ -1,5 +1,5 @@
import { LitElement, html, css } from "lit"; import { LitElement, html, css } from "lit";
import { narrativeManager } from "../../systems/NarrativeManager.js"; import { narrativeManager } from "../managers/NarrativeManager.js";
export class DialogueOverlay extends LitElement { export class DialogueOverlay extends LitElement {
static get styles() { static get styles() {

View file

@ -3,6 +3,8 @@ import { gameStateManager } from "../core/GameStateManager.js";
import { GameLoop } from "../core/GameLoop.js"; import { GameLoop } from "../core/GameLoop.js";
import "./deployment-hud.js"; import "./deployment-hud.js";
import "./dialogue-overlay.js";
import "./combat-hud.js";
export class GameViewport extends LitElement { export class GameViewport extends LitElement {
static styles = css` static styles = css`
@ -37,6 +39,12 @@ export class GameViewport extends LitElement {
gameStateManager.gameLoop.selectDeploymentUnit(index); gameStateManager.gameLoop.selectDeploymentUnit(index);
} }
#handleStartBattle() {
if (gameStateManager.gameLoop) {
gameStateManager.gameLoop.finalizeDeployment();
}
}
async firstUpdated() { async firstUpdated() {
const container = this.shadowRoot.getElementById("canvas-container"); const container = this.shadowRoot.getElementById("canvas-container");
const loop = new GameLoop(); const loop = new GameLoop();
@ -51,7 +59,10 @@ export class GameViewport extends LitElement {
.squad=${this.squad} .squad=${this.squad}
.deployedIds=${this.deployedIds} .deployedIds=${this.deployedIds}
@unit-selected=${this.#handleUnitSelected} @unit-selected=${this.#handleUnitSelected}
></deployment-hud>`; @start-battle=${this.#handleStartBattle}
></deployment-hud>
<combat-hud></combat-hud>
<dialogue-overlay></dialogue-overlay>`;
} }
} }

View file

@ -73,6 +73,12 @@ describe("Core: GameLoop (Integration)", function () {
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }], 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 // startLevel should now prepare the map but NOT spawn units immediately
await gameLoop.startLevel(runData); await gameLoop.startLevel(runData);

View file

@ -19,6 +19,8 @@ describe("Core: GameStateManager (Singleton)", () => {
init: sinon.stub().resolves(), init: sinon.stub().resolves(),
saveRun: sinon.stub().resolves(), saveRun: sinon.stub().resolves(),
loadRun: sinon.stub().resolves(null), loadRun: sinon.stub().resolves(null),
loadRoster: sinon.stub().resolves(null),
saveRoster: sinon.stub().resolves(),
}; };
// Inject Mock (replacing the real Persistence instance) // Inject Mock (replacing the real Persistence instance)
gameStateManager.persistence = mockPersistence; gameStateManager.persistence = mockPersistence;
@ -29,6 +31,23 @@ describe("Core: GameStateManager (Singleton)", () => {
startLevel: sinon.spy(), startLevel: sinon.spy(),
stop: 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 () => { it("CoA 1: Should initialize and transition to MAIN_MENU", async () => {
@ -60,20 +79,14 @@ describe("Core: GameStateManager (Singleton)", () => {
const mockSquad = [{ id: "u1" }]; const mockSquad = [{ id: "u1" }];
// Handle Async Chain // Mock startLevel to resolve immediately
let resolveEngineStart; mockGameLoop.startLevel = sinon.stub().resolves();
const engineStartPromise = new Promise((r) => {
resolveEngineStart = r;
});
mockGameLoop.startLevel = sinon.stub().callsFake(() => {
resolveEngineStart();
});
gameStateManager.handleEmbark({ detail: { squad: mockSquad } }); // Await the full async chain
await engineStartPromise; await gameStateManager.handleEmbark({ detail: { squad: mockSquad } });
expect(gameStateManager.currentState).to.equal( expect(gameStateManager.currentState).to.equal(
GameStateManager.STATES.GAME_RUN GameStateManager.STATES.DEPLOYMENT
); );
expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData)) expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData))
.to.be.true; .to.be.true;