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 __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!");

View file

@ -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!");
}

View file

@ -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;
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,21 +86,21 @@ class GameStateManagerClass {
})
);
// 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();
}
case GameStateManagerClass.STATES.DEPLOYMENT:
case GameStateManagerClass.STATES.COMBAT:
// These states are handled by GameLoop, no special logic needed here
break;
}
}
}
startNewGame() {
this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER);
@ -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);
}
}

View file

@ -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) => {
}
});
btnNewRun.addEventListener("click", async () => {
teamBuilder.addEventListener("embark", async (e) => {
gameStateManager.handleEmbark(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 () => {
gameStateManager.startNewGame();
});

View file

@ -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());
// Map narrative ID to filename
// NARRATIVE_TUTORIAL_INTRO -> tutorial_intro.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 = () => {
narrativeManager.removeEventListener('narrative-end', onEnd);
resolve();
};
narrativeManager.addEventListener('narrative-end', onEnd);
// 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.
// Start the narrative sequence
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
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) ---
/**

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
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

View file

@ -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() {

View file

@ -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}
></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" }],
};
// 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);

View file

@ -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;