From 17590cdab04e3023c22690521f016e83b590eb8c Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Mon, 22 Dec 2025 14:34:43 -0800 Subject: [PATCH] Add combat state management to GameLoop and GameStateManager. Implement updateCombatState method to manage turn queue and active unit status during combat. Integrate combat state updates in GameViewport for UI responsiveness. Enhance type definitions for combat-related structures. --- src/core/GameLoop.js | 119 +++++++++++++++++++++++++ src/core/GameStateManager.js | 33 ++++++- src/ui/game-viewport.js | 27 +++++- test/core/Persistence.test.js | 35 +++++--- test/generation/PostProcessing.test.js | 3 +- 5 files changed, 200 insertions(+), 17 deletions(-) diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 0790ae6..44ac2fb 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -2,6 +2,9 @@ * @typedef {import("./types.js").RunData} RunData * @typedef {import("../grid/types.js").Position} Position * @typedef {import("../units/Unit.js").Unit} Unit + * @typedef {import("../ui/combat-hud.d.ts").CombatState} CombatState + * @typedef {import("../ui/combat-hud.d.ts").UnitStatus} UnitStatus + * @typedef {import("../ui/combat-hud.d.ts").QueueEntry} QueueEntry */ import * as THREE from "three"; @@ -476,6 +479,9 @@ export class GameLoop { this.gameStateManager.transitionTo("STATE_COMBAT"); } + // Initialize combat state + this.updateCombatState(); + console.log("Combat Started!"); } @@ -594,4 +600,117 @@ export class GameLoop { if (this.inputManager) this.inputManager.detach(); if (this.controls) this.controls.dispose(); } + + /** + * Updates the combat state in GameStateManager. + * Called when combat starts or when combat state changes (turn changes, etc.) + */ + updateCombatState() { + if (!this.gameStateManager || !this.grid || !this.unitManager) { + return; + } + + // Get all units from the grid + const allUnits = Array.from(this.grid.unitMap.values()).filter( + (unit) => unit.isAlive && unit.isAlive() + ); + + if (allUnits.length === 0) { + // No units, clear combat state + this.gameStateManager.setCombatState(null); + return; + } + + // Build turn queue sorted by initiative (chargeMeter) + const turnQueue = allUnits + .map((unit) => { + // Get portrait path (placeholder for now) + const portrait = + unit.team === "PLAYER" + ? "/assets/images/portraits/default.png" + : "/assets/images/portraits/enemy.png"; + + return { + unitId: unit.id, + portrait: portrait, + team: unit.team || "ENEMY", + initiative: unit.chargeMeter || 0, + }; + }) + .sort((a, b) => b.initiative - a.initiative); // Sort by initiative descending + + // Get active unit (first in queue) + const activeUnitId = turnQueue.length > 0 ? turnQueue[0].unitId : null; + const activeUnit = allUnits.find((u) => u.id === activeUnitId); + + // Build active unit status if we have an active unit + let unitStatus = null; + if (activeUnit) { + // Get max AP (default to 10 for now, can be derived from stats later) + const maxAP = 10; + + // Convert status effects to status icons + const statuses = (activeUnit.statusEffects || []).map((effect) => ({ + id: effect.id || "unknown", + icon: effect.icon || "❓", + turnsRemaining: effect.duration || 0, + description: effect.description || effect.name || "Status Effect", + })); + + // Build skills (placeholder for now - will be populated from unit's actions/skill tree) + const skills = (activeUnit.actions || []).map((action, index) => ({ + id: action.id || `skill_${index}`, + name: action.name || "Unknown Skill", + icon: action.icon || "⚔", + costAP: action.costAP || 0, + cooldown: action.cooldown || 0, + isAvailable: + activeUnit.currentAP >= (action.costAP || 0) && + (action.cooldown || 0) === 0, + })); + + // If no skills from actions, provide a default attack skill + if (skills.length === 0) { + skills.push({ + id: "attack", + name: "Attack", + icon: "⚔", + costAP: 3, + cooldown: 0, + isAvailable: activeUnit.currentAP >= 3, + }); + } + + unitStatus = { + id: activeUnit.id, + name: activeUnit.name, + portrait: + activeUnit.team === "PLAYER" + ? "/assets/images/portraits/default.png" + : "/assets/images/portraits/enemy.png", + hp: { + current: activeUnit.currentHealth, + max: activeUnit.maxHealth, + }, + ap: { + current: activeUnit.currentAP, + max: maxAP, + }, + charge: activeUnit.chargeMeter || 0, + statuses: statuses, + skills: skills, + }; + } + + // Build combat state + const combatState = { + activeUnit: unitStatus, + turnQueue: turnQueue, + targetingMode: false, // Will be set when player selects a skill + roundNumber: 1, // TODO: Track actual round number + }; + + // Update GameStateManager + this.gameStateManager.setCombatState(combatState); + } } diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index a748328..2b75c8f 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -3,6 +3,7 @@ * @typedef {import("./types.js").RunData} RunData * @typedef {import("./types.js").EmbarkEventDetail} EmbarkEventDetail * @typedef {import("./types.js").SquadMember} SquadMember + * @typedef {import("../ui/combat-hud.d.ts").CombatState} CombatState */ import { Persistence } from "./Persistence.js"; @@ -42,6 +43,8 @@ class GameStateManagerClass { this.persistence = new Persistence(); /** @type {RunData | null} */ this.activeRunData = null; + /** @type {CombatState | null} */ + this.combatState = null; // Integrate Core Managers /** @type {RosterManager} */ @@ -91,6 +94,7 @@ class GameStateManagerClass { this.currentState = GameStateManagerClass.STATES.INIT; this.gameLoop = null; this.activeRunData = null; + this.combatState = null; this.rosterManager = new RosterManager(); this.missionManager = new MissionManager(); // Reset promise resolvers @@ -150,8 +154,12 @@ class GameStateManagerClass { break; case GameStateManagerClass.STATES.DEPLOYMENT: + // Clear combat state when leaving combat + this.combatState = null; + break; + case GameStateManagerClass.STATES.COMBAT: - // These states are handled by GameLoop, no special logic needed here + // Combat state will be managed by GameLoop via setCombatState() break; } } @@ -294,6 +302,29 @@ class GameStateManagerClass { const data = this.rosterManager.save(); await this.persistence.saveRoster(data); } + + /** + * Sets the current combat state. + * Called by GameLoop when combat state changes. + * @param {CombatState | null} combatState - The new combat state + */ + setCombatState(combatState) { + this.combatState = combatState; + // Dispatch event so UI components can react to combat state changes + window.dispatchEvent( + new CustomEvent("combat-state-changed", { + detail: { combatState }, + }) + ); + } + + /** + * Gets the current combat state. + * @returns {CombatState | null} The current combat state + */ + getCombatState() { + return this.combatState; + } } // Export the Singleton Instance diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index 300f396..8305ab7 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -24,6 +24,7 @@ export class GameViewport extends LitElement { return { squad: { type: Array }, deployedIds: { type: Array }, + combatState: { type: Object }, }; } @@ -31,6 +32,7 @@ export class GameViewport extends LitElement { super(); this.squad = []; this.deployedIds = []; + this.combatState = null; } #handleUnitSelected(event) { @@ -51,6 +53,29 @@ export class GameViewport extends LitElement { loop.init(container); gameStateManager.setGameLoop(loop); this.squad = await gameStateManager.rosterLoaded; + + // Set up combat state updates + this.#setupCombatStateUpdates(); + } + + #setupCombatStateUpdates() { + // Listen for combat state changes + window.addEventListener("combat-state-changed", (e) => { + this.combatState = e.detail.combatState; + }); + + // Listen for game state changes to clear combat state when leaving combat + window.addEventListener("gamestate-changed", () => { + this.#updateCombatState(); + }); + + // Initial update + this.#updateCombatState(); + } + + #updateCombatState() { + // Get combat state from GameStateManager + this.combatState = gameStateManager.getCombatState(); } render() { @@ -61,7 +86,7 @@ export class GameViewport extends LitElement { @unit-selected=${this.#handleUnitSelected} @start-battle=${this.#handleStartBattle} > - + `; } } diff --git a/test/core/Persistence.test.js b/test/core/Persistence.test.js index fdf0c2e..4301c68 100644 --- a/test/core/Persistence.test.js +++ b/test/core/Persistence.test.js @@ -33,7 +33,12 @@ describe("Core: Persistence", () => { }; // Use window or self for browser environment - globalObj = typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : globalThis); + globalObj = + typeof window !== "undefined" + ? window + : typeof self !== "undefined" + ? self + : globalThis; }); const createMockRequest = () => { @@ -46,7 +51,7 @@ describe("Core: Persistence", () => { }; // Mock indexedDB using defineProperty since it's read-only - Object.defineProperty(globalObj, 'indexedDB', { + Object.defineProperty(globalObj, "indexedDB", { value: { open: sinon.stub().returns(mockRequest), }, @@ -59,15 +64,15 @@ describe("Core: Persistence", () => { it("CoA 1: init should create database and object stores", async () => { const request = createMockRequest(); - + // Start init, which will call indexedDB.open const initPromise = persistence.init(); - + // Trigger upgrade first (happens synchronously during open) if (request.onupgradeneeded) { request.onupgradeneeded({ target: { result: mockDB } }); } - + // Then trigger success if (request.onsuccess) { request.onsuccess({ target: { result: mockDB } }); @@ -76,8 +81,10 @@ describe("Core: Persistence", () => { await initPromise; expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 2)).to.be.true; - expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be.true; - expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to.be.true; + expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be + .true; + expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to + .be.true; expect(persistence.db).to.equal(mockDB); }); @@ -166,7 +173,10 @@ describe("Core: Persistence", () => { it("CoA 6: loadRoster should extract data from stored object", async () => { persistence.db = mockDB; - const storedData = { id: "player_roster", data: { roster: [], graveyard: [] } }; + const storedData = { + id: "player_roster", + data: { roster: [], graveyard: [] }, + }; const mockGetRequest = { onsuccess: null, @@ -205,7 +215,7 @@ describe("Core: Persistence", () => { it("CoA 8: saveRun should auto-init if db not initialized", async () => { const request = createMockRequest(); - + const runData = { seed: 12345 }; const mockPutRequest = { onsuccess: sinon.stub(), @@ -215,7 +225,7 @@ describe("Core: Persistence", () => { // Start saveRun, which will trigger init const savePromise = persistence.saveRun(runData); - + // Trigger upgrade and success for init if (request.onupgradeneeded) { request.onupgradeneeded({ target: { result: mockDB } }); @@ -223,9 +233,9 @@ describe("Core: Persistence", () => { if (request.onsuccess) { request.onsuccess({ target: { result: mockDB } }); } - + // Wait a bit for init to complete, then trigger put success - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); mockPutRequest.onsuccess(); await savePromise; @@ -234,4 +244,3 @@ describe("Core: Persistence", () => { expect(mockStore.put.calledOnce).to.be.true; }); }); - diff --git a/test/generation/PostProcessing.test.js b/test/generation/PostProcessing.test.js index 6ca5199..d647ec3 100644 --- a/test/generation/PostProcessing.test.js +++ b/test/generation/PostProcessing.test.js @@ -84,7 +84,7 @@ describe("Generation: PostProcessor", () => { it("CoA 4: floodFill should not include disconnected tiles", () => { // Fill entire grid with solid first to isolate regions grid.fill(1); - + // Create two separate regions with proper floor setup // Region 1: connected tiles grid.setCell(1, 0, 1, 1); // Floor @@ -155,4 +155,3 @@ describe("Generation: PostProcessor", () => { expect(airCountAfter).to.be.greaterThan(0); }); }); -