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.

This commit is contained in:
Matthew Mone 2025-12-21 20:40:48 -08:00
parent aab681132e
commit ec25c71eb1
12 changed files with 658 additions and 361 deletions

View file

@ -1,23 +1,46 @@
{
"id": "MISSION_TUTORIAL_01",
"type": "TUTORIAL",
"config": {
"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
"difficulty_tier": 1,
"recommended_level": 1
},
"narrative_intro": "NARRATIVE_TUTORIAL_INTRO",
"narrative_outro": "NARRATIVE_TUTORIAL_SUCCESS",
"objectives": [
"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": [
{
"type": "ELIMINATE_ENEMIES",
"id": "OBJ_ELIMINATE_ENEMIES",
"type": "ELIMINATE_ALL",
"description": "Eliminate 2 enemies",
"target_count": 2
}
],
]
},
"rewards": {
"guaranteed": {
"xp": 100,
"currency": 50,
"unlock_class": "CLASS_TINKER"
"currency": {
"aether_shards": 50
}
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
});

View file

@ -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 () => {

View file

@ -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';
}
}
}
}

View file

@ -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();

View file

@ -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 {
</div>
<div class="bench-container">
${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 } })
);
}
}

View file

@ -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`<div id="canvas-container"></div>
<deployment-hud
.roster=${this.squad}
.squad=${this.squad}
.deployedIds=${this.deployedIds}
@unit-selected=${this.#handleUnitSelected}
></deployment-hud>`;
}

View file

