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("../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);
}
}

View file

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

View file

@ -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}
></deployment-hud>
<combat-hud></combat-hud>
<combat-hud .combatState=${this.combatState}></combat-hud>
<dialogue-overlay></dialogue-overlay>`;
}
}

View file

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

View file

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