Compare commits

...

2 commits

5 changed files with 238 additions and 31 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

@ -32,7 +32,17 @@ describe("Core: Persistence", () => {
transaction: sinon.stub().returns(mockTransaction),
};
// Mock indexedDB.open
// Use window or self for browser environment
globalObj =
typeof window !== "undefined"
? window
: typeof self !== "undefined"
? self
: globalThis;
});
const createMockRequest = () => {
// Mock indexedDB.open - create a new request each time
mockRequest = {
onerror: null,
onsuccess: null,
@ -40,34 +50,41 @@ describe("Core: Persistence", () => {
result: mockDB,
};
// Use window or self for browser environment
globalObj = typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : globalThis);
globalObj.indexedDB = {
open: sinon.stub().returns(mockRequest),
};
});
// Mock indexedDB using defineProperty since it's read-only
Object.defineProperty(globalObj, "indexedDB", {
value: {
open: sinon.stub().returns(mockRequest),
},
writable: true,
configurable: true,
});
const triggerSuccess = () => {
if (mockRequest.onsuccess) {
mockRequest.onsuccess({ target: { result: mockDB } });
}
};
const triggerUpgrade = () => {
if (mockRequest.onupgradeneeded) {
mockRequest.onupgradeneeded({ target: { result: mockDB } });
}
return mockRequest;
};
it("CoA 1: init should create database and object stores", async () => {
triggerUpgrade();
triggerSuccess();
const request = createMockRequest();
await persistence.init();
// 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 } });
}
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);
});
@ -156,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,
@ -194,17 +214,28 @@ describe("Core: Persistence", () => {
});
it("CoA 8: saveRun should auto-init if db not initialized", async () => {
triggerUpgrade();
triggerSuccess();
const request = createMockRequest();
const runData = { seed: 12345 };
const mockPutRequest = {
onsuccess: null,
onsuccess: sinon.stub(),
onerror: null,
};
mockStore.put.returns(mockPutRequest);
// 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 } });
}
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));
mockPutRequest.onsuccess();
await savePromise;
@ -213,4 +244,3 @@ describe("Core: Persistence", () => {
expect(mockStore.put.calledOnce).to.be.true;
});
});

View file

@ -82,6 +82,9 @@ 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
@ -89,7 +92,7 @@ describe("Generation: PostProcessor", () => {
grid.setCell(2, 0, 1, 1); // Floor
grid.setCell(2, 1, 1, 0); // Air (connected)
// Region 2: disconnected (no floor connection)
// Region 2: disconnected (surrounded by solid, no path to region 1)
grid.setCell(10, 0, 10, 1); // Floor
grid.setCell(10, 1, 10, 0); // Air (disconnected)
@ -152,4 +155,3 @@ describe("Generation: PostProcessor", () => {
expect(airCountAfter).to.be.greaterThan(0);
});
});