Implement game state management and persistence system. Introduce GameStateManager for handling game states, including transitions between main menu, team builder, and game run. Add Persistence class for saving and loading game data using IndexedDB. Enhance team builder UI with class definitions and improved layout. Include unit tests for GameStateManager functionality.

This commit is contained in:
Matthew Mone 2025-12-19 15:07:36 -08:00
parent 391abd6ea6
commit 5be96d2846
15 changed files with 910 additions and 278 deletions

View file

@ -1,11 +1,38 @@
import { build } from "esbuild";
import { copyFileSync, mkdirSync } from "fs";
import { dirname } from "path";
import { copyFileSync, mkdirSync, readdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Image file extensions to copy
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
// Recursively copy image files from src to dist
function copyImages(srcDir, distDir) {
const entries = readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(srcDir, entry.name);
const distPath = join(distDir, entry.name);
if (entry.isDirectory()) {
mkdirSync(distPath, { recursive: true });
copyImages(srcPath, distPath);
} else if (entry.isFile()) {
const lastDot = entry.name.lastIndexOf(".");
if (lastDot !== -1) {
const ext = entry.name.toLowerCase().substring(lastDot);
if (IMAGE_EXTENSIONS.includes(ext)) {
mkdirSync(distDir, { recursive: true });
copyFileSync(srcPath, distPath);
}
}
}
}
}
// Ensure dist directory exists
mkdirSync("dist", { recursive: true });
@ -23,4 +50,7 @@ await build({
// Copy HTML file
copyFileSync("src/index.html", "dist/index.html");
// Copy images
copyImages("src", "dist");
console.log("Build complete!");

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

111
src/core/GameLoop.js Normal file
View file

@ -0,0 +1,111 @@
import * as THREE from "three";
import { VoxelGrid } from "../grid/VoxelGrid.js";
import { VoxelManager } from "../grid/VoxelManager.js";
// import { UnitManager } from "../managers/UnitManager.js";
import { CaveGenerator } from "../generation/CaveGenerator.js";
import { RuinGenerator } from "../generation/RuinGenerator.js";
// import { TurnSystem } from '../systems/TurnSystem.js';
export class GameLoop {
constructor() {
this.isRunning = false;
// 1. Core Systems
this.scene = new THREE.Scene();
this.camera = null;
this.renderer = null;
this.grid = null;
this.voxelManager = null;
this.unitManager = null;
// 2. State
this.runData = null;
}
init(container) {
// Setup Three.js
this.camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(20, 20, 20);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0x111111); // Dark background
container.appendChild(this.renderer.domElement);
// Lighting
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(10, 20, 10);
this.scene.add(ambient);
this.scene.add(dirLight);
// Handle Resize
window.addEventListener("resize", () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
this.animate = this.animate.bind(this);
}
/**
* Starts a Level based on Run Data (New or Loaded).
*/
async startLevel(runData) {
console.log("GameLoop: Starting Level...");
this.runData = runData;
this.isRunning = true;
// 1. Initialize Grid (20x10x20 for prototype)
this.grid = new VoxelGrid(20, 10, 20);
// 2. Generate World (Using saved seed)
// TODO: Switch generator based on runData.biome_id
const generator = new RuinGenerator(this.grid, runData.seed);
generator.generate();
// 3. Initialize Visuals
this.voxelManager = new VoxelManager(this.grid, this.scene);
// Apply textures generated by the biome logic
this.voxelManager.updateMaterials(generator.generatedAssets);
this.voxelManager.update();
this.voxelManager.focusCamera(this.controls);
// 4. Initialize Units
// this.unitManager = new UnitManager();
// this.spawnSquad(runData.squad);
// Start Loop
this.animate();
}
spawnSquad(squadManifest) {
// TODO: Loop manifest and unitManager.createUnit()
// Place them at spawn points defined by Generator
}
animate() {
if (!this.isRunning) return;
requestAnimationFrame(this.animate);
// Update Logic
// TWEEN.update();
// Render
this.renderer.render(this.scene, this.camera);
}
stop() {
this.isRunning = false;
// Cleanup Three.js resources if needed
}
}

View file

@ -0,0 +1,119 @@
import { Persistence } from "./Persistence.js";
class GameStateManagerClass {
static STATES = {
INIT: "STATE_INIT",
MAIN_MENU: "STATE_MAIN_MENU",
TEAM_BUILDER: "STATE_TEAM_BUILDER",
GAME_RUN: "STATE_GAME_RUN",
};
constructor() {
this.currentState = GameStateManagerClass.STATES.INIT;
this.gameLoop = null;
this.persistence = new Persistence();
this.activeRunData = null;
this.gameLoopSet = Promise.withResolvers();
this.handleEmbark = this.handleEmbark.bind(this);
}
/**
* For Testing: Resets the manager to a clean state.
*/
reset() {
this.currentState = GameStateManagerClass.STATES.INIT;
this.gameLoop = null;
this.activeRunData = null;
}
setGameLoop(loop) {
this.gameLoop = loop;
this.gameLoopSet.resolve(loop);
}
async init() {
console.log("System: Initializing State Manager...");
await this.persistence.init();
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
}
async transitionTo(newState, payload = null) {
console.log(`State Transition: ${this.currentState} -> ${newState}`);
const oldState = this.currentState;
this.currentState = newState;
window.dispatchEvent(
new CustomEvent("gamestate-changed", {
detail: { oldState, newState, payload },
})
);
switch (newState) {
case GameStateManagerClass.STATES.MAIN_MENU:
if (this.gameLoop) this.gameLoop.stop();
await this._checkSaveGame();
break;
case GameStateManagerClass.STATES.GAME_RUN:
if (!this.activeRunData && payload) {
await this._initializeRun(payload);
} else {
await this._resumeRun();
}
break;
}
}
startNewGame() {
this.transitionTo(GameStateManagerClass.STATES.TEAM_BUILDER);
}
async continueGame() {
const save = await this.persistence.loadRun();
if (save) {
this.activeRunData = save;
this.transitionTo(GameStateManagerClass.STATES.GAME_RUN);
}
}
handleEmbark(e) {
this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad);
}
// --- INTERNAL HELPERS ---
async _initializeRun(squadManifest) {
await this.gameLoopSet.promise;
this.activeRunData = {
seed: Math.floor(Math.random() * 999999),
depth: 1,
squad: squadManifest,
world_state: {},
};
await this.persistence.saveRun(this.activeRunData);
this.gameLoop.startLevel(this.activeRunData);
}
async _resumeRun() {
await this.gameLoopSet.promise;
if (this.activeRunData) {
this.gameLoop.startLevel(this.activeRunData);
}
}
async _checkSaveGame() {
const save = await this.persistence.loadRun();
window.dispatchEvent(
new CustomEvent("save-check-complete", { detail: { hasSave: !!save } })
);
}
}
// Export the Singleton Instance
export const gameStateManager = new GameStateManagerClass();
// Export Class ref for constants/testing
export const GameStateManager = GameStateManagerClass;

71
src/core/Persistence.js Normal file
View file

@ -0,0 +1,71 @@
/**
* Persistence.js
* Handles asynchronous saving and loading using IndexedDB.
*/
const DB_NAME = "AetherShardsDB";
const STORE_NAME = "Runs";
const VERSION = 1;
export class Persistence {
constructor() {
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, VERSION);
request.onerror = (e) => reject("DB Error: " + e.target.error);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = (e) => {
this.db = e.target.result;
resolve();
};
});
}
async saveRun(runData) {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction([STORE_NAME], "readwrite");
const store = tx.objectStore(STORE_NAME);
// Always use ID 'active_run' for the single active session
runData.id = "active_run";
const req = store.put(runData);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async loadRun() {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction([STORE_NAME], "readonly");
const store = tx.objectStore(STORE_NAME);
const req = store.get("active_run");
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async clearRun() {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction([STORE_NAME], "readwrite");
const store = tx.objectStore(STORE_NAME);
const req = store.delete("active_run");
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
}

View file

@ -95,6 +95,11 @@ export class CaveGenerator extends BaseGenerator {
// 3. Apply Texture/Material Logic
// This replaces the placeholder IDs with our specific texture IDs (100-109, 200-209)
this.applyTextures();
// 4. Scatter Cover (Post-Texturing)
// ID 10 = Destructible Cover (Mushrooms/Rocks)
// 5% Density for open movement
this.scatterCover(10, 0.05);
}
smooth() {

View file

@ -86,6 +86,11 @@ export class RuinGenerator extends BaseGenerator {
// 3. Apply Texture/Material Logic
this.applyTextures();
// 4. Scatter Cover (Post-Texturing)
// ID 10 = Scrap/Crates
// 10% Density for Ruins (More cover than caves)
this.scatterCover(10, 0.1);
}
roomsOverlap(room, rooms) {

View file

@ -98,7 +98,7 @@
}
/* Hidden state for transitions */
.hidden {
[hidden] {
display: none !important;
opacity: 0;
pointer-events: none;
@ -320,7 +320,7 @@
<div id="a11y-announcer" class="sr-only" aria-live="assertive"></div>
<!-- MAIN MENU UI -->
<div id="landing-ui" class="ui-container">
<div id="main-menu" class="ui-container">
<div>
<h1 class="title">Aether<br />Shards</h1>
<div class="subtitle">The Great Stillness Awaits</div>
@ -328,23 +328,25 @@
<nav aria-label="Main Menu">
<button id="btn-start" class="menu-btn btn-start">New Descent</button>
<button class="menu-btn btn-load">Continue</button>
<button class="menu-btn btn-options">Guild Archives</button>
<button id="btn-load" disabled class="menu-btn btn-load">
Continue
</button>
<button id="btn-options" class="menu-btn btn-options">
Guild Archives
</button>
</nav>
</div>
<team-builder hidden aria-label="Team Builder"></team-builder>
<!-- GAME VIEWPORT CONTAINER -->
<game-viewport hidden aria-label="Game World"></game-viewport>
<!-- LOADING SCREEN (Hidden by default) -->
<div id="loading-overlay" class="hidden" role="alert" aria-busy="true">
<div id="loading-overlay" hidden role="alert" aria-busy="true">
<div class="loader-cube"></div>
<div class="loading-text" id="loading-message">
INITIALIZING VOXEL ENGINE...
</div>
</div>
<team-builder class="hidden" aria-label="Team Builder"></team-builder>
<!-- GAME VIEWPORT CONTAINER -->
<game-viewport class="hidden" aria-label="Game World"></game-viewport>
<!-- GAME LOGIC (MODULE SCRIPT) -->
</body>
</html>

View file

@ -1,63 +1,68 @@
const loader = document.getElementById("loading-overlay");
const landingUI = document.getElementById("landing-ui");
const loadingMsg = document.getElementById("loading-message");
import { gameStateManager } from "./core/GameStateManager.js";
// --- 2. Accessibility Helper ---
function announce(message) {
const announcer = document.getElementById("a11y-announcer");
announcer.textContent = message;
}
const gameViewport = document.querySelector("game-viewport");
const teamBuilder = document.querySelector("team-builder");
const mainMenu = document.getElementById("main-menu");
const btnNewRun = document.getElementById("btn-start");
const btnContinue = document.getElementById("btn-load");
const loadingOverlay = document.getElementById("loading-overlay");
const loadingMessage = document.getElementById("loading-message");
// --- 3. New Descent Logic (Using Dynamic Import) ---
// We attach listener inside the module script because module scope is local
document.getElementById("btn-start").addEventListener("click", startNewDescent);
// --- Event Listeners ---
async function startNewDescent() {
landingUI.classList.add("hidden");
loader.classList.remove("hidden");
// B. Accessibility Updates
announce("Starting new game. Entering the Team Builder.");
loadingMsg.textContent = "LOADING TEAM BUILDER COMPONENT...";
// C. Lazy Load logic (Components registered via import above)
try {
initiateTeamBuilder();
} catch (error) {
console.error("Failed to load team builder:", error);
loadingMsg.textContent = "ERROR LOADING TEAM BUILDER. PLEASE REFRESH.";
announce("Error loading team builder. Please refresh.");
window.addEventListener("gamestate-changed", async (e) => {
const { newState } = e.detail;
console.log("gamestate-changed", newState);
switch (newState) {
case "STATE_MAIN_MENU":
loadingMessage.textContent = "INITIALIZING MAIN MENU...";
break;
case "STATE_TEAM_BUILDER":
loadingMessage.textContent = "INITIALIZING TEAM BUILDER...";
break;
case "STATE_GAME_RUN":
loadingMessage.textContent = "INITIALIZING GAME ENGINE...";
break;
}
// try {
// // Simulate loading time
// setTimeout(() => {
// loadingMsg.textContent = "GENERATING VOXEL GRID...";
// initializeGameWorld();
// }, 1000);
// } catch (error) {
// console.error("Failed to load game:", error);
// loadingMsg.textContent = "ERROR LOADING ENGINE. PLEASE REFRESH.";
// announce("Error loading game engine. Please refresh.");
// }
}
async function initiateTeamBuilder() {
await import("./ui/team-builder.js");
const teamBuilder = document.querySelector("team-builder");
document.startViewTransition(() => {
teamBuilder.classList.remove("hidden");
loader.classList.add("hidden");
loadingOverlay.toggleAttribute("hidden", false);
mainMenu.toggleAttribute("hidden", true);
gameViewport.toggleAttribute("hidden", true);
teamBuilder.toggleAttribute("hidden", true);
switch (newState) {
case "STATE_MAIN_MENU":
mainMenu.toggleAttribute("hidden", false);
break;
case "STATE_TEAM_BUILDER":
await import("./ui/team-builder.js");
teamBuilder.toggleAttribute("hidden", false);
break;
case "STATE_GAME_RUN":
await import("./ui/game-viewport.js");
gameViewport.toggleAttribute("hidden", false);
break;
}
loadingOverlay.toggleAttribute("hidden", true);
});
window.addEventListener("save-check-complete", (e) => {
if (e.detail.hasSave) {
btnContinue.disabled = false;
btnContinue.style.borderColor = "#00ff00";
btnContinue.style.color = "#00ff00";
}
});
btnNewRun.addEventListener("click", async () => {
teamBuilder.addEventListener("embark", async (e) => {
gameStateManager.handleEmbark(e);
});
announce("Team Builder loaded. Ready to build your team.");
}
gameStateManager.startNewGame();
});
// --- 4. Game Initialization ---
async function initializeGameWorld() {
const gameViewport = document.querySelector("game-viewport");
await import("./ui/game-viewport.js");
btnContinue.addEventListener("click", async () => {
gameStateManager.continueGame();
});
// D. Transition to Game
loader.classList.add("hidden");
gameViewport.classList.remove("hidden");
announce("Game loaded. Tactical grid active.");
}
// Boot
gameStateManager.init();

View file

@ -1,8 +1,6 @@
import { LitElement, html, css } from "lit";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { VoxelGrid } from "../grid/VoxelGrid.js";
import { VoxelManager } from "../grid/VoxelManager.js";
import { gameStateManager } from "../core/GameStateManager.js";
import { GameLoop } from "../core/GameLoop.js";
export class GameViewport extends LitElement {
static styles = css`
@ -20,98 +18,13 @@ export class GameViewport extends LitElement {
constructor() {
super();
this.scene = null;
this.camera = null;
this.renderer = null;
this.voxelGrid = null;
this.voxelManager = null;
}
async firstUpdated() {
this.initThreeJS();
await this.initGameWorld();
this.animate();
}
initThreeJS() {
const container = this.shadowRoot.getElementById("canvas-container");
// Scene Setup
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0a0b10);
// Lighting (Essential for LambertMaterial)
const ambientLight = new THREE.AmbientLight(0x909090, 1.5); // Soft white light
this.scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(10, 20, 10);
this.scene.add(dirLight);
// Camera
this.camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(20, 20, 20);
this.camera.lookAt(0, 0, 0);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
// Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
// Handle Resize
window.addEventListener("resize", this.onWindowResize.bind(this));
}
async initGameWorld() {
// 1. Create Data Grid
this.voxelGrid = new VoxelGrid(20, 8, 20);
const { CaveGenerator } = await import("../generation/CaveGenerator.js");
const { RuinGenerator } = await import("../generation/RuinGenerator.js");
const { CrystalSpiresGenerator } = await import(
"../generation/CrystalSpiresGenerator.js"
);
const crystalSpiresGen = new CrystalSpiresGenerator(this.voxelGrid, 12345);
crystalSpiresGen.generate(5, 8);
// const ruinGen = new RuinGenerator(this.voxelGrid, 12345);
// ruinGen.generate(5, 4, 6);
// const caveGen = new CaveGenerator(this.voxelGrid, 12345);
// caveGen.generate(0.5, 1);
this.voxelManager = new VoxelManager(this.voxelGrid, this.scene);
// this.voxelManager.updateMaterials(ruinGen.generatedAssets);
// this.voxelManager.updateMaterials(caveGen.generatedAssets);
this.voxelManager.update();
this.voxelManager.focusCamera(this.controls);
}
animate() {
requestAnimationFrame(this.animate.bind(this));
this.controls.update();
// Update Voxels if dirty
if (this.voxelManager) this.voxelManager.update();
this.renderer.render(this.scene, this.camera);
}
onWindowResize() {
if (!this.camera || !this.renderer) return;
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
const loop = new GameLoop();
loop.init(container);
gameStateManager.setGameLoop(loop);
}
render() {

View file

@ -1,4 +1,48 @@
import { LitElement, html, css } from "lit";
import { LitElement, html, css } from 'lit';
// Import Tier 1 Class Definitions
// Note: This assumes the build environment supports JSON imports (e.g. Import Attributes or a loader)
import vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' };
import weaverDef from '../assets/data/classes/aether_weaver.json' with { type: 'json' };
import scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' };
import tinkerDef from '../assets/data/classes/tinker.json' with { type: 'json' };
import custodianDef from '../assets/data/classes/custodian.json' with { type: 'json' };
// UI Metadata Mapping (Data not in the raw engine JSONs)
const CLASS_METADATA = {
'CLASS_VANGUARD': {
icon: '🛡️',
image: 'assets/images/portraits/vanguard.png', // Placeholder path
role: 'Tank',
description: 'A heavy frontline tank specialized in absorbing damage and protecting allies.'
},
'CLASS_WEAVER': {
icon: '✨',
image: 'assets/images/portraits/weaver.png',
role: 'Magic DPS',
description: 'A master of elemental magic capable of creating powerful synergy chains.'
},
'CLASS_SCAVENGER': {
icon: '🎒',
image: 'assets/images/portraits/scavenger.png',
role: 'Utility',
description: 'Highly mobile utility expert who excels at finding loot and avoiding traps.'
},
'CLASS_TINKER': {
icon: '🔧',
image: 'assets/images/portraits/tinker.png',
role: 'Tech',
description: 'Uses ancient technology to deploy turrets and control the battlefield.'
},
'CLASS_CUSTODIAN': {
icon: '🌿',
image: 'assets/images/portraits/custodian.png',
role: 'Healer',
description: 'A spiritual healer focused on removing corruption and sustaining the squad.'
}
};
const RAW_TIER_1_CLASSES = [vanguardDef, weaverDef, scavengerDef, tinkerDef, custodianDef];
export class TeamBuilder extends LitElement {
static get styles() {
@ -10,109 +54,230 @@ export class TeamBuilder extends LitElement {
left: 0;
width: 100%;
height: 100%;
font-family: "Courier New", monospace; /* Placeholder for Voxel Font */
font-family: 'Courier New', monospace; /* Placeholder for Voxel Font */
color: white;
pointer-events: none; /* Let clicks pass through to 3D scene where empty */
z-index: 10;
box-sizing: border-box;
}
/* Responsive Container Layout */
.container {
display: grid;
grid-template-columns: 250px 1fr 250px;
grid-template-rows: 1fr 80px;
grid-template-columns: 280px 1fr 300px; /* Wider side panels on desktop */
grid-template-rows: 1fr 100px;
grid-template-areas:
"roster squad details"
"footer footer footer";
height: 100%;
width: 100%;
pointer-events: auto;
background: rgba(0, 0, 0, 0.4); /* Dim background */
background: rgba(0, 0, 0, 0.6); /* Slightly darker background for readability */
backdrop-filter: blur(4px);
}
/* Mobile Layout (< 1024px) */
@media (max-width: 1024px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 200px 1fr 200px 80px; /* Roster, Squad, Details, Footer */
grid-template-areas:
"roster"
"squad"
"details"
"footer";
}
}
/* --- LEFT PANEL: ROSTER --- */
.roster-panel {
grid-area: roster;
background: rgba(20, 20, 30, 0.9);
border-right: 2px solid #555;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
@media (max-width: 1024px) {
.roster-panel {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
border-right: none;
border-bottom: 2px solid #555;
align-items: center;
}
}
.class-card {
background: #333;
border: 2px solid #555;
padding: 10px;
margin-bottom: 10px;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 10px;
gap: 15px;
/* Button Reset */
width: 100%;
text-align: left;
font-family: inherit;
color: inherit;
appearance: none;
}
.class-card:hover {
@media (max-width: 1024px) {
.class-card {
width: 200px; /* Fixed width cards for horizontal scroll */
flex-shrink: 0;
height: 80%;
}
}
.class-card:hover:not(:disabled) {
border-color: #00ffff;
background: #444;
transform: translateX(5px);
}
@media (max-width: 1024px) {
.class-card:hover:not(:disabled) {
transform: translateY(-5px); /* Hop up on mobile */
}
}
.class-card.locked {
.class-card:disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
filter: grayscale(1);
border-color: #444;
}
/* --- CENTER PANEL: SLOTS --- */
.squad-panel {
grid-area: squad;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: 2rem;
gap: 20px;
align-items: center;
padding: 2rem;
gap: 30px;
flex-wrap: wrap; /* Allow wrapping on very small screens */
overflow-y: auto;
}
/* Wrapper to hold the slot button and the absolute remove button as siblings */
.slot-wrapper {
position: relative;
width: 180px; /* Increased size */
height: 240px; /* Increased size */
transition: transform 0.2s;
}
.slot-wrapper:hover {
transform: scale(1.05);
}
.squad-slot {
width: 120px;
height: 150px;
background: rgba(0, 0, 0, 0.6);
border: 2px dashed #666;
width: 100%;
height: 100%;
background: rgba(10, 10, 10, 0.8);
border: 3px dashed #666;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
/* Button Reset */
font-family: inherit;
color: inherit;
padding: 0;
appearance: none;
}
/* Image placeholder style */
.unit-image {
width: 100%;
height: 70%;
object-fit: cover;
background-color: #222; /* Fallback */
border-bottom: 2px solid #555;
}
.unit-info {
height: 30%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
background: rgba(30,30,40,0.9);
}
.squad-slot.filled {
border: 2px solid #00ff00;
background: rgba(0, 50, 0, 0.6);
border: 3px solid #00ff00;
border-style: solid;
background: rgba(0, 20, 0, 0.8);
}
.squad-slot.selected {
border-color: #00ffff;
box-shadow: 0 0 10px #00ffff;
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
.remove-btn {
position: absolute;
top: -10px;
right: -10px;
background: red;
border: none;
top: -15px;
right: -15px;
background: #cc0000;
border: 2px solid white;
color: white;
width: 24px;
height: 24px;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-weight: bold;
z-index: 2; /* Ensure it sits on top of the slot button */
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
}
.remove-btn:hover {
background: #ff0000;
transform: scale(1.1);
}
/* --- RIGHT PANEL: DETAILS --- */
.details-panel {
grid-area: details;
background: rgba(20, 20, 30, 0.9);
border-left: 2px solid #555;
padding: 1rem;
padding: 1.5rem;
overflow-y: auto;
}
@media (max-width: 1024px) {
.details-panel {
border-left: none;
border-top: 2px solid #555;
display: grid;
grid-template-columns: 1fr 1fr; /* Split content on mobile */
gap: 20px;
}
}
/* --- FOOTER --- */
.footer {
grid-column: 1 / -1;
grid-area: footer;
display: flex;
justify-content: center;
align-items: center;
@ -121,14 +286,24 @@ export class TeamBuilder extends LitElement {
}
.embark-btn {
padding: 15px 40px;
font-size: 1.5rem;
padding: 15px 60px;
font-size: 1.8rem;
background: #008800;
color: white;
border: 2px solid #00ff00;
border: 3px solid #00ff00;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
font-family: inherit;
letter-spacing: 2px;
transition: all 0.2s;
box-shadow: 0 0 15px rgba(0, 255, 0, 0.2);
}
.embark-btn:hover:not(:disabled) {
background: #00aa00;
box-shadow: 0 0 25px rgba(0, 255, 0, 0.6);
transform: scale(1.02);
}
.embark-btn:disabled {
@ -136,6 +311,21 @@ export class TeamBuilder extends LitElement {
border-color: #555;
color: #777;
cursor: not-allowed;
box-shadow: none;
}
h2, h3, h4 { margin-top: 0; color: #00ffff; }
ul { padding-left: 1.2rem; }
li { margin-bottom: 5px; }
/* Helper for placeholder images */
.placeholder-img {
display: flex;
align-items: center;
justify-content: center;
background: #444;
color: #888;
font-size: 3rem;
}
`;
}
@ -145,7 +335,7 @@ export class TeamBuilder extends LitElement {
availableClasses: { type: Array }, // Input: List of class definition objects
squad: { type: Array }, // Internal State: The 4 slots
selectedSlotIndex: { type: Number },
hoveredClass: { type: Object },
hoveredClass: { type: Object }
};
}
@ -153,94 +343,168 @@ export class TeamBuilder extends LitElement {
super();
this.squad = [null, null, null, null];
this.selectedSlotIndex = 0; // Default to first slot
this.availableClasses = []; // Passed in by parent
this.hoveredClass = null;
// Initialize by merging Raw Data with UI Metadata
this.availableClasses = RAW_TIER_1_CLASSES.map(cls => {
const meta = CLASS_METADATA[cls.id] || {};
return {
...cls,
...meta, // Adds icon, role, description, image path
unlocked: true // Default all Tier 1s to unlocked
};
});
}
connectedCallback() {
super.connectedCallback();
this._loadMetaProgression();
}
/**
* Loads unlocked classes from persistence (Local Storage / Game State).
* Merges Tier 2 classes into availableClasses if unlocked.
*/
_loadMetaProgression() {
// Mock Implementation: Retrieve unlocked Tier 2 classes from a service or storage
// In a real implementation, you would import a MetaProgressionManager here.
// Example: const unlockedIds = MetaProgression.getUnlockedClasses();
const storedData = localStorage.getItem('aether_shards_unlocks');
if (storedData) {
try {
const unlocks = JSON.parse(storedData);
// This is where you would fetch the full class definition for unlocked Tier 2s
// and append them to this.availableClasses
console.log('Loaded unlocks:', unlocks);
} catch (e) {
console.error('Failed to load meta progression', e);
}
}
}
render() {
const isSquadValid = this.squad.some((u) => u !== null);
const isSquadValid = this.squad.some(u => u !== null);
return html`
<div class="container">
<!-- ROSTER LIST -->
<div class="roster-panel">
<h3>Roster</h3>
${this.availableClasses.map(
(cls) => html`
<div
class="class-card ${cls.unlocked ? "" : "locked"}"
@click="${() => this._assignClass(cls)}"
@mouseenter="${() => (this.hoveredClass = cls)}"
@mouseleave="${() => (this.hoveredClass = null)}"
>
<div class="icon">${cls.icon || "⚔️"}</div>
<div>
<strong>${cls.name}</strong><br />
<small>${cls.role}</small>
</div>
${this.availableClasses.map(cls => html`
<button
type="button"
class="class-card"
?disabled="${!cls.unlocked}"
@click="${() => this._assignClass(cls)}"
@mouseenter="${() => this.hoveredClass = cls}"
@mouseleave="${() => this.hoveredClass = null}"
aria-label="Select Class: ${cls.name}"
>
<div class="icon" style="font-size: 1.5rem;">${cls.icon || '⚔️'}</div>
<div>
<strong>${cls.name}</strong><br>
<small>${cls.role || 'Tier ' + cls.tier}</small>
</div>
`
)}
</button>
`)}
</div>
<!-- CENTER SQUAD SLOTS -->
<div class="squad-panel">
${this.squad.map(
(unit, index) => html`
<div
class="squad-slot ${unit ? "filled" : ""} ${this
.selectedSlotIndex === index
? "selected"
: ""}"
${this.squad.map((unit, index) => html`
<div class="slot-wrapper">
<button
type="button"
class="squad-slot ${unit ? 'filled' : ''} ${this.selectedSlotIndex === index ? 'selected' : ''}"
@click="${() => this._selectSlot(index)}"
aria-label="${unit ? `Slot ${index + 1}: ${unit.name}` : `Slot ${index + 1}: Empty`}"
aria-pressed="${this.selectedSlotIndex === index}"
>
${unit
${unit
? html`
<div class="icon" style="font-size: 2rem;">
${unit.icon || "🛡️"}
<!-- Use image property if available, otherwise show large icon placeholder -->
${unit.image
? html`<img src="${unit.image}" alt="${unit.name}" class="unit-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="unit-image placeholder-img" style="${unit.image ? 'display:none' : ''}">
${unit.icon || '🛡️'}
</div>
<div class="unit-info">
<strong>${unit.name}</strong>
<small>${this.availableClasses.find(c => c.id === unit.classId)?.role}</small>
</div>
`
: html`
<div class="placeholder-img" style="background:transparent; color: #555;">+</div>
<div class="unit-info" style="background:transparent;">
<span>Slot ${index + 1}</span>
<small>Empty</small>
</div>
<span>${unit.name}</span>
<button
class="remove-btn"
@click="${(e) => this._removeUnit(e, index)}"
>
X
</button>
`
: html`<span
>Slot ${index + 1}<br /><small>Select Class</small></span
>`}
</div>
`
)}
}
</button>
${unit
? html`
<button
type="button"
class="remove-btn"
@click="${() => this._removeUnit(index)}"
aria-label="Remove ${unit.name} from Slot ${index + 1}"
>
X
</button>`
: ''
}
</div>
`)}
</div>
<!-- RIGHT DETAILS PANEL -->
<div class="details-panel">
${this.hoveredClass
${this.hoveredClass
? html`
<h2>${this.hoveredClass.name}</h2>
<p><em>${this.hoveredClass.role}</em></p>
<hr />
<p>
${this.hoveredClass.description ||
"No description available."}
</p>
<h4>Base Stats</h4>
<ul>
<li>HP: ${this.hoveredClass.base_stats?.health}</li>
<li>AP: ${this.hoveredClass.base_stats?.speed}</li>
<!-- Simplified AP calc -->
<li>Move: ${this.hoveredClass.base_stats?.movement}</li>
</ul>
<div>
<h2>${this.hoveredClass.name}</h2>
<p><em>${this.hoveredClass.role || 'Tier ' + this.hoveredClass.tier} Class</em></p>
<hr>
<p>${this.hoveredClass.description || 'A skilled explorer ready for the depths.'}</p>
</div>
<div>
<h4>Base Stats</h4>
<ul>
<li>HP: ${this.hoveredClass.base_stats?.health}</li>
<li>Atk: ${this.hoveredClass.base_stats?.attack}</li>
<li>Def: ${this.hoveredClass.base_stats?.defense}</li>
<li>Mag: ${this.hoveredClass.base_stats?.magic}</li>
<li>Spd: ${this.hoveredClass.base_stats?.speed}</li>
<li>Will: ${this.hoveredClass.base_stats?.willpower}</li>
<li>Move: ${this.hoveredClass.base_stats?.movement}</li>
${this.hoveredClass.base_stats?.tech ? html`<li>Tech: ${this.hoveredClass.base_stats.tech}</li>` : ''}
</ul>
<h4>Starting Gear</h4>
<ul>
${this.hoveredClass.starting_equipment
? this.hoveredClass.starting_equipment.map(item => html`<li>${this._formatItemName(item)}</li>`)
: html`<li>None</li>`}
</ul>
</div>
`
: html`<p>Hover over a class to see details.</p>`}
: html`<p>Hover over a class or squad member to see details.</p>`
}
</div>
<!-- FOOTER -->
<div class="footer">
<button
class="embark-btn"
<button
type="button"
class="embark-btn"
?disabled="${!isSquadValid}"
@click="${this._handleEmbark}"
>
@ -255,16 +519,23 @@ export class TeamBuilder extends LitElement {
_selectSlot(index) {
this.selectedSlotIndex = index;
// If slot has a unit, show its details in hover panel
if (this.squad[index]) {
// Need to find the original class ref to show details
const originalClass = this.availableClasses.find(c => c.id === this.squad[index].classId);
if (originalClass) this.hoveredClass = originalClass;
}
}
_assignClass(classDef) {
if (!classDef.unlocked) return;
if (!classDef.unlocked && classDef.unlocked !== undefined) return; // Logic check redundancy for tests without DOM checks
// 1. Create a lightweight manifest for the slot
const unitManifest = {
classId: classDef.id,
name: classDef.name, // In real app, auto-generate name
name: classDef.name, // In real app, auto-generate name like "Valerius"
icon: classDef.icon,
image: classDef.image // Pass image path
};
// 2. Update State (Trigger Re-render)
@ -278,43 +549,46 @@ export class TeamBuilder extends LitElement {
}
// 4. Dispatch Event (For 3D Scene to show model)
this.dispatchEvent(
new CustomEvent("squad-update", {
detail: { slot: this.selectedSlotIndex, unit: unitManifest },
bubbles: true,
composed: true,
})
);
this.dispatchEvent(new CustomEvent('squad-update', {
detail: { slot: this.selectedSlotIndex, unit: unitManifest },
bubbles: true,
composed: true
}));
}
_removeUnit(e, index) {
e.stopPropagation(); // Prevent slot selection
_removeUnit(index) {
// No stopPropagation needed as elements are siblings now
const newSquad = [...this.squad];
newSquad[index] = null;
this.squad = newSquad;
this.selectedSlotIndex = index; // Select the empty slot
// Dispatch Event (To clear 3D model)
this.dispatchEvent(
new CustomEvent("squad-update", {
detail: { slot: index, unit: null },
bubbles: true,
composed: true,
})
);
this.dispatchEvent(new CustomEvent('squad-update', {
detail: { slot: index, unit: null },
bubbles: true,
composed: true
}));
}
_handleEmbark() {
const manifest = this.squad.filter((u) => u !== null);
const manifest = this.squad.filter(u => u !== null);
this.dispatchEvent(new CustomEvent('embark', {
detail: { squad: manifest },
bubbles: true,
composed: true
}));
}
this.dispatchEvent(
new CustomEvent("embark", {
detail: { squad: manifest },
bubbles: true,
composed: true,
})
);
// Helpers to make IDs readable (e.g. "ITEM_RUSTY_BLADE" -> "Rusty Blade")
_formatItemName(id) {
return id.replace('ITEM_', '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
_formatSkillName(id) {
return id.replace('SKILL_', '').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
}
}
customElements.define("team-builder", TeamBuilder);
customElements.define('team-builder', TeamBuilder);

View file

@ -0,0 +1,97 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
// Import the singleton instance AND the class for constants
import {
gameStateManager,
GameStateManager,
} from "../../src/core/GameStateManager.js";
describe("Core: GameStateManager (Singleton)", () => {
let mockPersistence;
let mockGameLoop;
beforeEach(() => {
// 1. Reset Singleton State
gameStateManager.reset();
// 2. Mock Persistence
mockPersistence = {
init: sinon.stub().resolves(),
saveRun: sinon.stub().resolves(),
loadRun: sinon.stub().resolves(null),
};
// Inject Mock (replacing the real Persistence instance)
gameStateManager.persistence = mockPersistence;
// 3. Mock GameLoop
mockGameLoop = {
init: sinon.spy(),
startLevel: sinon.spy(),
stop: sinon.spy(),
};
});
it("CoA 1: Should initialize and transition to MAIN_MENU", async () => {
const eventSpy = sinon.spy();
window.addEventListener("gamestate-changed", eventSpy);
await gameStateManager.init();
expect(mockPersistence.init.calledOnce).to.be.true;
expect(gameStateManager.currentState).to.equal(
GameStateManager.STATES.MAIN_MENU
);
expect(eventSpy.called).to.be.true;
});
it("CoA 2: startNewGame should transition to TEAM_BUILDER", async () => {
await gameStateManager.init();
gameStateManager.startNewGame();
expect(gameStateManager.currentState).to.equal(
GameStateManager.STATES.TEAM_BUILDER
);
});
it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => {
gameStateManager.setGameLoop(mockGameLoop);
await gameStateManager.init();
const mockSquad = [{ id: "u1" }];
// Handle Async Chain
let resolveEngineStart;
const engineStartPromise = new Promise((r) => {
resolveEngineStart = r;
});
mockGameLoop.startLevel = sinon.stub().callsFake(() => {
resolveEngineStart();
});
gameStateManager.handleEmbark({ detail: { squad: mockSquad } });
await engineStartPromise;
expect(gameStateManager.currentState).to.equal(
GameStateManager.STATES.GAME_RUN
);
expect(mockPersistence.saveRun.calledWith(gameStateManager.activeRunData))
.to.be.true;
expect(mockGameLoop.startLevel.calledWith(gameStateManager.activeRunData))
.to.be.true;
});
it("CoA 4: continueGame should load save and resume engine", async () => {
gameStateManager.setGameLoop(mockGameLoop);
const savedData = { seed: 999, depth: 5, squad: [] };
mockPersistence.loadRun.resolves(savedData);
await gameStateManager.init();
await gameStateManager.continueGame();
expect(mockPersistence.loadRun.called).to.be.true;
expect(gameStateManager.activeRunData).to.deep.equal(savedData);
expect(mockGameLoop.startLevel.calledWith(savedData)).to.be.true;
});
});