Add character registry and NPC management features

- Implemented a global character registry to manage player and NPC data.
- Added methods for setting the player, registering NPCs, and retrieving character information.
- Integrated character registry updates into the NPC manager for seamless NPC registration.
- Created test scenarios for line prefix speaker format, including narrator modes and background changes.
- Developed comprehensive NPC sprite test scenario with various NPC interactions and items.
This commit is contained in:
Z. Cliffe Schreuders
2025-11-24 02:21:31 +00:00
parent 48e7860e0f
commit 2c8757de3e
29 changed files with 1415 additions and 119 deletions

View File

@@ -0,0 +1,138 @@
# Global Character Registry Implementation
## Overview
Implemented a global character registry system that maintains all available characters (player + NPCs) for the game, enabling reliable speaker resolution in multi-character conversations.
## Architecture
### Character Registry System (`js/systems/character-registry.js`)
Global object `window.characterRegistry` with methods:
- **setPlayer(playerData)** - Register player at game initialization
- **registerNPC(npcId, npcData)** - Register NPC when npcManager registers it
- **getCharacter(characterId)** - Get specific character by ID
- **getAllCharacters()** - Get complete dictionary of all characters
- **hasCharacter(characterId)** - Check if character exists
- **clearNPCs()** - Clear all NPCs (for scenario transitions)
- **debug()** - Log registry state
### Data Flow
1. **Initialization Phase** (game.js):
```
createPlayer()
→ window.player = player
→ characterRegistry.setPlayer(playerData)
```
2. **NPC Registration Phase** (npc-manager.js):
```
npcManager.registerNPC(id, opts)
→ this.npcs.set(id, entry)
→ characterRegistry.registerNPC(id, entry) ← NEW
```
3. **Person-Chat Minigame Phase** (person-chat-minigame.js):
```
buildCharacterIndex()
→ if window.characterRegistry exists
→ return characterRegistry.getAllCharacters()
→ Otherwise fallback to legacy local building
```
## Key Features
✅ **Automatic Population**: NPCs automatically added when registered via npcManager
✅ **Global Access**: Available to any minigame or system that needs speaker resolution
✅ **Early Availability**: Player and room NPCs available before person-chat minigame starts
✅ **Backward Compatible**: Falls back to legacy local character index building if registry unavailable
✅ **Clean Separation**: Registry only knows about character data, not minigame logic
## Files Modified
1. **NEW: `js/systems/character-registry.js`**
- 100+ lines of character registry implementation
- Self-documenting with comprehensive JSDoc comments
2. **`js/main.js`**
- Added import: `import './systems/character-registry.js';`
- Ensures registry available before any scripts that need it
3. **`js/core/game.js`**
- After `window.player = player`, added:
```javascript
if (window.characterRegistry && window.player) {
const playerData = { id, displayName, spriteSheet, ... };
window.characterRegistry.setPlayer(playerData);
}
```
4. **`js/systems/npc-manager.js`**
- In `registerNPC()` method, after `this.npcs.set(realId, entry)`:
```javascript
if (window.characterRegistry) {
window.characterRegistry.registerNPC(realId, entry);
}
```
5. **`js/minigames/person-chat/person-chat-minigame.js`**
- Simplified `buildCharacterIndex()` to:
```javascript
if (window.characterRegistry) {
return characterRegistry.getAllCharacters();
}
// Fallback to legacy local building...
```
## Test Verification
To verify the system is working:
1. **Check Console Logs**:
```
✅ Character Registry system initialized
✅ Character Registry: Added player (Agent 0x00)
✅ Character Registry: Added NPC test_npc_front (displayName: Helper NPC)
✅ Character Registry: Added NPC test_npc_back (displayName: Back NPC)
```
2. **Check Character Resolution**:
```
👥 Using global character registry with 3 characters: ['player', 'test_npc_front', 'test_npc_back']
```
3. **Check Speaker Display**:
- When `test_npc_back: "Welcome..."` appears in dialogue
- Should resolve to "Back NPC" (from registry displayName)
- Not show raw ID "test_npc_back"
## Browser Console Debug
Available in browser console:
```javascript
window.characterRegistry.debug()
// Output:
// 📋 Character Registry: {
// playerCount: 1,
// npcCount: 5,
// totalCharacters: 6,
// characters: ['player', 'test_npc_front', 'test_npc_back', ...]
// }
window.characterRegistry.getCharacter('test_npc_back')
// Returns: { id, displayName: 'Back NPC', spriteSheet, ... }
```
## Benefits
1. **No More Missing Secondary NPCs**: All room NPCs automatically available before minigame starts
2. **Consistent Speaker Resolution**: Single source of truth for all characters
3. **Extensible**: Easy to add more features (character filtering, abilities, etc.)
4. **Debuggable**: Clear logging and debug methods for troubleshooting
5. **Maintainable**: Centralized character management separate from minigame logic
## Next Steps
1. Reload game with hard refresh to clear cache
2. Test line-prefix speaker format with secondary NPCs
3. Verify both `test_npc_front` and `test_npc_back` display correct names
4. Check console for character registry logs

View File

