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",
|
"id": "MISSION_TUTORIAL_01",
|
||||||
"title": "Protocol: First Descent",
|
"type": "TUTORIAL",
|
||||||
"description": "Establish a foothold in the Rusting Wastes and secure the perimeter.",
|
"config": {
|
||||||
"biome_config": {
|
"title": "Protocol: First Descent",
|
||||||
"type": "RUINS",
|
"description": "Establish a foothold in the Rusting Wastes and secure the perimeter.",
|
||||||
"seed_type": "FIXED",
|
"difficulty_tier": 1,
|
||||||
"seed": 12345
|
"recommended_level": 1
|
||||||
},
|
},
|
||||||
"narrative_intro": "NARRATIVE_TUTORIAL_INTRO",
|
"biome": {
|
||||||
"narrative_outro": "NARRATIVE_TUTORIAL_SUCCESS",
|
"type": "BIOME_RUSTING_WASTES",
|
||||||
"objectives": [
|
"generator_config": {
|
||||||
{
|
"seed_type": "FIXED",
|
||||||
"type": "ELIMINATE_ENEMIES",
|
"seed": 12345,
|
||||||
"target_count": 2
|
"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": {
|
"rewards": {
|
||||||
"xp": 100,
|
"guaranteed": {
|
||||||
"currency": 50,
|
"xp": 100,
|
||||||
"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 { CaveGenerator } from "../generation/CaveGenerator.js";
|
||||||
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
||||||
import { InputManager } from "./InputManager.js";
|
import { InputManager } from "./InputManager.js";
|
||||||
import { MissionManager } from "../systems/MissionManager.js";
|
import { MissionManager } from "../managers/MissionManager.js";
|
||||||
|
|
||||||
export class GameLoop {
|
export class GameLoop {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -173,6 +173,7 @@ export class GameLoop {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by UI when a unit is clicked in the Roster.
|
* 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) {
|
selectDeploymentUnit(index) {
|
||||||
this.deploymentState.selectedUnitIndex = index;
|
this.deploymentState.selectedUnitIndex = index;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { Persistence } from "./Persistence.js";
|
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 {
|
class GameStateManagerClass {
|
||||||
static STATES = {
|
static STATES = {
|
||||||
|
|
@ -13,28 +16,44 @@ class GameStateManagerClass {
|
||||||
this.gameLoop = null;
|
this.gameLoop = null;
|
||||||
this.persistence = new Persistence();
|
this.persistence = new Persistence();
|
||||||
this.activeRunData = null;
|
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);
|
this.handleEmbark = this.handleEmbark.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#gameLoopInitialized = Promise.withResolvers();
|
||||||
* For Testing: Resets the manager to a clean state.
|
get gameLoopInitialized() {
|
||||||
*/
|
return this.#gameLoopInitialized.promise;
|
||||||
reset() {
|
}
|
||||||
this.currentState = GameStateManagerClass.STATES.INIT;
|
|
||||||
this.gameLoop = null;
|
#rosterLoaded = Promise.withResolvers();
|
||||||
this.activeRunData = null;
|
get rosterLoaded() {
|
||||||
|
return this.#rosterLoaded.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGameLoop(loop) {
|
setGameLoop(loop) {
|
||||||
this.gameLoop = loop;
|
this.gameLoop = loop;
|
||||||
this.gameLoopSet.resolve(loop);
|
this.#gameLoopInitialized.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log("System: Initializing State Manager...");
|
console.log("System: Initializing State Manager...");
|
||||||
await this.persistence.init();
|
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);
|
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,28 +97,65 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmbark(e) {
|
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);
|
this.transitionTo(GameStateManagerClass.STATES.GAME_RUN, e.detail.squad);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- INTERNAL HELPERS ---
|
// --- INTERNAL HELPERS ---
|
||||||
|
|
||||||
async _initializeRun(squadManifest) {
|
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 = {
|
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,
|
depth: 1,
|
||||||
|
biome: missionDef.biome, // Pass biome config to GameLoop
|
||||||
squad: squadManifest,
|
squad: squadManifest,
|
||||||
|
objectives: missionDef.objectives, // Pass objectives for UI display
|
||||||
world_state: {},
|
world_state: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 4. Save & Start
|
||||||
await this.persistence.saveRun(this.activeRunData);
|
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);
|
this.gameLoop.startLevel(this.activeRunData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _resumeRun() {
|
async _resumeRun() {
|
||||||
await this.gameLoopSet.promise;
|
await this.gameLoopInitialized;
|
||||||
if (this.activeRunData) {
|
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);
|
this.gameLoop.startLevel(this.activeRunData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +166,13 @@ class GameStateManagerClass {
|
||||||
new CustomEvent("save-check-complete", { detail: { hasSave: !!save } })
|
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 the Singleton Instance
|
||||||
export const gameStateManager = new GameStateManagerClass();
|
export const gameStateManager = new GameStateManagerClass();
|
||||||
|
|
||||||
// Export Class ref for constants/testing
|
|
||||||
export const GameStateManager = GameStateManagerClass;
|
export const GameStateManager = GameStateManagerClass;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* Persistence.js
|
* Persistence.js
|
||||||
* Handles asynchronous saving and loading using IndexedDB.
|
* Handles asynchronous saving and loading using IndexedDB.
|
||||||
|
* Manages both Active Runs and Persistent Roster data.
|
||||||
*/
|
*/
|
||||||
const DB_NAME = "AetherShardsDB";
|
const DB_NAME = "AetherShardsDB";
|
||||||
const STORE_NAME = "Runs";
|
const RUN_STORE = "Runs";
|
||||||
const VERSION = 1;
|
const ROSTER_STORE = "Roster";
|
||||||
|
const VERSION = 2; // Bumped version to add Roster store
|
||||||
|
|
||||||
export class Persistence {
|
export class Persistence {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -19,8 +21,15 @@ export class Persistence {
|
||||||
|
|
||||||
request.onupgradeneeded = (e) => {
|
request.onupgradeneeded = (e) => {
|
||||||
const db = e.target.result;
|
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) {
|
async saveRun(runData) {
|
||||||
if (!this.db) await this.init();
|
if (!this.db) await this.init();
|
||||||
return new Promise((resolve, reject) => {
|
return this._put(RUN_STORE, { ...runData, id: "active_run" });
|
||||||
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);
|
|
||||||
|
|
||||||
|
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.onsuccess = () => resolve();
|
||||||
req.onerror = () => reject(req.error);
|
req.onerror = () => reject(req.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRun() {
|
_get(storeName, key) {
|
||||||
if (!this.db) await this.init();
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.db.transaction([STORE_NAME], "readonly");
|
const tx = this.db.transaction([storeName], "readonly");
|
||||||
const store = tx.objectStore(STORE_NAME);
|
const store = tx.objectStore(storeName);
|
||||||
const req = store.get("active_run");
|
const req = store.get(key);
|
||||||
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
req.onsuccess = () => resolve(req.result);
|
||||||
req.onerror = () => reject(req.error);
|
req.onerror = () => reject(req.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearRun() {
|
_delete(storeName, key) {
|
||||||
if (!this.db) await this.init();
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.db.transaction([STORE_NAME], "readwrite");
|
const tx = this.db.transaction([storeName], "readwrite");
|
||||||
const store = tx.objectStore(STORE_NAME);
|
const store = tx.objectStore(storeName);
|
||||||
const req = store.delete("active_run");
|
const req = store.delete(key);
|
||||||
|
|
||||||
req.onsuccess = () => resolve();
|
req.onsuccess = () => resolve();
|
||||||
req.onerror = () => reject(req.error);
|
req.onerror = () => reject(req.error);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ btnNewRun.addEventListener("click", async () => {
|
||||||
gameStateManager.handleEmbark(e);
|
gameStateManager.handleEmbark(e);
|
||||||
gameViewport.squad = teamBuilder.squad;
|
gameViewport.squad = teamBuilder.squad;
|
||||||
});
|
});
|
||||||
gameStateManager.startMission("MISSION_TUTORIAL_01");
|
gameStateManager.startNewGame();
|
||||||
});
|
});
|
||||||
|
|
||||||
btnContinue.addEventListener("click", async () => {
|
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() {
|
static get properties() {
|
||||||
return {
|
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
|
deployedIds: { type: Array }, // List of IDs currently on the board
|
||||||
selectedId: { type: String }, // ID of unit currently being placed
|
selectedId: { type: String }, // ID of unit currently being placed
|
||||||
maxUnits: { type: Number },
|
maxUnits: { type: Number },
|
||||||
|
|
@ -136,10 +136,13 @@ export class DeploymentHUD extends LitElement {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.roster = [];
|
this.squad = [];
|
||||||
this.deployedIds = [];
|
this.deployedIds = [];
|
||||||
this.selectedId = null;
|
this.selectedId = null;
|
||||||
this.maxUnits = 4;
|
this.maxUnits = 4;
|
||||||
|
window.addEventListener("deployment-update", (e) => {
|
||||||
|
this.deployedIds = e.detail.deployedIndices;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
@ -168,7 +171,7 @@ export class DeploymentHUD extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bench-container">
|
<div class="bench-container">
|
||||||
${this.roster.map((unit) => {
|
${this.squad.map((unit) => {
|
||||||
const isDeployed = this.deployedIds.includes(unit.id);
|
const isDeployed = this.deployedIds.includes(unit.id);
|
||||||
const isSelected = this.selectedId === unit.id;
|
const isSelected = this.selectedId === unit.id;
|
||||||
|
|
||||||
|
|
@ -205,7 +208,7 @@ export class DeploymentHUD extends LitElement {
|
||||||
this.selectedId = unit.id;
|
this.selectedId = unit.id;
|
||||||
// Tell GameLoop we want to place this unit next click
|
// Tell GameLoop we want to place this unit next click
|
||||||
this.dispatchEvent(
|
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 { LitElement, html, css } from "lit";
|
||||||
import { gameStateManager } from "../core/GameStateManager.js";
|
import { gameStateManager } from "../core/GameStateManager.js";
|
||||||
import { RosterManager } from "../managers/RosterManager.js";
|
|
||||||
import { GameLoop } from "../core/GameLoop.js";
|
import { GameLoop } from "../core/GameLoop.js";
|
||||||
|
|
||||||
import "./deployment-hud.js";
|
import "./deployment-hud.js";
|
||||||
|
|
@ -22,16 +21,19 @@ export class GameViewport extends LitElement {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
squad: { type: Array },
|
squad: { type: Array },
|
||||||
|
deployedIds: { type: Array },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.squad = [];
|
this.squad = [];
|
||||||
|
this.deployedIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleUnitSelected(event) {
|
#handleUnitSelected(event) {
|
||||||
const index = event.detail.index;
|
const unit = event.detail.unit;
|
||||||
|
const index = this.squad.indexOf(unit);
|
||||||
gameStateManager.gameLoop.selectDeploymentUnit(index);
|
gameStateManager.gameLoop.selectDeploymentUnit(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,12 +42,14 @@ export class GameViewport extends LitElement {
|
||||||
const loop = new GameLoop();
|
const loop = new GameLoop();
|
||||||
loop.init(container);
|
loop.init(container);
|
||||||
gameStateManager.setGameLoop(loop);
|
gameStateManager.setGameLoop(loop);
|
||||||
|
this.squad = await gameStateManager.rosterLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`<div id="canvas-container"></div>
|
return html`<div id="canvas-container"></div>
|
||||||
<deployment-hud
|
<deployment-hud
|
||||||
.roster=${this.squad}
|
.squad=${this.squad}
|
||||||
|
.deployedIds=${this.deployedIds}
|
||||||
@unit-selected=${this.#handleUnitSelected}
|
@unit-selected=${this.#handleUnitSelected}
|
||||||
></deployment-hud>`;
|
></deployment-hud>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,43 @@
|
||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
|
|
||||||
// Import Tier 1 Class Definitions
|
// 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 vanguardDef from '../assets/data/classes/vanguard.json' with { type: 'json' };
|
||||||
import weaverDef from '../assets/data/classes/aether_weaver.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 scavengerDef from '../assets/data/classes/scavenger.json' with { type: 'json' };
|
||||||
import tinkerDef from '../assets/data/classes/tinker.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' };
|
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 = {
|
const CLASS_METADATA = {
|
||||||
'CLASS_VANGUARD': {
|
'CLASS_VANGUARD': {
|
||||||
icon: '🛡️',
|
icon: '🛡️',
|
||||||
image: 'assets/images/portraits/vanguard.png', // Placeholder path
|
image: 'assets/images/portraits/vanguard.png',
|
||||||
role: 'Tank',
|
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': {
|
'CLASS_WEAVER': {
|
||||||
icon: '✨',
|
icon: '✨',
|
||||||
image: 'assets/images/portraits/weaver.png',
|
image: 'assets/images/portraits/weaver.png',
|
||||||
role: 'Magic DPS',
|
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': {
|
'CLASS_SCAVENGER': {
|
||||||
icon: '🎒',
|
icon: '🎒',
|
||||||
image: 'assets/images/portraits/scavenger.png',
|
image: 'assets/images/portraits/scavenger.png',
|
||||||
role: 'Utility',
|
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': {
|
'CLASS_TINKER': {
|
||||||
icon: '🔧',
|
icon: '🔧',
|
||||||
image: 'assets/images/portraits/tinker.png',
|
image: 'assets/images/portraits/tinker.png',
|
||||||
role: 'Tech',
|
role: 'Tech',
|
||||||
description: 'Uses ancient technology to deploy turrets and control the battlefield.'
|
description: 'Uses ancient technology to deploy turrets.'
|
||||||
},
|
},
|
||||||
'CLASS_CUSTODIAN': {
|
'CLASS_CUSTODIAN': {
|
||||||
icon: '🌿',
|
icon: '🌿',
|
||||||
image: 'assets/images/portraits/custodian.png',
|
image: 'assets/images/portraits/custodian.png',
|
||||||
role: 'Healer',
|
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 {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0; left: 0; width: 100%; height: 100%;
|
||||||
left: 0;
|
font-family: 'Courier New', monospace;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-family: 'Courier New', monospace; /* Placeholder for Voxel Font */
|
|
||||||
color: white;
|
color: white;
|
||||||
pointer-events: none; /* Let clicks pass through to 3D scene where empty */
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Container Layout */
|
|
||||||
.container {
|
.container {
|
||||||
display: grid;
|
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-rows: 1fr 100px;
|
||||||
grid-template-areas:
|
grid-template-areas: "roster squad details" "footer footer footer";
|
||||||
"roster squad details"
|
height: 100%; width: 100%;
|
||||||
"footer footer footer";
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
pointer-events: auto;
|
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);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Layout (< 1024px) */
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.container {
|
.container {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 200px 1fr 200px 80px; /* Roster, Squad, Details, Footer */
|
grid-template-rows: 200px 1fr 200px 80px;
|
||||||
grid-template-areas:
|
grid-template-areas: "roster" "squad" "details" "footer";
|
||||||
"roster"
|
|
||||||
"squad"
|
|
||||||
"details"
|
|
||||||
"footer";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,18 +88,9 @@ export class TeamBuilder extends LitElement {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
h3 { margin-top: 0; color: #00ffff; border-bottom: 1px solid #555; padding-bottom: 10px; }
|
||||||
.roster-panel {
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 2px solid #555;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.class-card {
|
.card {
|
||||||
background: #333;
|
background: #333;
|
||||||
border: 2px solid #555;
|
border: 2px solid #555;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
|
@ -121,8 +99,6 @@ export class TeamBuilder extends LitElement {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
|
||||||
/* Button Reset */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
@ -130,34 +106,24 @@ export class TeamBuilder extends LitElement {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.card:hover:not(:disabled) {
|
||||||
.class-card {
|
|
||||||
width: 200px; /* Fixed width cards for horizontal scroll */
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 80%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.class-card:hover:not(:disabled) {
|
|
||||||
border-color: #00ffff;
|
border-color: #00ffff;
|
||||||
background: #444;
|
background: #444;
|
||||||
transform: translateX(5px);
|
transform: translateX(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.card.selected {
|
||||||
.class-card:hover:not(:disabled) {
|
border-color: #00ff00;
|
||||||
transform: translateY(-5px); /* Hop up on mobile */
|
background: #224422;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.class-card:disabled {
|
.card:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
border-color: #444;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- CENTER PANEL: SLOTS --- */
|
/* --- CENTER PANEL: SQUAD SLOTS --- */
|
||||||
.squad-panel {
|
.squad-panel {
|
||||||
grid-area: squad;
|
grid-area: squad;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -165,25 +131,19 @@ export class TeamBuilder extends LitElement {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
flex-wrap: wrap; /* Allow wrapping on very small screens */
|
flex-wrap: wrap;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wrapper to hold the slot button and the absolute remove button as siblings */
|
|
||||||
.slot-wrapper {
|
.slot-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px; /* Increased size */
|
width: 180px; /* Wider for portraits */
|
||||||
height: 240px; /* Increased size */
|
height: 240px; /* Taller for portraits */
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
.slot-wrapper:hover { transform: scale(1.05); }
|
||||||
.slot-wrapper:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squad-slot {
|
.squad-slot {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
|
||||||
background: rgba(10, 10, 10, 0.8);
|
background: rgba(10, 10, 10, 0.8);
|
||||||
border: 3px dashed #666;
|
border: 3px dashed #666;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -191,69 +151,57 @@ export class TeamBuilder extends LitElement {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
font-family: inherit; color: inherit; padding: 0; appearance: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
/* Button Reset */
|
|
||||||
font-family: inherit;
|
|
||||||
color: inherit;
|
|
||||||
padding: 0;
|
|
||||||
appearance: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image placeholder style */
|
/* Image placeholder style */
|
||||||
.unit-image {
|
.unit-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 70%;
|
height: 75%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background-color: #222; /* Fallback */
|
background-color: #222;
|
||||||
border-bottom: 2px solid #555;
|
border-bottom: 2px solid #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit-info {
|
.unit-info {
|
||||||
height: 30%;
|
height: 25%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
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 {
|
.squad-slot.filled {
|
||||||
border: 3px solid #00ff00;
|
border: 3px solid #00ff00;
|
||||||
border-style: solid;
|
|
||||||
background: rgba(0, 20, 0, 0.8);
|
background: rgba(0, 20, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.squad-slot.selected {
|
.squad-slot.selected {
|
||||||
border-color: #00ffff;
|
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 {
|
.remove-btn {
|
||||||
position: absolute;
|
position: absolute; top: -12px; right: -12px;
|
||||||
top: -15px;
|
background: #cc0000; color: white;
|
||||||
right: -15px;
|
width: 28px; height: 28px;
|
||||||
background: #cc0000;
|
border: 2px solid white; border-radius: 50%;
|
||||||
border: 2px solid white;
|
cursor: pointer; font-weight: bold; z-index: 2;
|
||||||
color: white;
|
}
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
.placeholder-img {
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
z-index: 2; /* Ensure it sits on top of the slot button */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.2rem;
|
background: transparent;
|
||||||
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
|
color: #555;
|
||||||
}
|
font-size: 3rem;
|
||||||
|
height: 100%;
|
||||||
.remove-btn:hover {
|
|
||||||
background: #ff0000;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- RIGHT PANEL: DETAILS --- */
|
/* --- RIGHT PANEL: DETAILS --- */
|
||||||
|
|
@ -265,17 +213,6 @@ export class TeamBuilder extends LitElement {
|
||||||
overflow-y: auto;
|
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 {
|
.footer {
|
||||||
grid-area: footer;
|
grid-area: footer;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -296,91 +233,56 @@ export class TeamBuilder extends LitElement {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
letter-spacing: 2px;
|
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 {
|
.embark-btn:disabled {
|
||||||
background: #333;
|
background: #333; border-color: #555; color: #777; cursor: not-allowed;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
availableClasses: { type: Array }, // Input: List of class definition objects
|
mode: { type: String }, // 'DRAFT' (Classes) or 'ROSTER' (Existing Units)
|
||||||
squad: { type: Array }, // Internal State: The 4 slots
|
availablePool: { type: Array }, // List of Classes OR Units
|
||||||
|
squad: { type: Array }, // The 4 slots
|
||||||
selectedSlotIndex: { type: Number },
|
selectedSlotIndex: { type: Number },
|
||||||
hoveredClass: { type: Object }
|
hoveredItem: { type: Object }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.squad = [null, null, null, null];
|
this.squad = [null, null, null, null];
|
||||||
this.selectedSlotIndex = 0; // Default to first slot
|
this.selectedSlotIndex = 0;
|
||||||
this.hoveredClass = null;
|
this.hoveredItem = null;
|
||||||
|
this.mode = 'DRAFT'; // Default
|
||||||
// Initialize by merging Raw Data with UI Metadata
|
this.availablePool = [];
|
||||||
this.availableClasses = RAW_TIER_1_CLASSES.map(cls => {
|
|
||||||
const meta = CLASS_METADATA[cls.id] || {};
|
|
||||||
return {
|
|
||||||
...cls,
|
|
||||||
...meta, // Adds icon, role, description, image path
|
|
||||||
unlocked: true // Default all Tier 1s to unlocked
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this._loadMetaProgression();
|
this._initializeData();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads unlocked classes from persistence (Local Storage / Game State).
|
* Configures the component based on provided data.
|
||||||
* Merges Tier 2 classes into availableClasses if unlocked.
|
|
||||||
*/
|
*/
|
||||||
_loadMetaProgression() {
|
_initializeData() {
|
||||||
// Mock Implementation: Retrieve unlocked Tier 2 classes from a service or storage
|
// 1. If we were passed an existing roster (e.g. from RosterManager), use it.
|
||||||
// In a real implementation, you would import a MetaProgressionManager here.
|
if (this.availablePool && this.availablePool.length > 0) {
|
||||||
|
this.mode = 'ROSTER';
|
||||||
// Example: const unlockedIds = MetaProgression.getUnlockedClasses();
|
console.log("TeamBuilder: Using Provided Roster", this.availablePool);
|
||||||
const storedData = localStorage.getItem('aether_shards_unlocks');
|
return;
|
||||||
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() {
|
render() {
|
||||||
|
|
@ -388,30 +290,35 @@ export class TeamBuilder extends LitElement {
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<!-- ROSTER PANEL -->
|
||||||
<!-- ROSTER LIST -->
|
|
||||||
<div class="roster-panel">
|
<div class="roster-panel">
|
||||||
<h3>Roster</h3>
|
<h3>${this.mode === 'DRAFT' ? 'Recruit Explorers' : 'Barracks Roster'}</h3>
|
||||||
${this.availableClasses.map(cls => html`
|
|
||||||
<button
|
${this.availablePool.map(item => {
|
||||||
type="button"
|
const isSelected = this.squad.some(s => s && (this.mode === 'ROSTER' ? s.id === item.id : false));
|
||||||
class="class-card"
|
|
||||||
?disabled="${!cls.unlocked}"
|
return html`
|
||||||
@click="${() => this._assignClass(cls)}"
|
<button
|
||||||
@mouseenter="${() => this.hoveredClass = cls}"
|
type="button"
|
||||||
@mouseleave="${() => this.hoveredClass = null}"
|
class="card ${isSelected ? 'selected' : ''}"
|
||||||
aria-label="Select Class: ${cls.name}"
|
?disabled="${this.mode === 'DRAFT' && !item.unlocked || isSelected}"
|
||||||
>
|
@click="${() => this._assignItem(item)}"
|
||||||
<div class="icon" style="font-size: 1.5rem;">${cls.icon || '⚔️'}</div>
|
@mouseenter="${() => this.hoveredItem = item}"
|
||||||
<div>
|
@mouseleave="${() => this.hoveredItem = null}"
|
||||||
<strong>${cls.name}</strong><br>
|
>
|
||||||
<small>${cls.role || 'Tier ' + cls.tier}</small>
|
<div class="icon" style="font-size: 1.5rem;">
|
||||||
</div>
|
${item.icon || CLASS_METADATA[item.classId]?.icon || '⚔️'}
|
||||||
</button>
|
</div>
|
||||||
`)}
|
<div>
|
||||||
|
<strong>${item.name}</strong><br>
|
||||||
|
<small>${this.mode === 'ROSTER' ? `Lvl ${item.level || 1} ${item.classId.replace('CLASS_', '')}` : item.role}</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CENTER SQUAD SLOTS -->
|
<!-- SQUAD SLOTS -->
|
||||||
<div class="squad-panel">
|
<div class="squad-panel">
|
||||||
${this.squad.map((unit, index) => html`
|
${this.squad.map((unit, index) => html`
|
||||||
<div class="slot-wrapper">
|
<div class="slot-wrapper">
|
||||||
|
|
@ -419,8 +326,6 @@ export class TeamBuilder extends LitElement {
|
||||||
type="button"
|
type="button"
|
||||||
class="squad-slot ${unit ? 'filled' : ''} ${this.selectedSlotIndex === index ? 'selected' : ''}"
|
class="squad-slot ${unit ? 'filled' : ''} ${this.selectedSlotIndex === index ? 'selected' : ''}"
|
||||||
@click="${() => this._selectSlot(index)}"
|
@click="${() => this._selectSlot(index)}"
|
||||||
aria-label="${unit ? `Slot ${index + 1}: ${unit.name}` : `Slot ${index + 1}: Empty`}"
|
|
||||||
aria-pressed="${this.selectedSlotIndex === index}"
|
|
||||||
>
|
>
|
||||||
${unit
|
${unit
|
||||||
? html`
|
? 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'">`
|
? 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 || '🛡️'}
|
${unit.icon || '🛡️'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="unit-info">
|
<div class="unit-info">
|
||||||
<strong>${unit.name}</strong>
|
<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>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div class="placeholder-img" style="background:transparent; color: #555;">+</div>
|
<div class="placeholder-img">+</div>
|
||||||
<div class="unit-info" style="background:transparent;">
|
<div class="unit-info" style="background:transparent;">
|
||||||
<span>Slot ${index + 1}</span>
|
<span>Slot ${index + 1}</span>
|
||||||
<small>Empty</small>
|
<small>Select ${this.mode === 'DRAFT' ? 'Class' : 'Unit'}</small>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
${unit ? html`<button type="button" class="remove-btn" @click="${() => this._removeUnit(index)}">X</button>` : ''}
|
||||||
${unit
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="remove-btn"
|
|
||||||
@click="${() => this._removeUnit(index)}"
|
|
||||||
aria-label="Remove ${unit.name} from Slot ${index + 1}"
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT DETAILS PANEL -->
|
<!-- DETAILS PANEL -->
|
||||||
<div class="details-panel">
|
<div class="details-panel">
|
||||||
${this.hoveredClass
|
${this._renderDetails()}
|
||||||
? 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>`
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
<!-- FOOTER -->
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<button
|
<button type="button" class="embark-btn" ?disabled="${!isSquadValid}" @click="${this._handleEmbark}">
|
||||||
type="button"
|
${this.mode === 'DRAFT' ? 'INITIALIZE SQUAD' : 'EMBARK'}
|
||||||
class="embark-btn"
|
|
||||||
?disabled="${!isSquadValid}"
|
|
||||||
@click="${this._handleEmbark}"
|
|
||||||
>
|
|
||||||
DESCEND
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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) {
|
_selectSlot(index) {
|
||||||
this.selectedSlotIndex = 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) {
|
_assignItem(item) {
|
||||||
if (!classDef.unlocked && classDef.unlocked !== undefined) return; // Logic check redundancy for tests without DOM checks
|
if (this.mode === 'DRAFT' && !item.unlocked) return;
|
||||||
|
|
||||||
// 1. Create a lightweight manifest for the slot
|
let unitManifest;
|
||||||
const unitManifest = {
|
|
||||||
classId: classDef.id,
|
if (this.mode === 'DRAFT') {
|
||||||
name: classDef.name, // In real app, auto-generate name like "Valerius"
|
// Create new unit definition
|
||||||
icon: classDef.icon,
|
unitManifest = {
|
||||||
image: classDef.image // Pass image path
|
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];
|
const newSquad = [...this.squad];
|
||||||
newSquad[this.selectedSlotIndex] = unitManifest;
|
newSquad[this.selectedSlotIndex] = unitManifest;
|
||||||
this.squad = newSquad;
|
this.squad = newSquad;
|
||||||
|
|
||||||
// 3. Auto-advance selection
|
if (this.selectedSlotIndex < 3) this.selectedSlotIndex++;
|
||||||
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
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeUnit(index) {
|
_removeUnit(index) {
|
||||||
// No stopPropagation needed as elements are siblings now
|
|
||||||
const newSquad = [...this.squad];
|
const newSquad = [...this.squad];
|
||||||
newSquad[index] = null;
|
newSquad[index] = null;
|
||||||
this.squad = newSquad;
|
this.squad = newSquad;
|
||||||
this.selectedSlotIndex = index; // Select the empty slot
|
this.selectedSlotIndex = index;
|
||||||
|
|
||||||
// Dispatch Event (To clear 3D model)
|
|
||||||
this.dispatchEvent(new CustomEvent('squad-update', {
|
|
||||||
detail: { slot: index, unit: null },
|
|
||||||
bubbles: true,
|
|
||||||
composed: true
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleEmbark() {
|
_handleEmbark() {
|
||||||
const manifest = this.squad.filter(u => u !== null);
|
const manifest = this.squad.filter(u => u !== null);
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('embark', {
|
this.dispatchEvent(new CustomEvent('embark', {
|
||||||
detail: { squad: manifest },
|
detail: { squad: manifest, mode: this.mode },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true
|
composed: true
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue