Files
BreakEscape/public/break_escape/js/minigames/phone-chat/phone-chat-conversation.js
Z. Cliffe Schreuders 91d4670b2a fix: update event mapping structure and validation for NPCs
- Changed 'eventMapping' to 'eventMappings' in NPC definitions for consistency.
- Updated target knot for closing debrief NPC to 'start' and adjusted story path.
- Enhanced validation script to check for correct eventMappings structure and properties.
- Added checks for missing properties in eventMappings and timedMessages.
- Provided best practice guidance for event-driven cutscenes and closing debrief implementation.
2026-02-18 00:47:37 +00:00

553 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PhoneChatConversation - Ink Story Management
*
* Manages Ink story execution for NPC conversations, interfacing with InkEngine.
* Handles story loading, continuation, choices, and state management.
*
* @module phone-chat-conversation
*/
export default class PhoneChatConversation {
/**
* Create a PhoneChatConversation instance
* @param {string} npcId - NPC identifier
* @param {Object} npcManager - NPCManager instance
* @param {Object} inkEngine - InkEngine instance
*/
constructor(npcId, npcManager, inkEngine) {
if (!npcId) {
throw new Error('PhoneChatConversation requires an npcId');
}
if (!npcManager) {
throw new Error('PhoneChatConversation requires an npcManager instance');
}
if (!inkEngine) {
throw new Error('PhoneChatConversation requires an inkEngine instance');
}
this.npcId = npcId;
this.npcManager = npcManager;
this.engine = inkEngine;
this.storyLoaded = false;
this.storyEnded = false;
console.log(`💬 PhoneChatConversation initialized for NPC: ${npcId}`);
}
/**
* Load the Ink story for this NPC
* @param {string|Object} storyPathOrJSON - Path to Ink JSON file OR direct JSON object
* @returns {Promise<boolean>} True if loaded successfully
*/
async loadStory(storyPathOrJSON) {
if (!storyPathOrJSON) {
console.error('❌ No story path or JSON provided');
return false;
}
try {
let storyJson;
// Check if we received a JSON object directly
if (typeof storyPathOrJSON === 'object') {
console.log(`📖 Loading story from inline JSON for ${this.npcId}`);
storyJson = storyPathOrJSON;
} else {
// It's a path, fetch the JSON
console.log(`📖 Loading story from: ${storyPathOrJSON}`);
const response = await fetch(storyPathOrJSON);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
storyJson = await response.json();
}
// Load into InkEngine
this.engine.loadStory(storyJson);
// Note: We don't set npc_name variable here because it causes issues with state serialization.
// The NPC display name is handled in the UI layer instead.
this.storyLoaded = true;
this.storyEnded = false;
// Set up external functions
this.setupExternalFunctions();
// Sync NPC items to Ink variables
this.syncItemsToInk();
// Set up event listener for item changes
if (window.eventDispatcher) {
this._itemsChangedListener = (data) => {
if (data.npcId === this.npcId) {
this.syncItemsToInk();
}
};
window.eventDispatcher.on('npc_items_changed', this._itemsChangedListener);
}
// Set up global variable observer to sync changes back to window.gameState
// This is critical for cross-NPC variable sharing
if (window.npcConversationStateManager && this.engine.story) {
window.npcConversationStateManager.discoverGlobalVariables(this.engine.story);
window.npcConversationStateManager.syncGlobalVariablesToStory(this.engine.story);
window.npcConversationStateManager.observeGlobalVariableChanges(this.engine.story, this.npcId);
console.log(`🌐 Global variable observer set up for ${this.npcId}`);
}
console.log(`✅ Story loaded successfully for ${this.npcId}`);
return true;
} catch (error) {
console.error(`❌ Error loading story for ${this.npcId}:`, error);
this.storyLoaded = false;
return false;
}
}
/**
* Set up external functions for Ink story
* These allow Ink to call game functions and get dynamic values
*/
setupExternalFunctions() {
if (!this.engine || !this.engine.story) return;
// Bind EXTERNAL functions that return values
// These are called from ink scripts with parentheses: {player_name()}
// Player name - return player's agent name or default
this.engine.bindExternalFunction('player_name', () => {
return window.gameState?.playerName || 'Agent';
});
// Current mission ID - return active mission identifier
this.engine.bindExternalFunction('current_mission_id', () => {
return window.gameState?.currentMissionId || 'mission_001';
});
// NPC location - where the conversation is happening
this.engine.bindExternalFunction('npc_location', () => {
const npc = this.npcManager.getNPC(this.npcId);
// Return location based on NPC or default
if (this.npcId === 'dr_chen' || npc?.id === 'dr_chen') {
return window.gameState?.npcLocation || 'lab';
} else if (this.npcId === 'director_netherton' || npc?.id === 'director_netherton') {
return window.gameState?.npcLocation || 'office';
} else if (this.npcId === 'haxolottle' || npc?.id === 'haxolottle') {
return window.gameState?.npcLocation || 'handler_station';
}
return window.gameState?.npcLocation || 'safehouse';
});
// Mission phase - what part of the mission we're in
this.engine.bindExternalFunction('mission_phase', () => {
return window.gameState?.missionPhase || 'downtime';
});
// Operational stress level - for handler conversations
this.engine.bindExternalFunction('operational_stress_level', () => {
return window.gameState?.operationalStressLevel || 'low';
});
// Equipment status - for Dr. Chen conversations
this.engine.bindExternalFunction('equipment_status', () => {
return window.gameState?.equipmentStatus || 'nominal';
});
console.log(`✅ External functions bound for ${this.npcId}`);
}
/**
* Navigate to a specific knot in the story
* @param {string} knotName - Name of the knot to navigate to
* @returns {boolean} True if navigation successful
*/
goToKnot(knotName) {
if (!this.storyLoaded) {
console.error('❌ Cannot navigate to knot: story not loaded');
return false;
}
if (!knotName) {
console.warn('⚠️ No knot name provided');
return false;
}
try {
this.engine.goToKnot(knotName);
// Update NPC's current knot in manager
const npc = this.npcManager.getNPC(this.npcId);
if (npc) {
npc.currentKnot = knotName;
}
console.log(`🎯 Navigated to knot: ${knotName}`);
return true;
} catch (error) {
console.error(`❌ Error navigating to knot ${knotName}:`, error);
return false;
}
}
/**
* Sync NPC's held items to Ink variables
* Sets has_<type> based on itemsHeld array
* IMPORTANT: Also sets variables to false for items NOT in inventory
*/
syncItemsToInk() {
if (!this.engine || !this.engine.story) return;
const npc = this.npcManager.getNPC(this.npcId);
if (!npc || !npc.itemsHeld) return;
const varState = this.engine.story.variablesState;
if (!varState._defaultGlobalVariables) return;
// Count items by type
const itemCounts = {};
npc.itemsHeld.forEach(item => {
itemCounts[item.type] = (itemCounts[item.type] || 0) + 1;
});
// Get all declared has_* variables from the story
const declaredVars = Array.from(varState._defaultGlobalVariables.keys());
const hasItemVars = declaredVars.filter(varName => varName.startsWith('has_'));
// Sync all has_* variables - set to true if NPC has item, false if not
hasItemVars.forEach(varName => {
// Extract item type from variable name (e.g., "has_lockpick" -> "lockpick")
const itemType = varName.replace(/^has_/, '');
const hasItem = (itemCounts[itemType] || 0) > 0;
try {
this.engine.setVariable(varName, hasItem);
console.log(`✅ Synced ${varName} = ${hasItem} for NPC ${npc.id} (${itemCounts[itemType] || 0} items)`);
} catch (err) {
console.warn(`⚠️ Could not sync ${varName}:`, err.message);
}
});
}
/**
* Continue the story and get the next text/choices
* @returns {Object} Story result { text, choices, tags, canContinue, hasEnded }
*/
continue() {
if (!this.storyLoaded) {
console.error('❌ Cannot continue: story not loaded');
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
if (this.storyEnded) {
console.log(' Story has ended');
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
try {
const result = this.engine.continue();
// Process tags for side effects (like #exit_conversation)
if (result.tags && result.tags.length > 0) {
this.processTags(result.tags);
}
// Check if story has ended (no more content and no choices)
if (!result.canContinue && (!result.choices || result.choices.length === 0)) {
this.storyEnded = true;
result.hasEnded = true;
console.log('🏁 Story has ended');
} else {
result.hasEnded = false;
}
return result;
} catch (error) {
console.error('❌ Error continuing story:', error);
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
}
/**
* Process conversation-specific Ink tags (like #exit_conversation)
* Note: Game action tags (#set_global, #unlock_door, etc.) are processed
* later by processGameActionTags in phone-chat-minigame.js
* @param {Array} tags - Tags from current line
*/
processTags(tags) {
if (!tags || tags.length === 0) return;
tags.forEach(tag => {
// Tag format: "action:param1:param2"
const [action, ...params] = tag.split(':');
switch (action.trim().toLowerCase()) {
case 'end_conversation':
case 'exit_conversation':
console.log(`🏷️ Processing conversation tag: ${tag}`);
this.handleEndConversation();
break;
default:
// Other tags are game action tags - will be processed by minigame layer
// Don't log them here to avoid confusion
break;
}
});
}
/**
* Handle end_conversation tag - signal conversation should close
* Tag: #end_conversation
* The ink script has already diverted to mission_hub, preserving state.
* This signals the UI layer to close the conversation window.
* Next time player talks to this NPC, it will resume from mission_hub.
*/
handleEndConversation() {
console.log(`👋 End conversation for ${this.npcId} - conversation state preserved at mission_hub`);
// Dispatch event for UI layer to close the conversation window
const event = new CustomEvent('npc-conversation-ended', {
detail: {
npcId: this.npcId,
preservedAtHub: true
}
});
window.dispatchEvent(event);
console.log(`✅ Conversation ended, will resume from mission_hub on next interaction`);
}
/**
* Make a choice and continue the story
* @param {number} choiceIndex - Index of the choice to make
* @returns {Object} Story result after choice
*/
makeChoice(choiceIndex) {
if (!this.storyLoaded) {
console.error('❌ Cannot make choice: story not loaded');
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
if (this.storyEnded) {
console.log(' Cannot make choice: story has ended');
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
try {
// Make the choice
this.engine.choose(choiceIndex);
console.log(`👆 Made choice ${choiceIndex}`);
// Continue after choice
return this.continue();
} catch (error) {
console.error(`❌ Error making choice ${choiceIndex}:`, error);
return { text: '', choices: [], tags: [], canContinue: false, hasEnded: true };
}
}
/**
* Get current state without continuing (for reopening conversations)
* @returns {Object} Current story state { choices, canContinue, hasEnded }
*/
getCurrentState() {
if (!this.storyLoaded) {
console.error('❌ Cannot get state: story not loaded');
return { choices: [], canContinue: false, hasEnded: true };
}
if (this.storyEnded) {
return { choices: [], canContinue: false, hasEnded: true };
}
try {
// Get current choices without continuing
const choices = this.engine.currentChoices || [];
const canContinue = this.engine.story?.canContinue || false;
const hasEnded = !canContinue && choices.length === 0;
return { choices, canContinue, hasEnded };
} catch (error) {
console.error('❌ Error getting current state:', error);
return { choices: [], canContinue: false, hasEnded: true };
}
}
/**
* Get an Ink variable value
* @param {string} name - Variable name
* @returns {*} Variable value or null
*/
getVariable(name) {
if (!this.storyLoaded) {
console.warn('⚠️ Cannot get variable: story not loaded');
return null;
}
try {
return this.engine.getVariable(name);
} catch (error) {
console.error(`❌ Error getting variable ${name}:`, error);
return null;
}
}
/**
* Set an Ink variable value
* @param {string} name - Variable name
* @param {*} value - Variable value
* @returns {boolean} True if set successfully
*/
setVariable(name, value) {
if (!this.storyLoaded) {
console.warn('⚠️ Cannot set variable: story not loaded');
return false;
}
try {
this.engine.setVariable(name, value);
console.log(`✅ Set variable ${name} = ${value}`);
return true;
} catch (error) {
console.error(`❌ Error setting variable ${name}:`, error);
return false;
}
}
/**
* Save the current story state
* @returns {string|null} Serialized state or null on error
*/
saveState() {
if (!this.storyLoaded) {
console.warn('⚠️ Cannot save state: story not loaded');
return null;
}
try {
const state = this.engine.story.state.ToJson();
console.log('💾 Saved story state');
return state;
} catch (error) {
console.error('❌ Error saving state:', error);
return null;
}
}
/**
* Restore a previously saved story state
* @param {string} state - Serialized state from saveState()
* @returns {boolean} True if restored successfully
*/
restoreState(state) {
if (!this.storyLoaded) {
console.warn('⚠️ Cannot restore state: story not loaded');
return false;
}
if (!state) {
console.warn('⚠️ No state provided');
return false;
}
try {
this.engine.story.state.LoadJson(state);
this.storyEnded = false; // Reset ended flag
console.log('📂 Restored story state');
return true;
} catch (error) {
console.error('❌ Error restoring state:', error);
return false;
}
}
/**
* Check if the story has ended
* @returns {boolean} True if story has ended
*/
hasEnded() {
return this.storyEnded;
}
/**
* Reset the story (reload from beginning)
* @param {string} storyPath - Path to Ink JSON file
* @returns {Promise<boolean>} True if reset successfully
*/
async reset(storyPath) {
console.log('🔄 Resetting conversation...');
this.storyLoaded = false;
this.storyEnded = false;
return await this.loadStory(storyPath);
}
/**
* Get all available tags from the current story state
* @returns {Array<string>} Array of tag strings
*/
getCurrentTags() {
if (!this.storyLoaded) {
return [];
}
try {
return this.engine.story.currentTags || [];
} catch (error) {
console.error('❌ Error getting tags:', error);
return [];
}
}
/**
* Clean up resources (event listeners, etc.)
*/
cleanup() {
// Remove event listener
if (window.eventDispatcher && this._itemsChangedListener) {
window.eventDispatcher.off('npc_items_changed', this._itemsChangedListener);
}
}
/**
* Get conversation metadata (variables, state)
* @returns {Object} Metadata about the conversation
*/
getMetadata() {
if (!this.storyLoaded) {
return {
loaded: false,
ended: false,
variables: {}
};
}
// Try to get common variables
const commonVars = ['trust_level', 'conversation_count', 'npc_name'];
const variables = {};
commonVars.forEach(varName => {
try {
const value = this.getVariable(varName);
if (value !== null && value !== undefined) {
variables[varName] = value;
}
} catch (error) {
// Variable doesn't exist, skip
}
});
return {
loaded: this.storyLoaded,
ended: this.storyEnded,
variables,
tags: this.getCurrentTags()
};
}
}