@@ -0,0 +1,126 @@
# Global Character Registry - Quick Reference
## What It Does
Maintains a single, global registry of all characters (player + NPCs) that's populated automatically as the game loads. Used by person-chat minigame for reliable speaker resolution in conversations.
## How to Use (Developer)
### Access the Registry
```javascript
// Get all characters
window.characterRegistry.getAllCharacters()
// Returns: { player: {...}, npc_id_1: {...}, npc_id_2: {...} }
// Get specific character
window.characterRegistry.getCharacter('test_npc_back')
// Returns: { id, displayName, spriteSheet, ... }
// Check if character exists
window.characterRegistry.hasCharacter('some_npc')
// Returns: true or false
// Debug current state
window.characterRegistry.debug()
```
### NPCs Added Automatically When:
1. **Game initializes**: Player registered via `game.js`
2. **NPC is registered**: Via `npcManager.registerNPC(id, opts)` in npc-lazy-loader.js
3. **Room loads**: NPCs from scenario json automatically registered by npc-lazy-loader
### How Person-Chat Uses It
```javascript
// In person-chat-minigame.js buildCharacterIndex():
buildCharacterIndex() {
if (window.characterRegistry) {
// Use global registry (has all characters already)
return characterRegistry.getAllCharacters();
}
// Fallback to legacy local building if needed
}
```
## Console Debug Commands
```javascript
// See all registered characters
window.characterRegistry.debug()
// Check if specific NPC is registered
window.characterRegistry.hasCharacter('test_npc_back')
// Get NPC display name
window.characterRegistry.getCharacter('test_npc_back').displayName
// Should output: "Back NPC"
// Get all character IDs
Object.keys(window.characterRegistry.getAllCharacters())
// Should output: ['player', 'test_npc_front', 'test_npc_back', ...]
// Clear all NPCs (for scenario transitions)
window.characterRegistry.clearNPCs()
```
## Expected Console Output
When game loads:
```
✅ Character Registry system initialized
✅ Character Registry: Added player (Agent 0x00)
✅ Character Registry: Added NPC test_npc_front (displayName: Helper NPC)
✅ Character Registry: Added NPC test_npc_back (displayName: Back NPC)
```
When person-chat starts:
```
👥 Using global character registry with 3 characters: ['player', 'test_npc_front', 'test_npc_back']
```
## Files Changed
| File | Change |
|------|--------|
| `js/systems/character-registry.js` | NEW: 100+ lines |
| `js/main.js` | Added import of character-registry.js |
| `js/core/game.js` | Register player in registry after initialization |
| `js/systems/npc-manager.js` | Register NPCs in registry when they're registered |
| `js/minigames/person-chat/person-chat-minigame.js` | Use global registry instead of building local index |
## How It Fixes the Bug
**Before**: Secondary NPCs like `test_npc_back` weren't in the character index when person-chat minigame started, so speaker resolution failed.
**After**:
1. `test_npc_back` registered in npcManager → immediately added to characterRegistry
2. When person-chat minigame starts, it uses characterRegistry
3. `test_npc_back` is already there with displayName "Back NPC"
4. Line-prefix parsing works: `test_npc_back:` → resolves to "Back NPC" ✅
## Scenario Example
Room with 3 NPCs in `scenario.json`:
```json
"test_room": {
"npcs": [
{ "id": "test_npc_front", "displayName": "Helper NPC", ... },
{ "id": "test_npc_back", "displayName": "Back NPC", ... },
{ "id": "test_npc_influence", "displayName": "Expert", ... }
]
}
```
Character Registry after room loads:
```javascript
{
player: { id: 'player', displayName: 'Agent 0x00', ... },
test_npc_front: { id: 'test_npc_front', displayName: 'Helper NPC', ... },
test_npc_back: { id: 'test_npc_back', displayName: 'Back NPC', ... },
test_npc_influence: { id: 'test_npc_influence', displayName: 'Expert', ... }
}
```
When dialogue line is `test_npc_back: "Welcome..."`:
1. Parser extracts speaker: `test_npc_back`
2. Looks up in characterRegistry: ✅ found
3. Gets displayName: "Back NPC"
4. Shows: "Back NPC: Welcome..."

View File

