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

+ + `; + } _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