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