@@ -149,6 +149,22 @@
color: #ff9a4a;
}
/* PHASE 4: Narrator mode styling */
.person-chat-portrait-section.narrator-mode {
background-color: #000;
}
.person-chat-speaker-name.narrator-speaker {
display: none; /* Hide speaker name in narrator mode */
}
/* Dialogue text styling for narrator */
.person-chat-portrait-section.narrator-mode + .person-chat-caption-area .person-chat-dialogue-text {
text-align: center;
font-style: italic;
color: #999;
}
/* Dialogue text box */
.person-chat-dialogue-box {
background-color: transparent;

View File

@@ -532,6 +532,18 @@ export async function create() {
// Store player globally for access from other modules
window.player = player;
// Register player in global character registry for speaker resolution
if (window.characterRegistry && window.player) {
const playerData = {
id: 'player',
displayName: window.gameState?.playerName || 'Agent 0x00',
spriteSheet: 'hacker',
spriteTalk: 'assets/characters/hacker-talk.png',
metadata: {}
};
window.characterRegistry.setPlayer(playerData);
}
// Create door opening animation (for N/S doors)
this.anims.create({
key: 'door_open',

View File

@@ -8,6 +8,9 @@ import { initializeDebugSystem } from './systems/debug.js?v=7';
import { initializeUI } from './ui/panels.js?v=9';
import { initializeModals } from './ui/modals.js?v=7';
// Import character registry system
import './systems/character-registry.js';
// Import minigame framework
import './minigames/index.js';

View File

@@ -80,15 +80,25 @@ export class PersonChatMinigame extends MinigameScene {
this.lastResult = null; // Store last continue() result for choice handling
this.isClickThroughMode = false; // If true, player must click to advance between dialogue lines (starts in AUTO mode)
this.pendingContinueCallback = null; // Callback waiting for player click in click-through mode
this.isProcessingDialogue = false; // PHASE 0: State locking to prevent race conditions during dialogue advancement
console.log(`🎭 PersonChatMinigame created for NPC: ${this.npcId}`);
}
/**
* Build index of all available characters (player + NPCs)
* Uses global character registry populated as NPCs are registered
* @returns {Object} Map of character ID to character data
*/
buildCharacterIndex() {
// Use global character registry if available
if (window.characterRegistry) {
const allCharacters = window.characterRegistry.getAllCharacters();
console.log(`👥 Using global character registry with ${Object.keys(allCharacters).length} characters:`, Object.keys(allCharacters));
return allCharacters;
}
// Fallback to legacy local building if registry not available
const characters = {};
// Add player
@@ -98,12 +108,13 @@ export class PersonChatMinigame extends MinigameScene {
characters[this.npc.id] = this.npc;
// Add other NPCs from current room (NPCs are now per-room, not at scenario root)
const currentRoom = window.currentRoom || this.npc.roomId;
// Use npc.roomId first, fallback to window.currentRoom
const currentRoom = this.npc.roomId || window.currentRoom;
if (currentRoom && this.scenario.rooms && this.scenario.rooms[currentRoom]) {
const roomNPCs = this.scenario.rooms[currentRoom].npcs || [];
roomNPCs.forEach(npc => {
if (npc.id !== this.npc.id) {
// Look up NPC data from npcManager for complete displayName
// Look up NPC data from npcManager for complete displayName and other properties
const npcData = window.npcManager?.getNPC(npc.id) || npc;
characters[npc.id] = npcData;
}
@@ -111,7 +122,7 @@ export class PersonChatMinigame extends MinigameScene {
}
// Fallback to legacy root-level NPCs for backward compatibility
if (!Object.keys(characters).length > 2 && this.scenario.npcs && Array.isArray(this.scenario.npcs)) {
if (Object.keys(characters).length <= 2 && this.scenario.npcs && Array.isArray(this.scenario.npcs)) {
this.scenario.npcs.forEach(npc => {
if (npc.id !== this.npc.id && !characters[npc.id]) {
characters[npc.id] = npc;
@@ -544,6 +555,182 @@ export class PersonChatMinigame extends MinigameScene {
return this.npc.id;
}
/**
* PHASE 1: Parse a dialogue line for speaker prefix format
*
* Validates that dialogue text is not empty (ignores "Speaker: " lines)
* Case-insensitive speaker IDs ("Player:", "player:", "PLAYER:" all work)
* First colon is delimiter ("Speaker: Text: with: colons" → speaker="Speaker", text="Text: with: colons")
* Rejects prefixes where speaker ID doesn't exist in character index
* Handles Narrator[character_id]: syntax for narrator with character portrait
*
* @param {string} line - Dialogue line to parse
* @returns {Object|null} Object with {speaker, text, isNarrator, narratorCharacter} or null if no prefix
*/
parseDialogueLine(line) {
if (!line || typeof line !== 'string') {
return null;
}
line = line.trim();
if (!line) {
return null;
}
// Check for Narrator[character]: pattern first (highest priority)
const narratorMatch = line.match(/^Narrator\s*\[\s*([^\]]*)\s*\]\s*:\s*(.+)$/i);
if (narratorMatch) {
const characterId = narratorMatch[1].trim();
const text = narratorMatch[2].trim();
// Must have non-empty text
if (!text) {
return null;
}
// If character ID is provided, validate it exists
if (characterId && !this.characters[characterId]) {
console.warn(`⚠️ parseDialogueLine: Unknown character in Narrator[${characterId}], treating as unprefixed`);
return null;
}
return {
speaker: 'narrator',
text: text,
isNarrator: true,
narratorCharacter: characterId || null
};
}
// Check for basic Narrator: pattern
const basicNarratorMatch = line.match(/^Narrator\s*:\s*(.+)$/i);
if (basicNarratorMatch) {
const text = basicNarratorMatch[1].trim();
// Must have non-empty text
if (!text) {
return null;
}
return {
speaker: 'narrator',
text: text,
isNarrator: true,
narratorCharacter: null
};
}
// Check for regular Speaker: pattern
const colonIndex = line.indexOf(':');
if (colonIndex === -1) {
// No colon - not a prefixed line
return null;
}
const speakerId = line.substring(0, colonIndex).trim();
const text = line.substring(colonIndex + 1).trim();
// Speaker ID must not be empty
if (!speakerId) {
return null;
}
// Text must not be empty (reject lines like "Speaker: ")
if (!text) {
return null;
}
// Validate speaker exists in characters
const normalizedSpeaker = this.normalizeSpeakerId(speakerId);
if (!normalizedSpeaker) {
// Speaker not found - treat as unprefixed line
return null;
}
return {
speaker: normalizedSpeaker,
text: text,
isNarrator: false,
narratorCharacter: null
};
}
/**
* PHASE 1: Normalize speaker ID for consistent lookup
*
* Converts raw speaker ID to canonical form and validates existence
* Returns null if speaker ID doesn't exist in character index
*
* @param {string} speakerId - Raw speaker ID from dialogue line
* @returns {string|null} Normalized speaker ID or null if invalid
*/
normalizeSpeakerId(speakerId) {
if (!speakerId || typeof speakerId !== 'string') {
return null;
}
const normalized = speakerId.toLowerCase().trim();
if (!normalized) {
return null;
}
// Handle special cases
if (normalized === 'player') {
return this.characters['player'] ? 'player' : null;
}
if (normalized === 'npc') {
return this.npc.id;
}
// Look up by ID (case-sensitive after normalization to lowercase)
for (const [id, character] of Object.entries(this.characters)) {
if (id.toLowerCase() === normalized) {
return id; // Return original casing
}
}
// Not found
return null;
}
/**
* PHASE 4.5: Parse a background change line
*
* Background syntax: Background[filename]: optional text after (ignored)
* Example: "Background[scary-room.png]: The room transforms..."
*
* @param {string} line - Dialogue line to parse
* @returns {string|null} Background filename if valid, null otherwise
*/
parseBackgroundLine(line) {
if (!line || typeof line !== 'string') {
return null;
}
line = line.trim();
if (!line) {
return null;
}
// Match Background[filename]: optional text pattern
const bgMatch = line.match(/^Background\s*\[\s*([^\]]+)\s*\]\s*(?::\s*(.*))?$/i);
if (!bgMatch) {
return null;
}
const filename = bgMatch[1].trim();
// Background filename must not be empty
if (!filename) {
return null;
}
console.log(`🎨 PHASE 4.5: Parsed background change to: ${filename}`);
return filename;
}
/**
* Handle choice selection
* @param {number} choiceIndex - Index of selected choice
@@ -631,112 +818,170 @@ export class PersonChatMinigame extends MinigameScene {
* @param {Object} result - Result with potentially multiple lines and tags
*/
displayAccumulatedDialogue(result) {
if (!result.text || !result.tags) {
// No content to display
if (result.hasEnded) {
// Story ended - save state and show message
if (this.inkEngine && this.inkEngine.story) {
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
}
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
console.log('🏁 Story has reached an end point');
}
// PHASE 0: State locking to prevent race conditions during rapid dialogue advancement
if (this.isProcessingDialogue) {
console.log('⏳ Already processing dialogue, ignoring call');
return;
}
this.isProcessingDialogue = true;
// Process any game action tags (give_item, unlock_door, etc.) BEFORE displaying dialogue
if (result.tags && result.tags.length > 0) {
console.log('🏷️ Processing action tags from accumulated dialogue:', result.tags);
processGameActionTags(result.tags, this.ui);
}
// Split text into lines
const lines = result.text.split('\n').filter(line => line.trim());
// We have lines and tags - pair them up
// Each tag corresponds to a line (or group of lines before the next tag)
if (lines.length === 0) {
if (result.hasEnded) {
// Story ended - save state and show message
if (this.inkEngine && this.inkEngine.story) {
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
try {
if (!result.text || !result.tags) {
// No content to display
if (result.hasEnded) {
// Story ended - save state and show message
if (this.inkEngine && this.inkEngine.story) {
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
}
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
console.log('🏁 Story has reached an end point');
}
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
console.log('🏁 Story has reached an end point');
return;
}
return;
// Process any game action tags (give_item, unlock_door, etc.) BEFORE displaying dialogue
if (result.tags && result.tags.length > 0) {
console.log('🏷️ Processing action tags from accumulated dialogue:', result.tags);
processGameActionTags(result.tags, this.ui);
}
// Split text into lines
const lines = result.text.split('\n').filter(line => line.trim());
// We have lines and tags - pair them up
// Each tag corresponds to a line (or group of lines before the next tag)
if (lines.length === 0) {
if (result.hasEnded) {
// Story ended - save state and show message
if (this.inkEngine && this.inkEngine.story) {
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
}
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
console.log('🏁 Story has reached an end point');
}
return;
}
// Create dialogue blocks: each block is one or more consecutive lines with the same speaker
// PHASE 0: Pass result object so createDialogueBlocks can use determineSpeaker() for tag-based fallback
const dialogueBlocks = this.createDialogueBlocks(lines, result.tags, result);
// Display blocks sequentially with delays
this.displayDialogueBlocksSequentially(dialogueBlocks, result, 0);
} finally {
// PHASE 0: Unlock state on all exit paths (including errors)
this.isProcessingDialogue = false;
}
// Create dialogue blocks: each block is one or more consecutive lines with the same speaker
const dialogueBlocks = this.createDialogueBlocks(lines, result.tags);
// Display blocks sequentially with delays
this.displayDialogueBlocksSequentially(dialogueBlocks, result, 0);
}
/**
* Create dialogue blocks from lines and speaker tags
* When speaker tags are missing, dialogue defaults to the main NPC being talked to
*
* PHASE 3: Enhanced to support line prefix format
* PHASE 4.5: Enhanced to detect and extract background changes
* - First checks if line is background change (Background[...])
* - Then tries to parse line prefix format using parseDialogueLine()
* - Falls back to tag-based grouping if no prefix found
* - Handles speaker changes mid-dialogue
* - Groups consecutive lines from same speaker into single block
*
* @param {Array<string>} lines - Text lines
* @param {Array<string>} tags - Speaker tags
* @returns {Array<Object>} Array of {speaker, text} blocks
* @param {Array<string>} tags - Speaker tags (for fallback)
* @param {Object} result - Result object for tag-based fallback
* @returns {Array<Object>} Array of {speaker, text, isNarrator, narratorCharacter, backgroundChange} blocks
*/
createDialogueBlocks(lines, tags) {
createDialogueBlocks(lines, tags, result) {
const blocks = [];
let blockIndex = 0;
let currentSpeaker = null;
let currentText = '';
let currentIsNarrator = false;
let currentNarratorCharacter = null;
let currentBackgroundChange = null;
// Special case: NO tags at all - all lines belong to main NPC
if (!tags || tags.length === 0) {
if (lines.length > 0) {
const allText = lines.join('\n').trim();
if (allText) {
blocks.push({ speaker: this.npc.id, text: allText, tag: null });
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// PHASE 4.5: Check for background change first
const bgChange = this.parseBackgroundLine(line);
if (bgChange) {
// Save current block if we have one
if (currentSpeaker !== null && currentText.trim()) {
blocks.push({
speaker: currentSpeaker,
text: currentText.trim(),
isNarrator: currentIsNarrator,
narratorCharacter: currentNarratorCharacter,
backgroundChange: currentBackgroundChange
});
}
// Create background-only block
blocks.push({
speaker: null,
text: '',
isNarrator: false,
narratorCharacter: null,
backgroundChange: bgChange
});
// Reset for next speaker
currentSpeaker = null;
currentText = '';
currentIsNarrator = false;
currentNarratorCharacter = null;
currentBackgroundChange = null;
continue;
}
// Try to parse line prefix format
const parsed = this.parseDialogueLine(line);
if (parsed) {
// This line has a prefix - speaker changed!
// First, save current block if we have one
if (currentSpeaker !== null && currentText.trim()) {
blocks.push({
speaker: currentSpeaker,
text: currentText.trim(),
isNarrator: currentIsNarrator,
narratorCharacter: currentNarratorCharacter,
backgroundChange: currentBackgroundChange
});
}
// Start new block with parsed line
currentSpeaker = parsed.speaker;
currentText = parsed.text;
currentIsNarrator = parsed.isNarrator;
currentNarratorCharacter = parsed.narratorCharacter;
currentBackgroundChange = null;
} else {
// No prefix - continues current speaker
if (currentSpeaker === null) {
// First line without prefix - use tag-based or default speaker
currentSpeaker = this.determineSpeaker(result);
currentIsNarrator = false;
currentNarratorCharacter = null;
currentBackgroundChange = null;
}
// Add to current text (newline-separated)
currentText += (currentText ? '\n' : '') + line;
}
return blocks;
}
// Group lines by speaker based on tags
for (let tagIdx = 0; tagIdx < tags.length; tagIdx++) {
const tag = tags[tagIdx];
// Determine speaker from tag - support multiple formats
// Default to main NPC if no speaker tag found
let speaker = this.npc.id;
if (tag.includes('speaker:player')) {
speaker = 'player';
} else if (tag.includes('speaker:npc:')) {
// Extract character ID from speaker:npc:character_id format
const match = tag.match(/speaker:npc:(\S+)/);
if (match && match[1]) {
speaker = match[1];
}
} else if (tag === 'player' || tag.includes('player')) {
speaker = 'player';
}
// Find how many lines belong to this speaker (until next tag or end)
const nextTagIdx = tagIdx + 1;
const startLineIdx = blockIndex;
// Count lines for this speaker - lines between this tag and the next
let endLineIdx = lines.length;
if (nextTagIdx < tags.length) {
// There's another tag coming, but we need to figure out how many lines
// For now, assume 1 line per tag (common case)
endLineIdx = startLineIdx + 1;
}
// Collect the text for this speaker
const blockText = lines.slice(startLineIdx, endLineIdx).join('\n').trim();
if (blockText) {
blocks.push({ speaker, text: blockText, tag });
}
blockIndex = endLineIdx;
// Don't forget the last block!
if (currentSpeaker !== null && currentText.trim()) {
blocks.push({
speaker: currentSpeaker,
text: currentText.trim(),
isNarrator: currentIsNarrator,
narratorCharacter: currentNarratorCharacter,
backgroundChange: currentBackgroundChange
});
}
console.log(`📝 PHASE 3: createDialogueBlocks created ${blocks.length} blocks from ${lines.length} lines`);
return blocks;
}
@@ -796,6 +1041,26 @@ export class PersonChatMinigame extends MinigameScene {
// Display current block's lines one at a time with accumulation
const block = blocks[blockIndex];
// PHASE 4.5: Handle background changes before displaying dialogue
if (block.backgroundChange) {
console.log(`🎨 Background change block detected: ${block.backgroundChange}`);
// Change background and move to next block
this.changeBackground(block.backgroundChange);
this.scheduleDialogueAdvance(() => {
this.displayDialogueBlocksSequentially(blocks, originalResult, blockIndex + 1, 0, '');
}, DIALOGUE_AUTO_ADVANCE_DELAY);
return;
}
// Skip empty dialogue blocks
if (!block.text || !block.text.trim()) {
this.displayDialogueBlocksSequentially(blocks, originalResult, blockIndex + 1, 0, '');
return;
}
const lines = block.text.split('\n').filter(line => line.trim());
if (lineIndex >= lines.length) {
@@ -810,8 +1075,14 @@ export class PersonChatMinigame extends MinigameScene {
console.log(`📋 Displaying line ${lineIndex + 1}/${lines.length} from block ${blockIndex + 1}/${blocks.length}: ${block.speaker}`);
// Show accumulated text (all lines up to and including current line)
this.ui.showDialogue(newAccumulatedText, block.speaker);
// PHASE 4: Show accumulated text with narrator support
this.ui.showDialogue(
newAccumulatedText,
block.speaker,
false, // preserveChoices
block.isNarrator || false, // isNarrator
block.narratorCharacter || null // narratorCharacter
);
// Display next line after delay
this.scheduleDialogueAdvance(() => {
@@ -1011,6 +1282,32 @@ export class PersonChatMinigame extends MinigameScene {
super.cleanup();
}
/**
* PHASE 4.5: Change background image for current portrait
*
* Updates the portrait renderer's background image and re-renders
*
* @param {string} backgroundFilename - Filename of new background image
* @returns {Promise<void>}
*/
async changeBackground(backgroundFilename) {
if (!backgroundFilename || !this.ui || !this.ui.portraitRenderer) {
console.warn(`⚠️ changeBackground: Invalid background or portrait renderer`);
return;
}
try {
console.log(`🎨 Changing background to: ${backgroundFilename}`);
// Call setBackground to load and render the new background
this.ui.portraitRenderer.setBackground(backgroundFilename);
console.log(`✅ Background changed successfully`);
} catch (error) {
console.error(`❌ Error changing background: ${error.message}`);
}
}
/**
* Show error message
* @param {string} message - Error message to display

View File

@@ -336,6 +336,22 @@ export default class PersonChatPortraits {
img.src = bgSrc;
}
/**
* PHASE 4.5: Change background to a new image
* @param {string} newBackgroundPath - Path to new background image
*/
setBackground(newBackgroundPath) {
if (!newBackgroundPath) {
console.warn('⚠️ setBackground: No background path provided');
return;
}
this.backgroundPath = newBackgroundPath;
this.backgroundImage = null; // Clear old image
console.log(`🎨 Setting new background: ${newBackgroundPath}`);
this.loadBackgroundImage();
}
/**
* Draw background image at same pixel scale as character sprite
* Fills the canvas while maintaining sprite's pixel scale (may extend beyond canvas if larger)
@@ -685,6 +701,30 @@ export default class PersonChatPortraits {
);
}
/**
* PHASE 4: Clear the portrait canvas (for narrator mode without portrait)
*/
clearPortrait() {
if (!this.canvas || !this.ctx) return;
// Clear canvas to black
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw placeholder text
this.ctx.fillStyle = '#666';
this.ctx.font = '16px Arial';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(
'Narrator',
this.canvas.width / 2,
this.canvas.height / 2
);
console.log('🖼️ Portrait cleared for narrator mode');
}
/**
* Destroy portrait and cleanup
*/

View File

@@ -212,47 +212,79 @@ export default class PersonChatUI {
* @param {string} text - Dialogue text to display
* @param {string} characterId - Character ID ('player', 'npc', or specific NPC ID)
* @param {boolean} preserveChoices - If true, don't hide existing choices
* @param {boolean} isNarrator - PHASE 4: If true, display as narrator mode (centered, no speaker name)
* @param {string} narratorCharacter - PHASE 4: For Narrator[character]: format, character to show portrait of
*/
showDialogue(text, characterId = 'npc', preserveChoices = false) {
showDialogue(text, characterId = 'npc', preserveChoices = false, isNarrator = false, narratorCharacter = null) {
this.currentSpeaker = characterId;
console.log(`📝 showDialogue called with character: ${characterId}, text length: ${text?.length || 0}`);
console.log(`📝 showDialogue called with character: ${characterId}, text length: ${text?.length || 0}, narrator: ${isNarrator}`);
// Get character data
let character = this.characters[characterId];
if (!character) {
// Fallback for legacy speaker values or main NPC ID
if (characterId === 'player') {
character = this.playerData;
} else if (characterId === 'npc' || !characterId) {
character = this.npc;
} else if (characterId === this.npc?.id) {
// Main NPC passed by ID - use main NPC data
character = this.npc;
// PHASE 4: Handle narrator mode
if (isNarrator) {
// Narrator mode - centered text, no speaker name
this.elements.portraitSection.className = 'person-chat-portrait-section narrator-mode';
this.elements.speakerName.className = 'person-chat-speaker-name narrator-speaker';
this.elements.portraitLabel.textContent = ''; // No label in narrator mode
this.elements.speakerName.textContent = 'Narrator'; // Hidden by CSS but available
// If narratorCharacter is specified, show that character's portrait
if (narratorCharacter) {
const character = this.characters[narratorCharacter];
if (character) {
this.updatePortraitForSpeaker(narratorCharacter, character);
}
} else {
// No character portrait in pure narrator mode
if (this.portraitRenderer) {
this.portraitRenderer.clearPortrait();
}
}
console.log(`📝 Narrator mode: character=${narratorCharacter}, portrait shown=${!!narratorCharacter}`);
} else {
// Normal dialogue mode
// Get character data
let character = this.characters[characterId];
if (!character) {
// Fallback for legacy speaker values or main NPC ID
if (characterId === 'player') {
character = this.playerData;
} else if (characterId === 'npc' || !characterId) {
character = this.npc;
} else if (characterId === this.npc?.id) {
// Main NPC passed by ID - use main NPC data
character = this.npc;
}
}
// Determine display name
// If no character found, use the character ID itself formatted as display name
const displayName = character?.displayName || (characterId === 'player' ? 'You' :
characterId === 'npc' ? 'NPC' :
// Format character ID: convert snake_case or camelCase to Title Case
characterId.replace(/[_-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
const speakerType = characterId === 'player' ? 'player' : 'npc';
this.elements.portraitLabel.textContent = displayName;
this.elements.speakerName.textContent = displayName;
console.log(`📝 Set speaker name to: ${displayName}`);
// Update speaker styling
this.elements.portraitSection.className = `person-chat-portrait-section speaker-${speakerType}`;
this.elements.speakerName.className = `person-chat-speaker-name ${speakerType}-speaker`;
// Reset portrait for new speaker
this.updatePortraitForSpeaker(characterId, character);
}
// Determine display name
const displayName = character?.displayName || (characterId === 'player' ? 'You' : 'NPC');
const speakerType = characterId === 'player' ? 'player' : 'npc';
this.elements.portraitLabel.textContent = displayName;
this.elements.speakerName.textContent = displayName;
console.log(`📝 Set speaker name to: ${displayName}`);
// Update speaker styling
this.elements.portraitSection.className = `person-chat-portrait-section speaker-${speakerType}`;
this.elements.speakerName.className = `person-chat-speaker-name ${speakerType}-speaker`;
// Update dialogue text
this.elements.dialogueText.textContent = text;
console.log(`📝 Set dialogue text, element content: "${this.elements.dialogueText.textContent}"`);
// Reset portrait for new speaker
this.updatePortraitForSpeaker(characterId, character);
// Hide choices only if not preserving them (i.e., when transitioning from choices back to text)
if (!preserveChoices) {
this.hideChoices();

View File

@@ -0,0 +1,99 @@
/**
* Global Character Registry
* ========================
* Maintains a registry of all characters (player, NPCs) available in the game.
* This registry is populated as NPCs are registered via npcManager, and as rooms are loaded.
* The person-chat minigame uses this registry for speaker resolution.
*
* When an NPC is registered via npcManager.registerNPC(), it's automatically added here.
* Format: { id: { id, displayName, spriteSheet, spriteTalk, ... }, ... }
*/
window.characterRegistry = {
// Player character - set when game initializes
player: null,
// All NPCs registered in the game
npcs: {},
/**
* Add player to registry
* @param {Object} playerData - Player object with id, displayName, etc.
*/
setPlayer(playerData) {
this.player = playerData;
console.log(`✅ Character Registry: Added player (${playerData.displayName})`);
},
/**
* Register an NPC in the character registry
* Called automatically when npcManager.registerNPC() is invoked
* @param {string} npcId - NPC identifier
* @param {Object} npcData - Full NPC data object
*/
registerNPC(npcId, npcData) {
this.npcs[npcId] = npcData;
console.log(`✅ Character Registry: Added NPC ${npcId} (displayName: ${npcData.displayName})`);
},
/**
* Get a character (player or NPC) by ID
* @param {string} characterId - Character identifier
* @returns {Object|null} Character data or null if not found
*/
getCharacter(characterId) {
if (characterId === 'player') {
return this.player;
}
return this.npcs[characterId] || null;
},
/**
* Get all available characters for speaker resolution
* Combines player and all registered NPCs
* @returns {Object} Dictionary of all characters
*/
getAllCharacters() {
const all = {};
if (this.player) {
all['player'] = this.player;
}
Object.assign(all, this.npcs);
return all;
},
/**
* Check if a character exists in registry
* @param {string} characterId - Character identifier
* @returns {boolean} True if character exists
*/
hasCharacter(characterId) {
if (characterId === 'player') {
return this.player !== null;
}
return characterId in this.npcs;
},
/**
* Clear all registered NPCs (used for scenario transitions)
*/
clearNPCs() {
this.npcs = {};
console.log(`🗑️ Character Registry: Cleared all NPCs`);
},
/**
* Debug: Log current registry state
*/
debug() {
const chars = Object.keys(this.getAllCharacters());
console.log(`📋 Character Registry:`, {
playerCount: this.player ? 1 : 0,
npcCount: Object.keys(this.npcs).length,
totalCharacters: chars.length,
characters: chars
});
}
};
console.log('✅ Character Registry system initialized');

View File

@@ -77,6 +77,11 @@ export default class NPCManager {
this.npcs.set(realId, entry);
// Register in global character registry for speaker resolution
if (window.characterRegistry) {
window.characterRegistry.registerNPC(realId, entry);
}
// Initialize conversation history for this NPC
if (!this.conversationHistory.has(realId)) {
this.conversationHistory.set(realId, []);

View File

@@ -0,0 +1,170 @@
// Test Line Prefix Speaker Format
// Tests for PHASE 1-4.5: Speaker prefixes, narrator mode, background changes
//
// Test Coverage:
// - Line prefix format: "Speaker: Text"
// - Narrator mode: "Narrator: Text"
// - Narrator with character: "Narrator[character]: Text"
// - Background changes: "Background[filename]: optional text"
// - Mixed format with traditional tags
// - Multi-line dialogue with speaker changes
VAR test_phase = 0
=== start ===
~ test_phase = 1
PHASE 1: Line Prefix Speaker Format
Please observe the dialogue displays with speaker names derived from line prefixes.
-> phase_1_basic
=== phase_1_basic ===
// Test basic line prefix format with regular speakers
// Each line should show the speaker name correctly from the prefix
Player: Hello! I'm here for the security briefing.
test_npc_front: Welcome to the facility. I'll run through the basics for you.
test_npc_front: We use biometric authentication and keycard access throughout the building.
Player: That sounds comprehensive. Any recent incidents?
test_npc_front: Nothing major. System updates last week, everything's stable now.
+ [Continue to Phase 2] -> phase_2_narrator
+ [Repeat Phase 1] -> phase_1_basic
=== phase_2_narrator ===
~ test_phase = 2
PHASE 2: Narrator Mode Testing
Narrator: The sun begins to set over the facility grounds. An eerie silence fills the corridors.
Narrator: Your briefing complete, you prepare to move deeper into the building.
+ [Continue] -> phase_2_narrator_with_character
=== phase_2_narrator_with_character ===
// Narrator with character portrait displayed
Narrator[test_npc_front]: The guard glances nervously at the security monitors.
Narrator[test_npc_front]: Something seems different about the usual patrol rotation today.
+ [Continue] -> phase_3_background_changes
=== phase_3_background_changes ===
~ test_phase = 3
PHASE 3: Background Changes Testing
Background[lab-normal.png]: The laboratory appears normal at first glance.
test_npc_back: Welcome to the lab. Everything is operating within normal parameters.
Background[lab-alert.png]: Suddenly, the room plunges into emergency lighting!
test_npc_back: Wait... the alarm system just triggered. Something's wrong with the main servers!
test_npc_back: We need to get to the server room immediately!
+ [Continue] -> phase_4_mixed_format
=== phase_4_mixed_format ===
~ test_phase = 4
PHASE 4: Mixed Format Testing
Now we'll test mixing line prefix format with traditional tag-based dialogue.
Test that both systems work together smoothly.
Player: What's the emergency protocol?
test_npc_front: Protocol Alpha - all personnel to designated zones.
test_npc_front: The facility goes into lockdown automatically.
Narrator: Red emergency lights begin flashing throughout the building.
Player: How long until the lockdown is complete?
test_npc_front: Usually takes about 30 seconds for all doors to secure.
test_npc_back: The server room needs to stay accessible for diagnostics!
+ [Continue] -> phase_5_speaker_changes
=== phase_5_speaker_changes ===
~ test_phase = 5
PHASE 5: Multi-Speaker Dialogue Testing
Testing rapid speaker changes using line prefixes.
Player: Who's in charge here?
test_npc_front: I am, in the security department.
test_npc_back: But I'm responsible for the lab systems.
test_npc_front: Right, so we need both perspectives.
Player: What do you recommend?
test_npc_back: We need lab access first.
test_npc_front: I'll authorize it - follow me.
Narrator: The tension in the room is palpable as both professionals prepare for action.
+ [Continue] -> phase_6_narrator_variations
=== phase_6_narrator_variations ===
~ test_phase = 6
PHASE 6: Narrator Variations Testing
Narrator: Pure narrator - no character portrait shown
Narrator[test_npc_back]: Narrator with test_npc_back portrait
Narrator[test_npc_front]: Narrator with security guard portrait
Background[hallway-dark.png]: The hallway descends into darkness
Narrator: Another narration after background change
Player: Are we ready to proceed?
Narrator[test_npc_front]: The guard nods solemnly
+ [Continue] -> phase_7_comprehensive
=== phase_7_comprehensive ===
~ test_phase = 7
PHASE 7: Comprehensive Scene
A scene combining all features.
Background[facility-entrance.png]: You stand at the facility entrance
Narrator[test_npc_front]: The security guard approaches your position
test_npc_front: Welcome to the facility. I hope you're here for a good reason.
Player: I'm here to investigate the recent incident.
test_npc_front: Incident? There is no incident.
Background[security-office.png]: The guard escorts you to the security office
Narrator: Inside, screens display various security feeds
Narrator[test_npc_back]: From a monitor, a test_npc_back's face appears urgently
test_npc_back: We need to talk. Alone.
test_npc_front: What's this about?
test_npc_back: It's classified. Speaker confidential access only.
Narrator: The guard looks conflicted but nods slowly
test_npc_front: Alright. Go ahead. But I'm logging this.
+ [Investigate Further] -> phase_8_edge_cases
+ [Conclude Test] -> ending
=== phase_8_edge_cases ===
~ test_phase = 8
PHASE 8: Edge Cases & Stress Testing
// Multiple consecutive narrator lines
Narrator: The tension builds.
Narrator: Everything is at stake.
Narrator: Time seems to slow down.
// Multiple consecutive same speaker
Player: I need answers.
Player: Real answers.
Player: Not more evasions.
// Rapid background changes
Background[room-a.png]: Location: Room A
Background[room-b.png]: Location: Room B
Background[room-c.png]: Location: Room C
// Mixed speakers after backgrounds
test_npc_back: The data is clear.
test_npc_front: But the implications are severe.
Player: We need to act fast.
Narrator: The three of you exchange worried glances.
+ [Conclude] -> ending
=== ending ===
~ test_phase = 9
All test phases completed!
Total phases run: {test_phase}
Thank you for testing the line prefix speaker format implementation.
The dialogue system should now support:
✓ Line prefix format (Speaker: Text)
✓ Narrator mode (Narrator: Text)
✓ Narrator with character (Narrator[char]: Text)
✓ Background changes (Background[file]: Text)
✓ Multi-speaker dialogue
✓ Mixed format compatibility
✓ Edge case handling
Test complete.
-> END

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,170 @@
// Test Line Prefix Speaker Format
// Tests for PHASE 1-4.5: Speaker prefixes, narrator mode, background changes
//
// Test Coverage:
// - Line prefix format: "Speaker: Text"
// - Narrator mode: "Narrator: Text"
// - Narrator with character: "Narrator[character]: Text"
// - Background changes: "Background[filename]: optional text"
// - Mixed format with traditional tags
// - Multi-line dialogue with speaker changes
VAR test_phase = 0
=== start ===
~ test_phase = 1
PHASE 1: Line Prefix Speaker Format
Please observe the dialogue displays with speaker names derived from line prefixes.
-> phase_1_basic
=== phase_1_basic ===
// Test basic line prefix format with regular speakers
// Each line should show the speaker name correctly from the prefix
Player: Hello! I'm here for the security briefing.
security_guard: Welcome to the facility. I'll run through the basics for you.
security_guard: We use biometric authentication and keycard access throughout the building.
Player: That sounds comprehensive. Any recent incidents?
security_guard: Nothing major. System updates last week, everything's stable now.
+ [Continue to Phase 2] -> phase_2_narrator
+ [Repeat Phase 1] -> phase_1_basic
=== phase_2_narrator ===
~ test_phase = 2
PHASE 2: Narrator Mode Testing
Narrator: The sun begins to set over the facility grounds. An eerie silence fills the corridors.
Narrator: Your briefing complete, you prepare to move deeper into the building.
+ [Continue] -> phase_2_narrator_with_character
=== phase_2_narrator_with_character ===
// Narrator with character portrait displayed
Narrator[security_guard]: The guard glances nervously at the security monitors.
Narrator[security_guard]: Something seems different about the usual patrol rotation today.
+ [Continue] -> phase_3_background_changes
=== phase_3_background_changes ===
~ test_phase = 3
PHASE 3: Background Changes Testing
Background[lab-normal.png]: The laboratory appears normal at first glance.
scientist: Welcome to the lab. Everything is operating within normal parameters.
Background[lab-alert.png]: Suddenly, the room plunges into emergency lighting!
scientist: Wait... the alarm system just triggered. Something's wrong with the main servers!
scientist: We need to get to the server room immediately!
+ [Continue] -> phase_4_mixed_format
=== phase_4_mixed_format ===
~ test_phase = 4
PHASE 4: Mixed Format Testing
Now we'll test mixing line prefix format with traditional tag-based dialogue.
Test that both systems work together smoothly.
Player: What's the emergency protocol?
security_guard: Protocol Alpha - all personnel to designated zones.
security_guard: The facility goes into lockdown automatically.
Narrator: Red emergency lights begin flashing throughout the building.
Player: How long until the lockdown is complete?
security_guard: Usually takes about 30 seconds for all doors to secure.
scientist: The server room needs to stay accessible for diagnostics!
+ [Continue] -> phase_5_speaker_changes
=== phase_5_speaker_changes ===
~ test_phase = 5
PHASE 5: Multi-Speaker Dialogue Testing
Testing rapid speaker changes using line prefixes.
Player: Who's in charge here?
security_guard: I am, in the security department.
scientist: But I'm responsible for the lab systems.
security_guard: Right, so we need both perspectives.
Player: What do you recommend?
scientist: We need lab access first.
security_guard: I'll authorize it - follow me.
Narrator: The tension in the room is palpable as both professionals prepare for action.
+ [Continue] -> phase_6_narrator_variations
=== phase_6_narrator_variations ===
~ test_phase = 6
PHASE 6: Narrator Variations Testing
Narrator: Pure narrator - no character portrait shown
Narrator[scientist]: Narrator with scientist portrait
Narrator[security_guard]: Narrator with security guard portrait
Background[hallway-dark.png]: The hallway descends into darkness
Narrator: Another narration after background change
Player: Are we ready to proceed?
Narrator[security_guard]: The guard nods solemnly
+ [Continue] -> phase_7_comprehensive
=== phase_7_comprehensive ===
~ test_phase = 7
PHASE 7: Comprehensive Scene
A scene combining all features.
Background[facility-entrance.png]: You stand at the facility entrance
Narrator[security_guard]: The security guard approaches your position
security_guard: Welcome to the facility. I hope you're here for a good reason.
Player: I'm here to investigate the recent incident.
security_guard: Incident? There is no incident.
Background[security-office.png]: The guard escorts you to the security office
Narrator: Inside, screens display various security feeds
Narrator[scientist]: From a monitor, a scientist's face appears urgently
scientist: We need to talk. Alone.
security_guard: What's this about?
scientist: It's classified. Speaker confidential access only.
Narrator: The guard looks conflicted but nods slowly
security_guard: Alright. Go ahead. But I'm logging this.
+ [Investigate Further] -> phase_8_edge_cases
+ [Conclude Test] -> ending
=== phase_8_edge_cases ===
~ test_phase = 8
PHASE 8: Edge Cases & Stress Testing
// Multiple consecutive narrator lines
Narrator: The tension builds.
Narrator: Everything is at stake.
Narrator: Time seems to slow down.
// Multiple consecutive same speaker
Player: I need answers.
Player: Real answers.
Player: Not more evasions.
// Rapid background changes
Background[room-a.png]: Location: Room A
Background[room-b.png]: Location: Room B
Background[room-c.png]: Location: Room C
// Mixed speakers after backgrounds
scientist: The data is clear.
security_guard: But the implications are severe.
Player: We need to act fast.
Narrator: The three of you exchange worried glances.
+ [Conclude] -> ending
=== ending ===
~ test_phase = 9
All test phases completed!
Total phases run: {test_phase}
Thank you for testing the line prefix speaker format implementation.
The dialogue system should now support:
✓ Line prefix format (Speaker: Text)
✓ Narrator mode (Narrator: Text)
✓ Narrator with character (Narrator[char]: Text)
✓ Background changes (Background[file]: Text)
✓ Multi-speaker dialogue
✓ Mixed format compatibility
✓ Edge case handling
Test complete.
-> END

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,186 @@
{
"scenario_brief": "Test scenario for NPC sprite functionality",
"globalVariables": {
"player_joined_organization": false
},
"startRoom": "test_room",
"startItemsInInventory": [
{
"type": "phone",
"name": "Your Phone 0",
"takeable": true,
"phoneId": "player_phone",
"npcIds": ["gossip_girl"],
"observations": "Your personal phone with some interesting contacts"
}
],
"player": {
"id": "player",
"displayName": "Agent 0x00",
"spriteSheet": "hacker",
"spriteTalk": "assets/characters/hacker-talk.png",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
}
},
"rooms": {
"test_room": {
"type": "room_office",
"connections": {},
"npcs": [
{
"id": "neye_eve",
"displayName": "Neye Eve",
"storyPath": "scenarios/ink/neye-eve.json",
"avatar": "assets/npc/avatars/npc_adversary.png",
"phoneId": "player_phone",
"currentKnot": "start",
"npcType": "phone"
},
{
"id": "gossip_girl",
"displayName": "Gossip Girl",
"storyPath": "scenarios/ink/gossip-girl.json",
"avatar": "assets/npc/avatars/npc_neutral.png",
"phoneId": "player_phone",
"currentKnot": "start",
"npcType": "phone",
"timedMessages": [
{
"delay": 5000,
"message": "Hey! 👋 Got any juicy gossip for me today?",
"type": "text"
}
]
},
{
"id": "test_npc_front",
"displayName": "Helper NPC",
"npcType": "person",
"position": { "x": 5, "y": 3 },
"spriteSheet": "hacker-red",
"spriteTalk": "assets/characters/hacker-red-talk.png",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/test-line-prefix.json",
"currentKnot": "start",
"itemsHeld": [
{
"type": "workstation",
"name": "Crypto Analysis Station",
"takeable": true,
"observations": "A powerful workstation for cryptographic analysis"
},
{
"type": "lockpick",
"name": "Lock Pick Kit",
"takeable": true,
"observations": "A professional lock picking kit with various picks and tension wrenches"
}
]
},
{
"id": "test_npc_back",
"displayName": "Back NPC",
"npcType": "person",
"position": { "x": 6, "y": 8 },
"spriteSheet": "hacker",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/test2.json",
"currentKnot": "hub",
"timedConversation": {
"delay": 0,
"targetKnot": "group_meeting",
"background": "assets/backgrounds/hq1.png"
}
},
{
"id": "test_npc_influence",
"displayName": "Influence NPC",
"npcType": "person",
"position": { "x": 2, "y": 2 },
"spriteSheet": "hacker",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/influence-demo.json",
"currentKnot": "start"
},
{
"id": "container_test_npc",
"displayName": "Equipment Officer",
"npcType": "person",
"position": { "x": 8, "y": 5 },
"spriteSheet": "hacker-red",
"spriteTalk": "assets/characters/hacker-red-talk.png",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/equipment-officer.json",
"currentKnot": "start",
"itemsHeld": [
{
"type": "phone",
"name": "Your Phone 1",
"takeable": true,
"phoneId": "player_phone",
"npcIds": ["gossip_girl"],
"observations": "Your personal phone with some interesting contacts"
},
{
"type": "phone",
"name": "Your Phone 2",
"takeable": true,
"phoneId": "player_phone",
"npcIds": ["neye_eve"],
"observations": "Your personal phone with some interesting contacts"
},
{
"type": "lockpick",
"name": "Basic Lock Pick Kit",
"takeable": true,
"observations": "A basic set of lock picking tools"
},
{
"type": "lockpick",
"name": "Advanced Lock Pick Kit",
"takeable": true,
"observations": "An advanced set with precision tools"
},
{
"type": "workstation",
"name": "Analysis Workstation",
"takeable": true,
"observations": "Portable analysis workstation"
},
{
"type": "keycard",
"name": "Security Keycard",
"takeable": true,
"observations": "Electronic access keycard"
},
{
"type": "notes",
"name": "Security Log",
"takeable": true,
"readable": true,
"text": "Unusual after-hours access detected:\n- CEO office: 11:30 PM\n- Server room: 2:15 AM\n- CEO office again: 3:45 AM",
"observations": "A concerning security log from last night"
}
]
}
]
}
}
}