Compare commits

..

No commits in common. "095bd778fd054f77cd94964bca7c29dfe5aba91e" and "33a64c460ce8f585cdae32c4f88679510d5b456f" have entirely different histories.

36 changed files with 153 additions and 3725 deletions

View file

@ -1,9 +1,3 @@
/**
* @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";
@ -14,68 +8,39 @@ 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(
@ -131,10 +96,6 @@ 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
@ -159,10 +120,6 @@ 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;
@ -178,13 +135,6 @@ 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;
@ -192,22 +142,11 @@ 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
@ -215,10 +154,6 @@ 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();
@ -244,9 +179,6 @@ 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);
@ -285,11 +217,6 @@ 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`
@ -298,11 +225,6 @@ 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;
@ -377,13 +299,6 @@ 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 ||
@ -447,9 +362,6 @@ export class GameLoop {
} }
} }
/**
* Finalizes deployment phase and starts combat.
*/
finalizeDeployment() { finalizeDeployment() {
if ( if (
!this.gameStateManager || !this.gameStateManager ||
@ -479,19 +391,11 @@ 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;
@ -504,9 +408,6 @@ 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,
@ -532,9 +433,6 @@ export class GameLoop {
}); });
} }
/**
* Main animation loop.
*/
animate() { animate() {
if (!this.isRunning) return; if (!this.isRunning) return;
requestAnimationFrame(this.animate); requestAnimationFrame(this.animate);
@ -586,9 +484,6 @@ 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();

View file

@ -1,21 +1,9 @@
/**
* @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",
@ -24,68 +12,37 @@ 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;
@ -98,10 +55,6 @@ 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();
@ -119,12 +72,6 @@ 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;
@ -157,9 +104,6 @@ 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();
@ -170,10 +114,6 @@ 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) {
@ -183,11 +123,6 @@ 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") {
@ -206,12 +141,6 @@ 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;
@ -253,11 +182,6 @@ 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) {
@ -273,11 +197,6 @@ 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(
@ -285,11 +204,6 @@ 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);
@ -297,6 +211,5 @@ 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;

View file

@ -1,47 +1,27 @@
/**
* @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)
@ -98,7 +78,7 @@ export class InputManager extends EventTarget {
/** /**
* Set a validation function to restrict cursor movement. * Set a validation function to restrict cursor movement.
* @param {(x: number, y: number, z: number) => false | Position} validatorFn - Takes (x, y, z). Returns false if invalid, or a modified position object. * @param {Function} validatorFn - Takes (x, y, z). Returns true/false OR a modified {x,y,z} object.
*/ */
setValidator(validatorFn) { setValidator(validatorFn) {
this.cursorValidator = validatorFn; this.cursorValidator = validatorFn;
@ -107,9 +87,6 @@ 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
@ -148,10 +125,6 @@ export class InputManager extends EventTarget {
); );
} }
/**
* Gets the current cursor position.
* @returns {THREE.Vector3} - Current cursor position
*/
getCursorPosition() { getCursorPosition() {
return this.cursorPos; return this.cursorPos;
} }
@ -172,9 +145,6 @@ 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() : [];
@ -236,11 +206,6 @@ 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);
} }
@ -279,9 +244,6 @@ 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();
@ -306,10 +268,6 @@ 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(

View file

@ -1,8 +1,3 @@
/**
* @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.
@ -13,20 +8,11 @@ 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);
@ -56,29 +42,16 @@ 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");
@ -86,21 +59,12 @@ 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");
@ -109,13 +73,6 @@ 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");
@ -126,13 +83,6 @@ 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");
@ -143,13 +93,6 @@ 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
View file

@ -1,103 +0,0 @@
/**
* 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>;
}

View file

@ -1,39 +1,14 @@
/**
* @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++) {

View file

@ -1,7 +1,3 @@
/**
* @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";
@ -11,23 +7,15 @@ 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
@ -40,10 +28,6 @@ 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;

View file

@ -1,25 +0,0 @@
/**
* 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;
}

View file

@ -1,76 +1,33 @@
/**
* @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") {
@ -91,25 +48,11 @@ 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;
@ -119,7 +62,6 @@ 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);
@ -128,7 +70,6 @@ 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);
@ -138,49 +79,27 @@ 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
@ -193,10 +112,6 @@ 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 = [];
@ -220,11 +135,6 @@ 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) {
@ -241,13 +151,6 @@ 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;
@ -265,23 +168,12 @@ 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));
} }

View file

@ -1,34 +1,20 @@
/**
* @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
@ -40,11 +26,9 @@ 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);
@ -54,7 +38,6 @@ 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;
@ -295,7 +278,7 @@ export class VoxelManager {
/** /**
* Helper to center the camera view on the grid. * Helper to center the camera view on the grid.
* @param {import("three/examples/jsm/controls/OrbitControls.js").OrbitControls} controls - The OrbitControls instance to update * @param {Object} controls - The OrbitControls instance to update
*/ */
focusCamera(controls) { focusCamera(controls) {
if (controls && this.focusTarget) { if (controls && this.focusTarget) {
@ -304,12 +287,6 @@ 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
View file

@ -1,80 +0,0 @@
/**
* 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;
}

View file

@ -1,18 +1,11 @@
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 ---

View file

@ -1,52 +1,31 @@
/**
* @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
@ -55,10 +34,6 @@ 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)
@ -69,7 +44,6 @@ 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');
@ -159,7 +133,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 {GameEventData} data - Context data * @param {Object} data - Context data
*/ */
onGameEvent(type, data) { onGameEvent(type, data) {
if (!this.currentObjectives.length) return; if (!this.currentObjectives.length) return;

View file

@ -1,28 +1,19 @@
/**
* @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 {NarrativeSequence} sequenceData - The JSON object of the conversation (from assets/data/narrative/). * @param {Object} sequenceData - The JSON object of the conversation (from assets/data/narrative/).
*/ */
startSequence(sequenceData) { startSequence(sequenceData) {
if (!sequenceData || !sequenceData.nodes) { if (!sequenceData || !sequenceData.nodes) {

View file

@ -1,27 +1,17 @@
/**
* @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 || [];
@ -30,7 +20,6 @@ export class RosterManager {
/** /**
* Serializes for save file. * Serializes for save file.
* @returns {RosterSaveData} - Serialized roster data
*/ */
save() { save() {
return { return {
@ -41,8 +30,7 @@ export class RosterManager {
/** /**
* Adds a new unit to the roster. * Adds a new unit to the roster.
* @param {Partial<ExplorerData>} unitData - The unit definition (Class, Name, Stats) * @param {Object} 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) {
@ -63,7 +51,6 @@ 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);
@ -78,7 +65,6 @@ 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");

View file

@ -1,10 +1,3 @@
/**
* @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";
@ -13,18 +6,14 @@ 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 {UnitRegistry} registry - Map or Object containing Unit Definitions (Stats, Models). * @param {Object} 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;
} }
@ -33,8 +22,7 @@ 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 {UnitTeam} team - 'PLAYER', 'ENEMY', 'NEUTRAL' * @param {string} 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
@ -75,11 +63,6 @@ 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);
@ -92,28 +75,14 @@ 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);
} }
@ -121,10 +90,9 @@ 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 {Position} centerPos - {x, y, z} * @param {Object} centerPos - {x, y, z}
* @param {number} range - Distance in tiles (Manhattan) * @param {number} range - Distance in tiles (Manhattan)
* @param {UnitTeam | null} [filterTeam] - Optional: Only return units of this team * @param {string} [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 = [];

View file

@ -1,108 +0,0 @@
/**
* 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;
}

View file

@ -1,54 +0,0 @@
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
}

View file

@ -13,574 +13,85 @@ 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;
} }
/* Top Bar */ .header {
.top-bar {
position: absolute; position: absolute;
top: 0; top: 20px;
left: 0; left: 50%;
right: 0; transform: translateX(-50%);
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);
position: relative; border: 2px solid #ff0000;
padding: 15px 30px;
text-align: center;
pointer-events: auto; pointer-events: auto;
} }
.queue-portrait.active { .status-bar {
width: 80px; margin-top: 5px;
height: 80px; font-size: 1.2rem;
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;
} }
/* Bottom Bar */ .turn-indicator {
.bottom-bar {
position: absolute; position: absolute;
bottom: 0; top: 100px;
left: 0; left: 30px;
right: 0;
height: 180px;
background: linear-gradient(
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;
}
/* Unit Status (Bottom-Left) */
.unit-status {
display: flex;
flex-direction: column;
gap: 10px;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
border: 2px solid #555; border: 2px solid #ff0000;
padding: 15px; padding: 10px 20px;
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-size: 1rem;
font-weight: bold;
margin-top: 5px;
} }
.status-icons { .instructions {
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: 100%; bottom: 30px;
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;
position: relative; padding: 10px 20px;
overflow: hidden; font-size: 0.9rem;
} 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 {
combatState: { type: Object }, currentState: { type: String },
currentTurn: { type: String },
}; };
} }
constructor() { constructor() {
super(); super();
this.combatState = null; this.currentState = null;
} this.currentTurn = "PLAYER";
window.addEventListener("gamestate-changed", (e) => {
_handleSkillClick(skillId) { this.currentState = e.detail.newState;
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() {
if (!this.combatState) { // Only show during COMBAT state
if (this.currentState !== "STATE_COMBAT") {
return html``; return html``;
} }
const { activeUnit, turnQueue, roundNumber } = this.combatState;
const threatLevel = this._getThreatLevel();
return html` return html`
<!-- Top Bar --> <div class="header">
<div class="top-bar"> <h2>COMBAT ACTIVE</h2>
<!-- Turn Queue (Center-Left) --> <div class="status-bar">Turn: ${this.currentTurn}</div>
<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>
<!-- Global Info (Top-Right) -->
<div class="global-info">
<div class="round-counter">Round ${roundNumber || 1}</div>
<div class="threat-level ${threatLevel}">
${threatLevel.toUpperCase()}
</div>
</div>
</div> </div>
<!-- Bottom Bar --> <div class="turn-indicator">
<div class="bottom-bar"> <div>State: ${this.currentState}</div>
<!-- Unit Status (Bottom-Left) --> </div>
${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 <div class="instructions">
? html` Use WASD or Arrow Keys to move cursor | SPACE/ENTER to select
<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>
`; `;
} }

View file

@ -1,113 +0,0 @@
# 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
}

View file

@ -1,34 +1,20 @@
/**
* @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

View file

@ -1,31 +1,17 @@
/**
* @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
@ -38,7 +24,6 @@ export class Explorer extends Unit {
} }
// Inventory // Inventory
/** @type {Equipment} */
this.equipment = { this.equipment = {
weapon: null, weapon: null,
armor: null, armor: null,
@ -47,16 +32,10 @@ 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] = {
@ -70,7 +49,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 {Record<string, unknown>} classDef - The JSON definition of the class stats. * @param {Object} classDef - The JSON definition of the class stats.
*/ */
recalculateBaseStats(classDef) { recalculateBaseStats(classDef) {
if (classDef.id !== this.activeClassId) { if (classDef.id !== this.activeClassId) {
@ -102,8 +81,6 @@ 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
@ -124,7 +101,6 @@ 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];
@ -132,10 +108,6 @@ 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;
} }

View file

@ -1,56 +1,28 @@
/**
* @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,
@ -61,17 +33,11 @@ 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 };
@ -79,8 +45,6 @@ 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) {
@ -90,10 +54,6 @@ 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
View file

@ -1,115 +0,0 @@
/**
* 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[];
}

View file

@ -2,28 +2,17 @@
* 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++) {
@ -37,10 +26,7 @@ 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);
@ -48,31 +34,17 @@ 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;
} }

View file

@ -2,14 +2,10 @@
* 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 =
@ -18,7 +14,6 @@ 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;
@ -33,9 +28,7 @@ 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];
@ -43,7 +36,6 @@ 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,
@ -53,9 +45,6 @@ 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
@ -124,15 +113,6 @@ 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;
} }

View file

@ -1,216 +0,0 @@
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;
});
});

View file

@ -1,135 +0,0 @@
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);
});
});

View file

@ -1,132 +0,0 @@
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);
});
});

View file

@ -1,155 +0,0 @@
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);
});
});

View file

@ -1,160 +0,0 @@
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);
});
});

View file

@ -1,181 +0,0 @@
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");
});
});

View file

@ -0,0 +1,102 @@
/**
* 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();

View file

@ -1,251 +0,0 @@
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();
});
});

View file

@ -1,135 +0,0 @@
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;
});
});

View file

@ -1,541 +0,0 @@
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");
});
});
});