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:
parent
391abd6ea6
commit
5be96d2846
15 changed files with 910 additions and 278 deletions
34
build.js
34
build.js
|
|
@ -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!");
|
||||
|
|
|
|||
BIN
src/assets/images/portraits/custodian.png
Normal file
BIN
src/assets/images/portraits/custodian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
BIN
src/assets/images/portraits/tinker.png
Normal file
BIN
src/assets/images/portraits/tinker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/images/portraits/vanguard.png
Normal file
BIN
src/assets/images/portraits/vanguard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
src/assets/images/portraits/weaver.png
Normal file
BIN
src/assets/images/portraits/weaver.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
111
src/core/GameLoop.js
Normal file
111
src/core/GameLoop.js
Normal 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
|
||||
}
|
||||
}
|
||||
119
src/core/GameStateManager.js
Normal file
119
src/core/GameStateManager.js
Normal 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
71
src/core/Persistence.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
src/index.js
115
src/index.js
|
|
@ -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() {
|
||||
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");
|
||||
const teamBuilder = document.querySelector("team-builder");
|
||||
document.startViewTransition(() => {
|
||||
teamBuilder.classList.remove("hidden");
|
||||
loader.classList.add("hidden");
|
||||
});
|
||||
announce("Team Builder loaded. Ready to build your team.");
|
||||
}
|
||||
|
||||
// --- 4. Game Initialization ---
|
||||
async function initializeGameWorld() {
|
||||
const gameViewport = document.querySelector("game-viewport");
|
||||
teamBuilder.toggleAttribute("hidden", false);
|
||||
break;
|
||||
case "STATE_GAME_RUN":
|
||||
await import("./ui/game-viewport.js");
|
||||
gameViewport.toggleAttribute("hidden", false);
|
||||
break;
|
||||
}
|
||||
loadingOverlay.toggleAttribute("hidden", true);
|
||||
});
|
||||
|
||||
// D. Transition to Game
|
||||
loader.classList.add("hidden");
|
||||
gameViewport.classList.remove("hidden");
|
||||
announce("Game loaded. Tactical grid active.");
|
||||
}
|
||||
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);
|
||||
});
|
||||
gameStateManager.startNewGame();
|
||||
});
|
||||
|
||||
btnContinue.addEventListener("click", async () => {
|
||||
gameStateManager.continueGame();
|
||||
});
|
||||
|
||||
// Boot
|
||||
gameStateManager.init();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
.class-card.locked {
|
||||
@media (max-width: 1024px) {
|
||||
.class-card:hover:not(:disabled) {
|
||||
transform: translateY(-5px); /* Hop up on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
.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,93 +343,167 @@ 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"}"
|
||||
${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)}"
|
||||
@mouseenter="${() => this.hoveredClass = cls}"
|
||||
@mouseleave="${() => this.hoveredClass = null}"
|
||||
aria-label="Select Class: ${cls.name}"
|
||||
>
|
||||
<div class="icon">${cls.icon || "⚔️"}</div>
|
||||
<div class="icon" style="font-size: 1.5rem;">${cls.icon || '⚔️'}</div>
|
||||
<div>
|
||||
<strong>${cls.name}</strong><br />
|
||||
<small>${cls.role}</small>
|
||||
<strong>${cls.name}</strong><br>
|
||||
<small>${cls.role || 'Tier ' + cls.tier}</small>
|
||||
</div>
|
||||
</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
|
||||
? 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>
|
||||
<span>${unit.name}</span>
|
||||
|
||||
<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>
|
||||
`
|
||||
}
|
||||
</button>
|
||||
|
||||
${unit
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
@click="${(e) => this._removeUnit(e, index)}"
|
||||
@click="${() => this._removeUnit(index)}"
|
||||
aria-label="Remove ${unit.name} from Slot ${index + 1}"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
`
|
||||
: html`<span
|
||||
>Slot ${index + 1}<br /><small>Select Class</small></span
|
||||
>`}
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<!-- RIGHT DETAILS PANEL -->
|
||||
<div class="details-panel">
|
||||
${this.hoveredClass
|
||||
? html`
|
||||
<div>
|
||||
<h2>${this.hoveredClass.name}</h2>
|
||||
<p><em>${this.hoveredClass.role}</em></p>
|
||||
<hr />
|
||||
<p>
|
||||
${this.hoveredClass.description ||
|
||||
"No description available."}
|
||||
</p>
|
||||
<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>AP: ${this.hoveredClass.base_stats?.speed}</li>
|
||||
<!-- Simplified AP calc -->
|
||||
<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
|
||||
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", {
|
||||
this.dispatchEvent(new CustomEvent('squad-update', {
|
||||
detail: { slot: this.selectedSlotIndex, unit: unitManifest },
|
||||
bubbles: true,
|
||||
composed: 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", {
|
||||
this.dispatchEvent(new CustomEvent('squad-update', {
|
||||
detail: { slot: index, unit: null },
|
||||
bubbles: true,
|
||||
composed: 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", {
|
||||
this.dispatchEvent(new CustomEvent('embark', {
|
||||
detail: { squad: manifest },
|
||||
bubbles: true,
|
||||
composed: 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);
|
||||
97
test/core/GameStateManager.test.js
Normal file
97
test/core/GameStateManager.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue