From ec25c71eb134ba78ab03226f6771518863649320 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Sun, 21 Dec 2025 20:40:48 -0800 Subject: [PATCH] Refactor mission management and game state integration. Replace tutorial mission initiation with a new game start function. Update mission JSON schema to enhance narrative and objective handling. Introduce MissionManager for mission state management and integrate with GameStateManager for improved game flow. Enhance UI components for squad management and deployment. Include tests for new mission and narrative functionalities. --- .../data/missions/mission_tutorial_01.json | 55 +- src/core/GameLoop.js | 3 +- src/core/GameStateManager.js | 87 +++- src/core/Persistence.js | 78 ++- src/index.js | 2 +- src/managers/MissionManager.js | 158 ++++++ src/managers/NarrativeManager.js | 143 ++++++ src/ui/deployment-hud.js | 11 +- src/ui/game-viewport.js | 10 +- src/ui/team-builder.js | 472 +++++++----------- test/{systems => managers}/MissionManager.js | 0 .../{systems => managers}/NarrativeManager.js | 0 12 files changed, 658 insertions(+), 361 deletions(-) create mode 100644 src/managers/MissionManager.js create mode 100644 src/managers/NarrativeManager.js rename test/{systems => managers}/MissionManager.js (100%) rename test/{systems => managers}/NarrativeManager.js (100%) diff --git a/src/assets/data/missions/mission_tutorial_01.json b/src/assets/data/missions/mission_tutorial_01.json index c470a88..b01517c 100644 --- a/src/assets/data/missions/mission_tutorial_01.json +++ b/src/assets/data/missions/mission_tutorial_01.json @@ -1,23 +1,46 @@ { "id": "MISSION_TUTORIAL_01", - "title": "Protocol: First Descent", - "description": "Establish a foothold in the Rusting Wastes and secure the perimeter.", - "biome_config": { - "type": "RUINS", - "seed_type": "FIXED", - "seed": 12345 + "type": "TUTORIAL", + "config": { + "title": "Protocol: First Descent", + "description": "Establish a foothold in the Rusting Wastes and secure the perimeter.", + "difficulty_tier": 1, + "recommended_level": 1 }, - "narrative_intro": "NARRATIVE_TUTORIAL_INTRO", - "narrative_outro": "NARRATIVE_TUTORIAL_SUCCESS", - "objectives": [ - { - "type": "ELIMINATE_ENEMIES", - "target_count": 2 + "biome": { + "type": "BIOME_RUSTING_WASTES", + "generator_config": { + "seed_type": "FIXED", + "seed": 12345, + "size": { + "x": 20, + "y": 5, + "z": 10 + }, + "density": "LOW", + "room_count": 4 } - ], + }, + "narrative": { + "intro_sequence": "NARRATIVE_TUTORIAL_INTRO", + "outro_success": "NARRATIVE_TUTORIAL_SUCCESS" + }, + "objectives": { + "primary": [ + { + "id": "OBJ_ELIMINATE_ENEMIES", + "type": "ELIMINATE_ALL", + "description": "Eliminate 2 enemies", + "target_count": 2 + } + ] + }, "rewards": { - "xp": 100, - "currency": 50, - "unlock_class": "CLASS_TINKER" + "guaranteed": { + "xp": 100, + "currency": { + "aether_shards": 50 + } + } } } diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index d8a7796..2da10ca 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -6,7 +6,7 @@ import { UnitManager } from "../managers/UnitManager.js"; import { CaveGenerator } from "../generation/CaveGenerator.js"; import { RuinGenerator } from "../generation/RuinGenerator.js"; import { InputManager } from "./InputManager.js"; -import { MissionManager } from "../systems/MissionManager.js"; +import { MissionManager } from "../managers/MissionManager.js"; export class GameLoop { constructor() { @@ -173,6 +173,7 @@ export class GameLoop { /** * Called by UI when a unit is clicked in the Roster. + * @param {number} index - The index of the unit in the squad to select. */ selectDeploymentUnit(index) { this.deploymentState.selectedUnitIndex = index; diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 3affd89..0e89d28 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -1,4 +1,7 @@ import { Persistence } from "./Persistence.js"; +import { RosterManager } from "../managers/RosterManager.js"; +import { MissionManager } from "../managers/MissionManager.js"; +import { narrativeManager } from "../managers/NarrativeManager.js"; class GameStateManagerClass { static STATES = { @@ -13,28 +16,44 @@ class GameStateManagerClass { this.gameLoop = null; this.persistence = new Persistence(); this.activeRunData = null; - this.gameLoopSet = Promise.withResolvers(); + + // Integrate Core Managers + this.rosterManager = new RosterManager(); + this.missionManager = new MissionManager(); + this.narrativeManager = narrativeManager; // Track the singleton instance 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; + #gameLoopInitialized = Promise.withResolvers(); + get gameLoopInitialized() { + return this.#gameLoopInitialized.promise; + } + + #rosterLoaded = Promise.withResolvers(); + get rosterLoaded() { + return this.#rosterLoaded.promise; } setGameLoop(loop) { this.gameLoop = loop; - this.gameLoopSet.resolve(loop); + this.#gameLoopInitialized.resolve(); } async init() { console.log("System: Initializing State Manager..."); await this.persistence.init(); + + // 1. Load Roster + const savedRoster = await this.persistence.loadRoster(); + if (savedRoster) { + this.rosterManager.load(savedRoster); + this.#rosterLoaded.resolve(this.rosterManager.roster); + } + + // 2. Load Campaign Progress + // (In future: this.missionManager.load(savedCampaignData)) + this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); } @@ -78,28 +97,65 @@ class GameStateManagerClass { } handleEmbark(e) { + // Handle Draft Mode (New Recruits) + if (e.detail.mode === "DRAFT") { + e.detail.squad.forEach((unit) => { + if (unit.isNew) { + this.rosterManager.recruitUnit(unit); + } + }); + this._saveRoster(); + } this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad); } // --- INTERNAL HELPERS --- async _initializeRun(squadManifest) { - await this.gameLoopSet.promise; + await this.gameLoopInitialized; + // 1. Mission Logic: Setup + // This resets objectives and prepares the logic for the new run + this.missionManager.setupActiveMission(); + const missionDef = this.missionManager.getActiveMission(); + + console.log(`Initializing Run for Mission: ${missionDef.config.title}`); + + // 2. Mission Logic: Narrative Intro + // If the mission has an intro, play it now. + // The game loop won't start until this promise resolves (or we could start it paused). + // This relies on the MissionManager internally calling narrativeManager.startSequence() + await this.missionManager.playIntro(); + + // 3. Build Run Data this.activeRunData = { - seed: Math.floor(Math.random() * 999999), + id: `RUN_${Date.now()}`, + missionId: missionDef.id, + seed: + missionDef.biome.generator_config.seed_type === "FIXED" + ? missionDef.biome.generator_config.seed + : Math.floor(Math.random() * 999999), depth: 1, + biome: missionDef.biome, // Pass biome config to GameLoop squad: squadManifest, + objectives: missionDef.objectives, // Pass objectives for UI display world_state: {}, }; + // 4. Save & Start await this.persistence.saveRun(this.activeRunData); + + // Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc) + this.gameLoop.missionManager = this.missionManager; this.gameLoop.startLevel(this.activeRunData); } async _resumeRun() { - await this.gameLoopSet.promise; + await this.gameLoopInitialized; if (this.activeRunData) { + // Re-hook the mission manager + this.gameLoop.missionManager = this.missionManager; + // TODO: Ideally we reload the mission state from the save file here this.gameLoop.startLevel(this.activeRunData); } } @@ -110,10 +166,13 @@ class GameStateManagerClass { new CustomEvent("save-check-complete", { detail: { hasSave: !!save } }) ); } + + async _saveRoster() { + const data = this.rosterManager.save(); + await this.persistence.saveRoster(data); + } } // Export the Singleton Instance export const gameStateManager = new GameStateManagerClass(); - -// Export Class ref for constants/testing export const GameStateManager = GameStateManagerClass; diff --git a/src/core/Persistence.js b/src/core/Persistence.js index 0fcc6a8..902062b 100644 --- a/src/core/Persistence.js +++ b/src/core/Persistence.js @@ -1,10 +1,12 @@ /** * Persistence.js * Handles asynchronous saving and loading using IndexedDB. + * Manages both Active Runs and Persistent Roster data. */ const DB_NAME = "AetherShardsDB"; -const STORE_NAME = "Runs"; -const VERSION = 1; +const RUN_STORE = "Runs"; +const ROSTER_STORE = "Roster"; +const VERSION = 2; // Bumped version to add Roster store export class Persistence { constructor() { @@ -19,8 +21,15 @@ export class Persistence { request.onupgradeneeded = (e) => { const db = e.target.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: "id" }); + + // Create Runs Store if missing + if (!db.objectStoreNames.contains(RUN_STORE)) { + db.createObjectStore(RUN_STORE, { keyPath: "id" }); + } + + // Create Roster Store if missing + if (!db.objectStoreNames.contains(ROSTER_STORE)) { + db.createObjectStore(ROSTER_STORE, { keyPath: "id" }); } }; @@ -31,39 +40,64 @@ export class Persistence { }); } + // --- RUN DATA --- + 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); + return this._put(RUN_STORE, { ...runData, id: "active_run" }); + } + async loadRun() { + if (!this.db) await this.init(); + return this._get(RUN_STORE, "active_run"); + } + + async clearRun() { + if (!this.db) await this.init(); + return this._delete(RUN_STORE, "active_run"); + } + + // --- ROSTER DATA --- + + async saveRoster(rosterData) { + if (!this.db) await this.init(); + // Wrap the raw data object in an ID for storage + return this._put(ROSTER_STORE, { id: "player_roster", data: rosterData }); + } + + async loadRoster() { + if (!this.db) await this.init(); + const result = await this._get(ROSTER_STORE, "player_roster"); + return result ? result.data : null; + } + + // --- INTERNAL HELPERS --- + + _put(storeName, item) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction([storeName], "readwrite"); + const store = tx.objectStore(storeName); + const req = store.put(item); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); } - async loadRun() { - if (!this.db) await this.init(); + _get(storeName, key) { 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"); - + const tx = this.db.transaction([storeName], "readonly"); + const store = tx.objectStore(storeName); + const req = store.get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } - async clearRun() { - if (!this.db) await this.init(); + _delete(storeName, key) { 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"); - + const tx = this.db.transaction([storeName], "readwrite"); + const store = tx.objectStore(storeName); + const req = store.delete(key); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); diff --git a/src/index.js b/src/index.js index 8927f78..cc3e7e2 100644 --- a/src/index.js +++ b/src/index.js @@ -58,7 +58,7 @@ btnNewRun.addEventListener("click", async () => { gameStateManager.handleEmbark(e); gameViewport.squad = teamBuilder.squad; }); - gameStateManager.startMission("MISSION_TUTORIAL_01"); + gameStateManager.startNewGame(); }); btnContinue.addEventListener("click", async () => { diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js new file mode 100644 index 0000000..cd34cd2 --- /dev/null +++ b/src/managers/MissionManager.js @@ -0,0 +1,158 @@ +import tutorialMission from '../assets/data/missions/mission_tutorial_01.json' with { type: 'json' }; +import { narrativeManager } from './NarrativeManager.js'; + +/** + * MissionManager.js + * Manages campaign progression, mission selection, narrative triggers, and objective tracking. + */ +export class MissionManager { + constructor() { + // Campaign State + this.activeMissionId = null; + this.completedMissions = new Set(); + this.missionRegistry = new Map(); + + // Active Run State + this.currentMissionDef = null; + this.currentObjectives = []; + + // Register default missions + this.registerMission(tutorialMission); + } + + registerMission(missionDef) { + this.missionRegistry.set(missionDef.id, missionDef); + } + + // --- PERSISTENCE (Campaign) --- + + load(saveData) { + this.completedMissions = new Set(saveData.completedMissions || []); + // Default to Tutorial if history is empty + if (this.completedMissions.size === 0) { + this.activeMissionId = 'MISSION_TUTORIAL_01'; + } + } + + save() { + return { + completedMissions: Array.from(this.completedMissions) + }; + } + + // --- MISSION SETUP & NARRATIVE --- + + /** + * Gets the configuration for the currently selected mission. + */ + getActiveMission() { + if (!this.activeMissionId) return this.missionRegistry.get('MISSION_TUTORIAL_01'); + return this.missionRegistry.get(this.activeMissionId); + } + + /** + * Prepares the manager for a new run. + * Resets objectives and prepares narrative hooks. + */ + setupActiveMission() { + const mission = this.getActiveMission(); + this.currentMissionDef = mission; + + // Hydrate objectives state + this.currentObjectives = mission.objectives.primary.map(obj => ({ + ...obj, + current: 0, + complete: false + })); + + console.log(`Mission Setup: ${mission.config.title} - Objectives:`, this.currentObjectives); + } + + /** + * Plays the intro narrative if one exists. + * Returns a Promise that resolves when the game should start. + */ + async playIntro() { + if (!this.currentMissionDef || !this.currentMissionDef.narrative || !this.currentMissionDef.narrative.intro_sequence) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const introId = this.currentMissionDef.narrative.intro_sequence; + + // Mock loader: In real app, fetch the JSON from assets/data/narrative/ + // For prototype, we'll assume narrativeManager can handle the ID or we pass a mock. + // const narrativeData = await fetch(`assets/data/narrative/${introId}.json`).then(r => r.json()); + + // We'll simulate the event listener logic + const onEnd = () => { + narrativeManager.removeEventListener('narrative-end', onEnd); + resolve(); + }; + narrativeManager.addEventListener('narrative-end', onEnd); + + // Trigger the manager (Assuming it has a loader, or we modify it to accept ID) + // For this snippet, we assume startSequence accepts data. + // In a full implementation, you'd load the JSON here. + console.log(`Playing Narrative Intro: ${introId}`); + // narrativeManager.startSequence(loadedJson); + + // Fallback for prototype if no JSON loader: + setTimeout(onEnd, 100); // Instant resolve for now to prevent hanging + }); + } + + // --- GAMEPLAY LOGIC (Objectives) --- + + /** + * Called by GameLoop whenever a relevant event occurs. + * @param {string} type - 'ENEMY_DEATH', 'TURN_END', etc. + * @param {Object} data - Context data + */ + onGameEvent(type, data) { + if (!this.currentObjectives.length) return; + + let statusChanged = false; + + this.currentObjectives.forEach(obj => { + if (obj.complete) return; + + // Logic for 'ELIMINATE_ALL' or 'ELIMINATE_UNIT' + if (type === 'ENEMY_DEATH') { + if (obj.type === 'ELIMINATE_ALL' || + (obj.type === 'ELIMINATE_UNIT' && data.unitId === obj.target_def_id)) { + + obj.current++; + if (obj.target_count && obj.current >= obj.target_count) { + obj.complete = true; + statusChanged = true; + } + } + } + }); + + if (statusChanged) { + this.checkVictory(); + } + } + + checkVictory() { + const allPrimaryComplete = this.currentObjectives.every(o => o.complete); + if (allPrimaryComplete) { + console.log("VICTORY! Mission Objectives Complete."); + this.completeActiveMission(); + // Dispatch event for GameLoop to handle Victory Screen + window.dispatchEvent(new CustomEvent('mission-victory', { detail: { missionId: this.activeMissionId }})); + } + } + + completeActiveMission() { + if (this.activeMissionId) { + this.completedMissions.add(this.activeMissionId); + // Simple campaign logic: If Tutorial done, unlock next (Placeholder) + if (this.activeMissionId === 'MISSION_TUTORIAL_01') { + // this.activeMissionId = 'MISSION_ACT1_01'; + } + } + } +} \ No newline at end of file diff --git a/src/managers/NarrativeManager.js b/src/managers/NarrativeManager.js new file mode 100644 index 0000000..1781a13 --- /dev/null +++ b/src/managers/NarrativeManager.js @@ -0,0 +1,143 @@ +/** + * NarrativeManager.js + * Manages the flow of story events, dialogue, and tutorials. + * Extends EventTarget to broadcast UI updates to the DialogueOverlay. + */ +export class NarrativeManager extends EventTarget { + constructor() { + super(); + this.currentSequence = null; + this.currentNode = null; + this.history = new Set(); // Track IDs of played sequences + } + + /** + * Loads and starts a narrative sequence. + * @param {Object} sequenceData - The JSON object of the conversation (from assets/data/narrative/). + */ + startSequence(sequenceData) { + if (!sequenceData || !sequenceData.nodes) { + console.error("NarrativeManager: Invalid sequence data", sequenceData); + return; + } + + console.log(`NarrativeManager: Starting Sequence '${sequenceData.id}'`); + this.currentSequence = sequenceData; + this.history.add(sequenceData.id); + + // Find first node (usually index 0 or has explicit start flag, here we use index 0) + this.currentNode = sequenceData.nodes[0]; + + // Process entry triggers for the first node + this._processNodeTriggers(this.currentNode); + + this.broadcastUpdate(); + } + + /** + * Advances to the next node in the linear sequence. + * If the current node has choices, this method should strictly be blocked by the UI, + * but we include a check here for safety. + */ + next() { + if (!this.currentNode) return; + if (this.currentNode.type === "CHOICE") { + console.warn( + "NarrativeManager: Cannot call next() on a CHOICE node. User must select option." + ); + return; + } + + const nextId = this.currentNode.next; + this._advanceToNode(nextId); + } + + /** + * Handles player choice selection from a branching node. + * @param {number} choiceIndex - The index of the chosen option in the `choices` array. + */ + makeChoice(choiceIndex) { + if (!this.currentNode || !this.currentNode.choices) return; + + const choice = this.currentNode.choices[choiceIndex]; + if (!choice) return; + + // Process Choice-specific triggers (e.g., immediate reputation gain) + if (choice.trigger) { + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: choice.trigger }, + }) + ); + } + + this._advanceToNode(choice.next); + } + + /** + * Internal helper to handle transition logic. + */ + _advanceToNode(nextId) { + if (!nextId || nextId === "END") { + this.endSequence(); + return; + } + + const nextNode = this.currentSequence.nodes.find((n) => n.id === nextId); + + if (!nextNode) { + console.error( + `NarrativeManager: Node '${nextId}' not found in sequence.` + ); + this.endSequence(); + return; + } + + this.currentNode = nextNode; + this._processNodeTriggers(this.currentNode); + + // If it's an ACTION node (invisible), execute trigger and auto-advance + if (this.currentNode.type === "ACTION") { + // Triggers are already processed above, just move to next + // Use setTimeout to allow event loop to breathe if needed, or sync recursion + this._advanceToNode(this.currentNode.next); + } else { + this.broadcastUpdate(); + } + } + + _processNodeTriggers(node) { + if (node && node.trigger) { + console.log("NarrativeManager: Dispatching Trigger", node.trigger); + this.dispatchEvent( + new CustomEvent("narrative-trigger", { + detail: { action: node.trigger }, + }) + ); + } + } + + endSequence() { + console.log("NarrativeManager: Sequence Ended"); + this.currentSequence = null; + this.currentNode = null; + this.dispatchEvent(new CustomEvent("narrative-end")); + } + + /** + * Sends the current node data to the UI (DialogueOverlay). + */ + broadcastUpdate() { + this.dispatchEvent( + new CustomEvent("narrative-update", { + detail: { + node: this.currentNode, + active: !!this.currentNode, + }, + }) + ); + } +} + +// Export singleton for global access +export const narrativeManager = new NarrativeManager(); diff --git a/src/ui/deployment-hud.js b/src/ui/deployment-hud.js index 45a1a49..1eab389 100644 --- a/src/ui/deployment-hud.js +++ b/src/ui/deployment-hud.js @@ -127,7 +127,7 @@ export class DeploymentHUD extends LitElement { static get properties() { return { - roster: { type: Array }, // List of all available units + squad: { type: Array }, // List of all available units deployedIds: { type: Array }, // List of IDs currently on the board selectedId: { type: String }, // ID of unit currently being placed maxUnits: { type: Number }, @@ -136,10 +136,13 @@ export class DeploymentHUD extends LitElement { constructor() { super(); - this.roster = []; + this.squad = []; this.deployedIds = []; this.selectedId = null; this.maxUnits = 4; + window.addEventListener("deployment-update", (e) => { + this.deployedIds = e.detail.deployedIndices; + }); } render() { @@ -168,7 +171,7 @@ export class DeploymentHUD extends LitElement {
- ${this.roster.map((unit) => { + ${this.squad.map((unit) => { const isDeployed = this.deployedIds.includes(unit.id); const isSelected = this.selectedId === unit.id; @@ -205,7 +208,7 @@ export class DeploymentHUD extends LitElement { this.selectedId = unit.id; // Tell GameLoop we want to place this unit next click this.dispatchEvent( - new CustomEvent("select-unit-for-placement", { detail: { unit } }) + new CustomEvent("unit-selected", { detail: { unit } }) ); } } diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index 082516d..902b58b 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -1,6 +1,5 @@ import { LitElement, html, css } from "lit"; import { gameStateManager } from "../core/GameStateManager.js"; -import { RosterManager } from "../managers/RosterManager.js"; import { GameLoop } from "../core/GameLoop.js"; import "./deployment-hud.js"; @@ -22,16 +21,19 @@ export class GameViewport extends LitElement { static get properties() { return { squad: { type: Array }, + deployedIds: { type: Array }, }; } constructor() { super(); this.squad = []; + this.deployedIds = []; } #handleUnitSelected(event) { - const index = event.detail.index; + const unit = event.detail.unit; + const index = this.squad.indexOf(unit); gameStateManager.gameLoop.selectDeploymentUnit(index); } @@ -40,12 +42,14 @@ export class GameViewport extends LitElement { const loop = new GameLoop(); loop.init(container); gameStateManager.setGameLoop(loop); + this.squad = await gameStateManager.rosterLoaded; } render() { return html`
`; } diff --git a/src/ui/team-builder.js b/src/ui/team-builder.js index 3ac5b45..bbb6f75 100644 --- a/src/ui/team-builder.js +++ b/src/ui/team-builder.js @@ -1,44 +1,43 @@ 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) +// UI Metadata Mapping 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.' + image: 'assets/images/portraits/vanguard.png', + role: 'Tank', + description: 'A heavy frontline tank specialized in absorbing damage.' }, 'CLASS_WEAVER': { icon: '✨', image: 'assets/images/portraits/weaver.png', - role: 'Magic DPS', - description: 'A master of elemental magic capable of creating powerful synergy chains.' + role: 'Magic DPS', + description: 'A master of elemental magic capable of creating 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.' + role: 'Utility', + description: 'Highly mobile utility expert who excels at finding loot.' }, 'CLASS_TINKER': { icon: '🔧', image: 'assets/images/portraits/tinker.png', - role: 'Tech', - description: 'Uses ancient technology to deploy turrets and control the battlefield.' + role: 'Tech', + description: 'Uses ancient technology to deploy turrets.' }, 'CLASS_CUSTODIAN': { icon: '🌿', image: 'assets/images/portraits/custodian.png', - role: 'Healer', - description: 'A spiritual healer focused on removing corruption and sustaining the squad.' + role: 'Healer', + description: 'A spiritual healer focused on removing corruption.' } }; @@ -50,42 +49,30 @@ export class TeamBuilder extends LitElement { :host { display: block; position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - font-family: 'Courier New', monospace; /* Placeholder for Voxel Font */ + top: 0; left: 0; width: 100%; height: 100%; + font-family: 'Courier New', monospace; color: white; - pointer-events: none; /* Let clicks pass through to 3D scene where empty */ + pointer-events: none; z-index: 10; box-sizing: border-box; } - /* Responsive Container Layout */ .container { display: grid; - grid-template-columns: 280px 1fr 300px; /* Wider side panels on desktop */ + grid-template-columns: 280px 1fr 300px; grid-template-rows: 1fr 100px; - grid-template-areas: - "roster squad details" - "footer footer footer"; - height: 100%; - width: 100%; + grid-template-areas: "roster squad details" "footer footer footer"; + height: 100%; width: 100%; pointer-events: auto; - background: rgba(0, 0, 0, 0.6); /* Slightly darker background for readability */ + background: rgba(0, 0, 0, 0.85); 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"; + grid-template-rows: 200px 1fr 200px 80px; + grid-template-areas: "roster" "squad" "details" "footer"; } } @@ -100,19 +87,10 @@ export class TeamBuilder extends LitElement { 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 { + h3 { margin-top: 0; color: #00ffff; border-bottom: 1px solid #555; padding-bottom: 10px; } + + .card { background: #333; border: 2px solid #555; padding: 15px; @@ -121,8 +99,6 @@ export class TeamBuilder extends LitElement { display: flex; align-items: center; gap: 15px; - - /* Button Reset */ width: 100%; text-align: left; font-family: inherit; @@ -130,34 +106,24 @@ export class TeamBuilder extends LitElement { appearance: none; } - @media (max-width: 1024px) { - .class-card { - width: 200px; /* Fixed width cards for horizontal scroll */ - flex-shrink: 0; - height: 80%; - } - } - - .class-card:hover:not(:disabled) { + .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 */ - } + + .card.selected { + border-color: #00ff00; + background: #224422; } - .class-card:disabled { + .card:disabled { opacity: 0.5; cursor: not-allowed; filter: grayscale(1); - border-color: #444; } - /* --- CENTER PANEL: SLOTS --- */ + /* --- CENTER PANEL: SQUAD SLOTS --- */ .squad-panel { grid-area: squad; display: flex; @@ -165,25 +131,19 @@ export class TeamBuilder extends LitElement { align-items: center; padding: 2rem; gap: 30px; - flex-wrap: wrap; /* Allow wrapping on very small screens */ - overflow-y: auto; + flex-wrap: wrap; } - /* 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 */ + width: 180px; /* Wider for portraits */ + height: 240px; /* Taller for portraits */ transition: transform 0.2s; } - - .slot-wrapper:hover { - transform: scale(1.05); - } + .slot-wrapper:hover { transform: scale(1.05); } .squad-slot { - width: 100%; - height: 100%; + width: 100%; height: 100%; background: rgba(10, 10, 10, 0.8); border: 3px dashed #666; display: flex; @@ -191,69 +151,57 @@ export class TeamBuilder extends LitElement { align-items: center; justify-content: center; cursor: pointer; - position: relative; + font-family: inherit; color: inherit; padding: 0; appearance: none; overflow: hidden; - - /* Button Reset */ - font-family: inherit; - color: inherit; - padding: 0; - appearance: none; } /* Image placeholder style */ .unit-image { width: 100%; - height: 70%; + height: 75%; object-fit: cover; - background-color: #222; /* Fallback */ + background-color: #222; border-bottom: 2px solid #555; } .unit-info { - height: 30%; + height: 25%; display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; - background: rgba(30,30,40,0.9); + background: rgba(30,30,40,0.95); + padding: 5px; + box-sizing: border-box; } .squad-slot.filled { border: 3px solid #00ff00; - border-style: solid; background: rgba(0, 20, 0, 0.8); } - + .squad-slot.selected { border-color: #00ffff; - box-shadow: 0 0 20px rgba(0, 255, 255, 0.3); + box-shadow: 0 0 15px rgba(0,255,255,0.3); } .remove-btn { - position: absolute; - top: -15px; - right: -15px; - background: #cc0000; - border: 2px solid white; - color: white; - 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); + position: absolute; top: -12px; right: -12px; + background: #cc0000; color: white; + width: 28px; height: 28px; + border: 2px solid white; border-radius: 50%; + cursor: pointer; font-weight: bold; z-index: 2; } - .remove-btn:hover { - background: #ff0000; - transform: scale(1.1); + .placeholder-img { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + color: #555; + font-size: 3rem; + height: 100%; } /* --- RIGHT PANEL: DETAILS --- */ @@ -264,18 +212,7 @@ export class TeamBuilder extends LitElement { 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-area: footer; display: flex; @@ -296,91 +233,56 @@ export class TeamBuilder extends LitElement { 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 { - background: #333; - 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; + background: #333; border-color: #555; color: #777; cursor: not-allowed; } `; } static get properties() { return { - availableClasses: { type: Array }, // Input: List of class definition objects - squad: { type: Array }, // Internal State: The 4 slots + mode: { type: String }, // 'DRAFT' (Classes) or 'ROSTER' (Existing Units) + availablePool: { type: Array }, // List of Classes OR Units + squad: { type: Array }, // The 4 slots selectedSlotIndex: { type: Number }, - hoveredClass: { type: Object } + hoveredItem: { type: Object } }; } constructor() { super(); this.squad = [null, null, null, null]; - this.selectedSlotIndex = 0; // Default to first slot - 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 - }; - }); + this.selectedSlotIndex = 0; + this.hoveredItem = null; + this.mode = 'DRAFT'; // Default + this.availablePool = []; } connectedCallback() { super.connectedCallback(); - this._loadMetaProgression(); + this._initializeData(); } /** - * Loads unlocked classes from persistence (Local Storage / Game State). - * Merges Tier 2 classes into availableClasses if unlocked. + * Configures the component based on provided data. */ - _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); - } + _initializeData() { + // 1. If we were passed an existing roster (e.g. from RosterManager), use it. + if (this.availablePool && this.availablePool.length > 0) { + this.mode = 'ROSTER'; + console.log("TeamBuilder: Using Provided Roster", this.availablePool); + return; } + + // 2. Default: Draft Mode (New Game) + // Populate with Tier 1 classes + this.mode = 'DRAFT'; + this.availablePool = RAW_TIER_1_CLASSES.map(cls => { + const meta = CLASS_METADATA[cls.id] || {}; + return { ...cls, ...meta, unlocked: true }; + }); + console.log("TeamBuilder: Initializing Draft Mode"); } render() { @@ -388,30 +290,35 @@ export class TeamBuilder extends LitElement { return html`
- - +
-

Roster

- ${this.availableClasses.map(cls => html` - - `)} +

${this.mode === 'DRAFT' ? 'Recruit Explorers' : 'Barracks Roster'}

+ + ${this.availablePool.map(item => { + const isSelected = this.squad.some(s => s && (this.mode === 'ROSTER' ? s.id === item.id : false)); + + return html` + + `; + })}
- +
${this.squad.map((unit, index) => html`
@@ -419,8 +326,6 @@ export class TeamBuilder extends LitElement { 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` @@ -429,153 +334,120 @@ export class TeamBuilder extends LitElement { ? html`${unit.name}` : '' } -
+
${unit.icon || '🛡️'}
${unit.name} - ${this.availableClasses.find(c => c.id === unit.classId)?.role} + ${this.mode === 'DRAFT' ? unit.role : unit.classId.replace('CLASS_', '')}
` : html` -
+
+
+
Slot ${index + 1} - Empty + Select ${this.mode === 'DRAFT' ? 'Class' : 'Unit'}
` } - - ${unit - ? html` - ` - : '' - } + ${unit ? html`` : ''}
`)}
- +
- ${this.hoveredClass - ? html` -
-

${this.hoveredClass.name}

-

${this.hoveredClass.role || 'Tier ' + this.hoveredClass.tier} Class

-
-

${this.hoveredClass.description || 'A skilled explorer ready for the depths.'}

-
- -
-

Base Stats

-
    -
  • HP: ${this.hoveredClass.base_stats?.health}
  • -
  • Atk: ${this.hoveredClass.base_stats?.attack}
  • -
  • Def: ${this.hoveredClass.base_stats?.defense}
  • -
  • Mag: ${this.hoveredClass.base_stats?.magic}
  • -
  • Spd: ${this.hoveredClass.base_stats?.speed}
  • -
  • Will: ${this.hoveredClass.base_stats?.willpower}
  • -
  • Move: ${this.hoveredClass.base_stats?.movement}
  • - ${this.hoveredClass.base_stats?.tech ? html`
  • Tech: ${this.hoveredClass.base_stats.tech}
  • ` : ''} -
- -

Starting Gear

-
    - ${this.hoveredClass.starting_equipment - ? this.hoveredClass.starting_equipment.map(item => html`
  • ${this._formatItemName(item)}
  • `) - : html`
  • None
  • `} -
-
- ` - : html`

Hover over a class or squad member to see details.

` - } + ${this._renderDetails()}
`; } - // --- LOGIC --- + _renderDetails() { + if (!this.hoveredItem) return html`

Hover over a unit to see details.

`; + + // Handle data structure diffs between ClassDef and UnitInstance + const name = this.hoveredItem.name; + const role = this.hoveredItem.role || this.hoveredItem.classId; + const stats = this.hoveredItem.base_stats || this.hoveredItem.stats || {}; + + return html` +

${name}

+

${role}

+
+

${this.hoveredItem.description || 'Ready for deployment.'}

+

Stats

+
    +
  • HP: ${stats.health}
  • +
  • Atk: ${stats.attack || 0}
  • +
  • Spd: ${stats.speed}
  • +
+ `; + } _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 && classDef.unlocked !== undefined) return; // Logic check redundancy for tests without DOM checks + _assignItem(item) { + if (this.mode === 'DRAFT' && !item.unlocked) return; - // 1. Create a lightweight manifest for the slot - const unitManifest = { - classId: classDef.id, - name: classDef.name, // In real app, auto-generate name like "Valerius" - icon: classDef.icon, - image: classDef.image // Pass image path - }; + let unitManifest; + + if (this.mode === 'DRAFT') { + // Create new unit definition + unitManifest = { + classId: item.id, + name: item.name, + icon: item.icon, + image: item.image, // Pass image path + role: item.role, + isNew: true // Flag for GameLoop/Manager to generate ID + }; + } else { + // Select existing unit + // Try to recover image from CLASS_METADATA if not stored on unit instance + const meta = CLASS_METADATA[item.classId] || {}; + + unitManifest = { + id: item.id, + classId: item.classId, + name: item.name, + icon: meta.icon, + image: meta.image, + role: meta.role, + ...item + }; + } - // 2. Update State (Trigger Re-render) const newSquad = [...this.squad]; newSquad[this.selectedSlotIndex] = unitManifest; this.squad = newSquad; - // 3. Auto-advance selection - if (this.selectedSlotIndex < 3) { - this.selectedSlotIndex++; - } - - // 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 - })); + if (this.selectedSlotIndex < 3) this.selectedSlotIndex++; } _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.selectedSlotIndex = index; } _handleEmbark() { const manifest = this.squad.filter(u => u !== null); this.dispatchEvent(new CustomEvent('embark', { - detail: { squad: manifest }, + detail: { squad: manifest, mode: this.mode }, bubbles: true, composed: true })); diff --git a/test/systems/MissionManager.js b/test/managers/MissionManager.js similarity index 100% rename from test/systems/MissionManager.js rename to test/managers/MissionManager.js diff --git a/test/systems/NarrativeManager.js b/test/managers/NarrativeManager.js similarity index 100% rename from test/systems/NarrativeManager.js rename to test/managers/NarrativeManager.js