mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
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.
This commit is contained in:
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -2,6 +2,10 @@
|
||||
"cursor.general.disableHttp2": true,
|
||||
"chat.agent.maxRequests": 100,
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"bin/inklecate": true
|
||||
"bin/inklecate": true,
|
||||
"/^ruby scripts/validate_scenario\\.rb scenarios/m01_first_contact/scenario\\.json\\.erb 2>&1 \\| grep -A 100 \"Found\\.\\*issue\"$/": {
|
||||
"approve": true,
|
||||
"matchCommandLine": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,10 @@ export function processGameActionTags(tags, ui) {
|
||||
if (!trimmedTag) return;
|
||||
|
||||
// Parse action and parameter (format: "action:param" or "action")
|
||||
const [action, param] = trimmedTag.split(':').map(s => s.trim());
|
||||
// Split only on FIRST colon to preserve colons in parameters (e.g., set_global:var:value)
|
||||
const colonIndex = trimmedTag.indexOf(':');
|
||||
const action = colonIndex === -1 ? trimmedTag : trimmedTag.substring(0, colonIndex).trim();
|
||||
const param = colonIndex === -1 ? '' : trimmedTag.substring(colonIndex + 1).trim();
|
||||
|
||||
let result = { action, param, success: false, message: '' };
|
||||
|
||||
@@ -404,6 +407,56 @@ export function processGameActionTags(tags, ui) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'set_global':
|
||||
if (param) {
|
||||
// Format: set_global:variableName:value
|
||||
const parts = param.split(':');
|
||||
const varName = parts[0]?.trim();
|
||||
const varValue = parts[1]?.trim();
|
||||
|
||||
if (!varName) {
|
||||
result.message = '⚠️ set_global tag missing variable name';
|
||||
console.warn(result.message);
|
||||
break;
|
||||
}
|
||||
|
||||
// Parse value (support booleans, numbers, strings)
|
||||
let parsedValue = varValue;
|
||||
if (varValue === 'true') parsedValue = true;
|
||||
else if (varValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(varValue)) parsedValue = Number(varValue);
|
||||
|
||||
// Set the global variable
|
||||
if (!window.gameState) {
|
||||
window.gameState = {};
|
||||
}
|
||||
if (!window.gameState.globalVariables) {
|
||||
window.gameState.globalVariables = {};
|
||||
}
|
||||
|
||||
const oldValue = window.gameState.globalVariables[varName];
|
||||
window.gameState.globalVariables[varName] = parsedValue;
|
||||
|
||||
console.log(`🌐 Set global variable: ${varName} = ${parsedValue} (was: ${oldValue})`);
|
||||
|
||||
// Emit event for any listeners (including NPCManager event mappings)
|
||||
if (window.eventDispatcher) {
|
||||
window.eventDispatcher.emit(`global_variable_changed:${varName}`, {
|
||||
name: varName,
|
||||
value: parsedValue,
|
||||
oldValue: oldValue
|
||||
});
|
||||
console.log(`📡 Emitted event: global_variable_changed:${varName}`);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = `🌐 Global variable set: ${varName} = ${parsedValue}`;
|
||||
} else {
|
||||
result.message = '⚠️ set_global tag missing parameters';
|
||||
console.warn(result.message);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown tag, log but don't fail
|
||||
console.log(`ℹ️ Unknown game action tag: ${action}`);
|
||||
|
||||
@@ -274,26 +274,29 @@ export default class PhoneChatConversation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Ink tags for game actions
|
||||
* 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 => {
|
||||
console.log(`🏷️ Processing tag: ${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:
|
||||
// Unknown tags are okay - they might be processed by the UI layer
|
||||
console.log(`ℹ️ Unhandled tag: ${action}`);
|
||||
// Other tags are game action tags - will be processed by minigame layer
|
||||
// Don't log them here to avoid confusion
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -705,24 +705,8 @@ export class PhoneChatMinigame extends MinigameScene {
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the story output contains the exit_conversation tag
|
||||
const shouldExit = accumulatedTags.some(tag => tag.includes('exit_conversation'));
|
||||
|
||||
// If this was an exit choice, close the minigame
|
||||
if (shouldExit) {
|
||||
console.log('🚪 Exit conversation tag detected - closing minigame');
|
||||
|
||||
// Save state before closing
|
||||
this.saveStoryState();
|
||||
|
||||
// Close minigame after brief delay to show final message
|
||||
setTimeout(() => {
|
||||
this.complete(true);
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process all accumulated game action tags
|
||||
// Process all accumulated game action tags FIRST (before exit check)
|
||||
// This ensures tags like #set_global are processed before conversation closes
|
||||
console.log('🔍 Checking for tags after choice...', {
|
||||
hasTags: accumulatedTags.length > 0,
|
||||
tagsLength: accumulatedTags.length,
|
||||
@@ -736,6 +720,22 @@ export class PhoneChatMinigame extends MinigameScene {
|
||||
console.log('⚠️ No tags to process after choice');
|
||||
}
|
||||
|
||||
// Check if the story output contains the exit_conversation tag
|
||||
const shouldExit = accumulatedTags.some(tag => tag.includes('exit_conversation'));
|
||||
|
||||
// If this was an exit choice, close the minigame
|
||||
if (shouldExit) {
|
||||
console.log('🚪 Exit conversation tag detected - closing minigame');
|
||||
|
||||
// Save state before closing
|
||||
this.saveStoryState();
|
||||
|
||||
// Complete immediately - don't delay, as this might trigger an event-driven cutscene
|
||||
// that needs to start right after this minigame closes
|
||||
this.complete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if conversation ended AFTER displaying the final text
|
||||
if (lastResult.hasEnded) {
|
||||
console.log('🏁 Conversation ended');
|
||||
|
||||
@@ -80,6 +80,13 @@ export default class NPCManager {
|
||||
entry.phoneId = 'player_phone';
|
||||
}
|
||||
|
||||
// Normalize eventMapping (singular) to eventMappings (plural) for backward compatibility
|
||||
if (entry.eventMapping && !entry.eventMappings) {
|
||||
console.log(`🔧 Normalizing eventMapping → eventMappings for ${realId}`);
|
||||
entry.eventMappings = entry.eventMapping;
|
||||
delete entry.eventMapping; // Remove the incorrect property
|
||||
}
|
||||
|
||||
this.npcs.set(realId, entry);
|
||||
|
||||
// Register in global character registry for speaker resolution
|
||||
@@ -95,6 +102,8 @@ export default class NPCManager {
|
||||
// Set up event listeners for auto-mapping
|
||||
if (entry.eventMappings && this.eventDispatcher) {
|
||||
this._setupEventMappings(realId, entry.eventMappings);
|
||||
} else if (entry.eventMappings && !this.eventDispatcher) {
|
||||
console.error(`❌ ${realId} has eventMappings but eventDispatcher is not available!`);
|
||||
}
|
||||
|
||||
// Schedule timed messages if any are defined
|
||||
@@ -379,13 +388,19 @@ export default class NPCManager {
|
||||
if (config.condition) {
|
||||
let conditionMet = false;
|
||||
|
||||
console.log(`🔍 Evaluating condition for ${eventPattern}:`, config.condition);
|
||||
console.log(` Event data:`, eventData);
|
||||
|
||||
if (typeof config.condition === 'function') {
|
||||
conditionMet = config.condition(eventData, npc);
|
||||
} else if (typeof config.condition === 'string') {
|
||||
// Evaluate condition string as JavaScript
|
||||
try {
|
||||
const data = eventData; // Make 'data' available in eval scope
|
||||
const value = eventData?.value; // Extract value for common pattern
|
||||
const name = eventData?.name; // Extract name for common pattern
|
||||
conditionMet = eval(config.condition);
|
||||
console.log(` Condition result: ${conditionMet}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error evaluating condition: ${config.condition}`, error);
|
||||
return;
|
||||
@@ -394,6 +409,7 @@ export default class NPCManager {
|
||||
|
||||
if (!conditionMet) {
|
||||
console.log(`🚫 Event ${eventPattern} condition not met:`, config.condition);
|
||||
console.log(` Expected: value === true, Got: value =`, eventData?.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -503,18 +519,21 @@ export default class NPCManager {
|
||||
console.log(`✅ Closed current minigame`);
|
||||
}
|
||||
|
||||
// Start the person-chat minigame
|
||||
// Start the person-chat minigame after a brief delay to allow previous minigame to fully clean up
|
||||
if (window.MinigameFramework) {
|
||||
console.log(`✅ Starting person-chat minigame for ${npcId}`);
|
||||
const knotToUse = config.targetKnot || config.knot || npc.currentKnot;
|
||||
window.MinigameFramework.startMinigame('person-chat', null, {
|
||||
npcId: npc.id,
|
||||
startKnot: knotToUse,
|
||||
background: config.background || null,
|
||||
scenario: window.gameScenario
|
||||
});
|
||||
console.log(`[NPCManager] Event '${eventPattern}' triggered for NPC '${npcId}' → person-chat conversation`);
|
||||
return; // Exit early - person-chat is handling it
|
||||
console.log(`⏳ Waiting 500ms before starting person-chat cutscene for ${npcId}`);
|
||||
setTimeout(() => {
|
||||
console.log(`✅ Starting person-chat minigame for ${npcId}`);
|
||||
const knotToUse = config.targetKnot || config.knot || npc.currentKnot;
|
||||
window.MinigameFramework.startMinigame('person-chat', null, {
|
||||
npcId: npc.id,
|
||||
startKnot: knotToUse,
|
||||
background: config.background || null,
|
||||
scenario: window.gameScenario
|
||||
});
|
||||
console.log(`[NPCManager] Event '${eventPattern}' triggered for NPC '${npcId}' → person-chat conversation`);
|
||||
}, 500); // 500ms delay for cleanup
|
||||
return; // Exit early - person-chat will start after delay
|
||||
} else {
|
||||
console.warn(`⚠️ MinigameFramework not available for person-chat`);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -380,12 +380,12 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A
|
||||
"id": "closing_debrief_person",
|
||||
"displayName": "Agent 0x99",
|
||||
"npcType": "person",
|
||||
"eventMapping": [
|
||||
"eventMappings": [
|
||||
{
|
||||
"eventPattern": "global_variable_changed:start_debrief_cutscene",
|
||||
"condition": "value === true",
|
||||
"conversationMode": "person-chat",
|
||||
"targetKnot": "debrief_location",
|
||||
"targetKnot": "start",
|
||||
"background": "assets/backgrounds/hq1.png",
|
||||
"onceOnly": true
|
||||
}
|
||||
@@ -393,12 +393,13 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A
|
||||
"position": { "x": 500, "y": 500 },
|
||||
"spriteSheet": "female_spy",
|
||||
"avatar": "assets/characters/female_spy_headshot.png",
|
||||
"spriteTalk": "assets/characters/female_spy_headshot.png",
|
||||
"spriteConfig": {
|
||||
"idleFrameRate": 6,
|
||||
"walkFrameRate": 10
|
||||
},
|
||||
"storyPath": "scenarios/m01_first_contact/ink/m01_phone_agent0x99.json",
|
||||
"currentKnot": "debrief_location",
|
||||
"storyPath": "scenarios/m01_first_contact/ink/m01_closing_debrief.json",
|
||||
"currentKnot": "start",
|
||||
"behavior": {
|
||||
"initiallyHidden": true
|
||||
}
|
||||
@@ -1023,6 +1024,8 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A
|
||||
"talked_to_kevin": false,
|
||||
"maya_identity_protected": true,
|
||||
"discussed_operation": false,
|
||||
|
||||
"start_debrief_cutscene": false,
|
||||
"operation_shatter_reported": false,
|
||||
|
||||
"objectives_completed": 0,
|
||||
|
||||
@@ -331,6 +331,43 @@ def check_common_issues(json_data)
|
||||
end
|
||||
end
|
||||
|
||||
# Validate eventMapping vs eventMappings (parameter name mismatch)
|
||||
if npc['eventMapping'] && !npc['eventMappings']
|
||||
issues << "❌ INVALID: '#{path}' uses 'eventMapping' (singular) - should use 'eventMappings' (plural). The NPCManager expects 'eventMappings' and won't register event listeners with 'eventMapping'"
|
||||
end
|
||||
|
||||
# Validate eventMappings structure
|
||||
if npc['eventMappings']
|
||||
# Check if it's an array
|
||||
unless npc['eventMappings'].is_a?(Array)
|
||||
issues << "❌ INVALID: '#{path}' eventMappings is not an array - must be an array of event mapping objects"
|
||||
else
|
||||
npc['eventMappings'].each_with_index do |mapping, idx|
|
||||
mapping_path = "#{path}/eventMappings[#{idx}]"
|
||||
|
||||
# Check for incorrect property name (knot vs targetKnot)
|
||||
if mapping['knot'] && !mapping['targetKnot']
|
||||
issues << "❌ INVALID: '#{mapping_path}' uses 'knot' property - should use 'targetKnot' instead. Change 'knot' to 'targetKnot'"
|
||||
end
|
||||
|
||||
# Check for missing eventPattern
|
||||
unless mapping['eventPattern']
|
||||
issues << "❌ INVALID: '#{mapping_path}' missing required 'eventPattern' property - must specify the event pattern to listen for (e.g., 'global_variable_changed:varName')"
|
||||
end
|
||||
|
||||
# Check for missing conversationMode when targetKnot is present
|
||||
if mapping['targetKnot'] && !mapping['conversationMode']
|
||||
issues << "⚠ WARNING: '#{mapping_path}' has targetKnot but no conversationMode - should specify 'phone-chat' or 'person-chat' to indicate which UI to use"
|
||||
end
|
||||
|
||||
# Check for missing background when conversationMode is person-chat
|
||||
if mapping['conversationMode'] == 'person-chat' && !mapping['background']
|
||||
issues << "⚠ WARNING: '#{mapping_path}' has conversationMode: 'person-chat' but no background - person-chat cutscenes typically need a background image (e.g., 'assets/backgrounds/hq1.png')"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Track phone NPCs (phone contacts)
|
||||
if npc['npcType'] == 'phone'
|
||||
has_phone_contacts = true
|
||||
@@ -355,6 +392,37 @@ def check_common_issues(json_data)
|
||||
issues << "⚠ WARNING: '#{path}' (phone NPC) has 'spriteSheet' field - phone NPCs should NOT have spriteSheet (they're not in-world sprites). Remove the spriteSheet field."
|
||||
end
|
||||
|
||||
# Validate timedMessages structure for phone NPCs
|
||||
if npc['timedMessages']
|
||||
unless npc['timedMessages'].is_a?(Array)
|
||||
issues << "❌ INVALID: '#{path}' timedMessages is not an array - must be an array of timed message objects"
|
||||
else
|
||||
npc['timedMessages'].each_with_index do |msg, idx|
|
||||
msg_path = "#{path}/timedMessages[#{idx}]"
|
||||
|
||||
# Check for missing message field
|
||||
unless msg['message']
|
||||
issues << "❌ INVALID: '#{msg_path}' missing required 'message' field - must specify the text content of the message"
|
||||
end
|
||||
|
||||
# Check for incorrect property name (text vs message)
|
||||
if msg['text'] && !msg['message']
|
||||
issues << "❌ INVALID: '#{msg_path}' uses 'text' property - should use 'message' instead. The NPCManager reads msg.message, not msg.text"
|
||||
end
|
||||
|
||||
# Check for missing delay field
|
||||
unless msg.key?('delay')
|
||||
issues << "⚠ WARNING: '#{msg_path}' missing 'delay' property - should specify delay in milliseconds (0 for immediate)"
|
||||
end
|
||||
|
||||
# Check for incorrect property name (knot vs targetKnot) in timed messages
|
||||
if msg['knot'] && !msg['targetKnot']
|
||||
issues << "❌ INVALID: '#{msg_path}' uses 'knot' property - should use 'targetKnot' instead. Change 'knot' to 'targetKnot'"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Track phone NPCs with messages in rooms
|
||||
if npc['timedMessages'] && !npc['timedMessages'].empty?
|
||||
has_phone_npc_with_messages = true
|
||||
@@ -437,6 +505,93 @@ def check_common_issues(json_data)
|
||||
end
|
||||
end
|
||||
|
||||
# Check for event-driven cutscene architecture patterns
|
||||
person_npcs_with_event_cutscenes = []
|
||||
global_variables_referenced = Set.new
|
||||
global_variables_defined = Set.new
|
||||
|
||||
# Collect global variables defined in scenario
|
||||
if json_data['globalVariables']
|
||||
global_variables_defined.merge(json_data['globalVariables'].keys)
|
||||
end
|
||||
|
||||
# Check all NPCs for event-driven cutscene patterns
|
||||
json_data['rooms']&.each do |room_id, room|
|
||||
room['npcs']&.each_with_index do |npc, idx|
|
||||
path = "rooms/#{room_id}/npcs[#{idx}]"
|
||||
|
||||
# Check for person NPCs with eventMappings (cutscene NPCs)
|
||||
if npc['npcType'] == 'person' && npc['eventMappings']
|
||||
npc['eventMappings'].each_with_index do |mapping, mapping_idx|
|
||||
mapping_path = "#{path}/eventMappings[#{mapping_idx}]"
|
||||
|
||||
# Check if this is a cutscene trigger (has conversationMode)
|
||||
if mapping['conversationMode'] == 'person-chat'
|
||||
person_npcs_with_event_cutscenes << {
|
||||
npc_id: npc['id'],
|
||||
path: path,
|
||||
mapping: mapping
|
||||
}
|
||||
|
||||
# Extract global variable name from event pattern
|
||||
if mapping['eventPattern']&.match(/global_variable_changed:(\w+)/)
|
||||
var_name = $1
|
||||
global_variables_referenced << var_name
|
||||
|
||||
# Check if the global variable is defined
|
||||
unless global_variables_defined.include?(var_name)
|
||||
issues << "❌ INVALID: '#{mapping_path}' references global variable '#{var_name}' in eventPattern, but it's not defined in scenario.globalVariables. Add '#{var_name}' with an initial value (typically false) to globalVariables"
|
||||
end
|
||||
end
|
||||
|
||||
# Check for missing spriteTalk when using non-numeric frame sprites
|
||||
if !npc['spriteTalk'] && npc['spriteSheet']
|
||||
# Sprites with named frames (not numeric indices) need spriteTalk
|
||||
named_frame_sprites = ['female_spy', 'male_spy', 'female_hacker_hood', 'male_doctor']
|
||||
if named_frame_sprites.include?(npc['spriteSheet'])
|
||||
issues << "⚠ WARNING: '#{path}' uses spriteSheet '#{npc['spriteSheet']}' which has named frames, but no 'spriteTalk' property. Person-chat cutscenes will show frame errors. Add 'spriteTalk' property pointing to a headshot image (e.g., 'assets/characters/#{npc['spriteSheet']}_headshot.png')"
|
||||
end
|
||||
end
|
||||
|
||||
# Validate background for person-chat cutscenes
|
||||
unless mapping['background']
|
||||
issues << "⚠ WARNING: '#{mapping_path}' is a person-chat cutscene but has no 'background' property. Person-chat cutscenes should have a background image for better visual presentation (e.g., 'assets/backgrounds/hq1.png')"
|
||||
end
|
||||
|
||||
# Check for onceOnly to prevent repeated cutscenes
|
||||
unless mapping['onceOnly']
|
||||
issues << "⚠ WARNING: '#{mapping_path}' is a person-chat cutscene without 'onceOnly: true'. Cutscenes typically should only trigger once. Add 'onceOnly: true' unless you want the cutscene to repeat"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check for phone NPCs setting global variables in their stories
|
||||
if npc['npcType'] == 'phone' && npc['storyPath']
|
||||
# Note: We can't easily check the Ink story content from Ruby, but we can suggest best practices
|
||||
if npc['eventMappings']
|
||||
# This phone NPC has both a story and event mappings, which suggests it might be setting up a cutscene
|
||||
cutscene_event_mappings = npc['eventMappings'].select { |m| m['sendTimedMessage'] }
|
||||
if cutscene_event_mappings.any?
|
||||
# This looks like a mission-ending phone NPC
|
||||
issues << "💡 BEST PRACTICE: '#{path}' appears to be a mission-ending phone NPC with sendTimedMessage. Consider using event-driven cutscene architecture instead: 1) Add #set_global:variable_name:true tag in Ink story, 2) Add #exit_conversation tag to close phone, 3) Create separate person NPC with eventMapping listening for global_variable_changed:variable_name. See scenarios/m01_first_contact/scenario.json.erb for reference implementation"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check for orphaned global variable references
|
||||
orphaned_vars = global_variables_referenced - global_variables_defined
|
||||
orphaned_vars.each do |var_name|
|
||||
issues << "❌ INVALID: Global variable '#{var_name}' is referenced in eventPatterns but not defined in scenario.globalVariables. Add '#{var_name}' to globalVariables with an initial value (typically false for cutscene triggers)"
|
||||
end
|
||||
|
||||
# Provide best practice guidance for event-driven cutscenes
|
||||
if person_npcs_with_event_cutscenes.any?
|
||||
issues << "✅ GOOD PRACTICE: Scenario uses event-driven cutscene architecture with #{person_npcs_with_event_cutscenes.size} person-chat cutscene(s). Ensure corresponding phone NPCs use #set_global tags to trigger these cutscenes"
|
||||
end
|
||||
|
||||
# Feature suggestions
|
||||
unless has_vm_launcher
|
||||
issues << "💡 SUGGESTION: Consider adding VM launcher terminals (type: 'vm-launcher') - see scenarios/secgen_vm_lab/scenario.json.erb for example"
|
||||
@@ -459,7 +614,12 @@ def check_common_issues(json_data)
|
||||
end
|
||||
|
||||
unless has_closing_debrief
|
||||
issues << "💡 SUGGESTION: Consider adding closing debrief trigger - phone NPC with eventMapping for global_variable_changed - see scenarios/m01_first_contact/scenario.json.erb for example"
|
||||
issues << "💡 SUGGESTION: Consider adding event-driven closing debrief cutscene using this architecture:"
|
||||
issues << " 1. Add global variable to scenario.globalVariables (e.g., 'start_debrief_cutscene': false)"
|
||||
issues << " 2. In phone NPC's Ink story, add tags: #set_global:start_debrief_cutscene:true and #exit_conversation"
|
||||
issues << " 3. Create person NPC with eventMappings: [{eventPattern: 'global_variable_changed:start_debrief_cutscene', condition: 'value === true', conversationMode: 'person-chat', targetKnot: 'start', background: 'assets/backgrounds/hq1.png', onceOnly: true}]"
|
||||
issues << " 4. Add behavior: {initiallyHidden: true} to person NPC so it doesn't appear in-world"
|
||||
issues << " See scenarios/m01_first_contact/scenario.json.erb for complete reference implementation"
|
||||
end
|
||||
|
||||
# Check for NPCs without waypoints
|
||||
|
||||
Reference in New Issue
Block a user