Compare commits
3 commits
33a64c460c
...
095bd778fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
095bd778fd | ||
|
|
ebaa3006f9 | ||
|
|
8d037bcd4d |
36 changed files with 3727 additions and 155 deletions
|
|
@ -1,3 +1,9 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").RunData} RunData
|
||||||
|
* @typedef {import("../grid/types.js").Position} Position
|
||||||
|
* @typedef {import("../units/Unit.js").Unit} Unit
|
||||||
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
import { VoxelGrid } from "../grid/VoxelGrid.js";
|
import { VoxelGrid } from "../grid/VoxelGrid.js";
|
||||||
|
|
@ -8,39 +14,68 @@ import { RuinGenerator } from "../generation/RuinGenerator.js";
|
||||||
import { InputManager } from "./InputManager.js";
|
import { InputManager } from "./InputManager.js";
|
||||||
import { MissionManager } from "../managers/MissionManager.js";
|
import { MissionManager } from "../managers/MissionManager.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main game loop managing rendering, input, and game state.
|
||||||
|
* @class
|
||||||
|
*/
|
||||||
export class GameLoop {
|
export class GameLoop {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {boolean} */
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
// 1. Core Systems
|
// 1. Core Systems
|
||||||
|
/** @type {THREE.Scene} */
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
|
/** @type {THREE.PerspectiveCamera | null} */
|
||||||
this.camera = null;
|
this.camera = null;
|
||||||
|
/** @type {THREE.WebGLRenderer | null} */
|
||||||
this.renderer = null;
|
this.renderer = null;
|
||||||
|
/** @type {OrbitControls | null} */
|
||||||
this.controls = null;
|
this.controls = null;
|
||||||
|
/** @type {InputManager | null} */
|
||||||
this.inputManager = null;
|
this.inputManager = null;
|
||||||
|
|
||||||
|
/** @type {VoxelGrid | null} */
|
||||||
this.grid = null;
|
this.grid = null;
|
||||||
|
/** @type {VoxelManager | null} */
|
||||||
this.voxelManager = null;
|
this.voxelManager = null;
|
||||||
|
/** @type {UnitManager | null} */
|
||||||
this.unitManager = null;
|
this.unitManager = null;
|
||||||
|
|
||||||
|
/** @type {Map<string, THREE.Mesh>} */
|
||||||
this.unitMeshes = new Map();
|
this.unitMeshes = new Map();
|
||||||
|
/** @type {RunData | null} */
|
||||||
this.runData = null;
|
this.runData = null;
|
||||||
|
/** @type {Position[]} */
|
||||||
this.playerSpawnZone = [];
|
this.playerSpawnZone = [];
|
||||||
|
/** @type {Position[]} */
|
||||||
this.enemySpawnZone = [];
|
this.enemySpawnZone = [];
|
||||||
|
|
||||||
// Input Logic State
|
// Input Logic State
|
||||||
|
/** @type {number} */
|
||||||
this.lastMoveTime = 0;
|
this.lastMoveTime = 0;
|
||||||
|
/** @type {number} */
|
||||||
this.moveCooldown = 120; // ms between cursor moves
|
this.moveCooldown = 120; // ms between cursor moves
|
||||||
|
/** @type {"MOVEMENT" | "TARGETING"} */
|
||||||
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
||||||
|
/** @type {MissionManager} */
|
||||||
this.missionManager = new MissionManager(this); // Init Mission Manager
|
this.missionManager = new MissionManager(this); // Init Mission Manager
|
||||||
|
|
||||||
// Deployment State
|
// Deployment State
|
||||||
|
/** @type {{ selectedUnitIndex: number; deployedUnits: Map<number, Unit> }} */
|
||||||
this.deploymentState = {
|
this.deploymentState = {
|
||||||
selectedUnitIndex: -1,
|
selectedUnitIndex: -1,
|
||||||
deployedUnits: new Map(), // Map<Index, UnitInstance>
|
deployedUnits: new Map(), // Map<Index, UnitInstance>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {import("./GameStateManager.js").GameStateManagerClass | null} */
|
||||||
|
this.gameStateManager = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the game loop with Three.js setup.
|
||||||
|
* @param {HTMLElement} container - DOM element to attach the renderer to
|
||||||
|
*/
|
||||||
init(container) {
|
init(container) {
|
||||||
// Setup Three.js
|
// Setup Three.js
|
||||||
this.camera = new THREE.PerspectiveCamera(
|
this.camera = new THREE.PerspectiveCamera(
|
||||||
|
|
@ -96,6 +131,10 @@ export class GameLoop {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation Logic for Standard Movement.
|
* Validation Logic for Standard Movement.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {false | Position} - False if invalid, or adjusted position object
|
||||||
*/
|
*/
|
||||||
validateCursorMove(x, y, z) {
|
validateCursorMove(x, y, z) {
|
||||||
if (!this.grid) return true; // Allow if grid not ready
|
if (!this.grid) return true; // Allow if grid not ready
|
||||||
|
|
@ -120,6 +159,10 @@ export class GameLoop {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation Logic for Deployment Phase.
|
* Validation Logic for Deployment Phase.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {false | Position} - False if invalid, or valid spawn position
|
||||||
*/
|
*/
|
||||||
validateDeploymentCursor(x, y, z) {
|
validateDeploymentCursor(x, y, z) {
|
||||||
if (!this.grid || this.playerSpawnZone.length === 0) return false;
|
if (!this.grid || this.playerSpawnZone.length === 0) return false;
|
||||||
|
|
@ -135,6 +178,13 @@ export class GameLoop {
|
||||||
return false; // Cursor cannot leave the spawn zone
|
return false; // Cursor cannot leave the spawn zone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a position is walkable.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {boolean} - True if walkable
|
||||||
|
*/
|
||||||
isWalkable(x, y, z) {
|
isWalkable(x, y, z) {
|
||||||
if (this.grid.getCell(x, y, z) !== 0) return false;
|
if (this.grid.getCell(x, y, z) !== 0) return false;
|
||||||
if (this.grid.getCell(x, y - 1, z) === 0) return false;
|
if (this.grid.getCell(x, y - 1, z) === 0) return false;
|
||||||
|
|
@ -142,11 +192,22 @@ export class GameLoop {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an interaction target position.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {boolean} - True if valid
|
||||||
|
*/
|
||||||
validateInteractionTarget(x, y, z) {
|
validateInteractionTarget(x, y, z) {
|
||||||
if (!this.grid) return true;
|
if (!this.grid) return true;
|
||||||
return this.grid.isValidBounds({ x, y, z });
|
return this.grid.isValidBounds({ x, y, z });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles gamepad button input.
|
||||||
|
* @param {{ buttonIndex: number; gamepadIndex: number }} detail - Button input detail
|
||||||
|
*/
|
||||||
handleButtonInput(detail) {
|
handleButtonInput(detail) {
|
||||||
if (detail.buttonIndex === 0) {
|
if (detail.buttonIndex === 0) {
|
||||||
// A / Cross
|
// A / Cross
|
||||||
|
|
@ -154,6 +215,10 @@ export class GameLoop {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles keyboard input.
|
||||||
|
* @param {string} code - Key code
|
||||||
|
*/
|
||||||
handleKeyInput(code) {
|
handleKeyInput(code) {
|
||||||
if (code === "Space" || code === "Enter") {
|
if (code === "Space" || code === "Enter") {
|
||||||
this.triggerSelection();
|
this.triggerSelection();
|
||||||
|
|
@ -179,6 +244,9 @@ export class GameLoop {
|
||||||
console.log(`Deployment: Selected Unit Index ${index}`);
|
console.log(`Deployment: Selected Unit Index ${index}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers selection action at cursor position.
|
||||||
|
*/
|
||||||
triggerSelection() {
|
triggerSelection() {
|
||||||
const cursor = this.inputManager.getCursorPosition();
|
const cursor = this.inputManager.getCursorPosition();
|
||||||
console.log("Action at:", cursor);
|
console.log("Action at:", cursor);
|
||||||
|
|
@ -217,6 +285,11 @@ export class GameLoop {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a mission by ID.
|
||||||
|
* @param {string} missionId - Mission identifier
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async startMission(missionId) {
|
async startMission(missionId) {
|
||||||
const mission = await fetch(
|
const mission = await fetch(
|
||||||
`assets/data/missions/${missionId.toLowerCase()}.json`
|
`assets/data/missions/${missionId.toLowerCase()}.json`
|
||||||
|
|
@ -225,6 +298,11 @@ export class GameLoop {
|
||||||
this.missionManager.startMission(missionData);
|
this.missionManager.startMission(missionData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a level with the given run data.
|
||||||
|
* @param {RunData} runData - Run data containing mission and squad info
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async startLevel(runData) {
|
async startLevel(runData) {
|
||||||
console.log("GameLoop: Generating Level...");
|
console.log("GameLoop: Generating Level...");
|
||||||
this.runData = runData;
|
this.runData = runData;
|
||||||
|
|
@ -299,6 +377,13 @@ export class GameLoop {
|
||||||
this.animate();
|
this.animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploys or moves a unit to a target tile.
|
||||||
|
* @param {import("./types.js").SquadMember} unitDef - Unit definition
|
||||||
|
* @param {Position} targetTile - Target position
|
||||||
|
* @param {Unit | null} [existingUnit] - Existing unit to move, or null to create new
|
||||||
|
* @returns {Unit | null} - The deployed/moved unit, or null if failed
|
||||||
|
*/
|
||||||
deployUnit(unitDef, targetTile, existingUnit = null) {
|
deployUnit(unitDef, targetTile, existingUnit = null) {
|
||||||
if (
|
if (
|
||||||
!this.gameStateManager ||
|
!this.gameStateManager ||
|
||||||
|
|
@ -362,6 +447,9 @@ export class GameLoop {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizes deployment phase and starts combat.
|
||||||
|
*/
|
||||||
finalizeDeployment() {
|
finalizeDeployment() {
|
||||||
if (
|
if (
|
||||||
!this.gameStateManager ||
|
!this.gameStateManager ||
|
||||||
|
|
@ -391,11 +479,19 @@ export class GameLoop {
|
||||||
console.log("Combat Started!");
|
console.log("Combat Started!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all unit meshes from the scene.
|
||||||
|
*/
|
||||||
clearUnitMeshes() {
|
clearUnitMeshes() {
|
||||||
this.unitMeshes.forEach((mesh) => this.scene.remove(mesh));
|
this.unitMeshes.forEach((mesh) => this.scene.remove(mesh));
|
||||||
this.unitMeshes.clear();
|
this.unitMeshes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a visual mesh for a unit.
|
||||||
|
* @param {Unit} unit - The unit instance
|
||||||
|
* @param {Position} pos - Position to place the mesh
|
||||||
|
*/
|
||||||
createUnitMesh(unit, pos) {
|
createUnitMesh(unit, pos) {
|
||||||
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
||||||
let color = 0xcccccc;
|
let color = 0xcccccc;
|
||||||
|
|
@ -408,6 +504,9 @@ export class GameLoop {
|
||||||
this.unitMeshes.set(unit.id, mesh);
|
this.unitMeshes.set(unit.id, mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlights spawn zones with visual indicators.
|
||||||
|
*/
|
||||||
highlightZones() {
|
highlightZones() {
|
||||||
const highlightMatPlayer = new THREE.MeshBasicMaterial({
|
const highlightMatPlayer = new THREE.MeshBasicMaterial({
|
||||||
color: 0x00ff00,
|
color: 0x00ff00,
|
||||||
|
|
@ -433,6 +532,9 @@ export class GameLoop {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main animation loop.
|
||||||
|
*/
|
||||||
animate() {
|
animate() {
|
||||||
if (!this.isRunning) return;
|
if (!this.isRunning) return;
|
||||||
requestAnimationFrame(this.animate);
|
requestAnimationFrame(this.animate);
|
||||||
|
|
@ -484,6 +586,9 @@ export class GameLoop {
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the game loop and cleans up resources.
|
||||||
|
*/
|
||||||
stop() {
|
stop() {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
if (this.inputManager) this.inputManager.detach();
|
if (this.inputManager) this.inputManager.detach();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,21 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").GameState} GameState
|
||||||
|
* @typedef {import("./types.js").RunData} RunData
|
||||||
|
* @typedef {import("./types.js").EmbarkEventDetail} EmbarkEventDetail
|
||||||
|
* @typedef {import("./types.js").SquadMember} SquadMember
|
||||||
|
*/
|
||||||
|
|
||||||
import { Persistence } from "./Persistence.js";
|
import { Persistence } from "./Persistence.js";
|
||||||
import { RosterManager } from "../managers/RosterManager.js";
|
import { RosterManager } from "../managers/RosterManager.js";
|
||||||
import { MissionManager } from "../managers/MissionManager.js";
|
import { MissionManager } from "../managers/MissionManager.js";
|
||||||
import { narrativeManager } from "../managers/NarrativeManager.js";
|
import { narrativeManager } from "../managers/NarrativeManager.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the overall game state and transitions between different game modes.
|
||||||
|
* @class
|
||||||
|
*/
|
||||||
class GameStateManagerClass {
|
class GameStateManagerClass {
|
||||||
|
/** @type {Record<string, GameState>} */
|
||||||
static STATES = {
|
static STATES = {
|
||||||
INIT: "STATE_INIT",
|
INIT: "STATE_INIT",
|
||||||
MAIN_MENU: "STATE_MAIN_MENU",
|
MAIN_MENU: "STATE_MAIN_MENU",
|
||||||
|
|
@ -12,37 +24,68 @@ class GameStateManagerClass {
|
||||||
COMBAT: "STATE_COMBAT",
|
COMBAT: "STATE_COMBAT",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {GameState} currentState - Current game state
|
||||||
|
* @param {import("./GameLoop.js").GameLoop | null} gameLoop - Reference to game loop
|
||||||
|
* @param {Persistence} persistence - Persistence manager
|
||||||
|
* @param {RunData | null} activeRunData - Current active run data
|
||||||
|
* @param {RosterManager} rosterManager - Roster manager instance
|
||||||
|
* @param {MissionManager} missionManager - Mission manager instance
|
||||||
|
* @param {import("../managers/NarrativeManager.js").NarrativeManager} narrativeManager - Narrative manager instance
|
||||||
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {GameState} */
|
||||||
this.currentState = GameStateManagerClass.STATES.INIT;
|
this.currentState = GameStateManagerClass.STATES.INIT;
|
||||||
|
/** @type {import("./GameLoop.js").GameLoop | null} */
|
||||||
this.gameLoop = null;
|
this.gameLoop = null;
|
||||||
|
/** @type {Persistence} */
|
||||||
this.persistence = new Persistence();
|
this.persistence = new Persistence();
|
||||||
|
/** @type {RunData | null} */
|
||||||
this.activeRunData = null;
|
this.activeRunData = null;
|
||||||
|
|
||||||
// Integrate Core Managers
|
// Integrate Core Managers
|
||||||
|
/** @type {RosterManager} */
|
||||||
this.rosterManager = new RosterManager();
|
this.rosterManager = new RosterManager();
|
||||||
|
/** @type {MissionManager} */
|
||||||
this.missionManager = new MissionManager();
|
this.missionManager = new MissionManager();
|
||||||
|
/** @type {import("../managers/NarrativeManager.js").NarrativeManager} */
|
||||||
this.narrativeManager = narrativeManager; // Track the singleton instance
|
this.narrativeManager = narrativeManager; // Track the singleton instance
|
||||||
|
|
||||||
this.handleEmbark = this.handleEmbark.bind(this);
|
this.handleEmbark = this.handleEmbark.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {PromiseWithResolvers<void>} */
|
||||||
#gameLoopInitialized = Promise.withResolvers();
|
#gameLoopInitialized = Promise.withResolvers();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
get gameLoopInitialized() {
|
get gameLoopInitialized() {
|
||||||
return this.#gameLoopInitialized.promise;
|
return this.#gameLoopInitialized.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {PromiseWithResolvers<unknown>} */
|
||||||
#rosterLoaded = Promise.withResolvers();
|
#rosterLoaded = Promise.withResolvers();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<unknown>}
|
||||||
|
*/
|
||||||
get rosterLoaded() {
|
get rosterLoaded() {
|
||||||
return this.#rosterLoaded.promise;
|
return this.#rosterLoaded.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the game loop reference.
|
||||||
|
* @param {import("./GameLoop.js").GameLoop} loop - The game loop instance
|
||||||
|
*/
|
||||||
setGameLoop(loop) {
|
setGameLoop(loop) {
|
||||||
this.gameLoop = loop;
|
this.gameLoop = loop;
|
||||||
this.#gameLoopInitialized.resolve();
|
this.#gameLoopInitialized.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the state manager to initial state.
|
||||||
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
// Reset singleton state for testing
|
// Reset singleton state for testing
|
||||||
this.currentState = GameStateManagerClass.STATES.INIT;
|
this.currentState = GameStateManagerClass.STATES.INIT;
|
||||||
|
|
@ -55,6 +98,10 @@ class GameStateManagerClass {
|
||||||
this.#rosterLoaded = Promise.withResolvers();
|
this.#rosterLoaded = Promise.withResolvers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the game state manager.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
console.log("System: Initializing State Manager...");
|
console.log("System: Initializing State Manager...");
|
||||||
await this.persistence.init();
|
await this.persistence.init();
|
||||||
|
|
@ -72,6 +119,12 @@ class GameStateManagerClass {
|
||||||
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transitions to a new game state.
|
||||||
|
* @param {GameState} newState - The new state to transition to
|
||||||
|
* @param {unknown} [payload] - Optional payload data for the transition
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async transitionTo(newState, payload = null) {
|
async transitionTo(newState, payload = null) {
|
||||||
const oldState = this.currentState;
|
const oldState = this.currentState;
|
||||||
const stateChanged = oldState !== newState;
|
const stateChanged = oldState !== newState;
|
||||||
|
|
@ -104,6 +157,9 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a new game session.
|
||||||
|
*/
|
||||||
startNewGame() {
|
startNewGame() {
|
||||||
// Clear roster for a fresh start
|
// Clear roster for a fresh start
|
||||||
this.rosterManager.clear();
|
this.rosterManager.clear();
|
||||||
|
|
@ -114,6 +170,10 @@ class GameStateManagerClass {
|
||||||
this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER);
|
this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continues a previously saved game.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async continueGame() {
|
async continueGame() {
|
||||||
const save = await this.persistence.loadRun();
|
const save = await this.persistence.loadRun();
|
||||||
if (save) {
|
if (save) {
|
||||||
|
|
@ -123,6 +183,11 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the embark event from the team builder.
|
||||||
|
* @param {CustomEvent<EmbarkEventDetail>} e - The embark event
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async handleEmbark(e) {
|
async handleEmbark(e) {
|
||||||
// Handle Draft Mode (New Recruits)
|
// Handle Draft Mode (New Recruits)
|
||||||
if (e.detail.mode === "DRAFT") {
|
if (e.detail.mode === "DRAFT") {
|
||||||
|
|
@ -141,6 +206,12 @@ class GameStateManagerClass {
|
||||||
|
|
||||||
// --- INTERNAL HELPERS ---
|
// --- INTERNAL HELPERS ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes a new run with the given squad.
|
||||||
|
* @param {SquadMember[]} squadManifest - The squad members to deploy
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
async _initializeRun(squadManifest) {
|
async _initializeRun(squadManifest) {
|
||||||
await this.gameLoopInitialized;
|
await this.gameLoopInitialized;
|
||||||
|
|
||||||
|
|
@ -182,6 +253,11 @@ class GameStateManagerClass {
|
||||||
await this.gameLoop.startLevel(this.activeRunData);
|
await this.gameLoop.startLevel(this.activeRunData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes a previously saved run.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
async _resumeRun() {
|
async _resumeRun() {
|
||||||
await this.gameLoopInitialized;
|
await this.gameLoopInitialized;
|
||||||
if (this.activeRunData) {
|
if (this.activeRunData) {
|
||||||
|
|
@ -197,6 +273,11 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a save game exists and dispatches an event.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
async _checkSaveGame() {
|
async _checkSaveGame() {
|
||||||
const save = await this.persistence.loadRun();
|
const save = await this.persistence.loadRun();
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
|
|
@ -204,6 +285,11 @@ class GameStateManagerClass {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the current roster.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
async _saveRoster() {
|
async _saveRoster() {
|
||||||
const data = this.rosterManager.save();
|
const data = this.rosterManager.save();
|
||||||
await this.persistence.saveRoster(data);
|
await this.persistence.saveRoster(data);
|
||||||
|
|
@ -211,5 +297,6 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the Singleton Instance
|
// Export the Singleton Instance
|
||||||
|
/** @type {GameStateManagerClass} */
|
||||||
export const gameStateManager = new GameStateManagerClass();
|
export const gameStateManager = new GameStateManagerClass();
|
||||||
export const GameStateManager = GameStateManagerClass;
|
export const GameStateManager = GameStateManagerClass;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,47 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("../grid/types.js").Position} Position
|
||||||
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InputManager.js
|
* InputManager.js
|
||||||
* Handles mouse interaction, raycasting, grid selection, keyboard, and Gamepad input.
|
* Handles mouse interaction, raycasting, grid selection, keyboard, and Gamepad input.
|
||||||
* Extends EventTarget for standard event handling.
|
* Extends EventTarget for standard event handling.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class InputManager extends EventTarget {
|
export class InputManager extends EventTarget {
|
||||||
|
/**
|
||||||
|
* @param {THREE.Camera} camera - Three.js camera
|
||||||
|
* @param {THREE.Scene} scene - Three.js scene
|
||||||
|
* @param {HTMLElement} domElement - DOM element for input
|
||||||
|
*/
|
||||||
constructor(camera, scene, domElement) {
|
constructor(camera, scene, domElement) {
|
||||||
super();
|
super();
|
||||||
|
/** @type {THREE.Camera} */
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
|
/** @type {THREE.Scene} */
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
/** @type {HTMLElement} */
|
||||||
this.domElement = domElement;
|
this.domElement = domElement;
|
||||||
|
|
||||||
|
/** @type {THREE.Raycaster} */
|
||||||
this.raycaster = new THREE.Raycaster();
|
this.raycaster = new THREE.Raycaster();
|
||||||
|
/** @type {THREE.Vector2} */
|
||||||
this.mouse = new THREE.Vector2();
|
this.mouse = new THREE.Vector2();
|
||||||
|
|
||||||
// Input State
|
// Input State
|
||||||
|
/** @type {Set<string>} */
|
||||||
this.keys = new Set();
|
this.keys = new Set();
|
||||||
|
/** @type {Map<number, { buttons: boolean[]; axes: number[] }>} */
|
||||||
this.gamepads = new Map();
|
this.gamepads = new Map();
|
||||||
|
/** @type {number} */
|
||||||
this.axisDeadzone = 0.2; // Increased slightly for stability
|
this.axisDeadzone = 0.2; // Increased slightly for stability
|
||||||
|
|
||||||
// Cursor State
|
// Cursor State
|
||||||
|
/** @type {THREE.Vector3} */
|
||||||
this.cursorPos = new THREE.Vector3(0, 0, 0);
|
this.cursorPos = new THREE.Vector3(0, 0, 0);
|
||||||
|
/** @type {((x: number, y: number, z: number) => false | Position) | null} */
|
||||||
this.cursorValidator = null; // Function to check if a position is valid
|
this.cursorValidator = null; // Function to check if a position is valid
|
||||||
|
|
||||||
// Visual Cursor (Wireframe Box)
|
// Visual Cursor (Wireframe Box)
|
||||||
|
|
@ -78,7 +98,7 @@ export class InputManager extends EventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a validation function to restrict cursor movement.
|
* Set a validation function to restrict cursor movement.
|
||||||
* @param {Function} validatorFn - Takes (x, y, z). Returns true/false OR a modified {x,y,z} object.
|
* @param {(x: number, y: number, z: number) => false | Position} validatorFn - Takes (x, y, z). Returns false if invalid, or a modified position object.
|
||||||
*/
|
*/
|
||||||
setValidator(validatorFn) {
|
setValidator(validatorFn) {
|
||||||
this.cursorValidator = validatorFn;
|
this.cursorValidator = validatorFn;
|
||||||
|
|
@ -87,6 +107,9 @@ export class InputManager extends EventTarget {
|
||||||
/**
|
/**
|
||||||
* Programmatically move the cursor (e.g. via Gamepad or Keyboard).
|
* Programmatically move the cursor (e.g. via Gamepad or Keyboard).
|
||||||
* This now simulates a raycast-like resolution to ensure consistent behavior.
|
* This now simulates a raycast-like resolution to ensure consistent behavior.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
*/
|
*/
|
||||||
setCursor(x, y, z) {
|
setCursor(x, y, z) {
|
||||||
// Instead of raw rounding, we try to "snap" using the same logic as raycast
|
// Instead of raw rounding, we try to "snap" using the same logic as raycast
|
||||||
|
|
@ -125,6 +148,10 @@ export class InputManager extends EventTarget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current cursor position.
|
||||||
|
* @returns {THREE.Vector3} - Current cursor position
|
||||||
|
*/
|
||||||
getCursorPosition() {
|
getCursorPosition() {
|
||||||
return this.cursorPos;
|
return this.cursorPos;
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +172,9 @@ export class InputManager extends EventTarget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates gamepad state. Should be called every frame.
|
||||||
|
*/
|
||||||
update() {
|
update() {
|
||||||
const activeGamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
const activeGamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||||
|
|
||||||
|
|
@ -206,6 +236,11 @@ export class InputManager extends EventTarget {
|
||||||
this.dispatchEvent(new CustomEvent("keyup", { detail: event.code }));
|
this.dispatchEvent(new CustomEvent("keyup", { detail: event.code }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a key is currently pressed.
|
||||||
|
* @param {string} code - Key code
|
||||||
|
* @returns {boolean} - True if key is pressed
|
||||||
|
*/
|
||||||
isKeyPressed(code) {
|
isKeyPressed(code) {
|
||||||
return this.keys.has(code);
|
return this.keys.has(code);
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +279,9 @@ export class InputManager extends EventTarget {
|
||||||
/**
|
/**
|
||||||
* Resolves a world-space point and normal into voxel coordinates.
|
* Resolves a world-space point and normal into voxel coordinates.
|
||||||
* Shared logic for Raycasting and potentially other inputs.
|
* Shared logic for Raycasting and potentially other inputs.
|
||||||
|
* @param {THREE.Vector3} point - World space point
|
||||||
|
* @param {THREE.Vector3} normal - Surface normal
|
||||||
|
* @returns {{ hitPosition: Position; voxelPosition: Position; normal: THREE.Vector3 }} - Resolved cursor data
|
||||||
*/
|
*/
|
||||||
resolveCursorFromWorldPosition(point, normal) {
|
resolveCursorFromWorldPosition(point, normal) {
|
||||||
const p = point.clone();
|
const p = point.clone();
|
||||||
|
|
@ -268,6 +306,10 @@ export class InputManager extends EventTarget {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs raycast from camera through mouse position.
|
||||||
|
* @returns {null | { hitPosition: Position; voxelPosition: Position; normal: THREE.Vector3 }} - Hit data or null
|
||||||
|
*/
|
||||||
raycast() {
|
raycast() {
|
||||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
const intersects = this.raycaster.intersectObjects(
|
const intersects = this.raycaster.intersectObjects(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").RunData} RunData
|
||||||
|
* @typedef {import("../units/types.js").RosterSaveData} RosterSaveData
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistence.js
|
* Persistence.js
|
||||||
* Handles asynchronous saving and loading using IndexedDB.
|
* Handles asynchronous saving and loading using IndexedDB.
|
||||||
|
|
@ -8,11 +13,20 @@ const RUN_STORE = "Runs";
|
||||||
const ROSTER_STORE = "Roster";
|
const ROSTER_STORE = "Roster";
|
||||||
const VERSION = 2; // Bumped version to add Roster store
|
const VERSION = 2; // Bumped version to add Roster store
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles game data persistence using IndexedDB.
|
||||||
|
* @class
|
||||||
|
*/
|
||||||
export class Persistence {
|
export class Persistence {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {IDBDatabase | null} */
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the IndexedDB database.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, VERSION);
|
const request = indexedDB.open(DB_NAME, VERSION);
|
||||||
|
|
@ -42,16 +56,29 @@ export class Persistence {
|
||||||
|
|
||||||
// --- RUN DATA ---
|
// --- RUN DATA ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves run data.
|
||||||
|
* @param {RunData} runData - Run data to save
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async saveRun(runData) {
|
async saveRun(runData) {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
return this._put(RUN_STORE, { ...runData, id: "active_run" });
|
return this._put(RUN_STORE, { ...runData, id: "active_run" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads run data.
|
||||||
|
* @returns {Promise<RunData | undefined>}
|
||||||
|
*/
|
||||||
async loadRun() {
|
async loadRun() {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
return this._get(RUN_STORE, "active_run");
|
return this._get(RUN_STORE, "active_run");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears saved run data.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async clearRun() {
|
async clearRun() {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
return this._delete(RUN_STORE, "active_run");
|
return this._delete(RUN_STORE, "active_run");
|
||||||
|
|
@ -59,12 +86,21 @@ export class Persistence {
|
||||||
|
|
||||||
// --- ROSTER DATA ---
|
// --- ROSTER DATA ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves roster data.
|
||||||
|
* @param {RosterSaveData} rosterData - Roster data to save
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async saveRoster(rosterData) {
|
async saveRoster(rosterData) {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
// Wrap the raw data object in an ID for storage
|
// Wrap the raw data object in an ID for storage
|
||||||
return this._put(ROSTER_STORE, { id: "player_roster", data: rosterData });
|
return this._put(ROSTER_STORE, { id: "player_roster", data: rosterData });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads roster data.
|
||||||
|
* @returns {Promise<RosterSaveData | null>}
|
||||||
|
*/
|
||||||
async loadRoster() {
|
async loadRoster() {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
const result = await this._get(ROSTER_STORE, "player_roster");
|
const result = await this._get(ROSTER_STORE, "player_roster");
|
||||||
|
|
@ -73,6 +109,13 @@ export class Persistence {
|
||||||
|
|
||||||
// --- INTERNAL HELPERS ---
|
// --- INTERNAL HELPERS ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to put data into a store.
|
||||||
|
* @param {string} storeName - Store name
|
||||||
|
* @param {unknown} item - Item to store
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
_put(storeName, item) {
|
_put(storeName, item) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.db.transaction([storeName], "readwrite");
|
const tx = this.db.transaction([storeName], "readwrite");
|
||||||
|
|
@ -83,6 +126,13 @@ export class Persistence {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to get data from a store.
|
||||||
|
* @param {string} storeName - Store name
|
||||||
|
* @param {string} key - Key to retrieve
|
||||||
|
* @returns {Promise<unknown>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
_get(storeName, key) {
|
_get(storeName, key) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.db.transaction([storeName], "readonly");
|
const tx = this.db.transaction([storeName], "readonly");
|
||||||
|
|
@ -93,6 +143,13 @@ export class Persistence {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper to delete data from a store.
|
||||||
|
* @param {string} storeName - Store name
|
||||||
|
* @param {string} key - Key to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
_delete(storeName, key) {
|
_delete(storeName, key) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.db.transaction([storeName], "readwrite");
|
const tx = this.db.transaction([storeName], "readwrite");
|
||||||
|
|
|
||||||
103
src/core/types.d.ts
vendored
Normal file
103
src/core/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for core game systems
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GameLoop } from "./GameLoop.js";
|
||||||
|
import type { RosterManager } from "../managers/RosterManager.js";
|
||||||
|
import type { MissionManager } from "../managers/MissionManager.js";
|
||||||
|
import type { NarrativeManager } from "../managers/NarrativeManager.js";
|
||||||
|
import type { Persistence } from "./Persistence.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game state constants
|
||||||
|
*/
|
||||||
|
export type GameState = "STATE_INIT" | "STATE_MAIN_MENU" | "STATE_TEAM_BUILDER" | "STATE_DEPLOYMENT" | "STATE_COMBAT";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run data structure for active game sessions
|
||||||
|
*/
|
||||||
|
export interface RunData {
|
||||||
|
id: string;
|
||||||
|
missionId: string;
|
||||||
|
seed: number;
|
||||||
|
depth: number;
|
||||||
|
biome: BiomeConfig;
|
||||||
|
squad: SquadMember[];
|
||||||
|
objectives: Objective[];
|
||||||
|
world_state: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squad member definition
|
||||||
|
*/
|
||||||
|
export interface SquadMember {
|
||||||
|
id?: string;
|
||||||
|
classId?: string;
|
||||||
|
name?: string;
|
||||||
|
isNew?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biome configuration
|
||||||
|
*/
|
||||||
|
export interface BiomeConfig {
|
||||||
|
generator_config: {
|
||||||
|
seed_type: "FIXED" | "RANDOM";
|
||||||
|
seed?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objective definition
|
||||||
|
*/
|
||||||
|
export interface Objective {
|
||||||
|
type: string;
|
||||||
|
target_count?: number;
|
||||||
|
target_def_id?: string;
|
||||||
|
current?: number;
|
||||||
|
complete?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game state change event detail
|
||||||
|
*/
|
||||||
|
export interface GameStateChangeDetail {
|
||||||
|
oldState: GameState;
|
||||||
|
newState: GameState;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embark event detail
|
||||||
|
*/
|
||||||
|
export interface EmbarkEventDetail {
|
||||||
|
mode: "DRAFT" | "DEPLOY";
|
||||||
|
squad: SquadMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameStateManager class interface
|
||||||
|
*/
|
||||||
|
export interface GameStateManagerInterface {
|
||||||
|
currentState: GameState;
|
||||||
|
gameLoop: GameLoop | null;
|
||||||
|
persistence: Persistence;
|
||||||
|
activeRunData: RunData | null;
|
||||||
|
rosterManager: RosterManager;
|
||||||
|
missionManager: MissionManager;
|
||||||
|
narrativeManager: NarrativeManager;
|
||||||
|
gameLoopInitialized: Promise<void>;
|
||||||
|
rosterLoaded: Promise<unknown>;
|
||||||
|
setGameLoop(loop: GameLoop): void;
|
||||||
|
reset(): void;
|
||||||
|
init(): Promise<void>;
|
||||||
|
transitionTo(newState: GameState, payload?: unknown): Promise<void>;
|
||||||
|
startNewGame(): void;
|
||||||
|
continueGame(): Promise<void>;
|
||||||
|
handleEmbark(e: CustomEvent<EmbarkEventDetail>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,14 +1,39 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("../grid/VoxelGrid.js").VoxelGrid} VoxelGrid
|
||||||
|
* @typedef {import("../grid/types.js").VoxelId} VoxelId
|
||||||
|
*/
|
||||||
|
|
||||||
import { SeededRandom } from "../utils/SeededRandom.js";
|
import { SeededRandom } from "../utils/SeededRandom.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for world generators.
|
||||||
|
* @class
|
||||||
|
*/
|
||||||
export class BaseGenerator {
|
export class BaseGenerator {
|
||||||
|
/**
|
||||||
|
* @param {VoxelGrid} grid - Voxel grid to generate into
|
||||||
|
* @param {number} seed - Random seed
|
||||||
|
*/
|
||||||
constructor(grid, seed) {
|
constructor(grid, seed) {
|
||||||
|
/** @type {VoxelGrid} */
|
||||||
this.grid = grid;
|
this.grid = grid;
|
||||||
|
/** @type {SeededRandom} */
|
||||||
this.rng = new SeededRandom(seed);
|
this.rng = new SeededRandom(seed);
|
||||||
|
/** @type {number} */
|
||||||
this.width = grid.size.x;
|
this.width = grid.size.x;
|
||||||
|
/** @type {number} */
|
||||||
this.height = grid.size.y;
|
this.height = grid.size.y;
|
||||||
|
/** @type {number} */
|
||||||
this.depth = grid.size.z;
|
this.depth = grid.size.z;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts solid neighbors around a position.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {number} - Number of solid neighbors
|
||||||
|
*/
|
||||||
getSolidNeighbors(x, y, z) {
|
getSolidNeighbors(x, y, z) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = -1; i <= 1; i++) {
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("../grid/types.js").GeneratedAssets} GeneratedAssets
|
||||||
|
*/
|
||||||
|
|
||||||
import { BaseGenerator } from "./BaseGenerator.js";
|
import { BaseGenerator } from "./BaseGenerator.js";
|
||||||
// We can reuse the texture generators or create specific Ruin ones.
|
// We can reuse the texture generators or create specific Ruin ones.
|
||||||
import { RustedWallTextureGenerator } from "./textures/RustedWallTextureGenerator.js";
|
import { RustedWallTextureGenerator } from "./textures/RustedWallTextureGenerator.js";
|
||||||
|
|
@ -7,15 +11,23 @@ import { RustedFloorTextureGenerator } from "./textures/RustedFloorTextureGenera
|
||||||
* Generates structured rooms and corridors.
|
* Generates structured rooms and corridors.
|
||||||
* Uses an "Additive" approach (Building in Void) to ensure good visibility.
|
* Uses an "Additive" approach (Building in Void) to ensure good visibility.
|
||||||
* Integrated with Procedural Texture Palette.
|
* Integrated with Procedural Texture Palette.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class RuinGenerator extends BaseGenerator {
|
export class RuinGenerator extends BaseGenerator {
|
||||||
|
/**
|
||||||
|
* @param {import("../grid/VoxelGrid.js").VoxelGrid} grid - Voxel grid to generate into
|
||||||
|
* @param {number} seed - Random seed
|
||||||
|
*/
|
||||||
constructor(grid, seed) {
|
constructor(grid, seed) {
|
||||||
super(grid, seed);
|
super(grid, seed);
|
||||||
|
|
||||||
// Use Rusted Floor for the Industrial aesthetic
|
// Use Rusted Floor for the Industrial aesthetic
|
||||||
|
/** @type {RustedFloorTextureGenerator} */
|
||||||
this.floorGen = new RustedFloorTextureGenerator(seed);
|
this.floorGen = new RustedFloorTextureGenerator(seed);
|
||||||
|
/** @type {RustedWallTextureGenerator} */
|
||||||
this.wallGen = new RustedWallTextureGenerator(seed);
|
this.wallGen = new RustedWallTextureGenerator(seed);
|
||||||
|
|
||||||
|
/** @type {GeneratedAssets} */
|
||||||
this.generatedAssets = {
|
this.generatedAssets = {
|
||||||
palette: {},
|
palette: {},
|
||||||
// New: Explicitly track valid spawn locations for teams
|
// New: Explicitly track valid spawn locations for teams
|
||||||
|
|
@ -28,6 +40,10 @@ export class RuinGenerator extends BaseGenerator {
|
||||||
this.preloadTextures();
|
this.preloadTextures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads texture variations.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
preloadTextures() {
|
preloadTextures() {
|
||||||
const VARIATIONS = 10;
|
const VARIATIONS = 10;
|
||||||
const TEXTURE_SIZE = 128;
|
const TEXTURE_SIZE = 128;
|
||||||
|
|
|
||||||
25
src/generation/types.d.ts
vendored
Normal file
25
src/generation/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for generation-related types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VoxelGrid } from "../grid/VoxelGrid.js";
|
||||||
|
import type { GeneratedAssets } from "../grid/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base generator interface
|
||||||
|
*/
|
||||||
|
export interface BaseGenerator {
|
||||||
|
grid: VoxelGrid;
|
||||||
|
seed: number;
|
||||||
|
generatedAssets: GeneratedAssets;
|
||||||
|
generate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generator configuration
|
||||||
|
*/
|
||||||
|
export interface GeneratorConfig {
|
||||||
|
seed: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,33 +1,76 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").GridSize} GridSize
|
||||||
|
* @typedef {import("./types.js").Position} Position
|
||||||
|
* @typedef {import("./types.js").VoxelId} VoxelId
|
||||||
|
* @typedef {import("./types.js").Hazard} Hazard
|
||||||
|
* @typedef {import("./types.js").VoxelData} VoxelData
|
||||||
|
* @typedef {import("./types.js").MoveUnitOptions} MoveUnitOptions
|
||||||
|
* @typedef {import("../units/Unit.js").Unit} Unit
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoxelGrid.js
|
* VoxelGrid.js
|
||||||
* The spatial data structure for the game world.
|
* The spatial data structure for the game world.
|
||||||
* Manages terrain IDs (Uint8Array) and spatial unit lookups (Map).
|
* Manages terrain IDs (Uint8Array) and spatial unit lookups (Map).
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class VoxelGrid {
|
export class VoxelGrid {
|
||||||
|
/**
|
||||||
|
* @param {number} width - Grid width
|
||||||
|
* @param {number} height - Grid height
|
||||||
|
* @param {number} depth - Grid depth
|
||||||
|
*/
|
||||||
constructor(width, height, depth) {
|
constructor(width, height, depth) {
|
||||||
|
/** @type {GridSize} */
|
||||||
this.size = { x: width, y: height, z: depth };
|
this.size = { x: width, y: height, z: depth };
|
||||||
|
|
||||||
// Flat array for terrain IDs (0=Air, 1=Floor, 10=Cover, etc.)
|
// Flat array for terrain IDs (0=Air, 1=Floor, 10=Cover, etc.)
|
||||||
|
/** @type {Uint8Array} */
|
||||||
this.cells = new Uint8Array(width * height * depth);
|
this.cells = new Uint8Array(width * height * depth);
|
||||||
|
|
||||||
// Spatial Hash for Units: "x,y,z" -> UnitObject
|
// Spatial Hash for Units: "x,y,z" -> UnitObject
|
||||||
|
/** @type {Map<string, Unit>} */
|
||||||
this.unitMap = new Map();
|
this.unitMap = new Map();
|
||||||
|
|
||||||
// Hazard Map: "x,y,z" -> { id, duration }
|
// Hazard Map: "x,y,z" -> { id, duration }
|
||||||
|
/** @type {Map<string, Hazard>} */
|
||||||
this.hazardMap = new Map();
|
this.hazardMap = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- COORDINATE HELPERS ---
|
// --- COORDINATE HELPERS ---
|
||||||
|
/**
|
||||||
|
* Generates a key string from coordinates.
|
||||||
|
* @param {number | Position} x - X coordinate or position object
|
||||||
|
* @param {number} [y] - Y coordinate
|
||||||
|
* @param {number} [z] - Z coordinate
|
||||||
|
* @returns {string} - Key string
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
_key(x, y, z) {
|
_key(x, y, z) {
|
||||||
// Handle object input {x,y,z} or raw args
|
// Handle object input {x,y,z} or raw args
|
||||||
if (typeof x === "object") return `${x.x},${x.y},${x.z}`;
|
if (typeof x === "object") return `${x.x},${x.y},${x.z}`;
|
||||||
return `${x},${y},${z}`;
|
return `${x},${y},${z}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates array index from coordinates.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {number} - Array index
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
_index(x, y, z) {
|
_index(x, y, z) {
|
||||||
return y * this.size.x * this.size.z + z * this.size.x + x;
|
return y * this.size.x * this.size.z + z * this.size.x + x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if coordinates are within bounds.
|
||||||
|
* @param {number | Position} x - X coordinate or position object
|
||||||
|
* @param {number} [y] - Y coordinate
|
||||||
|
* @param {number} [z] - Z coordinate
|
||||||
|
* @returns {boolean} - True if within bounds
|
||||||
|
*/
|
||||||
isValidBounds(x, y, z) {
|
isValidBounds(x, y, z) {
|
||||||
// Handle object input
|
// Handle object input
|
||||||
if (typeof x === "object") {
|
if (typeof x === "object") {
|
||||||
|
|
@ -48,11 +91,25 @@ export class VoxelGrid {
|
||||||
|
|
||||||
// --- CORE VOXEL MANIPULATION ---
|
// --- CORE VOXEL MANIPULATION ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the voxel ID at the specified coordinates.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @returns {VoxelId} - Voxel ID (0 for air)
|
||||||
|
*/
|
||||||
getCell(x, y, z) {
|
getCell(x, y, z) {
|
||||||
if (!this.isValidBounds(x, y, z)) return 0; // Out of bounds is Air
|
if (!this.isValidBounds(x, y, z)) return 0; // Out of bounds is Air
|
||||||
return this.cells[this._index(x, y, z)];
|
return this.cells[this._index(x, y, z)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the voxel ID at the specified coordinates.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
* @param {VoxelId} id - Voxel ID to set
|
||||||
|
*/
|
||||||
setCell(x, y, z, id) {
|
setCell(x, y, z, id) {
|
||||||
if (this.isValidBounds(x, y, z)) {
|
if (this.isValidBounds(x, y, z)) {
|
||||||
this.cells[this._index(x, y, z)] = id;
|
this.cells[this._index(x, y, z)] = id;
|
||||||
|
|
@ -62,6 +119,7 @@ export class VoxelGrid {
|
||||||
/**
|
/**
|
||||||
* Fills the entire grid with a specific ID.
|
* Fills the entire grid with a specific ID.
|
||||||
* Used by RuinGenerator to create a solid block before carving.
|
* Used by RuinGenerator to create a solid block before carving.
|
||||||
|
* @param {VoxelId} id - Voxel ID to fill with
|
||||||
*/
|
*/
|
||||||
fill(id) {
|
fill(id) {
|
||||||
this.cells.fill(id);
|
this.cells.fill(id);
|
||||||
|
|
@ -70,6 +128,7 @@ export class VoxelGrid {
|
||||||
/**
|
/**
|
||||||
* Creates a copy of the grid data.
|
* Creates a copy of the grid data.
|
||||||
* Used by Cellular Automata for smoothing passes.
|
* Used by Cellular Automata for smoothing passes.
|
||||||
|
* @returns {VoxelGrid} - Cloned grid
|
||||||
*/
|
*/
|
||||||
clone() {
|
clone() {
|
||||||
const newGrid = new VoxelGrid(this.size.x, this.size.y, this.size.z);
|
const newGrid = new VoxelGrid(this.size.x, this.size.y, this.size.z);
|
||||||
|
|
@ -79,27 +138,49 @@ export class VoxelGrid {
|
||||||
|
|
||||||
// --- QUERY & PHYSICS ---
|
// --- QUERY & PHYSICS ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a position is solid.
|
||||||
|
* @param {Position} pos - Position to check
|
||||||
|
* @returns {boolean} - True if solid
|
||||||
|
*/
|
||||||
isSolid(pos) {
|
isSolid(pos) {
|
||||||
const id = this.getCell(pos.x, pos.y, pos.z);
|
const id = this.getCell(pos.x, pos.y, pos.z);
|
||||||
return id !== 0; // 0 is Air
|
return id !== 0; // 0 is Air
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a position is occupied by a unit.
|
||||||
|
* @param {Position} pos - Position to check
|
||||||
|
* @returns {boolean} - True if occupied
|
||||||
|
*/
|
||||||
isOccupied(pos) {
|
isOccupied(pos) {
|
||||||
return this.unitMap.has(this._key(pos));
|
return this.unitMap.has(this._key(pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the unit at a position.
|
||||||
|
* @param {Position} pos - Position to check
|
||||||
|
* @returns {Unit | undefined} - Unit at position or undefined
|
||||||
|
*/
|
||||||
getUnitAt(pos) {
|
getUnitAt(pos) {
|
||||||
return this.unitMap.get(this._key(pos));
|
return this.unitMap.get(this._key(pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the voxel is destructible cover (IDs 10-20).
|
* Returns true if the voxel is destructible cover (IDs 10-20).
|
||||||
|
* @param {Position} pos - Position to check
|
||||||
|
* @returns {boolean} - True if destructible
|
||||||
*/
|
*/
|
||||||
isDestructible(pos) {
|
isDestructible(pos) {
|
||||||
const id = this.getCell(pos.x, pos.y, pos.z);
|
const id = this.getCell(pos.x, pos.y, pos.z);
|
||||||
return id >= 10 && id <= 20;
|
return id >= 10 && id <= 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys a destructible voxel.
|
||||||
|
* @param {Position} pos - Position to destroy
|
||||||
|
* @returns {boolean} - True if destroyed
|
||||||
|
*/
|
||||||
destroyVoxel(pos) {
|
destroyVoxel(pos) {
|
||||||
if (this.isDestructible(pos)) {
|
if (this.isDestructible(pos)) {
|
||||||
this.setCell(pos.x, pos.y, pos.z, 0); // Turn to Air
|
this.setCell(pos.x, pos.y, pos.z, 0); // Turn to Air
|
||||||
|
|
@ -112,6 +193,10 @@ export class VoxelGrid {
|
||||||
/**
|
/**
|
||||||
* Helper for AI to find cover or hazards.
|
* Helper for AI to find cover or hazards.
|
||||||
* Returns list of {x,y,z,id} objects within radius.
|
* Returns list of {x,y,z,id} objects within radius.
|
||||||
|
* @param {Position} center - Center position
|
||||||
|
* @param {number} radius - Search radius
|
||||||
|
* @param {((id: VoxelId, x: number, y: number, z: number) => boolean) | null} [filterFn] - Optional filter function
|
||||||
|
* @returns {VoxelData[]} - Array of voxel data
|
||||||
*/
|
*/
|
||||||
getVoxelsInRadius(center, radius, filterFn = null) {
|
getVoxelsInRadius(center, radius, filterFn = null) {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
@ -135,6 +220,11 @@ export class VoxelGrid {
|
||||||
|
|
||||||
// --- UNIT MOVEMENT ---
|
// --- UNIT MOVEMENT ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a unit at a position.
|
||||||
|
* @param {Unit} unit - Unit to place
|
||||||
|
* @param {Position} pos - Target position
|
||||||
|
*/
|
||||||
placeUnit(unit, pos) {
|
placeUnit(unit, pos) {
|
||||||
// Remove from old location
|
// Remove from old location
|
||||||
if (unit.position) {
|
if (unit.position) {
|
||||||
|
|
@ -151,6 +241,13 @@ export class VoxelGrid {
|
||||||
this.unitMap.set(this._key(pos), unit);
|
this.unitMap.set(this._key(pos), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a unit to a target position.
|
||||||
|
* @param {Unit} unit - Unit to move
|
||||||
|
* @param {Position} targetPos - Target position
|
||||||
|
* @param {MoveUnitOptions} [options] - Move options
|
||||||
|
* @returns {boolean} - True if moved successfully
|
||||||
|
*/
|
||||||
moveUnit(unit, targetPos, options = {}) {
|
moveUnit(unit, targetPos, options = {}) {
|
||||||
if (!this.isValidBounds(targetPos)) return false;
|
if (!this.isValidBounds(targetPos)) return false;
|
||||||
|
|
||||||
|
|
@ -168,12 +265,23 @@ export class VoxelGrid {
|
||||||
|
|
||||||
// --- HAZARDS ---
|
// --- HAZARDS ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a hazard at a position.
|
||||||
|
* @param {Position} pos - Position to add hazard
|
||||||
|
* @param {string} typeId - Hazard type ID
|
||||||
|
* @param {number} duration - Hazard duration
|
||||||
|
*/
|
||||||
addHazard(pos, typeId, duration) {
|
addHazard(pos, typeId, duration) {
|
||||||
if (this.isValidBounds(pos)) {
|
if (this.isValidBounds(pos)) {
|
||||||
this.hazardMap.set(this._key(pos), { id: typeId, duration });
|
this.hazardMap.set(this._key(pos), { id: typeId, duration });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the hazard at a position.
|
||||||
|
* @param {Position} pos - Position to check
|
||||||
|
* @returns {Hazard | undefined} - Hazard data or undefined
|
||||||
|
*/
|
||||||
getHazardAt(pos) {
|
getHazardAt(pos) {
|
||||||
return this.hazardMap.get(this._key(pos));
|
return this.hazardMap.get(this._key(pos));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,34 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").GeneratedAssets} GeneratedAssets
|
||||||
|
* @typedef {import("./VoxelGrid.js").VoxelGrid} VoxelGrid
|
||||||
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VoxelManager.js
|
* VoxelManager.js
|
||||||
* Handles the Three.js rendering of the VoxelGrid data.
|
* Handles the Three.js rendering of the VoxelGrid data.
|
||||||
* Updated to support Camera Focus Targeting, Emissive Textures, and Multi-Material Voxels.
|
* Updated to support Camera Focus Targeting, Emissive Textures, and Multi-Material Voxels.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class VoxelManager {
|
export class VoxelManager {
|
||||||
|
/**
|
||||||
|
* @param {VoxelGrid} grid - Voxel grid to render
|
||||||
|
* @param {THREE.Scene} scene - Three.js scene
|
||||||
|
*/
|
||||||
constructor(grid, scene) {
|
constructor(grid, scene) {
|
||||||
|
/** @type {VoxelGrid} */
|
||||||
this.grid = grid;
|
this.grid = grid;
|
||||||
|
/** @type {THREE.Scene} */
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
|
||||||
// Map of Voxel ID -> InstancedMesh
|
// Map of Voxel ID -> InstancedMesh
|
||||||
|
/** @type {Map<number, THREE.InstancedMesh>} */
|
||||||
this.meshes = new Map();
|
this.meshes = new Map();
|
||||||
|
|
||||||
// Default Material Definitions (Fallback)
|
// Default Material Definitions (Fallback)
|
||||||
// Store actual Material instances, not just configs
|
// Store actual Material instances, not just configs
|
||||||
|
/** @type {Record<number, THREE.MeshStandardMaterial | THREE.MeshStandardMaterial[]>} */
|
||||||
this.materials = {
|
this.materials = {
|
||||||
1: new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.8 }), // Stone
|
1: new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.8 }), // Stone
|
||||||
2: new THREE.MeshStandardMaterial({ color: 0x3d2817, roughness: 1.0 }), // Dirt/Floor Base
|
2: new THREE.MeshStandardMaterial({ color: 0x3d2817, roughness: 1.0 }), // Dirt/Floor Base
|
||||||
|
|
@ -26,9 +40,11 @@ export class VoxelManager {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared Geometry
|
// Shared Geometry
|
||||||
|
/** @type {THREE.BoxGeometry} */
|
||||||
this.geometry = new THREE.BoxGeometry(1, 1, 1);
|
this.geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
|
||||||
// Camera Anchor: Invisible object to serve as OrbitControls target
|
// Camera Anchor: Invisible object to serve as OrbitControls target
|
||||||
|
/** @type {THREE.Object3D} */
|
||||||
this.focusTarget = new THREE.Object3D();
|
this.focusTarget = new THREE.Object3D();
|
||||||
this.focusTarget.name = "CameraFocusTarget";
|
this.focusTarget.name = "CameraFocusTarget";
|
||||||
this.scene.add(this.focusTarget);
|
this.scene.add(this.focusTarget);
|
||||||
|
|
@ -38,6 +54,7 @@ export class VoxelManager {
|
||||||
* Updates the material definitions with generated assets.
|
* Updates the material definitions with generated assets.
|
||||||
* Supports both simple Canvas textures and complex {diffuse, emissive, normal, roughness, bump} objects.
|
* Supports both simple Canvas textures and complex {diffuse, emissive, normal, roughness, bump} objects.
|
||||||
* NOW SUPPORTS: 'palette' for batch loading procedural variations.
|
* NOW SUPPORTS: 'palette' for batch loading procedural variations.
|
||||||
|
* @param {GeneratedAssets} assets - Generated assets from world generator
|
||||||
*/
|
*/
|
||||||
updateMaterials(assets) {
|
updateMaterials(assets) {
|
||||||
if (!assets) return;
|
if (!assets) return;
|
||||||
|
|
@ -278,7 +295,7 @@ export class VoxelManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to center the camera view on the grid.
|
* Helper to center the camera view on the grid.
|
||||||
* @param {Object} controls - The OrbitControls instance to update
|
* @param {import("three/examples/jsm/controls/OrbitControls.js").OrbitControls} controls - The OrbitControls instance to update
|
||||||
*/
|
*/
|
||||||
focusCamera(controls) {
|
focusCamera(controls) {
|
||||||
if (controls && this.focusTarget) {
|
if (controls && this.focusTarget) {
|
||||||
|
|
@ -287,6 +304,12 @@ export class VoxelManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single voxel (triggers full update).
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
|
*/
|
||||||
updateVoxel(x, y, z) {
|
updateVoxel(x, y, z) {
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
80
src/grid/types.d.ts
vendored
Normal file
80
src/grid/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for grid-related types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Unit } from "../units/Unit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid size
|
||||||
|
*/
|
||||||
|
export interface GridSize {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position coordinate
|
||||||
|
*/
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voxel ID (0 = air, 1+ = solid)
|
||||||
|
*/
|
||||||
|
export type VoxelId = number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hazard data
|
||||||
|
*/
|
||||||
|
export interface Hazard {
|
||||||
|
id: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voxel data for queries
|
||||||
|
*/
|
||||||
|
export interface VoxelData extends Position {
|
||||||
|
id: VoxelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move unit options
|
||||||
|
*/
|
||||||
|
export interface MoveUnitOptions {
|
||||||
|
force?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generated assets from world generators
|
||||||
|
*/
|
||||||
|
export interface GeneratedAssets {
|
||||||
|
textures?: {
|
||||||
|
floor?: HTMLCanvasElement | OffscreenCanvas | TextureAsset;
|
||||||
|
wall?: HTMLCanvasElement | OffscreenCanvas | TextureAsset;
|
||||||
|
[key: string]: HTMLCanvasElement | OffscreenCanvas | TextureAsset | undefined;
|
||||||
|
};
|
||||||
|
palette?: Record<string, HTMLCanvasElement | OffscreenCanvas | TextureAsset>;
|
||||||
|
spawnZones?: {
|
||||||
|
player?: Position[];
|
||||||
|
enemy?: Position[];
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture asset (composite with multiple maps)
|
||||||
|
*/
|
||||||
|
export interface TextureAsset {
|
||||||
|
diffuse?: HTMLCanvasElement | OffscreenCanvas;
|
||||||
|
emissive?: HTMLCanvasElement | OffscreenCanvas;
|
||||||
|
normal?: HTMLCanvasElement | OffscreenCanvas;
|
||||||
|
roughness?: HTMLCanvasElement | OffscreenCanvas;
|
||||||
|
bump?: HTMLCanvasElement | OffscreenCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import { gameStateManager } from "./core/GameStateManager.js";
|
import { gameStateManager } from "./core/GameStateManager.js";
|
||||||
|
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const gameViewport = document.querySelector("game-viewport");
|
const gameViewport = document.querySelector("game-viewport");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const teamBuilder = document.querySelector("team-builder");
|
const teamBuilder = document.querySelector("team-builder");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const mainMenu = document.getElementById("main-menu");
|
const mainMenu = document.getElementById("main-menu");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const btnNewRun = document.getElementById("btn-start");
|
const btnNewRun = document.getElementById("btn-start");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const btnContinue = document.getElementById("btn-load");
|
const btnContinue = document.getElementById("btn-load");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const loadingOverlay = document.getElementById("loading-overlay");
|
const loadingOverlay = document.getElementById("loading-overlay");
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
const loadingMessage = document.getElementById("loading-message");
|
const loadingMessage = document.getElementById("loading-message");
|
||||||
|
|
||||||
// --- Event Listeners ---
|
// --- Event Listeners ---
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,52 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").MissionDefinition} MissionDefinition
|
||||||
|
* @typedef {import("./types.js").MissionSaveData} MissionSaveData
|
||||||
|
* @typedef {import("./types.js").Objective} Objective
|
||||||
|
* @typedef {import("./types.js").GameEventData} GameEventData
|
||||||
|
*/
|
||||||
|
|
||||||
import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' };
|
import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' };
|
||||||
import { narrativeManager } from './NarrativeManager.js';
|
import { narrativeManager } from './NarrativeManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MissionManager.js
|
* MissionManager.js
|
||||||
* Manages campaign progression, mission selection, narrative triggers, and objective tracking.
|
* Manages campaign progression, mission selection, narrative triggers, and objective tracking.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class MissionManager {
|
export class MissionManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Campaign State
|
// Campaign State
|
||||||
|
/** @type {string | null} */
|
||||||
this.activeMissionId = null;
|
this.activeMissionId = null;
|
||||||
|
/** @type {Set<string>} */
|
||||||
this.completedMissions = new Set();
|
this.completedMissions = new Set();
|
||||||
|
/** @type {Map<string, MissionDefinition>} */
|
||||||
this.missionRegistry = new Map();
|
this.missionRegistry = new Map();
|
||||||
|
|
||||||
// Active Run State
|
// Active Run State
|
||||||
|
/** @type {MissionDefinition | null} */
|
||||||
this.currentMissionDef = null;
|
this.currentMissionDef = null;
|
||||||
|
/** @type {Objective[]} */
|
||||||
this.currentObjectives = [];
|
this.currentObjectives = [];
|
||||||
|
|
||||||
// Register default missions
|
// Register default missions
|
||||||
this.registerMission(tutorialMission);
|
this.registerMission(tutorialMission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a mission definition.
|
||||||
|
* @param {MissionDefinition} missionDef - Mission definition to register
|
||||||
|
*/
|
||||||
registerMission(missionDef) {
|
registerMission(missionDef) {
|
||||||
this.missionRegistry.set(missionDef.id, missionDef);
|
this.missionRegistry.set(missionDef.id, missionDef);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PERSISTENCE (Campaign) ---
|
// --- PERSISTENCE (Campaign) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads campaign save data.
|
||||||
|
* @param {MissionSaveData} saveData - Save data to load
|
||||||
|
*/
|
||||||
load(saveData) {
|
load(saveData) {
|
||||||
this.completedMissions = new Set(saveData.completedMissions || []);
|
this.completedMissions = new Set(saveData.completedMissions || []);
|
||||||
// Default to Tutorial if history is empty
|
// Default to Tutorial if history is empty
|
||||||
|
|
@ -34,6 +55,10 @@ export class MissionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves campaign data.
|
||||||
|
* @returns {MissionSaveData} - Serialized campaign data
|
||||||
|
*/
|
||||||
save() {
|
save() {
|
||||||
return {
|
return {
|
||||||
completedMissions: Array.from(this.completedMissions)
|
completedMissions: Array.from(this.completedMissions)
|
||||||
|
|
@ -44,6 +69,7 @@ export class MissionManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the configuration for the currently selected mission.
|
* Gets the configuration for the currently selected mission.
|
||||||
|
* @returns {MissionDefinition | undefined} - Active mission definition
|
||||||
*/
|
*/
|
||||||
getActiveMission() {
|
getActiveMission() {
|
||||||
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
|
if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01');
|
||||||
|
|
@ -133,7 +159,7 @@ export class MissionManager {
|
||||||
/**
|
/**
|
||||||
* Called by GameLoop whenever a relevant event occurs.
|
* Called by GameLoop whenever a relevant event occurs.
|
||||||
* @param {string} type - 'ENEMY_DEATH', 'TURN_END', etc.
|
* @param {string} type - 'ENEMY_DEATH', 'TURN_END', etc.
|
||||||
* @param {Object} data - Context data
|
* @param {GameEventData} data - Context data
|
||||||
*/
|
*/
|
||||||
onGameEvent(type, data) {
|
onGameEvent(type, data) {
|
||||||
if (!this.currentObjectives.length) return;
|
if (!this.currentObjectives.length) return;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").NarrativeSequence} NarrativeSequence
|
||||||
|
* @typedef {import("./types.js").NarrativeNode} NarrativeNode
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NarrativeManager.js
|
* NarrativeManager.js
|
||||||
* Manages the flow of story events, dialogue, and tutorials.
|
* Manages the flow of story events, dialogue, and tutorials.
|
||||||
* Extends EventTarget to broadcast UI updates to the DialogueOverlay.
|
* Extends EventTarget to broadcast UI updates to the DialogueOverlay.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class NarrativeManager extends EventTarget {
|
export class NarrativeManager extends EventTarget {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
/** @type {NarrativeSequence | null} */
|
||||||
this.currentSequence = null;
|
this.currentSequence = null;
|
||||||
|
/** @type {NarrativeNode | null} */
|
||||||
this.currentNode = null;
|
this.currentNode = null;
|
||||||
|
/** @type {Set<string>} */
|
||||||
this.history = new Set(); // Track IDs of played sequences
|
this.history = new Set(); // Track IDs of played sequences
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and starts a narrative sequence.
|
* Loads and starts a narrative sequence.
|
||||||
* @param {Object} sequenceData - The JSON object of the conversation (from assets/data/narrative/).
|
* @param {NarrativeSequence} sequenceData - The JSON object of the conversation (from assets/data/narrative/).
|
||||||
*/
|
*/
|
||||||
startSequence(sequenceData) {
|
startSequence(sequenceData) {
|
||||||
if (!sequenceData || !sequenceData.nodes) {
|
if (!sequenceData || !sequenceData.nodes) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("../units/types.js").ExplorerData} ExplorerData
|
||||||
|
* @typedef {import("../units/types.js").RosterSaveData} RosterSaveData
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RosterManager.js
|
* RosterManager.js
|
||||||
* Manages the persistent pool of Explorer units (The Barracks).
|
* Manages the persistent pool of Explorer units (The Barracks).
|
||||||
* Handles recruitment, death, and selection for missions.
|
* Handles recruitment, death, and selection for missions.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class RosterManager {
|
export class RosterManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {ExplorerData[]} */
|
||||||
this.roster = []; // List of active Explorer objects (Data only)
|
this.roster = []; // List of active Explorer objects (Data only)
|
||||||
|
/** @type {ExplorerData[]} */
|
||||||
this.graveyard = []; // List of dead units
|
this.graveyard = []; // List of dead units
|
||||||
|
/** @type {number} */
|
||||||
this.rosterLimit = 12;
|
this.rosterLimit = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the roster from saved data.
|
* Initializes the roster from saved data.
|
||||||
|
* @param {RosterSaveData} saveData - Saved roster data
|
||||||
*/
|
*/
|
||||||
load(saveData) {
|
load(saveData) {
|
||||||
this.roster = saveData.roster || [];
|
this.roster = saveData.roster || [];
|
||||||
|
|
@ -20,6 +30,7 @@ export class RosterManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes for save file.
|
* Serializes for save file.
|
||||||
|
* @returns {RosterSaveData} - Serialized roster data
|
||||||
*/
|
*/
|
||||||
save() {
|
save() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -30,7 +41,8 @@ export class RosterManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a new unit to the roster.
|
* Adds a new unit to the roster.
|
||||||
* @param {Object} unitData - The unit definition (Class, Name, Stats)
|
* @param {Partial<ExplorerData>} unitData - The unit definition (Class, Name, Stats)
|
||||||
|
* @returns {ExplorerData | false} - The recruited unit or false if roster is full
|
||||||
*/
|
*/
|
||||||
recruitUnit(unitData) {
|
recruitUnit(unitData) {
|
||||||
if (this.roster.length >= this.rosterLimit) {
|
if (this.roster.length >= this.rosterLimit) {
|
||||||
|
|
@ -51,6 +63,7 @@ export class RosterManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks a unit as dead and moves them to the graveyard.
|
* Marks a unit as dead and moves them to the graveyard.
|
||||||
|
* @param {string} unitId - Unit ID to mark as dead
|
||||||
*/
|
*/
|
||||||
handleUnitDeath(unitId) {
|
handleUnitDeath(unitId) {
|
||||||
const index = this.roster.findIndex((u) => u.id === unitId);
|
const index = this.roster.findIndex((u) => u.id === unitId);
|
||||||
|
|
@ -65,6 +78,7 @@ export class RosterManager {
|
||||||
/**
|
/**
|
||||||
* Returns units eligible for a mission.
|
* Returns units eligible for a mission.
|
||||||
* Filters out injured or dead units.
|
* Filters out injured or dead units.
|
||||||
|
* @returns {ExplorerData[]} - Array of deployable units
|
||||||
*/
|
*/
|
||||||
getDeployableUnits() {
|
getDeployableUnits() {
|
||||||
return this.roster.filter((u) => u.status === "READY");
|
return this.roster.filter((u) => u.status === "READY");
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").UnitRegistry} UnitRegistry
|
||||||
|
* @typedef {import("../units/types.js").UnitDefinition} UnitDefinition
|
||||||
|
* @typedef {import("../units/types.js").Position} Position
|
||||||
|
* @typedef {import("../units/types.js").UnitTeam} UnitTeam
|
||||||
|
*/
|
||||||
|
|
||||||
import { Unit } from "../units/Unit.js";
|
import { Unit } from "../units/Unit.js";
|
||||||
import { Explorer } from "../units/Explorer.js";
|
import { Explorer } from "../units/Explorer.js";
|
||||||
import { Enemy } from "../units/Enemy.js";
|
import { Enemy } from "../units/Enemy.js";
|
||||||
|
|
@ -6,14 +13,18 @@ import { Enemy } from "../units/Enemy.js";
|
||||||
* UnitManager.js
|
* UnitManager.js
|
||||||
* Manages the lifecycle (creation, tracking, death) of all active units.
|
* Manages the lifecycle (creation, tracking, death) of all active units.
|
||||||
* Acts as the Source of Truth for "Who is alive?" and "Where are they relative to me?".
|
* Acts as the Source of Truth for "Who is alive?" and "Where are they relative to me?".
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class UnitManager {
|
export class UnitManager {
|
||||||
/**
|
/**
|
||||||
* @param {Object} registry - Map or Object containing Unit Definitions (Stats, Models).
|
* @param {UnitRegistry} registry - Map or Object containing Unit Definitions (Stats, Models).
|
||||||
*/
|
*/
|
||||||
constructor(registry) {
|
constructor(registry) {
|
||||||
|
/** @type {UnitRegistry} */
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
|
/** @type {Map<string, Unit>} */
|
||||||
this.activeUnits = new Map(); // ID -> Unit Instance
|
this.activeUnits = new Map(); // ID -> Unit Instance
|
||||||
|
/** @type {number} */
|
||||||
this.nextInstanceId = 0;
|
this.nextInstanceId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +33,8 @@ export class UnitManager {
|
||||||
/**
|
/**
|
||||||
* Factory method to spawn a unit from a template ID.
|
* Factory method to spawn a unit from a template ID.
|
||||||
* @param {string} defId - The definition ID (e.g. 'CLASS_VANGUARD', 'ENEMY_SENTINEL')
|
* @param {string} defId - The definition ID (e.g. 'CLASS_VANGUARD', 'ENEMY_SENTINEL')
|
||||||
* @param {string} team - 'PLAYER', 'ENEMY', 'NEUTRAL'
|
* @param {UnitTeam} team - 'PLAYER', 'ENEMY', 'NEUTRAL'
|
||||||
|
* @returns {Unit | null} - Created unit or null if definition not found
|
||||||
*/
|
*/
|
||||||
createUnit(defId, team) {
|
createUnit(defId, team) {
|
||||||
// Support both Map interface and Object interface for registry
|
// Support both Map interface and Object interface for registry
|
||||||
|
|
@ -63,6 +75,11 @@ export class UnitManager {
|
||||||
return unit;
|
return unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a unit from the active units map.
|
||||||
|
* @param {string} unitId - Unit ID to remove
|
||||||
|
* @returns {boolean} - True if unit was removed
|
||||||
|
*/
|
||||||
removeUnit(unitId) {
|
removeUnit(unitId) {
|
||||||
if (this.activeUnits.has(unitId)) {
|
if (this.activeUnits.has(unitId)) {
|
||||||
const unit = this.activeUnits.get(unitId);
|
const unit = this.activeUnits.get(unitId);
|
||||||
|
|
@ -75,14 +92,28 @@ export class UnitManager {
|
||||||
|
|
||||||
// --- QUERIES ---
|
// --- QUERIES ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a unit by ID.
|
||||||
|
* @param {string} id - Unit ID
|
||||||
|
* @returns {Unit | undefined} - Unit instance or undefined
|
||||||
|
*/
|
||||||
getUnitById(id) {
|
getUnitById(id) {
|
||||||
return this.activeUnits.get(id);
|
return this.activeUnits.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all active units.
|
||||||
|
* @returns {Unit[]} - Array of all active units
|
||||||
|
*/
|
||||||
getAllUnits() {
|
getAllUnits() {
|
||||||
return Array.from(this.activeUnits.values());
|
return Array.from(this.activeUnits.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all units on a specific team.
|
||||||
|
* @param {UnitTeam} team - Team to filter by
|
||||||
|
* @returns {Unit[]} - Array of units on the team
|
||||||
|
*/
|
||||||
getUnitsByTeam(team) {
|
getUnitsByTeam(team) {
|
||||||
return this.getAllUnits().filter((u) => u.team === team);
|
return this.getAllUnits().filter((u) => u.team === team);
|
||||||
}
|
}
|
||||||
|
|
@ -90,9 +121,10 @@ export class UnitManager {
|
||||||
/**
|
/**
|
||||||
* Finds all units within 'range' of 'centerPos'.
|
* Finds all units within 'range' of 'centerPos'.
|
||||||
* Used for AoE spells, Auras, and AI scanning.
|
* Used for AoE spells, Auras, and AI scanning.
|
||||||
* @param {Object} centerPos - {x, y, z}
|
* @param {Position} centerPos - {x, y, z}
|
||||||
* @param {number} range - Distance in tiles (Manhattan)
|
* @param {number} range - Distance in tiles (Manhattan)
|
||||||
* @param {string} [filterTeam] - Optional: Only return units of this team
|
* @param {UnitTeam | null} [filterTeam] - Optional: Only return units of this team
|
||||||
|
* @returns {Unit[]} - Array of units in range
|
||||||
*/
|
*/
|
||||||
getUnitsInRange(centerPos, range, filterTeam = null) {
|
getUnitsInRange(centerPos, range, filterTeam = null) {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
|
||||||
108
src/managers/types.d.ts
vendored
Normal file
108
src/managers/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for manager-related types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExplorerData, RosterSaveData } from "../units/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission definition
|
||||||
|
*/
|
||||||
|
export interface MissionDefinition {
|
||||||
|
id: string;
|
||||||
|
config: {
|
||||||
|
title: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
biome: {
|
||||||
|
generator_config: {
|
||||||
|
seed_type: "FIXED" | "RANDOM";
|
||||||
|
seed?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
objectives: {
|
||||||
|
primary: Objective[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
narrative?: {
|
||||||
|
intro_sequence?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objective definition
|
||||||
|
*/
|
||||||
|
export interface Objective {
|
||||||
|
type: string;
|
||||||
|
target_count?: number;
|
||||||
|
target_def_id?: string;
|
||||||
|
current?: number;
|
||||||
|
complete?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission save data
|
||||||
|
*/
|
||||||
|
export interface MissionSaveData {
|
||||||
|
completedMissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrative sequence data
|
||||||
|
*/
|
||||||
|
export interface NarrativeSequence {
|
||||||
|
id: string;
|
||||||
|
nodes: NarrativeNode[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrative node
|
||||||
|
*/
|
||||||
|
export interface NarrativeNode {
|
||||||
|
id: string;
|
||||||
|
type: "DIALOGUE" | "CHOICE" | "ACTION";
|
||||||
|
text?: string;
|
||||||
|
speaker?: string;
|
||||||
|
next?: string;
|
||||||
|
choices?: NarrativeChoice[];
|
||||||
|
trigger?: {
|
||||||
|
action: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrative choice
|
||||||
|
*/
|
||||||
|
export interface NarrativeChoice {
|
||||||
|
text: string;
|
||||||
|
next: string;
|
||||||
|
trigger?: {
|
||||||
|
action: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit registry interface
|
||||||
|
*/
|
||||||
|
export interface UnitRegistry {
|
||||||
|
get?(id: string): unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game event data
|
||||||
|
*/
|
||||||
|
export interface GameEventData {
|
||||||
|
unitId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
54
src/ui/combat-hud.d.ts
vendored
Normal file
54
src/ui/combat-hud.d.ts
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
export interface CombatState {
|
||||||
|
/** The unit currently taking their turn */
|
||||||
|
activeUnit: UnitStatus | null;
|
||||||
|
|
||||||
|
/** Sorted list of units acting next */
|
||||||
|
turnQueue: QueueEntry[];
|
||||||
|
|
||||||
|
/** Is the player currently targeting a skill? */
|
||||||
|
targetingMode: boolean;
|
||||||
|
|
||||||
|
/** Global combat info */
|
||||||
|
roundNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
portrait: string;
|
||||||
|
hp: { current: number; max: number };
|
||||||
|
ap: { current: number; max: number };
|
||||||
|
charge: number; // 0-100
|
||||||
|
statuses: StatusIcon[];
|
||||||
|
skills: SkillButton[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueEntry {
|
||||||
|
unitId: string;
|
||||||
|
portrait: string;
|
||||||
|
team: "PLAYER" | "ENEMY";
|
||||||
|
/** 0-100 progress to next turn */
|
||||||
|
initiative: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusIcon {
|
||||||
|
id: string;
|
||||||
|
icon: string; // URL or Emoji
|
||||||
|
turnsRemaining: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillButton {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
costAP: number;
|
||||||
|
cooldown: number; // 0 = Ready
|
||||||
|
isAvailable: boolean; // True if affordable and ready
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatEvents {
|
||||||
|
"skill-click": { skillId: string };
|
||||||
|
"end-turn": void;
|
||||||
|
"hover-skill": { skillId: string }; // For showing range grid
|
||||||
|
}
|
||||||
|
|
@ -13,85 +13,574 @@ export class CombatHUD extends LitElement {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
color: white;
|
color: white;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
/* Top Bar */
|
||||||
|
.top-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translateX(-50%);
|
right: 0;
|
||||||
|
height: 120px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.9) 0%,
|
||||||
|
rgba(0, 0, 0, 0.7) 80%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-queue {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-portrait {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #666;
|
||||||
|
overflow: hidden;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
border: 2px solid #ff0000;
|
position: relative;
|
||||||
padding: 15px 30px;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.queue-portrait.active {
|
||||||
margin-top: 5px;
|
width: 80px;
|
||||||
font-size: 1.2rem;
|
height: 80px;
|
||||||
|
border: 3px solid #ffd700;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-portrait img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-portrait.enemy {
|
||||||
|
border-color: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-portrait.player {
|
||||||
|
border-color: #66ff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enemy-intent {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -5px;
|
||||||
|
right: -5px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
border: 1px solid #ff6666;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border: 2px solid #555;
|
||||||
|
padding: 10px 20px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-counter {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threat-level {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threat-level.low {
|
||||||
|
background: rgba(0, 255, 0, 0.3);
|
||||||
|
color: #66ff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threat-level.medium {
|
||||||
|
background: rgba(255, 255, 0, 0.3);
|
||||||
|
color: #ffff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threat-level.high {
|
||||||
|
background: rgba(255, 0, 0, 0.3);
|
||||||
color: #ff6666;
|
color: #ff6666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.turn-indicator {
|
/* Bottom Bar */
|
||||||
|
.bottom-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100px;
|
bottom: 0;
|
||||||
left: 30px;
|
left: 0;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
right: 0;
|
||||||
border: 2px solid #ff0000;
|
height: 180px;
|
||||||
padding: 10px 20px;
|
background: linear-gradient(
|
||||||
font-size: 1rem;
|
to top,
|
||||||
|
rgba(0, 0, 0, 0.9) 0%,
|
||||||
|
rgba(0, 0, 0, 0.7) 80%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
/* Unit Status (Bottom-Left) */
|
||||||
|
.unit-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border: 2px solid #555;
|
||||||
|
padding: 15px;
|
||||||
|
min-width: 200px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-portrait {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 2px solid #666;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-portrait img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-name {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
cursor: help;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon:hover::after {
|
||||||
|
content: attr(data-description);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 30px;
|
bottom: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 5px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 20px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
padding: 10px 20px;
|
position: relative;
|
||||||
font-size: 0.9rem;
|
overflow: hidden;
|
||||||
color: #ccc;
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill.hp {
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill.ap {
|
||||||
|
background: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill.charge {
|
||||||
|
background: #0066ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Bar (Bottom-Center) */
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border: 2px solid #666;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button:hover:not(:disabled) {
|
||||||
|
border-color: #ffd700;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button .hotkey {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button .icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button .name {
|
||||||
|
font-size: 0.7rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button .cost {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-button .cooldown {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* End Turn Button (Bottom-Right) */
|
||||||
|
.end-turn-button {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border: 2px solid #ff6666;
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
pointer-events: auto;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-turn-button:hover {
|
||||||
|
background: rgba(255, 102, 102, 0.2);
|
||||||
|
box-shadow: 0 0 15px rgba(255, 102, 102, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-turn-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design - Mobile (< 768px) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.bottom-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 15px;
|
||||||
|
height: auto;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-status {
|
||||||
|
width: 100%;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-turn-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
min-height: 120px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-queue {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-info {
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
currentState: { type: String },
|
combatState: { type: Object },
|
||||||
currentTurn: { type: String },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.currentState = null;
|
this.combatState = null;
|
||||||
this.currentTurn = "PLAYER";
|
}
|
||||||
window.addEventListener("gamestate-changed", (e) => {
|
|
||||||
this.currentState = e.detail.newState;
|
_handleSkillClick(skillId) {
|
||||||
});
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("skill-click", {
|
||||||
|
detail: { skillId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleEndTurn() {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("end-turn", {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSkillHover(skillId) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("hover-skill", {
|
||||||
|
detail: { skillId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getThreatLevel() {
|
||||||
|
if (!this.combatState?.turnQueue) return "low";
|
||||||
|
const enemyCount = this.combatState.turnQueue.filter(
|
||||||
|
(entry) => entry.team === "ENEMY"
|
||||||
|
).length;
|
||||||
|
if (enemyCount >= 3) return "high";
|
||||||
|
if (enemyCount >= 2) return "medium";
|
||||||
|
return "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderBar(label, current, max, type) {
|
||||||
|
const percentage = max > 0 ? (current / max) * 100 : 0;
|
||||||
|
return html`
|
||||||
|
<div class="bar-container">
|
||||||
|
<div class="bar-label">
|
||||||
|
<span>${label}</span>
|
||||||
|
<span>${current}/${max}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<div class="bar-fill ${type}" style="width: ${percentage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// Only show during COMBAT state
|
if (!this.combatState) {
|
||||||
if (this.currentState !== "STATE_COMBAT") {
|
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { activeUnit, turnQueue, roundNumber } = this.combatState;
|
||||||
|
const threatLevel = this._getThreatLevel();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="header">
|
<!-- Top Bar -->
|
||||||
<h2>COMBAT ACTIVE</h2>
|
<div class="top-bar">
|
||||||
<div class="status-bar">Turn: ${this.currentTurn}</div>
|
<!-- Turn Queue (Center-Left) -->
|
||||||
|
<div class="turn-queue">
|
||||||
|
${turnQueue?.map(
|
||||||
|
(entry, index) => html`
|
||||||
|
<div
|
||||||
|
class="queue-portrait ${entry.team.toLowerCase()} ${index === 0
|
||||||
|
? "active"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<img src="${entry.portrait}" alt="${entry.unitId}" />
|
||||||
|
${index === 0 && entry.team === "ENEMY"
|
||||||
|
? html`<div class="enemy-intent">⚔</div>`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
) || html``}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="turn-indicator">
|
<!-- Global Info (Top-Right) -->
|
||||||
<div>State: ${this.currentState}</div>
|
<div class="global-info">
|
||||||
|
<div class="round-counter">Round ${roundNumber || 1}</div>
|
||||||
|
<div class="threat-level ${threatLevel}">
|
||||||
|
${threatLevel.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="instructions">
|
<!-- Bottom Bar -->
|
||||||
Use WASD or Arrow Keys to move cursor | SPACE/ENTER to select
|
<div class="bottom-bar">
|
||||||
|
<!-- Unit Status (Bottom-Left) -->
|
||||||
|
${activeUnit
|
||||||
|
? html`
|
||||||
|
<div class="unit-status">
|
||||||
|
<div class="unit-portrait">
|
||||||
|
<img src="${activeUnit.portrait}" alt="${activeUnit.name}" />
|
||||||
|
</div>
|
||||||
|
<div class="unit-name">${activeUnit.name}</div>
|
||||||
|
|
||||||
|
${activeUnit.statuses?.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="status-icons">
|
||||||
|
${activeUnit.statuses.map(
|
||||||
|
(status) => html`
|
||||||
|
<div
|
||||||
|
class="status-icon"
|
||||||
|
data-description="${status.description} (${status.turnsRemaining} turns)"
|
||||||
|
title="${status.description}"
|
||||||
|
>
|
||||||
|
${status.icon}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
${this._renderBar(
|
||||||
|
"HP",
|
||||||
|
activeUnit.hp.current,
|
||||||
|
activeUnit.hp.max,
|
||||||
|
"hp"
|
||||||
|
)}
|
||||||
|
${this._renderBar(
|
||||||
|
"AP",
|
||||||
|
activeUnit.ap.current,
|
||||||
|
activeUnit.ap.max,
|
||||||
|
"ap"
|
||||||
|
)}
|
||||||
|
${this._renderBar("Charge", activeUnit.charge, 100, "charge")}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html``}
|
||||||
|
|
||||||
|
<!-- Action Bar (Bottom-Center) -->
|
||||||
|
<div class="action-bar">
|
||||||
|
${activeUnit?.skills?.map(
|
||||||
|
(skill, index) => html`
|
||||||
|
<button
|
||||||
|
class="skill-button"
|
||||||
|
?disabled="${!skill.isAvailable}"
|
||||||
|
@click="${() => this._handleSkillClick(skill.id)}"
|
||||||
|
@mouseenter="${() => this._handleSkillHover(skill.id)}"
|
||||||
|
title="${skill.name} - ${skill.costAP} AP${skill.cooldown > 0
|
||||||
|
? ` (CD: ${skill.cooldown})`
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<span class="hotkey">${index + 1}</span>
|
||||||
|
<span class="icon">${skill.icon}</span>
|
||||||
|
<span class="name">${skill.name}</span>
|
||||||
|
<span class="cost">${skill.costAP}AP</span>
|
||||||
|
${skill.cooldown > 0
|
||||||
|
? html`<div class="cooldown">${skill.cooldown}</div>`
|
||||||
|
: ""}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
) || html``}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End Turn Button (Bottom-Right) -->
|
||||||
|
<button class="end-turn-button" @click="${this._handleEndTurn}">
|
||||||
|
END TURN
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
113
src/ui/combat-hud.spec.js
Normal file
113
src/ui/combat-hud.spec.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Combat HUD Specification: The Tactical Interface
|
||||||
|
|
||||||
|
This document defines the UI overlay active during the `GAME_RUN` / `ACTIVE` phase. It communicates turn order, unit status, and available actions to the player.
|
||||||
|
|
||||||
|
## 1. Visual Description
|
||||||
|
|
||||||
|
**Layout:** A "Letterbox" style overlay that leaves the center of the screen clear for the 3D action.
|
||||||
|
|
||||||
|
### A. Top Bar (Turn & Status)
|
||||||
|
|
||||||
|
* **Turn Queue (Center-Left):** A horizontal list of circular portraits.
|
||||||
|
|
||||||
|
* *Active Unit:* Larger, highlighted with a gold border on the far left.
|
||||||
|
|
||||||
|
* *Next Units:* Smaller icons trailing to the right.
|
||||||
|
|
||||||
|
* *Enemy Intent:* If an enemy is active, a small icon (Sword/Shield) indicates their planned action type.
|
||||||
|
|
||||||
|
* **Global Info (Top-Right):**
|
||||||
|
|
||||||
|
* *Round Counter:* "Round 3"
|
||||||
|
|
||||||
|
* *Threat Level:* "High" (Color coded).
|
||||||
|
|
||||||
|
### B. Bottom Bar (The Dashboard)
|
||||||
|
|
||||||
|
* **Unit Status (Bottom-Left):**
|
||||||
|
|
||||||
|
* *Portrait:* Large 2D art of the active unit.
|
||||||
|
|
||||||
|
* *Bars:* Health (Red), Action Points (Yellow), Charge (Blue).
|
||||||
|
|
||||||
|
* *Buffs:* Small icons row above the bars.
|
||||||
|
|
||||||
|
* **Action Bar (Bottom-Center):**
|
||||||
|
|
||||||
|
* A row of interactive buttons for Skills and Items.
|
||||||
|
|
||||||
|
* *Hotkeys:* (1-5) displayed on the buttons.
|
||||||
|
|
||||||
|
* *State:* Buttons go grey if AP is insufficient or Cooldown is active.
|
||||||
|
|
||||||
|
* *Tooltip:* Hovering shows damage, range, and AP cost.
|
||||||
|
|
||||||
|
* **End Turn (Bottom-Right):**
|
||||||
|
|
||||||
|
* A prominent button to manually end the turn early (saving AP or Charge).
|
||||||
|
|
||||||
|
### C. Floating Elements (World Space)
|
||||||
|
|
||||||
|
* **Damage Numbers:** Pop up over units when hit.
|
||||||
|
|
||||||
|
* **Health Bars:** Small bars hovering over every unit in the 3D scene (billboarded).
|
||||||
|
|
||||||
|
## 2. TypeScript Interfaces (Data Model)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/types/CombatHUD.ts
|
||||||
|
|
||||||
|
export interface CombatState {
|
||||||
|
/** The unit currently taking their turn */
|
||||||
|
activeUnit: UnitStatus | null;
|
||||||
|
|
||||||
|
/** Sorted list of units acting next */
|
||||||
|
turnQueue: QueueEntry[];
|
||||||
|
|
||||||
|
/** Is the player currently targeting a skill? */
|
||||||
|
targetingMode: boolean;
|
||||||
|
|
||||||
|
/** Global combat info */
|
||||||
|
roundNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
portrait: string;
|
||||||
|
hp: { current: number; max: number };
|
||||||
|
ap: { current: number; max: number };
|
||||||
|
charge: number; // 0-100
|
||||||
|
statuses: StatusIcon[];
|
||||||
|
skills: SkillButton[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueEntry {
|
||||||
|
unitId: string;
|
||||||
|
portrait: string;
|
||||||
|
team: 'PLAYER' | 'ENEMY';
|
||||||
|
/** 0-100 progress to next turn */
|
||||||
|
initiative: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusIcon {
|
||||||
|
id: string;
|
||||||
|
icon: string; // URL or Emoji
|
||||||
|
turnsRemaining: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillButton {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
costAP: number;
|
||||||
|
cooldown: number; // 0 = Ready
|
||||||
|
isAvailable: boolean; // True if affordable and ready
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatEvents {
|
||||||
|
'skill-click': { skillId: string };
|
||||||
|
'end-turn': void;
|
||||||
|
'hover-skill': { skillId: string }; // For showing range grid
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,34 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").UnitDefinition} UnitDefinition
|
||||||
|
*/
|
||||||
|
|
||||||
import { Unit } from "./Unit.js";
|
import { Unit } from "./Unit.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enemy.js
|
* Enemy.js
|
||||||
* NPC Unit controlled by the AI Controller.
|
* NPC Unit controlled by the AI Controller.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class Enemy extends Unit {
|
export class Enemy extends Unit {
|
||||||
|
/**
|
||||||
|
* @param {string} id - Unique unit identifier
|
||||||
|
* @param {string} name - Enemy name
|
||||||
|
* @param {UnitDefinition} def - Enemy definition
|
||||||
|
*/
|
||||||
constructor(id, name, def) {
|
constructor(id, name, def) {
|
||||||
// Construct with ID, Name, Type='ENEMY', and ModelID from def
|
// Construct with ID, Name, Type='ENEMY', and ModelID from def
|
||||||
super(id, name, "ENEMY", def.model || "MODEL_ENEMY_DEFAULT");
|
super(id, name, "ENEMY", def.model || "MODEL_ENEMY_DEFAULT");
|
||||||
|
|
||||||
// AI Logic
|
// AI Logic
|
||||||
|
/** @type {string} */
|
||||||
this.archetypeId = def.ai_archetype || "BRUISER"; // e.g., 'BRUISER', 'KITER'
|
this.archetypeId = def.ai_archetype || "BRUISER"; // e.g., 'BRUISER', 'KITER'
|
||||||
|
/** @type {number} */
|
||||||
this.aggroRange = def.aggro_range || 8;
|
this.aggroRange = def.aggro_range || 8;
|
||||||
|
|
||||||
// Rewards
|
// Rewards
|
||||||
|
/** @type {number} */
|
||||||
this.xpValue = def.xp_value || 10;
|
this.xpValue = def.xp_value || 10;
|
||||||
|
/** @type {string} */
|
||||||
this.lootTableId = def.loot_table || "LOOT_TIER_1_COMMON";
|
this.lootTableId = def.loot_table || "LOOT_TIER_1_COMMON";
|
||||||
|
|
||||||
// Hydrate Stats
|
// Hydrate Stats
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,31 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").ClassMastery} ClassMastery
|
||||||
|
* @typedef {import("./types.js").Equipment} Equipment
|
||||||
|
*/
|
||||||
|
|
||||||
import { Unit } from "./Unit.js";
|
import { Unit } from "./Unit.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explorer.js
|
* Explorer.js
|
||||||
* Player character class supporting Multi-Class Mastery and Persistent Progression.
|
* Player character class supporting Multi-Class Mastery and Persistent Progression.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class Explorer extends Unit {
|
export class Explorer extends Unit {
|
||||||
|
/**
|
||||||
|
* @param {string} id - Unique unit identifier
|
||||||
|
* @param {string} name - Explorer name
|
||||||
|
* @param {string} startingClassId - Starting class ID
|
||||||
|
* @param {Record<string, unknown>} classDefinition - Class definition data
|
||||||
|
*/
|
||||||
constructor(id, name, startingClassId, classDefinition) {
|
constructor(id, name, startingClassId, classDefinition) {
|
||||||
super(id, name, "EXPLORER", `${startingClassId}_MODEL`);
|
super(id, name, "EXPLORER", `${startingClassId}_MODEL`);
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
this.activeClassId = startingClassId;
|
this.activeClassId = startingClassId;
|
||||||
|
|
||||||
// Persistent Mastery: Tracks progress for EVERY class this character has played
|
// Persistent Mastery: Tracks progress for EVERY class this character has played
|
||||||
// Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] }
|
// Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] }
|
||||||
|
/** @type {Record<string, ClassMastery>} */
|
||||||
this.classMastery = {};
|
this.classMastery = {};
|
||||||
|
|
||||||
// Initialize the starting class entry
|
// Initialize the starting class entry
|
||||||
|
|
@ -24,6 +38,7 @@ export class Explorer extends Unit {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
|
/** @type {Equipment} */
|
||||||
this.equipment = {
|
this.equipment = {
|
||||||
weapon: null,
|
weapon: null,
|
||||||
armor: null,
|
armor: null,
|
||||||
|
|
@ -32,10 +47,16 @@ export class Explorer extends Unit {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active Skills (Populated by Skill Tree)
|
// Active Skills (Populated by Skill Tree)
|
||||||
|
/** @type {unknown[]} */
|
||||||
this.actions = [];
|
this.actions = [];
|
||||||
|
/** @type {unknown[]} */
|
||||||
this.passives = [];
|
this.passives = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes mastery data for a class.
|
||||||
|
* @param {string} classId - Class ID to initialize
|
||||||
|
*/
|
||||||
initializeMastery(classId) {
|
initializeMastery(classId) {
|
||||||
if (!this.classMastery[classId]) {
|
if (!this.classMastery[classId]) {
|
||||||
this.classMastery[classId] = {
|
this.classMastery[classId] = {
|
||||||
|
|
@ -49,7 +70,7 @@ export class Explorer extends Unit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates base stats based on the active class's base + growth rates * level.
|
* Updates base stats based on the active class's base + growth rates * level.
|
||||||
* @param {Object} classDef - The JSON definition of the class stats.
|
* @param {Record<string, unknown>} classDef - The JSON definition of the class stats.
|
||||||
*/
|
*/
|
||||||
recalculateBaseStats(classDef) {
|
recalculateBaseStats(classDef) {
|
||||||
if (classDef.id !== this.activeClassId) {
|
if (classDef.id !== this.activeClassId) {
|
||||||
|
|
@ -81,6 +102,8 @@ export class Explorer extends Unit {
|
||||||
/**
|
/**
|
||||||
* Swaps the active class logic.
|
* Swaps the active class logic.
|
||||||
* NOTE: Does NOT check unlock requirements (handled by UI/MetaSystem).
|
* NOTE: Does NOT check unlock requirements (handled by UI/MetaSystem).
|
||||||
|
* @param {string} newClassId - New class ID
|
||||||
|
* @param {Record<string, unknown>} newClassDef - New class definition
|
||||||
*/
|
*/
|
||||||
changeClass(newClassId, newClassDef) {
|
changeClass(newClassId, newClassDef) {
|
||||||
// 1. Ensure mastery record exists
|
// 1. Ensure mastery record exists
|
||||||
|
|
@ -101,6 +124,7 @@ export class Explorer extends Unit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds XP to the *current* class.
|
* Adds XP to the *current* class.
|
||||||
|
* @param {number} amount - XP amount to add
|
||||||
*/
|
*/
|
||||||
gainExperience(amount) {
|
gainExperience(amount) {
|
||||||
const mastery = this.classMastery[this.activeClassId];
|
const mastery = this.classMastery[this.activeClassId];
|
||||||
|
|
@ -108,6 +132,10 @@ export class Explorer extends Unit {
|
||||||
// Level up logic would be handled by a system checking XP curves
|
// Level up logic would be handled by a system checking XP curves
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current level of the active class.
|
||||||
|
* @returns {number} - Current level
|
||||||
|
*/
|
||||||
getLevel() {
|
getLevel() {
|
||||||
return this.classMastery[this.activeClassId].level;
|
return this.classMastery[this.activeClassId].level;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,56 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import("./types.js").UnitStats} UnitStats
|
||||||
|
* @typedef {import("./types.js").Position} Position
|
||||||
|
* @typedef {import("./types.js").FacingDirection} FacingDirection
|
||||||
|
* @typedef {import("./types.js").UnitType} UnitType
|
||||||
|
* @typedef {import("./types.js").UnitTeam} UnitTeam
|
||||||
|
* @typedef {import("./types.js").StatusEffect} StatusEffect
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit.js
|
* Unit.js
|
||||||
* Base class for all entities on the grid (Explorers, Enemies, Structures).
|
* Base class for all entities on the grid (Explorers, Enemies, Structures).
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class Unit {
|
export class Unit {
|
||||||
|
/**
|
||||||
|
* @param {string} id - Unique unit identifier
|
||||||
|
* @param {string} name - Unit name
|
||||||
|
* @param {UnitType} type - Unit type
|
||||||
|
* @param {string} voxelModelID - Voxel model identifier
|
||||||
|
*/
|
||||||
constructor(id, name, type, voxelModelID) {
|
constructor(id, name, type, voxelModelID) {
|
||||||
|
/** @type {string} */
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
/** @type {string} */
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
/** @type {UnitType} */
|
||||||
this.type = type; // 'EXPLORER', 'ENEMY', 'STRUCTURE'
|
this.type = type; // 'EXPLORER', 'ENEMY', 'STRUCTURE'
|
||||||
|
/** @type {string} */
|
||||||
this.voxelModelID = voxelModelID;
|
this.voxelModelID = voxelModelID;
|
||||||
|
|
||||||
// Grid State
|
// Grid State
|
||||||
|
/** @type {Position} */
|
||||||
this.position = { x: 0, y: 0, z: 0 };
|
this.position = { x: 0, y: 0, z: 0 };
|
||||||
|
/** @type {FacingDirection} */
|
||||||
this.facing = "NORTH";
|
this.facing = "NORTH";
|
||||||
|
|
||||||
// Combat State
|
// Combat State
|
||||||
|
/** @type {number} */
|
||||||
this.currentHealth = 100;
|
this.currentHealth = 100;
|
||||||
|
/** @type {number} */
|
||||||
this.maxHealth = 100; // Derived from effective stats later
|
this.maxHealth = 100; // Derived from effective stats later
|
||||||
|
|
||||||
|
/** @type {number} */
|
||||||
this.currentAP = 0; // Action Points for current turn
|
this.currentAP = 0; // Action Points for current turn
|
||||||
|
/** @type {number} */
|
||||||
this.chargeMeter = 0; // Dynamic Initiative (0-100)
|
this.chargeMeter = 0; // Dynamic Initiative (0-100)
|
||||||
|
|
||||||
|
/** @type {StatusEffect[]} */
|
||||||
this.statusEffects = []; // Active debuffs/buffs
|
this.statusEffects = []; // Active debuffs/buffs
|
||||||
|
|
||||||
// Base Stats (Raw values before gear/buffs)
|
// Base Stats (Raw values before gear/buffs)
|
||||||
|
/** @type {UnitStats} */
|
||||||
this.baseStats = {
|
this.baseStats = {
|
||||||
health: 100,
|
health: 100,
|
||||||
attack: 10,
|
attack: 10,
|
||||||
|
|
@ -33,11 +61,17 @@ export class Unit {
|
||||||
movement: 4,
|
movement: 4,
|
||||||
tech: 0,
|
tech: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @type {UnitTeam | undefined} */
|
||||||
|
this.team = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates position.
|
* Updates position.
|
||||||
* Note: Validation happens in VoxelGrid/MovementSystem.
|
* Note: Validation happens in VoxelGrid/MovementSystem.
|
||||||
|
* @param {number} x - X coordinate
|
||||||
|
* @param {number} y - Y coordinate
|
||||||
|
* @param {number} z - Z coordinate
|
||||||
*/
|
*/
|
||||||
setPosition(x, y, z) {
|
setPosition(x, y, z) {
|
||||||
this.position = { x, y, z };
|
this.position = { x, y, z };
|
||||||
|
|
@ -45,6 +79,8 @@ export class Unit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes AP. Returns true if successful.
|
* Consumes AP. Returns true if successful.
|
||||||
|
* @param {number} amount - Amount of AP to spend
|
||||||
|
* @returns {boolean} - True if successful
|
||||||
*/
|
*/
|
||||||
spendAP(amount) {
|
spendAP(amount) {
|
||||||
if (this.currentAP >= amount) {
|
if (this.currentAP >= amount) {
|
||||||
|
|
@ -54,6 +90,10 @@ export class Unit {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the unit is alive.
|
||||||
|
* @returns {boolean} - True if alive
|
||||||
|
*/
|
||||||
isAlive() {
|
isAlive() {
|
||||||
return this.currentHealth > 0;
|
return this.currentHealth > 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
src/units/types.d.ts
vendored
Normal file
115
src/units/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for unit-related types
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit base stats
|
||||||
|
*/
|
||||||
|
export interface UnitStats {
|
||||||
|
health: number;
|
||||||
|
attack: number;
|
||||||
|
defense: number;
|
||||||
|
magic: number;
|
||||||
|
speed: number;
|
||||||
|
willpower: number;
|
||||||
|
movement: number;
|
||||||
|
tech: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit position
|
||||||
|
*/
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit facing direction
|
||||||
|
*/
|
||||||
|
export type FacingDirection = "NORTH" | "SOUTH" | "EAST" | "WEST";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit type
|
||||||
|
*/
|
||||||
|
export type UnitType = "EXPLORER" | "ENEMY" | "STRUCTURE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit team
|
||||||
|
*/
|
||||||
|
export type UnitTeam = "PLAYER" | "ENEMY" | "NEUTRAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit status
|
||||||
|
*/
|
||||||
|
export type UnitStatus = "READY" | "INJURED" | "DEPLOYED" | "DEAD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status effect
|
||||||
|
*/
|
||||||
|
export interface StatusEffect {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
duration: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class mastery data
|
||||||
|
*/
|
||||||
|
export interface ClassMastery {
|
||||||
|
level: number;
|
||||||
|
xp: number;
|
||||||
|
skillPoints: number;
|
||||||
|
unlockedNodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equipment slots
|
||||||
|
*/
|
||||||
|
export interface Equipment {
|
||||||
|
weapon: unknown | null;
|
||||||
|
armor: unknown | null;
|
||||||
|
utility: unknown | null;
|
||||||
|
relic: unknown | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit definition from registry
|
||||||
|
*/
|
||||||
|
export interface UnitDefinition {
|
||||||
|
type?: UnitType;
|
||||||
|
name: string;
|
||||||
|
stats?: Partial<UnitStats>;
|
||||||
|
model?: string;
|
||||||
|
ai_archetype?: string;
|
||||||
|
aggro_range?: number;
|
||||||
|
xp_value?: number;
|
||||||
|
loot_table?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explorer unit data (for roster)
|
||||||
|
*/
|
||||||
|
export interface ExplorerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
classId: string;
|
||||||
|
status: UnitStatus;
|
||||||
|
history: {
|
||||||
|
missions: number;
|
||||||
|
kills: number;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roster save data
|
||||||
|
*/
|
||||||
|
export interface RosterSaveData {
|
||||||
|
roster: ExplorerData[];
|
||||||
|
graveyard: ExplorerData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,17 +2,28 @@
|
||||||
* SeededRandom.js
|
* SeededRandom.js
|
||||||
* A deterministic pseudo-random number generator using Mulberry32.
|
* A deterministic pseudo-random number generator using Mulberry32.
|
||||||
* Essential for reproducible procedural generation.
|
* Essential for reproducible procedural generation.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
export class SeededRandom {
|
export class SeededRandom {
|
||||||
|
/**
|
||||||
|
* @param {number | string} seed - Random seed (number or string)
|
||||||
|
*/
|
||||||
constructor(seed) {
|
constructor(seed) {
|
||||||
// Hash the string seed to a number if necessary
|
// Hash the string seed to a number if necessary
|
||||||
if (typeof seed === "string") {
|
if (typeof seed === "string") {
|
||||||
this.state = this.hashString(seed);
|
this.state = this.hashString(seed);
|
||||||
} else {
|
} else {
|
||||||
|
/** @type {number} */
|
||||||
this.state = seed || Math.floor(Math.random() * 2147483647);
|
this.state = seed || Math.floor(Math.random() * 2147483647);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a string to a number.
|
||||||
|
* @param {string} str - String to hash
|
||||||
|
* @returns {number} - Hashed value
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
hashString(str) {
|
hashString(str) {
|
||||||
let hash = 1779033703 ^ str.length;
|
let hash = 1779033703 ^ str.length;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
|
@ -26,7 +37,10 @@ export class SeededRandom {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mulberry32 Algorithm
|
/**
|
||||||
|
* Mulberry32 Algorithm - generates next random number.
|
||||||
|
* @returns {number} - Random number between 0 and 1
|
||||||
|
*/
|
||||||
next() {
|
next() {
|
||||||
let t = (this.state += 0x6d2b79f5);
|
let t = (this.state += 0x6d2b79f5);
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
|
@ -34,17 +48,31 @@ export class SeededRandom {
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns float between [min, max)
|
/**
|
||||||
|
* Returns float between [min, max).
|
||||||
|
* @param {number} min - Minimum value
|
||||||
|
* @param {number} max - Maximum value
|
||||||
|
* @returns {number} - Random float
|
||||||
|
*/
|
||||||
range(min, max) {
|
range(min, max) {
|
||||||
return min + this.next() * (max - min);
|
return min + this.next() * (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns integer between [min, max] (inclusive)
|
/**
|
||||||
|
* Returns integer between [min, max] (inclusive).
|
||||||
|
* @param {number} min - Minimum value
|
||||||
|
* @param {number} max - Maximum value
|
||||||
|
* @returns {number} - Random integer
|
||||||
|
*/
|
||||||
rangeInt(min, max) {
|
rangeInt(min, max) {
|
||||||
return Math.floor(this.range(min, max + 1));
|
return Math.floor(this.range(min, max + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true/false based on probability (0.0 - 1.0)
|
/**
|
||||||
|
* Returns true/false based on probability (0.0 - 1.0).
|
||||||
|
* @param {number} probability - Probability between 0 and 1
|
||||||
|
* @returns {boolean} - True if random value is less than probability
|
||||||
|
*/
|
||||||
chance(probability) {
|
chance(probability) {
|
||||||
return this.next() < probability;
|
return this.next() < probability;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@
|
||||||
* SimplexNoise.js
|
* SimplexNoise.js
|
||||||
* A dependency-free, seeded 2D Simplex Noise implementation.
|
* A dependency-free, seeded 2D Simplex Noise implementation.
|
||||||
* Based on the standard Stefan Gustavson algorithm.
|
* Based on the standard Stefan Gustavson algorithm.
|
||||||
|
* @class
|
||||||
*/
|
*/
|
||||||
import { SeededRandom } from "./SeededRandom.js";
|
import { SeededRandom } from "./SeededRandom.js";
|
||||||
|
|
||||||
export class SimplexNoise {
|
export class SimplexNoise {
|
||||||
|
/**
|
||||||
|
* @param {number | string | SeededRandom} seedOrRng - Seed value or SeededRandom instance
|
||||||
|
*/
|
||||||
constructor(seedOrRng) {
|
constructor(seedOrRng) {
|
||||||
// Allow passing a seed OR an existing RNG instance
|
// Allow passing a seed OR an existing RNG instance
|
||||||
const rng =
|
const rng =
|
||||||
|
|
@ -14,6 +18,7 @@ export class SimplexNoise {
|
||||||
: new SeededRandom(seedOrRng);
|
: new SeededRandom(seedOrRng);
|
||||||
|
|
||||||
// 1. Build Permutation Table
|
// 1. Build Permutation Table
|
||||||
|
/** @type {Uint8Array} */
|
||||||
this.p = new Uint8Array(256);
|
this.p = new Uint8Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
this.p[i] = i;
|
this.p[i] = i;
|
||||||
|
|
@ -28,7 +33,9 @@ export class SimplexNoise {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate for overflow handling
|
// Duplicate for overflow handling
|
||||||
|
/** @type {Uint8Array} */
|
||||||
this.perm = new Uint8Array(512);
|
this.perm = new Uint8Array(512);
|
||||||
|
/** @type {Uint8Array} */
|
||||||
this.permMod12 = new Uint8Array(512);
|
this.permMod12 = new Uint8Array(512);
|
||||||
for (let i = 0; i < 512; i++) {
|
for (let i = 0; i < 512; i++) {
|
||||||
this.perm[i] = this.p[i & 255];
|
this.perm[i] = this.p[i & 255];
|
||||||
|
|
@ -36,6 +43,7 @@ export class SimplexNoise {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gradient vectors
|
// Gradient vectors
|
||||||
|
/** @type {number[]} */
|
||||||
this.grad3 = [
|
this.grad3 = [
|
||||||
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1,
|
1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1,
|
||||||
0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
|
0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1,
|
||||||
|
|
@ -45,6 +53,9 @@ export class SimplexNoise {
|
||||||
/**
|
/**
|
||||||
* Samples 2D Noise at coordinates x, y.
|
* Samples 2D Noise at coordinates x, y.
|
||||||
* Returns a value roughly between -1.0 and 1.0.
|
* Returns a value roughly between -1.0 and 1.0.
|
||||||
|
* @param {number} xin - X coordinate
|
||||||
|
* @param {number} yin - Y coordinate
|
||||||
|
* @returns {number} - Noise value
|
||||||
*/
|
*/
|
||||||
noise2D(xin, yin) {
|
noise2D(xin, yin) {
|
||||||
let n0, n1, n2; // Noise contributions from the three corners
|
let n0, n1, n2; // Noise contributions from the three corners
|
||||||
|
|
@ -113,6 +124,15 @@ export class SimplexNoise {
|
||||||
return 70.0 * (n0 + n1 + n2);
|
return 70.0 * (n0 + n1 + n2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dot product helper.
|
||||||
|
* @param {number[]} g - Gradient array
|
||||||
|
* @param {number} gi - Gradient index
|
||||||
|
* @param {number} x - X component
|
||||||
|
* @param {number} y - Y component
|
||||||
|
* @returns {number} - Dot product
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
dot(g, gi, x, y) {
|
dot(g, gi, x, y) {
|
||||||
return g[gi * 3] * x + g[gi * 3 + 1] * y;
|
return g[gi * 3] * x + g[gi * 3 + 1] * y;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
216
test/core/Persistence.test.js
Normal file
216
test/core/Persistence.test.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import sinon from "sinon";
|
||||||
|
import { Persistence } from "../../src/core/Persistence.js";
|
||||||
|
|
||||||
|
describe("Core: Persistence", () => {
|
||||||
|
let persistence;
|
||||||
|
let mockDB;
|
||||||
|
let mockStore;
|
||||||
|
let mockTransaction;
|
||||||
|
let mockRequest;
|
||||||
|
let globalObj;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
persistence = new Persistence();
|
||||||
|
|
||||||
|
// Mock IndexedDB
|
||||||
|
mockStore = {
|
||||||
|
put: sinon.stub(),
|
||||||
|
get: sinon.stub(),
|
||||||
|
delete: sinon.stub(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTransaction = {
|
||||||
|
objectStore: sinon.stub().returns(mockStore),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDB = {
|
||||||
|
objectStoreNames: {
|
||||||
|
contains: sinon.stub().returns(false),
|
||||||
|
},
|
||||||
|
createObjectStore: sinon.stub(),
|
||||||
|
transaction: sinon.stub().returns(mockTransaction),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock indexedDB.open
|
||||||
|
mockRequest = {
|
||||||
|
onerror: null,
|
||||||
|
onsuccess: null,
|
||||||
|
onupgradeneeded: null,
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerSuccess = () => {
|
||||||
|
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 () => {
|
||||||
|
triggerUpgrade();
|
||||||
|
triggerSuccess();
|
||||||
|
|
||||||
|
await persistence.init();
|
||||||
|
|
||||||
|
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(persistence.db).to.equal(mockDB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: saveRun should store run data with active_run id", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
const runData = { seed: 12345, depth: 5, squad: [] };
|
||||||
|
|
||||||
|
const mockPutRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
mockStore.put.returns(mockPutRequest);
|
||||||
|
|
||||||
|
const savePromise = persistence.saveRun(runData);
|
||||||
|
mockPutRequest.onsuccess();
|
||||||
|
|
||||||
|
await savePromise;
|
||||||
|
|
||||||
|
expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true;
|
||||||
|
expect(mockStore.put.calledOnce).to.be.true;
|
||||||
|
const savedData = mockStore.put.firstCall.args[0];
|
||||||
|
expect(savedData.id).to.equal("active_run");
|
||||||
|
expect(savedData.seed).to.equal(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: loadRun should retrieve active_run data", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
const savedData = { id: "active_run", seed: 12345, depth: 5 };
|
||||||
|
|
||||||
|
const mockGetRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
result: savedData,
|
||||||
|
};
|
||||||
|
mockStore.get.returns(mockGetRequest);
|
||||||
|
|
||||||
|
const loadPromise = persistence.loadRun();
|
||||||
|
mockGetRequest.onsuccess();
|
||||||
|
|
||||||
|
const result = await loadPromise;
|
||||||
|
|
||||||
|
expect(mockDB.transaction.calledWith(["Runs"], "readonly")).to.be.true;
|
||||||
|
expect(mockStore.get.calledWith("active_run")).to.be.true;
|
||||||
|
expect(result).to.deep.equal(savedData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: clearRun should delete active_run", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
|
||||||
|
const mockDeleteRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
mockStore.delete.returns(mockDeleteRequest);
|
||||||
|
|
||||||
|
const deletePromise = persistence.clearRun();
|
||||||
|
mockDeleteRequest.onsuccess();
|
||||||
|
|
||||||
|
await deletePromise;
|
||||||
|
|
||||||
|
expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true;
|
||||||
|
expect(mockStore.delete.calledWith("active_run")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: saveRoster should wrap roster data with id", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
const rosterData = { roster: [], graveyard: [] };
|
||||||
|
|
||||||
|
const mockPutRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
mockStore.put.returns(mockPutRequest);
|
||||||
|
|
||||||
|
const savePromise = persistence.saveRoster(rosterData);
|
||||||
|
mockPutRequest.onsuccess();
|
||||||
|
|
||||||
|
await savePromise;
|
||||||
|
|
||||||
|
expect(mockDB.transaction.calledWith(["Roster"], "readwrite")).to.be.true;
|
||||||
|
expect(mockStore.put.calledOnce).to.be.true;
|
||||||
|
const savedData = mockStore.put.firstCall.args[0];
|
||||||
|
expect(savedData.id).to.equal("player_roster");
|
||||||
|
expect(savedData.data).to.deep.equal(rosterData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: loadRoster should extract data from stored object", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
const storedData = { id: "player_roster", data: { roster: [], graveyard: [] } };
|
||||||
|
|
||||||
|
const mockGetRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
result: storedData,
|
||||||
|
};
|
||||||
|
mockStore.get.returns(mockGetRequest);
|
||||||
|
|
||||||
|
const loadPromise = persistence.loadRoster();
|
||||||
|
mockGetRequest.onsuccess();
|
||||||
|
|
||||||
|
const result = await loadPromise;
|
||||||
|
|
||||||
|
expect(mockDB.transaction.calledWith(["Roster"], "readonly")).to.be.true;
|
||||||
|
expect(mockStore.get.calledWith("player_roster")).to.be.true;
|
||||||
|
expect(result).to.deep.equal(storedData.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: loadRoster should return null if no data exists", async () => {
|
||||||
|
persistence.db = mockDB;
|
||||||
|
|
||||||
|
const mockGetRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
result: undefined,
|
||||||
|
};
|
||||||
|
mockStore.get.returns(mockGetRequest);
|
||||||
|
|
||||||
|
const loadPromise = persistence.loadRoster();
|
||||||
|
mockGetRequest.onsuccess();
|
||||||
|
|
||||||
|
const result = await loadPromise;
|
||||||
|
|
||||||
|
expect(result).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: saveRun should auto-init if db not initialized", async () => {
|
||||||
|
triggerUpgrade();
|
||||||
|
triggerSuccess();
|
||||||
|
|
||||||
|
const runData = { seed: 12345 };
|
||||||
|
const mockPutRequest = {
|
||||||
|
onsuccess: null,
|
||||||
|
onerror: null,
|
||||||
|
};
|
||||||
|
mockStore.put.returns(mockPutRequest);
|
||||||
|
|
||||||
|
const savePromise = persistence.saveRun(runData);
|
||||||
|
mockPutRequest.onsuccess();
|
||||||
|
|
||||||
|
await savePromise;
|
||||||
|
|
||||||
|
expect(persistence.db).to.equal(mockDB);
|
||||||
|
expect(mockStore.put.calledOnce).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
135
test/generation/BaseGenerator.test.js
Normal file
135
test/generation/BaseGenerator.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { BaseGenerator } from "../../src/generation/BaseGenerator.js";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Generation: BaseGenerator", () => {
|
||||||
|
let grid;
|
||||||
|
let generator;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new VoxelGrid(10, 5, 10);
|
||||||
|
generator = new BaseGenerator(grid, 12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with grid and RNG", () => {
|
||||||
|
expect(generator.grid).to.equal(grid);
|
||||||
|
expect(generator.rng).to.exist;
|
||||||
|
expect(generator.width).to.equal(10);
|
||||||
|
expect(generator.height).to.equal(5);
|
||||||
|
expect(generator.depth).to.equal(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: getSolidNeighbors should count solid neighbors correctly", () => {
|
||||||
|
// Set up a pattern: center is air, surrounded by solids
|
||||||
|
grid.setCell(5, 2, 5, 0); // Center (air)
|
||||||
|
grid.setCell(4, 2, 5, 1); // Left
|
||||||
|
grid.setCell(6, 2, 5, 1); // Right
|
||||||
|
grid.setCell(5, 2, 4, 1); // Front
|
||||||
|
grid.setCell(5, 2, 6, 1); // Back
|
||||||
|
grid.setCell(5, 1, 5, 1); // Below
|
||||||
|
grid.setCell(5, 3, 5, 1); // Above
|
||||||
|
|
||||||
|
const count = generator.getSolidNeighbors(5, 2, 5);
|
||||||
|
|
||||||
|
// Should count at least 6 neighbors (excluding center)
|
||||||
|
expect(count).to.be.greaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: getSolidNeighbors should handle out-of-bounds as solid", () => {
|
||||||
|
// Test edge case - corner position
|
||||||
|
grid.setCell(0, 0, 0, 0); // Air at corner
|
||||||
|
|
||||||
|
const count = generator.getSolidNeighbors(0, 0, 0);
|
||||||
|
|
||||||
|
// Out-of-bounds neighbors should count as solid
|
||||||
|
expect(count).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: scatterCover should place objects on valid floor tiles", () => {
|
||||||
|
// Create a floor: solid at y=0, air at y=1
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1); // Floor
|
||||||
|
grid.setCell(x, 1, z, 0); // Air above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.scatterCover(10, 0.5); // 50% density
|
||||||
|
|
||||||
|
// Check that some objects were placed
|
||||||
|
let objectCount = 0;
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
if (grid.getCell(x, 1, z) === 10) {
|
||||||
|
objectCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With 50% density on ~64 tiles, we should get some objects
|
||||||
|
expect(objectCount).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: scatterCover should not place objects on invalid tiles", () => {
|
||||||
|
// Create invalid floor: air at y=0 (no solid below)
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
grid.setCell(x, 0, z, 0); // No floor
|
||||||
|
grid.setCell(x, 1, z, 0); // Air
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.scatterCover(10, 1.0); // 100% density
|
||||||
|
|
||||||
|
// No objects should be placed
|
||||||
|
let objectCount = 0;
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
if (grid.getCell(x, 1, z) === 10) {
|
||||||
|
objectCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(objectCount).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: scatterCover should respect density parameter", () => {
|
||||||
|
// Create valid floor
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1);
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with different densities
|
||||||
|
generator.scatterCover(10, 0.1); // 10% density
|
||||||
|
let lowCount = 0;
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
if (grid.getCell(x, 1, z) === 10) lowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and test higher density
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.scatterCover(10, 0.9); // 90% density
|
||||||
|
let highCount = 0;
|
||||||
|
for (let x = 1; x < 9; x++) {
|
||||||
|
for (let z = 1; z < 9; z++) {
|
||||||
|
if (grid.getCell(x, 1, z) === 10) highCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher density should generally produce more objects
|
||||||
|
// (allowing for randomness, but trend should be clear)
|
||||||
|
expect(highCount).to.be.greaterThan(lowCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
132
test/generation/CaveGenerator.test.js
Normal file
132
test/generation/CaveGenerator.test.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { CaveGenerator } from "../../src/generation/CaveGenerator.js";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Generation: CaveGenerator", () => {
|
||||||
|
let grid;
|
||||||
|
let generator;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new VoxelGrid(20, 10, 20);
|
||||||
|
generator = new CaveGenerator(grid, 12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with texture generators", () => {
|
||||||
|
expect(generator.floorGen).to.exist;
|
||||||
|
expect(generator.wallGen).to.exist;
|
||||||
|
expect(generator.generatedAssets).to.have.property("palette");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: preloadTextures should generate texture palette", () => {
|
||||||
|
generator.preloadTextures();
|
||||||
|
|
||||||
|
// Should have wall variations (100-109)
|
||||||
|
expect(generator.generatedAssets.palette[100]).to.exist;
|
||||||
|
expect(generator.generatedAssets.palette[109]).to.exist;
|
||||||
|
|
||||||
|
// Should have floor variations (200-209)
|
||||||
|
expect(generator.generatedAssets.palette[200]).to.exist;
|
||||||
|
expect(generator.generatedAssets.palette[209]).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: generate should create foundation layer", () => {
|
||||||
|
generator.generate(0.5, 2);
|
||||||
|
|
||||||
|
// Foundation (y=0) should be solid
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
expect(grid.getCell(x, 0, z)).to.not.equal(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: generate should keep sky clear", () => {
|
||||||
|
generator.generate(0.5, 2);
|
||||||
|
|
||||||
|
// Top layer (y=height-1) should be air
|
||||||
|
const topY = grid.size.y - 1;
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
expect(grid.getCell(x, topY, z)).to.equal(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: smooth should apply cellular automata rules", () => {
|
||||||
|
// Set up initial pattern
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
for (let y = 1; y < grid.size.y - 1; y++) {
|
||||||
|
grid.setCell(x, y, z, Math.random() > 0.5 ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeState = grid.cells.slice();
|
||||||
|
generator.smooth();
|
||||||
|
const afterState = grid.cells.slice();
|
||||||
|
|
||||||
|
// Smoothing should change the grid
|
||||||
|
expect(afterState).to.not.deep.equal(beforeState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: applyTextures should assign floor and wall IDs", () => {
|
||||||
|
// Create a simple structure: floor at y=1, wall above
|
||||||
|
// Floor surface: solid at y=1 with air above (y=2)
|
||||||
|
// Wall: solid at y=2 with solid above (y=3)
|
||||||
|
for (let x = 1; x < 10; x++) {
|
||||||
|
for (let z = 1; z < 10; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1); // Foundation
|
||||||
|
grid.setCell(x, 1, z, 1); // Floor surface (will be textured as floor if air above)
|
||||||
|
grid.setCell(x, 2, z, 0); // Air above floor (makes y=1 a floor surface)
|
||||||
|
grid.setCell(x, 3, z, 1); // Wall (solid with solid above)
|
||||||
|
grid.setCell(x, 4, z, 1); // Solid above wall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.applyTextures();
|
||||||
|
|
||||||
|
// Floor surfaces (y=1 with air above) should have IDs 200-209
|
||||||
|
const floorId = grid.getCell(5, 1, 5);
|
||||||
|
expect(floorId).to.be.greaterThanOrEqual(200);
|
||||||
|
expect(floorId).to.be.lessThanOrEqual(209);
|
||||||
|
|
||||||
|
// Walls (y=3 with solid above) should have IDs 100-109
|
||||||
|
const wallId = grid.getCell(5, 3, 5);
|
||||||
|
expect(wallId).to.be.greaterThanOrEqual(100);
|
||||||
|
expect(wallId).to.be.lessThanOrEqual(109);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: generate should scatter cover objects", () => {
|
||||||
|
generator.generate(0.5, 2);
|
||||||
|
|
||||||
|
// Check for cover objects (ID 10)
|
||||||
|
let coverCount = 0;
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
for (let y = 0; y < grid.size.y; y++) {
|
||||||
|
if (grid.getCell(x, y, z) === 10) {
|
||||||
|
coverCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have some cover objects
|
||||||
|
expect(coverCount).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: generate with same seed should produce consistent results", () => {
|
||||||
|
const grid1 = new VoxelGrid(20, 10, 20);
|
||||||
|
const gen1 = new CaveGenerator(grid1, 12345);
|
||||||
|
gen1.generate(0.5, 2);
|
||||||
|
|
||||||
|
const grid2 = new VoxelGrid(20, 10, 20);
|
||||||
|
const gen2 = new CaveGenerator(grid2, 12345);
|
||||||
|
gen2.generate(0.5, 2);
|
||||||
|
|
||||||
|
// Same seed should produce same results
|
||||||
|
expect(grid1.cells).to.deep.equal(grid2.cells);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
155
test/generation/PostProcessing.test.js
Normal file
155
test/generation/PostProcessing.test.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { PostProcessor } from "../../src/generation/PostProcessing.js";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Generation: PostProcessor", () => {
|
||||||
|
let grid;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new VoxelGrid(20, 5, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: ensureConnectivity should identify separate regions", () => {
|
||||||
|
// Create two disconnected floor regions
|
||||||
|
// Region 1: left side
|
||||||
|
for (let x = 1; x < 5; x++) {
|
||||||
|
for (let z = 1; z < 5; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1); // Floor
|
||||||
|
grid.setCell(x, 1, z, 0); // Air
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region 2: right side (disconnected)
|
||||||
|
for (let x = 15; x < 19; x++) {
|
||||||
|
for (let z = 15; z < 19; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1); // Floor
|
||||||
|
grid.setCell(x, 1, z, 0); // Air
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostProcessor.ensureConnectivity(grid);
|
||||||
|
|
||||||
|
// After processing, smaller regions should be filled
|
||||||
|
// The grid should have connectivity ensured
|
||||||
|
expect(grid).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: ensureConnectivity should keep largest region", () => {
|
||||||
|
// Create one large region and one small region
|
||||||
|
// Large region
|
||||||
|
for (let x = 1; x < 10; x++) {
|
||||||
|
for (let z = 1; z < 10; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1);
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small region
|
||||||
|
for (let x = 15; x < 17; x++) {
|
||||||
|
for (let z = 15; z < 17; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1);
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallRegionAirBefore = grid.getCell(15, 1, 15);
|
||||||
|
|
||||||
|
PostProcessor.ensureConnectivity(grid);
|
||||||
|
|
||||||
|
// Small region should be filled (no longer air)
|
||||||
|
const smallRegionAfter = grid.getCell(15, 1, 15);
|
||||||
|
// If connectivity was ensured, small region might be filled
|
||||||
|
// (exact behavior depends on implementation)
|
||||||
|
expect(smallRegionAfter).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: floodFill should collect all connected air tiles", () => {
|
||||||
|
// Create a connected region
|
||||||
|
for (let x = 1; x < 5; x++) {
|
||||||
|
for (let z = 1; z < 5; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1);
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const region = PostProcessor.floodFill(grid, 2, 1, 2, visited);
|
||||||
|
|
||||||
|
// Should collect multiple tiles
|
||||||
|
expect(region.length).to.be.greaterThan(1);
|
||||||
|
expect(region).to.deep.include({ x: 2, y: 1, z: 2 });
|
||||||
|
expect(region).to.deep.include({ x: 3, y: 1, z: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: floodFill should not include disconnected tiles", () => {
|
||||||
|
// Create two separate regions with proper floor setup
|
||||||
|
// Region 1: connected tiles
|
||||||
|
grid.setCell(1, 0, 1, 1); // Floor
|
||||||
|
grid.setCell(1, 1, 1, 0); // Air
|
||||||
|
grid.setCell(2, 0, 1, 1); // Floor
|
||||||
|
grid.setCell(2, 1, 1, 0); // Air (connected)
|
||||||
|
|
||||||
|
// Region 2: disconnected (no floor connection)
|
||||||
|
grid.setCell(10, 0, 10, 1); // Floor
|
||||||
|
grid.setCell(10, 1, 10, 0); // Air (disconnected)
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const region = PostProcessor.floodFill(grid, 1, 1, 1, visited);
|
||||||
|
|
||||||
|
// Should only include connected tiles from region 1
|
||||||
|
expect(region.length).to.equal(2);
|
||||||
|
expect(region).to.deep.include({ x: 1, y: 1, z: 1 });
|
||||||
|
expect(region).to.deep.include({ x: 2, y: 1, z: 1 });
|
||||||
|
expect(region).to.not.deep.include({ x: 10, y: 1, z: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: floodFill should respect bounds", () => {
|
||||||
|
// Start at edge
|
||||||
|
grid.setCell(0, 0, 0, 1);
|
||||||
|
grid.setCell(0, 1, 0, 0);
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const region = PostProcessor.floodFill(grid, 0, 1, 0, visited);
|
||||||
|
|
||||||
|
// Should only include valid positions
|
||||||
|
expect(region.length).to.be.greaterThanOrEqual(1);
|
||||||
|
region.forEach((pos) => {
|
||||||
|
expect(grid.isValidBounds(pos.x, pos.y, pos.z)).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: ensureConnectivity should handle empty grid", () => {
|
||||||
|
// Grid with no floor regions
|
||||||
|
grid.fill(0);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => PostProcessor.ensureConnectivity(grid)).to.not.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: ensureConnectivity should handle single region", () => {
|
||||||
|
// Create one connected region
|
||||||
|
for (let x = 1; x < 10; x++) {
|
||||||
|
for (let z = 1; z < 10; z++) {
|
||||||
|
grid.setCell(x, 0, z, 1);
|
||||||
|
grid.setCell(x, 1, z, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const airCountBefore = Array.from(grid.cells).filter((v, i) => {
|
||||||
|
const y = Math.floor(i / (grid.size.x * grid.size.z)) % grid.size.y;
|
||||||
|
return y === 1 && v === 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
PostProcessor.ensureConnectivity(grid);
|
||||||
|
|
||||||
|
// Single region should remain intact
|
||||||
|
const airCountAfter = Array.from(grid.cells).filter((v, i) => {
|
||||||
|
const y = Math.floor(i / (grid.size.x * grid.size.z)) % grid.size.y;
|
||||||
|
return y === 1 && v === 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// Air count should be similar (allowing for minor changes)
|
||||||
|
expect(airCountAfter).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
160
test/generation/RuinGenerator.test.js
Normal file
160
test/generation/RuinGenerator.test.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { RuinGenerator } from "../../src/generation/RuinGenerator.js";
|
||||||
|
import { VoxelGrid } from "../../src/grid/VoxelGrid.js";
|
||||||
|
|
||||||
|
describe("Generation: RuinGenerator", () => {
|
||||||
|
let grid;
|
||||||
|
let generator;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new VoxelGrid(30, 10, 30);
|
||||||
|
generator = new RuinGenerator(grid, 12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with texture generators and spawn zones", () => {
|
||||||
|
expect(generator.floorGen).to.exist;
|
||||||
|
expect(generator.wallGen).to.exist;
|
||||||
|
expect(generator.generatedAssets).to.have.property("palette");
|
||||||
|
expect(generator.generatedAssets).to.have.property("spawnZones");
|
||||||
|
expect(generator.generatedAssets.spawnZones).to.have.property("player");
|
||||||
|
expect(generator.generatedAssets.spawnZones).to.have.property("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: generate should create rooms", () => {
|
||||||
|
generator.generate(5, 4, 8);
|
||||||
|
|
||||||
|
// Should have some air spaces (rooms)
|
||||||
|
let airCount = 0;
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
if (grid.getCell(x, 1, z) === 0) {
|
||||||
|
airCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(airCount).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: generate should mark spawn zones", () => {
|
||||||
|
generator.generate(3, 4, 6);
|
||||||
|
|
||||||
|
// Should have spawn zones if rooms were created
|
||||||
|
if (generator.generatedAssets.spawnZones.player.length > 0) {
|
||||||
|
expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: roomsOverlap should detect overlapping rooms", () => {
|
||||||
|
const rooms = [
|
||||||
|
{ x: 5, z: 5, w: 6, d: 6 },
|
||||||
|
{ x: 8, z: 8, w: 6, d: 6 }, // Overlaps with first
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(generator.roomsOverlap(rooms[1], rooms)).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: roomsOverlap should return false for non-overlapping rooms", () => {
|
||||||
|
const rooms = [
|
||||||
|
{ x: 5, z: 5, w: 4, d: 4 },
|
||||||
|
];
|
||||||
|
const newRoom = { x: 15, z: 15, w: 4, d: 4 }; // Far away
|
||||||
|
|
||||||
|
// roomsOverlap checks if newRoom overlaps with any existing room
|
||||||
|
expect(generator.roomsOverlap(newRoom, rooms)).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: getCenter should calculate room center", () => {
|
||||||
|
const room = { x: 10, z: 10, w: 6, d: 8, y: 1 };
|
||||||
|
|
||||||
|
const center = generator.getCenter(room);
|
||||||
|
|
||||||
|
expect(center.x).to.equal(13); // 10 + 6/2 = 13
|
||||||
|
expect(center.z).to.equal(14); // 10 + 8/2 = 14
|
||||||
|
expect(center.y).to.equal(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: buildRoom should create floor and walls", () => {
|
||||||
|
const room = { x: 5, y: 1, z: 5, w: 6, d: 6 };
|
||||||
|
|
||||||
|
generator.buildRoom(room);
|
||||||
|
|
||||||
|
// Floor should exist
|
||||||
|
expect(grid.getCell(7, 0, 7)).to.not.equal(0);
|
||||||
|
|
||||||
|
// Interior should be air
|
||||||
|
expect(grid.getCell(7, 1, 7)).to.equal(0);
|
||||||
|
|
||||||
|
// Perimeter should be walls
|
||||||
|
expect(grid.getCell(5, 1, 5)).to.not.equal(0); // Corner wall
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: buildCorridor should connect two points", () => {
|
||||||
|
const start = { x: 5, y: 1, z: 5 };
|
||||||
|
const end = { x: 15, y: 1, z: 15 };
|
||||||
|
|
||||||
|
generator.buildCorridor(start, end);
|
||||||
|
|
||||||
|
// Path should have floor
|
||||||
|
expect(grid.getCell(10, 0, 5)).to.not.equal(0);
|
||||||
|
expect(grid.getCell(15, 0, 10)).to.not.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 9: markSpawnZone should collect valid floor tiles", () => {
|
||||||
|
// Create a room manually
|
||||||
|
const room = { x: 5, y: 1, z: 5, w: 6, d: 6 };
|
||||||
|
generator.buildRoom(room);
|
||||||
|
|
||||||
|
generator.markSpawnZone(room, "player");
|
||||||
|
|
||||||
|
// Should have some spawn positions
|
||||||
|
expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0);
|
||||||
|
|
||||||
|
// Spawn positions should be valid floor tiles
|
||||||
|
const spawn = generator.generatedAssets.spawnZones.player[0];
|
||||||
|
expect(grid.getCell(spawn.x, spawn.y - 1, spawn.z)).to.not.equal(0); // Solid below
|
||||||
|
expect(grid.getCell(spawn.x, spawn.y, spawn.z)).to.equal(0); // Air at spawn
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 10: applyTextures should assign floor and wall IDs", () => {
|
||||||
|
// Create a simple structure
|
||||||
|
const room = { x: 5, y: 1, z: 5, w: 6, d: 6 };
|
||||||
|
generator.buildRoom(room);
|
||||||
|
|
||||||
|
generator.applyTextures();
|
||||||
|
|
||||||
|
// Floor should have IDs 200-209
|
||||||
|
const floorId = grid.getCell(7, 1, 7);
|
||||||
|
if (floorId !== 0) {
|
||||||
|
// If it's a floor surface
|
||||||
|
expect(floorId).to.be.greaterThanOrEqual(200);
|
||||||
|
expect(floorId).to.be.lessThanOrEqual(209);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall should have IDs 100-109
|
||||||
|
const wallId = grid.getCell(5, 1, 5);
|
||||||
|
if (wallId !== 0) {
|
||||||
|
expect(wallId).to.be.greaterThanOrEqual(100);
|
||||||
|
expect(wallId).to.be.lessThanOrEqual(109);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 11: generate should scatter cover objects", () => {
|
||||||
|
generator.generate(3, 4, 6);
|
||||||
|
|
||||||
|
// Check for cover objects (ID 10)
|
||||||
|
let coverCount = 0;
|
||||||
|
for (let x = 0; x < grid.size.x; x++) {
|
||||||
|
for (let z = 0; z < grid.size.z; z++) {
|
||||||
|
for (let y = 0; y < grid.size.y; y++) {
|
||||||
|
if (grid.getCell(x, y, z) === 10) {
|
||||||
|
coverCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(coverCount).to.be.greaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
181
test/managers/MissionManager.test.js
Normal file
181
test/managers/MissionManager.test.js
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import sinon from "sinon";
|
||||||
|
import { MissionManager } from "../../src/managers/MissionManager.js";
|
||||||
|
import { narrativeManager } from "../../src/managers/NarrativeManager.js";
|
||||||
|
|
||||||
|
describe("Manager: MissionManager", () => {
|
||||||
|
let manager;
|
||||||
|
let mockNarrativeManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new MissionManager();
|
||||||
|
|
||||||
|
// Mock narrativeManager
|
||||||
|
mockNarrativeManager = {
|
||||||
|
startSequence: sinon.stub(),
|
||||||
|
addEventListener: sinon.stub(),
|
||||||
|
removeEventListener: sinon.stub(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the singleton reference in the manager if possible
|
||||||
|
// Since it's imported, we'll need to stub the methods we use
|
||||||
|
sinon.stub(narrativeManager, "startSequence");
|
||||||
|
sinon.stub(narrativeManager, "addEventListener");
|
||||||
|
sinon.stub(narrativeManager, "removeEventListener");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with tutorial mission registered", () => {
|
||||||
|
expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||||
|
expect(manager.activeMissionId).to.be.null;
|
||||||
|
expect(manager.completedMissions).to.be.instanceof(Set);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: registerMission should add mission to registry", () => {
|
||||||
|
const newMission = {
|
||||||
|
id: "MISSION_TEST_01",
|
||||||
|
config: { title: "Test Mission" },
|
||||||
|
objectives: { primary: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.registerMission(newMission);
|
||||||
|
|
||||||
|
expect(manager.missionRegistry.has("MISSION_TEST_01")).to.be.true;
|
||||||
|
expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: getActiveMission should return tutorial if no active mission", () => {
|
||||||
|
const mission = manager.getActiveMission();
|
||||||
|
|
||||||
|
expect(mission).to.exist;
|
||||||
|
expect(mission.id).to.equal("MISSION_TUTORIAL_01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: getActiveMission should return active mission if set", () => {
|
||||||
|
const testMission = {
|
||||||
|
id: "MISSION_TEST_01",
|
||||||
|
config: { title: "Test" },
|
||||||
|
objectives: { primary: [] },
|
||||||
|
};
|
||||||
|
manager.registerMission(testMission);
|
||||||
|
manager.activeMissionId = "MISSION_TEST_01";
|
||||||
|
|
||||||
|
const mission = manager.getActiveMission();
|
||||||
|
|
||||||
|
expect(mission.id).to.equal("MISSION_TEST_01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: setupActiveMission should initialize objectives", () => {
|
||||||
|
const mission = manager.getActiveMission();
|
||||||
|
mission.objectives = {
|
||||||
|
primary: [
|
||||||
|
{ type: "ELIMINATE_ALL", target_count: 5 },
|
||||||
|
{ type: "ELIMINATE_UNIT", target_def_id: "ENEMY_GOBLIN", target_count: 3 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.setupActiveMission();
|
||||||
|
|
||||||
|
expect(manager.currentObjectives).to.have.length(2);
|
||||||
|
expect(manager.currentObjectives[0].current).to.equal(0);
|
||||||
|
expect(manager.currentObjectives[0].complete).to.be.false;
|
||||||
|
expect(manager.currentObjectives[1].target_count).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: onGameEvent should update ELIMINATE_ALL objectives", () => {
|
||||||
|
manager.setupActiveMission();
|
||||||
|
manager.currentObjectives = [
|
||||||
|
{ type: "ELIMINATE_ALL", target_count: 3, current: 0, complete: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" });
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_2" });
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_3" });
|
||||||
|
|
||||||
|
expect(manager.currentObjectives[0].current).to.equal(3);
|
||||||
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: onGameEvent should update ELIMINATE_UNIT objectives for specific unit", () => {
|
||||||
|
manager.setupActiveMission();
|
||||||
|
manager.currentObjectives = [
|
||||||
|
{
|
||||||
|
type: "ELIMINATE_UNIT",
|
||||||
|
target_def_id: "ENEMY_GOBLIN",
|
||||||
|
target_count: 2,
|
||||||
|
current: 0,
|
||||||
|
complete: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" });
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER" }); // Should not count
|
||||||
|
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" });
|
||||||
|
|
||||||
|
expect(manager.currentObjectives[0].current).to.equal(2);
|
||||||
|
expect(manager.currentObjectives[0].complete).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", () => {
|
||||||
|
const victorySpy = sinon.spy();
|
||||||
|
window.addEventListener("mission-victory", victorySpy);
|
||||||
|
|
||||||
|
manager.setupActiveMission();
|
||||||
|
manager.currentObjectives = [
|
||||||
|
{ type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true },
|
||||||
|
];
|
||||||
|
manager.activeMissionId = "MISSION_TUTORIAL_01";
|
||||||
|
|
||||||
|
manager.checkVictory();
|
||||||
|
|
||||||
|
expect(victorySpy.called).to.be.true;
|
||||||
|
expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||||
|
|
||||||
|
window.removeEventListener("mission-victory", victorySpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 9: completeActiveMission should add mission to completed set", () => {
|
||||||
|
manager.activeMissionId = "MISSION_TUTORIAL_01";
|
||||||
|
|
||||||
|
manager.completeActiveMission();
|
||||||
|
|
||||||
|
expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 10: load should restore completed missions", () => {
|
||||||
|
const saveData = {
|
||||||
|
completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.load(saveData);
|
||||||
|
|
||||||
|
expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true;
|
||||||
|
expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 11: save should serialize completed missions", () => {
|
||||||
|
manager.completedMissions.add("MISSION_TUTORIAL_01");
|
||||||
|
manager.completedMissions.add("MISSION_TEST_01");
|
||||||
|
|
||||||
|
const saved = manager.save();
|
||||||
|
|
||||||
|
expect(saved.completedMissions).to.be.an("array");
|
||||||
|
expect(saved.completedMissions).to.include("MISSION_TUTORIAL_01");
|
||||||
|
expect(saved.completedMissions).to.include("MISSION_TEST_01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => {
|
||||||
|
expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")).to.equal(
|
||||||
|
"tutorial_intro"
|
||||||
|
);
|
||||||
|
expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")).to.equal(
|
||||||
|
"tutorial_success"
|
||||||
|
);
|
||||||
|
// The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed)
|
||||||
|
expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal("narrative_unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
/**
|
|
||||||
* NarrativeManager.js
|
|
||||||
* Manages the flow of story events, dialogue, and tutorials.
|
|
||||||
* Extends EventTarget to broadcast UI updates.
|
|
||||||
*/
|
|
||||||
export class NarrativeManager extends EventTarget {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.currentSequence = null;
|
|
||||||
this.currentNode = null;
|
|
||||||
this.history = new Set(); // Track played sequences IDs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and starts a narrative sequence.
|
|
||||||
* @param {Object} sequenceData - The JSON object of the conversation.
|
|
||||||
*/
|
|
||||||
startSequence(sequenceData) {
|
|
||||||
if (!sequenceData || !sequenceData.nodes) {
|
|
||||||
console.error("Invalid sequence data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Starting Narrative: ${sequenceData.id}`);
|
|
||||||
this.currentSequence = sequenceData;
|
|
||||||
this.history.add(sequenceData.id);
|
|
||||||
|
|
||||||
// Find first node (usually index 0 or id '1')
|
|
||||||
this.currentNode = sequenceData.nodes[0];
|
|
||||||
this.broadcastUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advances to the next node in the sequence.
|
|
||||||
*/
|
|
||||||
next() {
|
|
||||||
if (!this.currentNode) return;
|
|
||||||
|
|
||||||
// 1. Handle Triggers (Side Effects)
|
|
||||||
if (this.currentNode.trigger) {
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent("narrative-trigger", {
|
|
||||||
detail: { action: this.currentNode.trigger },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Find Next Node
|
|
||||||
const nextId = this.currentNode.next;
|
|
||||||
|
|
||||||
if (nextId === "END" || !nextId) {
|
|
||||||
this.endSequence();
|
|
||||||
} else {
|
|
||||||
this.currentNode = this.currentSequence.nodes.find(
|
|
||||||
(n) => n.id === nextId
|
|
||||||
);
|
|
||||||
this.broadcastUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles player choice selection.
|
|
||||||
*/
|
|
||||||
makeChoice(choiceIndex) {
|
|
||||||
if (!this.currentNode.choices) return;
|
|
||||||
|
|
||||||
const choice = this.currentNode.choices[choiceIndex];
|
|
||||||
const nextId = choice.next;
|
|
||||||
|
|
||||||
if (choice.trigger) {
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent("narrative-trigger", {
|
|
||||||
detail: { action: choice.trigger },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentNode = this.currentSequence.nodes.find((n) => n.id === nextId);
|
|
||||||
this.broadcastUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
endSequence() {
|
|
||||||
console.log("Narrative Ended");
|
|
||||||
this.currentSequence = null;
|
|
||||||
this.currentNode = null;
|
|
||||||
this.dispatchEvent(new CustomEvent("narrative-end"));
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastUpdate() {
|
|
||||||
this.dispatchEvent(
|
|
||||||
new CustomEvent("narrative-update", {
|
|
||||||
detail: {
|
|
||||||
node: this.currentNode,
|
|
||||||
active: !!this.currentNode,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton for global access
|
|
||||||
export const narrativeManager = new NarrativeManager();
|
|
||||||
251
test/managers/NarrativeManager.test.js
Normal file
251
test/managers/NarrativeManager.test.js
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import sinon from "sinon";
|
||||||
|
import { narrativeManager, NarrativeManager } from "../../src/managers/NarrativeManager.js";
|
||||||
|
|
||||||
|
describe("Manager: NarrativeManager", () => {
|
||||||
|
let manager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a fresh instance for testing
|
||||||
|
manager = new NarrativeManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with empty state", () => {
|
||||||
|
expect(manager.currentSequence).to.be.null;
|
||||||
|
expect(manager.currentNode).to.be.null;
|
||||||
|
expect(manager.history).to.be.instanceof(Set);
|
||||||
|
expect(manager.history.size).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: startSequence should load sequence and set first node", () => {
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{ id: "1", type: "DIALOGUE", text: "Hello", next: "2" },
|
||||||
|
{ id: "2", type: "DIALOGUE", text: "World", next: "END" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSpy = sinon.spy(manager, "broadcastUpdate");
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
|
||||||
|
expect(manager.currentSequence).to.equal(sequenceData);
|
||||||
|
expect(manager.currentNode.id).to.equal("1");
|
||||||
|
expect(manager.history.has("TEST_SEQUENCE")).to.be.true;
|
||||||
|
expect(updateSpy.called).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: startSequence should handle invalid data gracefully", () => {
|
||||||
|
const consoleError = sinon.stub(console, "error");
|
||||||
|
|
||||||
|
manager.startSequence(null);
|
||||||
|
expect(manager.currentSequence).to.be.null;
|
||||||
|
|
||||||
|
manager.startSequence({});
|
||||||
|
expect(manager.currentSequence).to.be.null;
|
||||||
|
|
||||||
|
consoleError.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: next should advance to next node in sequence", () => {
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{ id: "1", type: "DIALOGUE", text: "First", next: "2" },
|
||||||
|
{ id: "2", type: "DIALOGUE", text: "Second", next: "END" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
expect(manager.currentNode.id).to.equal("1");
|
||||||
|
|
||||||
|
manager.next();
|
||||||
|
|
||||||
|
expect(manager.currentNode.id).to.equal("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: next should end sequence when reaching END", () => {
|
||||||
|
const endSpy = sinon.spy(manager, "endSequence");
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [{ id: "1", type: "DIALOGUE", text: "Last", next: "END" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.next();
|
||||||
|
|
||||||
|
expect(endSpy.called).to.be.true;
|
||||||
|
expect(manager.currentSequence).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: next should not advance on CHOICE nodes", () => {
|
||||||
|
const consoleWarn = sinon.stub(console, "warn");
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "CHOICE",
|
||||||
|
text: "Choose",
|
||||||
|
choices: [{ text: "Option 1", next: "2" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.next();
|
||||||
|
|
||||||
|
expect(consoleWarn.called).to.be.true;
|
||||||
|
expect(manager.currentNode.id).to.equal("1");
|
||||||
|
|
||||||
|
consoleWarn.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: makeChoice should advance based on choice selection", () => {
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "CHOICE",
|
||||||
|
choices: [
|
||||||
|
{ text: "Option A", next: "2" },
|
||||||
|
{ text: "Option B", next: "3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "2", type: "DIALOGUE", text: "Result A", next: "END" },
|
||||||
|
{ id: "3", type: "DIALOGUE", text: "Result B", next: "END" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.makeChoice(1); // Select Option B
|
||||||
|
|
||||||
|
expect(manager.currentNode.id).to.equal("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: makeChoice should dispatch trigger events", () => {
|
||||||
|
const triggerSpy = sinon.spy();
|
||||||
|
manager.addEventListener("narrative-trigger", triggerSpy);
|
||||||
|
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "CHOICE",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
text: "Option",
|
||||||
|
next: "2",
|
||||||
|
trigger: { action: "GAIN_REPUTATION", value: 10 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "2", type: "DIALOGUE", next: "END" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.makeChoice(0);
|
||||||
|
|
||||||
|
expect(triggerSpy.called).to.be.true;
|
||||||
|
expect(triggerSpy.firstCall.args[0].detail.action).to.deep.equal({
|
||||||
|
action: "GAIN_REPUTATION",
|
||||||
|
value: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 9: _advanceToNode should process node triggers", () => {
|
||||||
|
const triggerSpy = sinon.spy();
|
||||||
|
manager.addEventListener("narrative-trigger", triggerSpy);
|
||||||
|
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{ id: "1", type: "DIALOGUE", next: "2" },
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "DIALOGUE",
|
||||||
|
next: "END",
|
||||||
|
trigger: { action: "UNLOCK_MISSION" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.next();
|
||||||
|
|
||||||
|
expect(triggerSpy.called).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 10: _advanceToNode should auto-advance ACTION nodes", () => {
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [
|
||||||
|
{ id: "1", type: "DIALOGUE", next: "2" },
|
||||||
|
{ id: "2", type: "ACTION", next: "3", trigger: { action: "DO_SOMETHING" } },
|
||||||
|
{ id: "3", type: "DIALOGUE", text: "After action", next: "END" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.next(); // Should skip ACTION node and go to 3
|
||||||
|
|
||||||
|
expect(manager.currentNode.id).to.equal("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 11: endSequence should dispatch narrative-end event", () => {
|
||||||
|
const endSpy = sinon.spy();
|
||||||
|
manager.addEventListener("narrative-end", endSpy);
|
||||||
|
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [{ id: "1", type: "DIALOGUE", next: "END" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.endSequence();
|
||||||
|
|
||||||
|
expect(endSpy.called).to.be.true;
|
||||||
|
expect(manager.currentSequence).to.be.null;
|
||||||
|
expect(manager.currentNode).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 12: broadcastUpdate should dispatch narrative-update event", () => {
|
||||||
|
const updateSpy = sinon.spy();
|
||||||
|
manager.addEventListener("narrative-update", updateSpy);
|
||||||
|
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [{ id: "1", type: "DIALOGUE", text: "Test", next: "END" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
|
||||||
|
expect(updateSpy.called).to.be.true;
|
||||||
|
const eventDetail = updateSpy.firstCall.args[0].detail;
|
||||||
|
expect(eventDetail.node).to.equal(manager.currentNode);
|
||||||
|
expect(eventDetail.active).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 13: _advanceToNode should handle missing nodes gracefully", () => {
|
||||||
|
const consoleError = sinon.stub(console, "error");
|
||||||
|
const endSpy = sinon.spy(manager, "endSequence");
|
||||||
|
|
||||||
|
const sequenceData = {
|
||||||
|
id: "TEST_SEQUENCE",
|
||||||
|
nodes: [{ id: "1", type: "DIALOGUE", next: "MISSING_NODE" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.startSequence(sequenceData);
|
||||||
|
manager.next();
|
||||||
|
|
||||||
|
expect(consoleError.called).to.be.true;
|
||||||
|
expect(endSpy.called).to.be.true;
|
||||||
|
|
||||||
|
consoleError.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
135
test/managers/RosterManager.test.js
Normal file
135
test/managers/RosterManager.test.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { RosterManager } from "../../src/managers/RosterManager.js";
|
||||||
|
|
||||||
|
describe("Manager: RosterManager", () => {
|
||||||
|
let manager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new RosterManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should initialize with empty roster and graveyard", () => {
|
||||||
|
expect(manager.roster).to.be.an("array").that.is.empty;
|
||||||
|
expect(manager.graveyard).to.be.an("array").that.is.empty;
|
||||||
|
expect(manager.rosterLimit).to.equal(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: recruitUnit should add unit to roster with generated ID", () => {
|
||||||
|
const unitData = {
|
||||||
|
class: "CLASS_VANGUARD",
|
||||||
|
name: "Test Unit",
|
||||||
|
stats: { hp: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const newUnit = manager.recruitUnit(unitData);
|
||||||
|
|
||||||
|
expect(newUnit).to.exist;
|
||||||
|
expect(newUnit.id).to.match(/^UNIT_\d+_\d+$/);
|
||||||
|
expect(newUnit.class).to.equal("CLASS_VANGUARD");
|
||||||
|
expect(newUnit.name).to.equal("Test Unit");
|
||||||
|
expect(newUnit.status).to.equal("READY");
|
||||||
|
expect(newUnit.history).to.deep.equal({ missions: 0, kills: 0 });
|
||||||
|
expect(manager.roster).to.have.length(1);
|
||||||
|
expect(manager.roster[0]).to.equal(newUnit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: recruitUnit should return false when roster is full", () => {
|
||||||
|
// Fill roster to limit
|
||||||
|
for (let i = 0; i < manager.rosterLimit; i++) {
|
||||||
|
manager.recruitUnit({ class: "CLASS_VANGUARD", name: `Unit ${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Extra" });
|
||||||
|
|
||||||
|
expect(result).to.be.false;
|
||||||
|
expect(manager.roster).to.have.length(manager.rosterLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: handleUnitDeath should move unit to graveyard and remove from roster", () => {
|
||||||
|
const unit = manager.recruitUnit({
|
||||||
|
class: "CLASS_VANGUARD",
|
||||||
|
name: "Test Unit",
|
||||||
|
});
|
||||||
|
const unitId = unit.id;
|
||||||
|
|
||||||
|
manager.handleUnitDeath(unitId);
|
||||||
|
|
||||||
|
expect(manager.roster).to.be.empty;
|
||||||
|
expect(manager.graveyard).to.have.length(1);
|
||||||
|
expect(manager.graveyard[0].id).to.equal(unitId);
|
||||||
|
expect(manager.graveyard[0].status).to.equal("DEAD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: handleUnitDeath should do nothing if unit not found", () => {
|
||||||
|
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||||
|
|
||||||
|
manager.handleUnitDeath("NONEXISTENT_ID");
|
||||||
|
|
||||||
|
expect(manager.roster).to.have.length(1);
|
||||||
|
expect(manager.graveyard).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: getDeployableUnits should return only READY units", () => {
|
||||||
|
const ready1 = manager.recruitUnit({
|
||||||
|
class: "CLASS_VANGUARD",
|
||||||
|
name: "Ready 1",
|
||||||
|
});
|
||||||
|
const ready2 = manager.recruitUnit({
|
||||||
|
class: "CLASS_TINKER",
|
||||||
|
name: "Ready 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manually set a unit to INJURED
|
||||||
|
manager.roster[0].status = "INJURED";
|
||||||
|
|
||||||
|
const deployable = manager.getDeployableUnits();
|
||||||
|
|
||||||
|
expect(deployable).to.have.length(1);
|
||||||
|
expect(deployable[0].id).to.equal(ready2.id);
|
||||||
|
expect(deployable[0].status).to.equal("READY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: load should restore roster and graveyard from save data", () => {
|
||||||
|
const saveData = {
|
||||||
|
roster: [
|
||||||
|
{ id: "UNIT_1", class: "CLASS_VANGUARD", status: "READY" },
|
||||||
|
{ id: "UNIT_2", class: "CLASS_TINKER", status: "INJURED" },
|
||||||
|
],
|
||||||
|
graveyard: [{ id: "UNIT_3", class: "CLASS_VANGUARD", status: "DEAD" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.load(saveData);
|
||||||
|
|
||||||
|
expect(manager.roster).to.have.length(2);
|
||||||
|
expect(manager.graveyard).to.have.length(1);
|
||||||
|
expect(manager.roster[0].id).to.equal("UNIT_1");
|
||||||
|
expect(manager.graveyard[0].id).to.equal("UNIT_3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: save should serialize roster and graveyard", () => {
|
||||||
|
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||||
|
manager.recruitUnit({ class: "CLASS_TINKER", name: "Unit 2" });
|
||||||
|
const unitId = manager.roster[0].id;
|
||||||
|
manager.handleUnitDeath(unitId);
|
||||||
|
|
||||||
|
const saved = manager.save();
|
||||||
|
|
||||||
|
expect(saved).to.have.property("roster");
|
||||||
|
expect(saved).to.have.property("graveyard");
|
||||||
|
expect(saved.roster).to.have.length(1);
|
||||||
|
expect(saved.graveyard).to.have.length(1);
|
||||||
|
expect(saved.roster[0].name).to.equal("Unit 2");
|
||||||
|
expect(saved.graveyard[0].id).to.equal(unitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 9: clear should reset roster and graveyard", () => {
|
||||||
|
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||||
|
manager.handleUnitDeath(manager.roster[0].id);
|
||||||
|
|
||||||
|
manager.clear();
|
||||||
|
|
||||||
|
expect(manager.roster).to.be.empty;
|
||||||
|
expect(manager.graveyard).to.be.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
541
test/ui/combat-hud.test.js
Normal file
541
test/ui/combat-hud.test.js
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import { CombatHUD } from "../../src/ui/combat-hud.js";
|
||||||
|
|
||||||
|
describe("UI: CombatHUD", () => {
|
||||||
|
let element;
|
||||||
|
let container;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
element = document.createElement("combat-hud");
|
||||||
|
container.appendChild(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container.parentNode) {
|
||||||
|
container.parentNode.removeChild(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to create mock combat state
|
||||||
|
function createMockCombatState(overrides = {}) {
|
||||||
|
return {
|
||||||
|
activeUnit: {
|
||||||
|
id: "unit1",
|
||||||
|
name: "Test Unit",
|
||||||
|
portrait: "/test/portrait.png",
|
||||||
|
hp: { current: 80, max: 100 },
|
||||||
|
ap: { current: 5, max: 10 },
|
||||||
|
charge: 50,
|
||||||
|
statuses: [
|
||||||
|
{
|
||||||
|
id: "buff1",
|
||||||
|
icon: "⚡",
|
||||||
|
turnsRemaining: 2,
|
||||||
|
description: "Energized",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: "skill1",
|
||||||
|
name: "Attack",
|
||||||
|
icon: "⚔",
|
||||||
|
costAP: 3,
|
||||||
|
cooldown: 0,
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skill2",
|
||||||
|
name: "Heal",
|
||||||
|
icon: "💚",
|
||||||
|
costAP: 5,
|
||||||
|
cooldown: 0,
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
turnQueue: [
|
||||||
|
{
|
||||||
|
unitId: "unit1",
|
||||||
|
portrait: "/test/portrait1.png",
|
||||||
|
team: "PLAYER",
|
||||||
|
initiative: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unitId: "unit2",
|
||||||
|
portrait: "/test/portrait2.png",
|
||||||
|
team: "ENEMY",
|
||||||
|
initiative: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unitId: "unit3",
|
||||||
|
portrait: "/test/portrait3.png",
|
||||||
|
team: "PLAYER",
|
||||||
|
initiative: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unitId: "unit4",
|
||||||
|
portrait: "/test/portrait4.png",
|
||||||
|
team: "ENEMY",
|
||||||
|
initiative: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unitId: "unit5",
|
||||||
|
portrait: "/test/portrait5.png",
|
||||||
|
team: "PLAYER",
|
||||||
|
initiative: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetingMode: false,
|
||||||
|
roundNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to wait for Lit updates
|
||||||
|
async function waitForUpdate() {
|
||||||
|
await element.updateComplete;
|
||||||
|
// Give DOM a moment to settle
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to query shadow DOM
|
||||||
|
function queryShadow(selector) {
|
||||||
|
return element.shadowRoot?.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryShadowAll(selector) {
|
||||||
|
return element.shadowRoot?.querySelectorAll(selector) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CoA 1: State Hydration", () => {
|
||||||
|
it("should re-render when combatState is updated", async () => {
|
||||||
|
const initialState = createMockCombatState();
|
||||||
|
element.combatState = initialState;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
// Verify initial render
|
||||||
|
expect(queryShadow(".unit-name")?.textContent.trim()).to.equal(
|
||||||
|
"Test Unit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update combat state
|
||||||
|
const updatedState = createMockCombatState({
|
||||||
|
activeUnit: {
|
||||||
|
...initialState.activeUnit,
|
||||||
|
name: "Updated Unit",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.combatState = updatedState;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
// Verify re-render
|
||||||
|
expect(queryShadow(".unit-name")?.textContent.trim()).to.equal(
|
||||||
|
"Updated Unit"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide Action Bar when activeUnit is null", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
activeUnit: null,
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
// Action bar should not be visible or should be empty
|
||||||
|
const actionBar = queryShadow(".action-bar");
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
|
||||||
|
// Either action bar doesn't exist or has no skill buttons
|
||||||
|
expect(skillButtons.length).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide Unit Status when activeUnit is null", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
activeUnit: null,
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
// Unit status should not be visible
|
||||||
|
const unitStatus = queryShadow(".unit-status");
|
||||||
|
expect(unitStatus).to.be.null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CoA 2: Turn Queue Visualization", () => {
|
||||||
|
it("should display at least 5 units in the queue", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const queuePortraits = queryShadowAll(".queue-portrait");
|
||||||
|
expect(queuePortraits.length).to.be.at.least(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should make the first unit visually distinct (larger and gold border)", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const queuePortraits = queryShadowAll(".queue-portrait");
|
||||||
|
expect(queuePortraits.length).to.be.greaterThan(0);
|
||||||
|
|
||||||
|
const firstPortrait = queuePortraits[0];
|
||||||
|
expect(firstPortrait.classList.contains("active")).to.be.true;
|
||||||
|
|
||||||
|
// Check computed styles for size difference
|
||||||
|
const firstRect = firstPortrait.getBoundingClientRect();
|
||||||
|
if (queuePortraits.length > 1) {
|
||||||
|
const secondRect = queuePortraits[1].getBoundingClientRect();
|
||||||
|
// First should be larger
|
||||||
|
expect(firstRect.width).to.be.greaterThan(secondRect.width);
|
||||||
|
expect(firstRect.height).to.be.greaterThan(secondRect.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for gold border class
|
||||||
|
const styles = window.getComputedStyle(firstPortrait);
|
||||||
|
// The active class should be present, which applies gold border via CSS
|
||||||
|
expect(firstPortrait.classList.contains("active")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show enemy intent icon when enemy is active", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
turnQueue: [
|
||||||
|
{
|
||||||
|
unitId: "enemy1",
|
||||||
|
portrait: "/test/enemy.png",
|
||||||
|
team: "ENEMY",
|
||||||
|
initiative: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const enemyIntent = queryShadow(".enemy-intent");
|
||||||
|
// Enemy intent should be visible when first unit is enemy
|
||||||
|
const firstPortrait = queryShadow(".queue-portrait.active");
|
||||||
|
if (firstPortrait) {
|
||||||
|
const intent = firstPortrait.querySelector(".enemy-intent");
|
||||||
|
// Intent icon should exist for enemy active unit
|
||||||
|
expect(intent).to.exist;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CoA 3: Action Bar Logic", () => {
|
||||||
|
it("should display AP cost on skill buttons", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
expect(skillButtons.length).to.be.greaterThan(0);
|
||||||
|
|
||||||
|
// Check first skill button has AP cost displayed
|
||||||
|
const firstButton = skillButtons[0];
|
||||||
|
const costElement = firstButton.querySelector(".cost");
|
||||||
|
expect(costElement).to.exist;
|
||||||
|
expect(costElement.textContent).to.include("AP");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable skill button when unit.ap.current < skill.costAP", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
activeUnit: {
|
||||||
|
id: "unit1",
|
||||||
|
name: "Low AP Unit",
|
||||||
|
portrait: "/test/portrait.png",
|
||||||
|
hp: { current: 100, max: 100 },
|
||||||
|
ap: { current: 2, max: 10 }, // Only 2 AP
|
||||||
|
charge: 0,
|
||||||
|
statuses: [],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: "skill1",
|
||||||
|
name: "Expensive Skill",
|
||||||
|
icon: "⚔",
|
||||||
|
costAP: 5, // Costs 5 AP, but unit only has 2
|
||||||
|
cooldown: 0,
|
||||||
|
isAvailable: false, // Should be false due to insufficient AP
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skill2",
|
||||||
|
name: "Cheap Skill",
|
||||||
|
icon: "💚",
|
||||||
|
costAP: 1, // Costs 1 AP, unit has 2
|
||||||
|
cooldown: 0,
|
||||||
|
isAvailable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
expect(skillButtons.length).to.equal(2);
|
||||||
|
|
||||||
|
// First skill (expensive) should be disabled
|
||||||
|
const expensiveButton = Array.from(skillButtons).find((btn) =>
|
||||||
|
btn.textContent.includes("Expensive")
|
||||||
|
);
|
||||||
|
expect(expensiveButton).to.exist;
|
||||||
|
expect(expensiveButton.disabled).to.be.true;
|
||||||
|
expect(expensiveButton.classList.contains("disabled") ||
|
||||||
|
expensiveButton.hasAttribute("disabled")).to.be.true;
|
||||||
|
|
||||||
|
// Second skill (cheap) should be enabled
|
||||||
|
const cheapButton = Array.from(skillButtons).find((btn) =>
|
||||||
|
btn.textContent.includes("Cheap")
|
||||||
|
);
|
||||||
|
expect(cheapButton).to.exist;
|
||||||
|
expect(cheapButton.disabled).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dispatch skill-click event with correct ID when skill button is clicked", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
let capturedEvent = null;
|
||||||
|
element.addEventListener("skill-click", (e) => {
|
||||||
|
capturedEvent = e;
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
expect(skillButtons.length).to.be.greaterThan(0);
|
||||||
|
|
||||||
|
const firstButton = skillButtons[0];
|
||||||
|
firstButton.click();
|
||||||
|
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
expect(capturedEvent).to.exist;
|
||||||
|
expect(capturedEvent.detail.skillId).to.equal("skill1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable skill button when isAvailable is false", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
activeUnit: {
|
||||||
|
id: "unit1",
|
||||||
|
name: "Test Unit",
|
||||||
|
portrait: "/test/portrait.png",
|
||||||
|
hp: { current: 100, max: 100 },
|
||||||
|
ap: { current: 10, max: 10 },
|
||||||
|
charge: 0,
|
||||||
|
statuses: [],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: "skill1",
|
||||||
|
name: "On Cooldown",
|
||||||
|
icon: "⚔",
|
||||||
|
costAP: 3,
|
||||||
|
cooldown: 2, // On cooldown
|
||||||
|
isAvailable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
expect(skillButtons.length).to.equal(1);
|
||||||
|
|
||||||
|
const button = skillButtons[0];
|
||||||
|
expect(button.disabled).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display cooldown number when skill is on cooldown", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
activeUnit: {
|
||||||
|
id: "unit1",
|
||||||
|
name: "Test Unit",
|
||||||
|
portrait: "/test/portrait.png",
|
||||||
|
hp: { current: 100, max: 100 },
|
||||||
|
ap: { current: 10, max: 10 },
|
||||||
|
charge: 0,
|
||||||
|
statuses: [],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: "skill1",
|
||||||
|
name: "On Cooldown",
|
||||||
|
icon: "⚔",
|
||||||
|
costAP: 3,
|
||||||
|
cooldown: 3,
|
||||||
|
isAvailable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const skillButton = queryShadow(".skill-button");
|
||||||
|
const cooldownElement = skillButton?.querySelector(".cooldown");
|
||||||
|
expect(cooldownElement).to.exist;
|
||||||
|
expect(cooldownElement.textContent.trim()).to.equal("3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CoA 4: Responsive Design", () => {
|
||||||
|
it("should stack Unit Status and Action Bar vertically on mobile (< 768px)", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
// Simulate mobile viewport
|
||||||
|
const originalWidth = window.innerWidth;
|
||||||
|
const originalHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Mock window.innerWidth to be mobile size
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a resize or re-render
|
||||||
|
element.combatState = { ...state };
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const bottomBar = queryShadow(".bottom-bar");
|
||||||
|
expect(bottomBar).to.exist;
|
||||||
|
|
||||||
|
// Check if flex-direction is column on mobile
|
||||||
|
// Note: We can't easily test media queries in unit tests without more setup
|
||||||
|
// But we can verify the structure exists and can be styled responsively
|
||||||
|
const unitStatus = queryShadow(".unit-status");
|
||||||
|
const actionBar = queryShadow(".action-bar");
|
||||||
|
|
||||||
|
expect(unitStatus).to.exist;
|
||||||
|
expect(actionBar).to.exist;
|
||||||
|
|
||||||
|
// Restore original width
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: originalWidth,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Additional Functionality", () => {
|
||||||
|
it("should dispatch end-turn event when End Turn button is clicked", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
let eventDispatched = false;
|
||||||
|
element.addEventListener("end-turn", () => {
|
||||||
|
eventDispatched = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endTurnButton = queryShadow(".end-turn-button");
|
||||||
|
expect(endTurnButton).to.exist;
|
||||||
|
|
||||||
|
endTurnButton.click();
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
expect(eventDispatched).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display round number in global info", async () => {
|
||||||
|
const state = createMockCombatState({ roundNumber: 5 });
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const roundCounter = queryShadow(".round-counter");
|
||||||
|
expect(roundCounter).to.exist;
|
||||||
|
expect(roundCounter.textContent).to.include("Round 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display threat level in global info", async () => {
|
||||||
|
const state = createMockCombatState({
|
||||||
|
turnQueue: [
|
||||||
|
{ unitId: "e1", portrait: "/test/e1.png", team: "ENEMY", initiative: 100 },
|
||||||
|
{ unitId: "e2", portrait: "/test/e2.png", team: "ENEMY", initiative: 90 },
|
||||||
|
{ unitId: "e3", portrait: "/test/e3.png", team: "ENEMY", initiative: 80 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const threatLevel = queryShadow(".threat-level");
|
||||||
|
expect(threatLevel).to.exist;
|
||||||
|
// With 3+ enemies, should be HIGH
|
||||||
|
expect(threatLevel.classList.contains("high")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display unit HP, AP, and Charge bars", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const hpBar = queryShadow(".bar-fill.hp");
|
||||||
|
const apBar = queryShadow(".bar-fill.ap");
|
||||||
|
const chargeBar = queryShadow(".bar-fill.charge");
|
||||||
|
|
||||||
|
expect(hpBar).to.exist;
|
||||||
|
expect(apBar).to.exist;
|
||||||
|
expect(chargeBar).to.exist;
|
||||||
|
|
||||||
|
// Check that bars have correct width percentages
|
||||||
|
expect(hpBar.style.width).to.include("%");
|
||||||
|
expect(apBar.style.width).to.include("%");
|
||||||
|
expect(chargeBar.style.width).to.include("%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display status icons with tooltips", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const statusIcons = queryShadowAll(".status-icon");
|
||||||
|
expect(statusIcons.length).to.equal(1);
|
||||||
|
|
||||||
|
const statusIcon = statusIcons[0];
|
||||||
|
expect(statusIcon.getAttribute("data-description")).to.exist;
|
||||||
|
expect(statusIcon.textContent).to.include("⚡");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display hotkeys (1-5) on skill buttons", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
const skillButtons = queryShadowAll(".skill-button");
|
||||||
|
expect(skillButtons.length).to.be.greaterThan(0);
|
||||||
|
|
||||||
|
skillButtons.forEach((button, index) => {
|
||||||
|
const hotkey = button.querySelector(".hotkey");
|
||||||
|
expect(hotkey).to.exist;
|
||||||
|
expect(hotkey.textContent.trim()).to.equal(String(index + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dispatch hover-skill event when hovering over skill", async () => {
|
||||||
|
const state = createMockCombatState();
|
||||||
|
element.combatState = state;
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
let capturedEvent = null;
|
||||||
|
element.addEventListener("hover-skill", (e) => {
|
||||||
|
capturedEvent = e;
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillButton = queryShadow(".skill-button");
|
||||||
|
expect(skillButton).to.exist;
|
||||||
|
|
||||||
|
// Simulate mouseenter event
|
||||||
|
skillButton.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
await waitForUpdate();
|
||||||
|
|
||||||
|
expect(capturedEvent).to.exist;
|
||||||
|
expect(capturedEvent.detail.skillId).to.equal("skill1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Reference in a new issue