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); }); }); -