Enhance GameLoop and GameStateManager for improved mission handling and health management

- Integrate narrative management into the GameLoop to handle mission completion and transitions, ensuring a smooth player experience.
- Implement health restoration logic for units based on roster data, preserving health values across missions.
- Update GameStateManager to clear active run data upon mission completion or failure, enhancing state management.
- Enhance Persistence layer with detailed logging for campaign data loading and saving, improving debugging and data integrity.
- Add event listeners in UI components to refresh mission data upon campaign changes, ensuring real-time updates for players.
This commit is contained in:
Matthew Mone 2025-12-31 20:48:12 -08:00
parent 45276d1bd4
commit 8d2baacd5f
9 changed files with 243 additions and 30 deletions

View file

@ -25,6 +25,7 @@ import { skillRegistry } from "../managers/SkillRegistry.js";
import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryManager } from "../managers/InventoryManager.js";
import { InventoryContainer } from "../models/InventoryContainer.js"; import { InventoryContainer } from "../models/InventoryContainer.js";
import { itemRegistry } from "../managers/ItemRegistry.js"; import { itemRegistry } from "../managers/ItemRegistry.js";
import { narrativeManager } from "../managers/NarrativeManager.js";
// Import class definitions // Import class definitions
import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" }; import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" };
@ -1223,6 +1224,13 @@ export class GameLoop {
} }
} }
} }
// Restore currentHealth from roster (preserve HP that was paid for)
if (rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null) {
// Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased)
unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100);
console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`);
}
} }
} }
@ -1256,11 +1264,16 @@ export class GameLoop {
} }
} }
// Ensure unit starts with full health // Ensure unit has valid health values
// Explorer constructor might set health to 0 if classDef is missing base_stats // Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster
if (unit.currentHealth <= 0) { // This preserves HP that was paid for in the barracks
if (unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find(r => r.id === unit.rosterId)?.currentHealth)) {
// Only set to full if we didn't restore from roster (new unit or roster had no saved HP)
unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100; unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100;
unit.maxHealth = unit.maxHealth || unit.baseStats?.health || 100; }
// Ensure maxHealth is set
if (!unit.maxHealth) {
unit.maxHealth = unit.baseStats?.health || 100;
} }
this.grid.placeUnit(unit, targetTile); this.grid.placeUnit(unit, targetTile);
@ -2588,13 +2601,49 @@ export class GameLoop {
// Stop the game loop // Stop the game loop
this.stop(); this.stop();
// TODO: Show victory screen UI // Clear the active run from persistence since mission is complete
// For now, just log and transition back to main menu after a delay if (this.gameStateManager) {
setTimeout(() => { this.gameStateManager.clearActiveRun();
if (this.gameStateManager) { }
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
} // Wait for the outro narrative to complete before transitioning
}, 3000); // The outro is played in MissionManager.completeActiveMission()
// We'll listen for the narrative-end event to know when it's done
const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative?.outro_success;
if (hasOutro) {
console.log('GameLoop: Waiting for outro narrative to complete...');
const handleNarrativeEnd = () => {
console.log('GameLoop: Narrative end event received, transitioning to hub');
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
// Small delay after narrative ends to let user see the final message
setTimeout(() => {
if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
}
}, 500);
};
narrativeManager.addEventListener('narrative-end', handleNarrativeEnd);
// Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway
setTimeout(() => {
console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway');
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
}
}, 30000);
} else {
// No outro, transition immediately after a short delay
console.log('GameLoop: No outro narrative, transitioning to hub');
setTimeout(() => {
if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
}
}, 1000);
}
} }
/** /**
@ -2663,6 +2712,11 @@ export class GameLoop {
// Stop the game loop // Stop the game loop
this.stop(); this.stop();
// Clear the active run from persistence since mission is failed
if (this.gameStateManager) {
this.gameStateManager.clearActiveRun();
}
// TODO: Show failure screen UI // TODO: Show failure screen UI
// For now, just log and transition back to main menu after a delay // For now, just log and transition back to main menu after a delay
setTimeout(() => { setTimeout(() => {

View file

@ -159,8 +159,15 @@ class GameStateManagerClass {
// 4. Load Campaign Progress // 4. Load Campaign Progress
const savedCampaignData = await this.persistence.loadCampaign(); const savedCampaignData = await this.persistence.loadCampaign();
console.log("Loaded campaign data:", savedCampaignData);
if (savedCampaignData) { if (savedCampaignData) {
this.missionManager.load(savedCampaignData); this.missionManager.load(savedCampaignData);
console.log(
"Campaign data loaded. Completed missions:",
Array.from(this.missionManager.completedMissions)
);
} else {
console.log("No saved campaign data found");
} }
// 5. Set up mission rewards listener // 5. Set up mission rewards listener
@ -426,8 +433,42 @@ class GameStateManagerClass {
* @private * @private
*/ */
async _saveCampaign() { async _saveCampaign() {
const data = this.missionManager.save(); try {
await this.persistence.saveCampaign(data); const data = this.missionManager.save();
console.log("GameStateManager: Saving campaign data:", data);
console.log(
"GameStateManager: Completed missions count:",
this.missionManager.completedMissions.size
);
console.log(
"GameStateManager: Completed missions array:",
Array.from(this.missionManager.completedMissions)
);
if (
!data ||
!data.completedMissions ||
data.completedMissions.length === 0
) {
console.warn(
"GameStateManager: Warning - no completed missions to save!"
);
}
await this.persistence.saveCampaign(data);
console.log("GameStateManager: Campaign data saved successfully");
} catch (error) {
console.error("GameStateManager: Error saving campaign data:", error);
throw error;
}
}
/**
* Clears the active run from memory and persistence.
* Called when a mission is completed or failed.
* @returns {Promise<void>}
*/
async clearActiveRun() {
this.activeRunData = null;
await this.persistence.clearRun();
} }
/** /**
@ -468,8 +509,12 @@ class GameStateManagerClass {
* @private * @private
*/ */
_setupCampaignDataListener() { _setupCampaignDataListener() {
window.addEventListener("campaign-data-changed", () => { window.addEventListener("campaign-data-changed", async (event) => {
this._saveCampaign(); console.log(
"GameStateManager: Received campaign-data-changed event:",
event.detail
);
await this._saveCampaign();
}); });
} }

View file

@ -15,7 +15,7 @@ const MARKET_STORE = "Market";
const CAMPAIGN_STORE = "Campaign"; const CAMPAIGN_STORE = "Campaign";
const HUB_STASH_STORE = "HubStash"; const HUB_STASH_STORE = "HubStash";
const UNLOCKS_STORE = "Unlocks"; const UNLOCKS_STORE = "Unlocks";
const VERSION = 5; // Bumped version to add HubStash and Unlocks stores const VERSION = 6; // Bumped version to add Campaign store
/** /**
* Handles game data persistence using IndexedDB. * Handles game data persistence using IndexedDB.
@ -162,7 +162,14 @@ export class Persistence {
*/ */
async saveCampaign(campaignData) { async saveCampaign(campaignData) {
if (!this.db) await this.init(); if (!this.db) await this.init();
return this._put(CAMPAIGN_STORE, { id: "campaign_data", data: campaignData }); console.log("Persistence: Saving campaign data to Campaign store:", campaignData);
try {
await this._put(CAMPAIGN_STORE, { id: "campaign_data", data: campaignData });
console.log("Persistence: Campaign data saved successfully");
} catch (error) {
console.error("Persistence: Error saving campaign data:", error);
throw error;
}
} }
/** /**
@ -171,8 +178,15 @@ export class Persistence {
*/ */
async loadCampaign() { async loadCampaign() {
if (!this.db) await this.init(); if (!this.db) await this.init();
const result = await this._get(CAMPAIGN_STORE, "campaign_data"); console.log("Persistence: Loading campaign data from Campaign store");
return result ? result.data : null; try {
const result = await this._get(CAMPAIGN_STORE, "campaign_data");
console.log("Persistence: Loaded campaign data result:", result);
return result ? result.data : null;
} catch (error) {
console.error("Persistence: Error loading campaign data:", error);
return null;
}
} }
// --- HUB STASH DATA --- // --- HUB STASH DATA ---

View file

@ -419,19 +419,24 @@ export class MissionManager {
// Mark mission as completed // Mark mission as completed
this.completedMissions.add(this.activeMissionId); this.completedMissions.add(this.activeMissionId);
console.log("MissionManager: Mission completed. Active mission ID:", this.activeMissionId);
console.log("MissionManager: Completed missions now:", Array.from(this.completedMissions));
// Dispatch event to save campaign data IMMEDIATELY (before outro)
// This ensures the save happens even if the outro doesn't complete
console.log("MissionManager: Dispatching campaign-data-changed event");
window.dispatchEvent(new CustomEvent('campaign-data-changed', {
detail: { missionCompleted: this.activeMissionId }
}));
console.log("MissionManager: campaign-data-changed event dispatched");
// Distribute rewards // Distribute rewards
this.distributeRewards(); this.distributeRewards();
// Play outro narrative if available // Play outro narrative if available (after saving)
if (this.currentMissionDef.narrative?.outro_success) { if (this.currentMissionDef.narrative?.outro_success) {
await this.playOutro(this.currentMissionDef.narrative.outro_success); await this.playOutro(this.currentMissionDef.narrative.outro_success);
} }
// Dispatch event to save campaign data
window.dispatchEvent(new CustomEvent('campaign-data-changed', {
detail: { missionCompleted: this.activeMissionId }
}));
} }
/** /**

View file

@ -58,6 +58,15 @@ export class NarrativeManager extends EventTarget {
} }
const nextId = this.currentNode.next; const nextId = this.currentNode.next;
// If the next node is "END", we still need to wait for user to click again
// to actually close the dialogue. This gives them time to read the last message.
if (!nextId || nextId === "END") {
// User clicked on the last message - now close it
this.endSequence();
return;
}
this._advanceToNode(nextId); this._advanceToNode(nextId);
} }
@ -128,6 +137,11 @@ export class NarrativeManager extends EventTarget {
endSequence() { endSequence() {
console.log("NarrativeManager: Sequence Ended"); console.log("NarrativeManager: Sequence Ended");
// Only end if we actually have a sequence active
if (!this.currentSequence) {
console.warn("NarrativeManager: endSequence called but no active sequence");
return;
}
this.currentSequence = null; this.currentSequence = null;
this.currentNode = null; this.currentNode = null;
this.dispatchEvent(new CustomEvent("narrative-end")); this.dispatchEvent(new CustomEvent("narrative-end"));

View file

@ -208,6 +208,24 @@ export class MissionBoard extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._loadMissions(); this._loadMissions();
// Listen for campaign data changes to refresh completed missions
this._boundHandleCampaignChange = this._handleCampaignChange.bind(this);
window.addEventListener('campaign-data-changed', this._boundHandleCampaignChange);
window.addEventListener('gamestate-changed', this._boundHandleCampaignChange);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._boundHandleCampaignChange) {
window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange);
window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange);
}
}
_handleCampaignChange() {
// Reload missions when campaign data changes (mission completed)
this._loadMissions();
} }
_loadMissions() { _loadMissions() {

View file

@ -127,6 +127,7 @@ export class DialogueOverlay extends LitElement {
super(); super();
this.activeNode = null; this.activeNode = null;
this.isVisible = false; this.isVisible = false;
this._isProcessingClick = false; // Prevent rapid clicks
} }
connectedCallback() { connectedCallback() {
@ -150,6 +151,9 @@ export class DialogueOverlay extends LitElement {
_onUpdate(e) { _onUpdate(e) {
this.activeNode = e.detail.node; this.activeNode = e.detail.node;
this.isVisible = e.detail.active; this.isVisible = e.detail.active;
// Reset processing flag when a new node is shown
// This ensures each message can be clicked independently
this._isProcessingClick = false;
} }
_onEnd() { _onEnd() {
@ -160,9 +164,28 @@ export class DialogueOverlay extends LitElement {
_handleInput(e) { _handleInput(e) {
if (!this.isVisible) return; if (!this.isVisible) return;
if (e.code === "Space" || e.code === "Enter") { if (e.code === "Space" || e.code === "Enter") {
// Prevent rapid key presses
if (this._isProcessingClick) {
return;
}
// Only advance if no choices // Only advance if no choices
if (!this.activeNode.choices) { if (!this.activeNode.choices) {
narrativeManager.next(); this._isProcessingClick = true;
// Check if this is the last message
const isLastMessage = !this.activeNode.next || this.activeNode.next === "END";
if (isLastMessage) {
narrativeManager.endSequence();
} else {
narrativeManager.next();
}
// Reset flag after a short delay
setTimeout(() => {
this._isProcessingClick = false;
}, 300);
} }
} }
} }
@ -175,7 +198,40 @@ export class DialogueOverlay extends LitElement {
class="dialogue-box ${this.activeNode.type === "TUTORIAL" class="dialogue-box ${this.activeNode.type === "TUTORIAL"
? "type-tutorial" ? "type-tutorial"
: ""}" : ""}"
@click="${() => !this.activeNode.choices && narrativeManager.next()}" @click="${(e) => {
// Prevent event bubbling and default behavior
e.stopPropagation();
e.preventDefault();
// Prevent rapid clicks
if (this._isProcessingClick) {
return;
}
// Only advance on click if there are no choices
// Check if we're clicking on the content area (not buttons or other interactive elements)
const isContentClick = e.target.closest('.content') && !e.target.closest('button');
if (!this.activeNode.choices && isContentClick) {
this._isProcessingClick = true;
// Check if this is the last message (next === "END")
const isLastMessage = !this.activeNode.next || this.activeNode.next === "END";
if (isLastMessage) {
// For the last message, close the dialogue
narrativeManager.endSequence();
} else {
// For other messages, advance normally
narrativeManager.next();
}
// Reset flag after a short delay to prevent double-clicks
setTimeout(() => {
this._isProcessingClick = false;
}, 300);
}
}}"
> >
${this.activeNode.portrait ${this.activeNode.portrait
? html` ? html`
@ -210,7 +266,9 @@ export class DialogueOverlay extends LitElement {
</div> </div>
` `
: html`<div class="next-indicator"> : html`<div class="next-indicator">
Press SPACE to continue... ${!this.activeNode.next || this.activeNode.next === "END"
? "Press SPACE or click to close"
: "Press SPACE to continue..."}
</div>`} </div>`}
</div> </div>
</div> </div>

View file

@ -335,7 +335,12 @@ export class TeamBuilder extends LitElement {
</div> </div>
<div> <div>
<strong>${item.name}</strong><br> <strong>${item.name}</strong><br>
<small>${this.mode === 'ROSTER' ? `Lvl ${item.level || 1} ${item.classId.replace('CLASS_', '')}` : item.role}</small> <small>${this.mode === 'ROSTER' ? (() => {
// Calculate level from classMastery
const activeClassId = item.activeClassId || item.classId;
const level = item.classMastery?.[activeClassId]?.level || 1;
return `Lvl ${level} ${item.classId.replace('CLASS_', '')}`;
})() : item.role}</small>
</div> </div>
</button> </button>
`; `;

View file

@ -80,7 +80,7 @@ describe("Core: Persistence", () => {
await initPromise; await initPromise;
expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 4)).to.be.true; expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 6)).to.be.true;
expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be
.true; .true;
expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to