@ -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
image: 'assets/images/portraits/vanguard.png',
role: 'Tank',
description: 'A heavy frontline tank specialized in absorbing damage and protecting allies.'
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.'
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.'
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.'
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.'
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";
}
}
@ -101,18 +88,9 @@ export class TeamBuilder extends LitElement {
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;
}
}
h3 { margin-top: 0; color: #00ffff; border-bottom: 1px solid #555; padding-bottom: 10px; }
.class-card {
.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 */
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;
}
.placeholder-img {
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);
background: transparent;
color: #555;
font-size: 3rem;
height: 100%;
}
/* --- RIGHT PANEL: DETAILS --- */
@ -265,17 +213,6 @@ export class TeamBuilder extends LitElement {
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.
_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;
}
// 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);
}
}
// 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`
<div class="container">
<!-- ROSTER LIST -->
<!-- ROSTER PANEL -->
<div class="roster-panel">
<h3>Roster</h3>
${this.availableClasses.map(cls => html`
<h3>${this.mode === 'DRAFT' ? 'Recruit Explorers' : 'Barracks Roster'}</h3>
${this.availablePool.map(item => {
const isSelected = this.squad.some(s => s && (this.mode === 'ROSTER' ? s.id === item.id : false));
return html`
<button
type="button"
class="class-card"
?disabled="${!cls.unlocked}"
@click="${() => this._assignClass(cls)}"
@mouseenter="${() => this.hoveredClass = cls}"
@mouseleave="${() => this.hoveredClass = null}"
aria-label="Select Class: ${cls.name}"
class="card ${isSelected ? 'selected' : ''}"
?disabled="${this.mode === 'DRAFT' && !item.unlocked || isSelected}"
@click="${() => this._assignItem(item)}"
@mouseenter="${() => this.hoveredItem = item}"
@mouseleave="${() => this.hoveredItem = null}"
>
<div class="icon" style="font-size: 1.5rem;">${cls.icon || '⚔️'}</div>
<div class="icon" style="font-size: 1.5rem;">
${item.icon || CLASS_METADATA[item.classId]?.icon || '⚔️'}
</div>
<div>
<strong>${cls.name}</strong><br>
<small>${cls.role || 'Tier ' + cls.tier}</small>
<strong>${item.name}</strong><br>
<small>${this.mode === 'ROSTER' ? `Lvl ${item.level || 1} ${item.classId.replace('CLASS_', '')}` : item.role}</small>
</div>
</button>
`)}
`;
})}
</div>
<!-- CENTER SQUAD SLOTS -->
<!-- SQUAD SLOTS -->
<div class="squad-panel">
${this.squad.map((unit, index) => html`
<div class="slot-wrapper">
@ -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`<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' : ''}">
<div class="placeholder-img" style="${unit.image ? 'display:none;' : ''} font-size: 3rem;">
${unit.icon || '🛡️'}
</div>
<div class="unit-info">
<strong>${unit.name}</strong>
<small>${this.availableClasses.find(c => c.id === unit.classId)?.role}</small>
<small style="font-size: 0.7rem; color: #aaa;">${this.mode === 'DRAFT' ? unit.role : unit.classId.replace('CLASS_', '')}</small>
</div>
`
: html`
<div class="placeholder-img" style="background:transparent; color: #555;">+</div>
<div class="placeholder-img">+</div>
<div class="unit-info" style="background:transparent;">
<span>Slot ${index + 1}</span>
<small>Empty</small>
<small>Select ${this.mode === 'DRAFT' ? 'Class' : 'Unit'}</small>
</div>
`
}
</button>
${unit
? html`
<button
type="button"
class="remove-btn"
@click="${() => this._removeUnit(index)}"
aria-label="Remove ${unit.name} from Slot ${index + 1}"
>
X
</button>`
: ''
}
${unit ? html`<button type="button" class="remove-btn" @click="${() => this._removeUnit(index)}">X</button>` : ''}
</div>
`)}
</div>
<!-- RIGHT DETAILS PANEL -->
<!-- DETAILS PANEL -->
<div class="details-panel">
${this.hoveredClass
? html`
<div>
<h2>${this.hoveredClass.name}</h2>
<p><em>${this.hoveredClass.role || 'Tier ' + this.hoveredClass.tier} Class</em></p>
<hr>
<p>${this.hoveredClass.description || 'A skilled explorer ready for the depths.'}</p>
</div>
<div>
<h4>Base Stats</h4>
<ul>
<li>HP: ${this.hoveredClass.base_stats?.health}</li>
<li>Atk: ${this.hoveredClass.base_stats?.attack}</li>
<li>Def: ${this.hoveredClass.base_stats?.defense}</li>
<li>Mag: ${this.hoveredClass.base_stats?.magic}</li>
<li>Spd: ${this.hoveredClass.base_stats?.speed}</li>
<li>Will: ${this.hoveredClass.base_stats?.willpower}</li>
<li>Move: ${this.hoveredClass.base_stats?.movement}</li>
${this.hoveredClass.base_stats?.tech ? html`<li>Tech: ${this.hoveredClass.base_stats.tech}</li>` : ''}
</ul>
<h4>Starting Gear</h4>
<ul>
${this.hoveredClass.starting_equipment
? this.hoveredClass.starting_equipment.map(item => html`<li>${this._formatItemName(item)}</li>`)
: html`<li>None</li>`}
</ul>
</div>
`
: html`<p>Hover over a class or squad member to see details.</p>`
}
${this._renderDetails()}
</div>
<!-- FOOTER -->
<div class="footer">
<button
type="button"
class="embark-btn"
?disabled="${!isSquadValid}"
@click="${this._handleEmbark}"
>
DESCEND
<button type="button" class="embark-btn" ?disabled="${!isSquadValid}" @click="${this._handleEmbark}">
${this.mode === 'DRAFT' ? 'INITIALIZE SQUAD' : 'EMBARK'}
</button>
</div>
</div>
`;
}
// --- LOGIC ---
_renderDetails() {
if (!this.hoveredItem) return html`<p>Hover over a unit to see details.</p>`;
// 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`
<h2>${name}</h2>
<p><em>${role}</em></p>
<hr>
<p>${this.hoveredItem.description || 'Ready for deployment.'}</p>
<h4>Stats</h4>
<ul>
<li>HP: ${stats.health}</li>
<li>Atk: ${stats.attack || 0}</li>
<li>Spd: ${stats.speed}</li>
</ul>
`;
}
_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
}));