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.

This commit is contained in:
Matthew Mone 2025-12-22 14:34:43 -08:00
parent 1b8775657f
commit 17590cdab0
5 changed files with 200 additions and 17 deletions

View file

@ -2,6 +2,9 @@
* @typedef {import("./types.js").RunData} RunData * @typedef {import("./types.js").RunData} RunData
* @typedef {import("../grid/types.js").Position} Position * @typedef {import("../grid/types.js").Position} Position
* @typedef {import("../units/Unit.js").Unit} Unit * @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"; import * as THREE from "three";
@ -476,6 +479,9 @@ export class GameLoop {
this.gameStateManager.transitionTo("STATE_COMBAT"); this.gameStateManager.transitionTo("STATE_COMBAT");
} }
// Initialize combat state
this.updateCombatState();
console.log("Combat Started!"); console.log("Combat Started!");
} }
@ -594,4 +600,117 @@ export class GameLoop {
if (this.inputManager) this.inputManager.detach(); if (this.inputManager) this.inputManager.detach();
if (this.controls) this.controls.dispose(); 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);
}
} }

View file

@ -3,6 +3,7 @@
* @typedef {import("./types.js").RunData} RunData * @typedef {import("./types.js").RunData} RunData
* @typedef {import("./types.js").EmbarkEventDetail} EmbarkEventDetail * @typedef {import("./types.js").EmbarkEventDetail} EmbarkEventDetail
* @typedef {import("./types.js").SquadMember} SquadMember * @typedef {import("./types.js").SquadMember} SquadMember
* @typedef {import("../ui/combat-hud.d.ts").CombatState} CombatState
*/ */
import { Persistence } from "./Persistence.js"; import { Persistence } from "./Persistence.js";
@ -42,6 +43,8 @@ class GameStateManagerClass {
this.persistence = new Persistence(); this.persistence = new Persistence();
/** @type {RunData | null} */ /** @type {RunData | null} */
this.activeRunData = null; this.activeRunData = null;
/** @type {CombatState | null} */
this.combatState = null;
// Integrate Core Managers // Integrate Core Managers
/** @type {RosterManager} */ /** @type {RosterManager} */
@ -91,6 +94,7 @@ class GameStateManagerClass {
this.currentState = GameStateManagerClass.STATES.INIT; this.currentState = GameStateManagerClass.STATES.INIT;
this.gameLoop = null; this.gameLoop = null;
this.activeRunData = null; this.activeRunData = null;
this.combatState = null;
this.rosterManager = new RosterManager(); this.rosterManager = new RosterManager();
this.missionManager = new MissionManager(); this.missionManager = new MissionManager();
// Reset promise resolvers // Reset promise resolvers
@ -150,8 +154,12 @@ class GameStateManagerClass {
break; break;
case GameStateManagerClass.STATES.DEPLOYMENT: case GameStateManagerClass.STATES.DEPLOYMENT:
// Clear combat state when leaving combat
this.combatState = null;
break;
case GameStateManagerClass.STATES.COMBAT: 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; break;
} }
} }
@ -294,6 +302,29 @@ class GameStateManagerClass {
const data = this.rosterManager.save(); const data = this.rosterManager.save();
await this.persistence.saveRoster(data); 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 // Export the Singleton Instance

View file

@ -24,6 +24,7 @@ export class GameViewport extends LitElement {
return { return {
squad: { type: Array }, squad: { type: Array },
deployedIds: { type: Array }, deployedIds: { type: Array },
combatState: { type: Object },
}; };
} }
@ -31,6 +32,7 @@ export class GameViewport extends LitElement {
super(); super();
this.squad = []; this.squad = [];
this.deployedIds = []; this.deployedIds = [];
this.combatState = null;
} }
#handleUnitSelected(event) { #handleUnitSelected(event) {
@ -51,6 +53,29 @@ export class GameViewport extends LitElement {
loop.init(container); loop.init(container);
gameStateManager.setGameLoop(loop); gameStateManager.setGameLoop(loop);
this.squad = await gameStateManager.rosterLoaded; 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() { render() {
@ -61,7 +86,7 @@ export class GameViewport extends LitElement {
@unit-selected=${this.#handleUnitSelected} @unit-selected=${this.#handleUnitSelected}
@start-battle=${this.#handleStartBattle} @start-battle=${this.#handleStartBattle}
></deployment-hud> ></deployment-hud>
<combat-hud></combat-hud> <combat-hud .combatState=${this.combatState}></combat-hud>
<dialogue-overlay></dialogue-overlay>`; <dialogue-overlay></dialogue-overlay>`;
} }
} }

View file

@ -33,7 +33,12 @@ describe("Core: Persistence", () => {
}; };
// Use window or self for browser environment // 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 = () => { const createMockRequest = () => {
@ -46,7 +51,7 @@ describe("Core: Persistence", () => {
}; };
// Mock indexedDB using defineProperty since it's read-only // Mock indexedDB using defineProperty since it's read-only
Object.defineProperty(globalObj, 'indexedDB', { Object.defineProperty(globalObj, "indexedDB", {
value: { value: {
open: sinon.stub().returns(mockRequest), open: sinon.stub().returns(mockRequest),
}, },
@ -76,8 +81,10 @@ describe("Core: Persistence", () => {
await initPromise; await initPromise;
expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 2)).to.be.true; expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 2)).to.be.true;
expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be.true; expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be
expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to.be.true; .true;
expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to
.be.true;
expect(persistence.db).to.equal(mockDB); expect(persistence.db).to.equal(mockDB);
}); });
@ -166,7 +173,10 @@ describe("Core: Persistence", () => {
it("CoA 6: loadRoster should extract data from stored object", async () => { it("CoA 6: loadRoster should extract data from stored object", async () => {
persistence.db = mockDB; persistence.db = mockDB;
const storedData = { id: "player_roster", data: { roster: [], graveyard: [] } }; const storedData = {
id: "player_roster",
data: { roster: [], graveyard: [] },
};
const mockGetRequest = { const mockGetRequest = {
onsuccess: null, onsuccess: null,
@ -225,7 +235,7 @@ describe("Core: Persistence", () => {
} }
// Wait a bit for init to complete, then trigger put success // 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(); mockPutRequest.onsuccess();
await savePromise; await savePromise;
@ -234,4 +244,3 @@ describe("Core: Persistence", () => {
expect(mockStore.put.calledOnce).to.be.true; expect(mockStore.put.calledOnce).to.be.true;
}); });
}); });

View file

@ -155,4 +155,3 @@ describe("Generation: PostProcessor", () => {
expect(airCountAfter).to.be.greaterThan(0); expect(airCountAfter).to.be.greaterThan(0);
}); });
}); });