Compare commits
2 commits
095bd778fd
...
17590cdab0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17590cdab0 | ||
|
|
1b8775657f |
5 changed files with 238 additions and 31 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,17 @@ describe("Core: Persistence", () => {
|
||||||
transaction: sinon.stub().returns(mockTransaction),
|
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 = {
|
mockRequest = {
|
||||||
onerror: null,
|
onerror: null,
|
||||||
onsuccess: null,
|
onsuccess: null,
|
||||||
|
|
@ -40,34 +50,41 @@ describe("Core: Persistence", () => {
|
||||||
result: mockDB,
|
result: mockDB,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use window or self for browser environment
|
// Mock indexedDB using defineProperty since it's read-only
|
||||||
globalObj = typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : globalThis);
|
Object.defineProperty(globalObj, "indexedDB", {
|
||||||
globalObj.indexedDB = {
|
value: {
|
||||||
open: sinon.stub().returns(mockRequest),
|
open: sinon.stub().returns(mockRequest),
|
||||||
};
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerSuccess = () => {
|
return mockRequest;
|
||||||
if (mockRequest.onsuccess) {
|
|
||||||
mockRequest.onsuccess({ target: { result: mockDB } });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerUpgrade = () => {
|
|
||||||
if (mockRequest.onupgradeneeded) {
|
|
||||||
mockRequest.onupgradeneeded({ target: { result: mockDB } });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("CoA 1: init should create database and object stores", async () => {
|
it("CoA 1: init should create database and object stores", async () => {
|
||||||
triggerUpgrade();
|
const request = createMockRequest();
|
||||||
triggerSuccess();
|
|
||||||
|
|
||||||
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(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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -156,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,
|
||||||
|
|
@ -194,17 +214,28 @@ describe("Core: Persistence", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 8: saveRun should auto-init if db not initialized", async () => {
|
it("CoA 8: saveRun should auto-init if db not initialized", async () => {
|
||||||
triggerUpgrade();
|
const request = createMockRequest();
|
||||||
triggerSuccess();
|
|
||||||
|
|
||||||
const runData = { seed: 12345 };
|
const runData = { seed: 12345 };
|
||||||
const mockPutRequest = {
|
const mockPutRequest = {
|
||||||
onsuccess: null,
|
onsuccess: sinon.stub(),
|
||||||
onerror: null,
|
onerror: null,
|
||||||
};
|
};
|
||||||
mockStore.put.returns(mockPutRequest);
|
mockStore.put.returns(mockPutRequest);
|
||||||
|
|
||||||
|
// Start saveRun, which will trigger init
|
||||||
const savePromise = persistence.saveRun(runData);
|
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();
|
mockPutRequest.onsuccess();
|
||||||
|
|
||||||
await savePromise;
|
await savePromise;
|
||||||
|
|
@ -213,4 +244,3 @@ describe("Core: Persistence", () => {
|
||||||
expect(mockStore.put.calledOnce).to.be.true;
|
expect(mockStore.put.calledOnce).to.be.true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ describe("Generation: PostProcessor", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 4: floodFill should not include disconnected tiles", () => {
|
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
|
// Create two separate regions with proper floor setup
|
||||||
// Region 1: connected tiles
|
// Region 1: connected tiles
|
||||||
grid.setCell(1, 0, 1, 1); // Floor
|
grid.setCell(1, 0, 1, 1); // Floor
|
||||||
|
|
@ -89,7 +92,7 @@ describe("Generation: PostProcessor", () => {
|
||||||
grid.setCell(2, 0, 1, 1); // Floor
|
grid.setCell(2, 0, 1, 1); // Floor
|
||||||
grid.setCell(2, 1, 1, 0); // Air (connected)
|
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, 0, 10, 1); // Floor
|
||||||
grid.setCell(10, 1, 10, 0); // Air (disconnected)
|
grid.setCell(10, 1, 10, 0); // Air (disconnected)
|
||||||
|
|
||||||
|
|
@ -152,4 +155,3 @@ describe("Generation: PostProcessor", () => {
|
||||||
expect(airCountAfter).to.be.greaterThan(0);
|
expect(airCountAfter).to.be.greaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue