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:
parent
aab681132e
commit
ec25c71eb1
12 changed files with 658 additions and 361 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
158
src/managers/MissionManager.js
Normal file
158
src/managers/MissionManager.js
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/managers/NarrativeManager.js
Normal file
143
src/managers/NarrativeManager.js
Normal 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();
|
||||
|
|
@ -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 } })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue