Add NPC dialogue and interaction scripts

- Created a generic NPC script with conversation handling.
- Developed an Alice NPC script demonstrating branching dialogue and state tracking.
- Implemented a test NPC script for development purposes.
- Added JSON representations for the NPC scripts.
- Created an HTML test interface for NPC integration testing.
- Included event handling and bark systems for NPC interactions.
This commit is contained in:
Z. Cliffe Schreuders
2025-10-29 13:48:22 +00:00
parent 887e5f6443
commit 9fffb6b4e4
29 changed files with 2371 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
{
"cursor.general.disableHttp2": true,
"chat.agent.maxRequests": 50
"chat.agent.maxRequests": 100
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

2
assets/vendor/ink.js vendored Normal file

File diff suppressed because one or more lines are too long

155
css/npc-barks.css Normal file
View File

@@ -0,0 +1,155 @@
/* NPC Bark Notifications */
@keyframes bark-slide-in {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes bark-slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.npc-bark-notification {
position: fixed;
right: 20px;
width: 320px;
background: #fff;
border: 2px solid #000;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.3);
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
animation: bark-slide-in 0.3s ease-out;
z-index: 9999;
font-family: 'VT323', monospace;
transition: transform 0.1s, box-shadow 0.1s;
}
.npc-bark-notification:hover {
background: #f0f0f0;
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0, 0, 0, 0.3);
}
.npc-bark-avatar {
width: 48px;
height: 48px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
border: 2px solid #000;
flex-shrink: 0;
}
.npc-bark-content {
flex: 1;
min-width: 0;
}
.npc-bark-name {
font-size: 14px;
font-weight: bold;
color: #000;
margin-bottom: 4px;
}
.npc-bark-message {
font-size: 12px;
color: #333;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.npc-bark-close {
width: 24px;
height: 24px;
background: #ff0000;
color: #fff;
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
font-weight: bold;
line-height: 1;
flex-shrink: 0;
transition: background-color 0.1s;
}
.npc-bark-close:hover {
background: #cc0000;
}
/* Ensure barks appear above inventory */
#npc-bark-container {
z-index: 9999 !important;
}
/* Phone access button (will be added later) */
.phone-access-button {
position: fixed;
bottom: 20px;
right: 20px;
width: 64px;
height: 64px;
background: #5fcf69;
border: 2px solid #000;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3);
z-index: 9998;
transition: transform 0.1s, box-shadow 0.1s;
}
.phone-access-button:hover {
background: #4fb759;
transform: translate(-2px, -2px);
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.3);
}
.phone-access-button-icon {
width: 40px;
height: 40px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
.phone-access-button-badge {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
background: #ff0000;
color: #fff;
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
font-family: 'VT323', monospace;
}

175
css/phone-chat-minigame.css Normal file
View File

@@ -0,0 +1,175 @@
/* Phone Chat Minigame - Ink-based NPC conversations */
.phone-chat-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1a1a;
font-family: 'VT323', monospace;
color: #fff;
}
.phone-chat-header {
display: flex;
align-items: center;
padding: 12px;
background: #2a2a2a;
border-bottom: 2px solid #4a9eff;
gap: 12px;
}
.phone-back-btn {
background: transparent;
border: 2px solid #fff;
color: #fff;
font-family: 'VT323', monospace;
font-size: 24px;
padding: 4px 12px;
cursor: pointer;
line-height: 1;
}
.phone-back-btn:hover {
background: #4a9eff;
}
.phone-contact-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.contact-avatar {
width: 32px;
height: 32px;
image-rendering: pixelated;
border: 2px solid #fff;
}
.contact-name {
font-size: 20px;
color: #4a9eff;
}
.phone-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-message {
display: flex;
max-width: 80%;
animation: messageSlideIn 0.3s ease-out;
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-npc {
align-self: flex-start;
}
.message-player {
align-self: flex-end;
}
.message-system {
align-self: center;
}
.message-bubble {
padding: 10px 14px;
border: 2px solid #666;
background: #2a2a2a;
font-size: 16px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
}
.message-npc .message-bubble {
border-color: #4a9eff;
color: #fff;
}
.message-player .message-bubble {
border-color: #6acc6a;
background: #1a3a1a;
color: #6acc6a;
}
.message-system .message-bubble {
border-color: #999;
background: #1a1a1a;
color: #999;
font-style: italic;
text-align: center;
}
.phone-chat-choices {
padding: 12px;
background: #2a2a2a;
border-top: 2px solid #666;
}
.choices-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.choice-btn {
background: #1a1a1a;
color: #fff;
border: 2px solid #4a9eff;
padding: 10px 14px;
font-family: 'VT323', monospace;
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.1s;
}
.choice-btn:hover {
background: #4a9eff;
color: #000;
transform: translateX(4px);
}
.choice-btn:active {
background: #6acc6a;
border-color: #6acc6a;
}
/* Scrollbar styling */
.phone-chat-messages::-webkit-scrollbar {
width: 8px;
}
.phone-chat-messages::-webkit-scrollbar-track {
background: #1a1a1a;
border: 2px solid #2a2a2a;
}
.phone-chat-messages::-webkit-scrollbar-thumb {
background: #4a9eff;
border: 2px solid #2a2a2a;
}
.phone-chat-messages::-webkit-scrollbar-thumb:hover {
background: #6acc6a;
}

View File

@@ -41,14 +41,17 @@
<link rel="stylesheet" href="css/lockpick-set-minigame.css">
<link rel="stylesheet" href="css/container-minigame.css">
<link rel="stylesheet" href="css/phone.css">
<link rel="stylesheet" href="css/phone-chat-minigame.css">
<link rel="stylesheet" href="css/pin.css">
<link rel="stylesheet" href="css/minigames-framework.css">
<link rel="stylesheet" href="css/password-minigame.css">
<link rel="stylesheet" href="css/text-file-minigame.css">
<link rel="stylesheet" href="css/npc-barks.css">
<!-- External JavaScript libraries -->
<script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easystarjs@0.4.4/bin/easystar-0.4.4.js"></script>
<script src="assets/vendor/ink.js"></script>
</head>
<body>
<div id="game-container">

View File

@@ -1646,6 +1646,9 @@ export function updatePlayerRoom() {
return; // Player not created yet
}
// Store previous room for event emission
const previousRoom = currentPlayerRoom;
// Check for door transitions first
const doorTransitionRoom = checkDoorTransitions(player);
if (doorTransitionRoom && doorTransitionRoom !== currentPlayerRoom) {
@@ -1655,10 +1658,27 @@ export function updatePlayerRoom() {
window.currentPlayerRoom = doorTransitionRoom;
// Reveal the room if not already discovered
if (!discoveredRooms.has(doorTransitionRoom)) {
const isFirstVisit = !discoveredRooms.has(doorTransitionRoom);
if (isFirstVisit) {
revealRoom(doorTransitionRoom);
}
// Emit NPC event for room entry
if (window.npcEvents && previousRoom !== doorTransitionRoom) {
window.npcEvents.emit(`room_entered:${doorTransitionRoom}`, {
roomId: doorTransitionRoom,
previousRoom: previousRoom,
firstVisit: isFirstVisit
});
if (previousRoom) {
window.npcEvents.emit(`room_exited:${previousRoom}`, {
roomId: previousRoom,
nextRoom: doorTransitionRoom
});
}
}
// Player depth is now handled by the simplified updatePlayerDepth function in player.js
return; // Exit early to prevent overlap-based detection from overriding
}

View File

@@ -11,6 +11,12 @@ import { initializeModals } from './ui/modals.js?v=7';
// Import minigame framework
import './minigames/index.js';
// Import NPC systems
import './systems/ink/ink-engine.js?v=1';
import './systems/npc-events.js?v=1';
import './systems/npc-manager.js?v=1';
import './systems/npc-barks.js?v=1';
// Global game variables
window.game = null;
window.gameScenario = null;
@@ -67,6 +73,11 @@ function initializeGame() {
initializeNotifications();
// Bluetooth scanner and biometrics are now handled as minigames
// Initialize NPC systems
if (window.npcBarkSystem) {
window.npcBarkSystem.init();
}
// Make lockpicking function available globally
window.startLockpickingMinigame = startLockpickingMinigame;

View File

@@ -10,6 +10,7 @@ export { BluetoothScannerMinigame, startBluetoothScannerMinigame } from './bluet
export { BiometricsMinigame, startBiometricsMinigame } from './biometrics/biometrics-minigame.js';
export { ContainerMinigame, startContainerMinigame, returnToContainerAfterNotes } from './container/container-minigame.js';
export { PhoneMessagesMinigame, returnToPhoneAfterNotes } from './phone/phone-messages-minigame.js';
export { PhoneChatMinigame } from './phone-chat/phone-chat-minigame.js';
export { PinMinigame, startPinMinigame } from './pin/pin-minigame.js';
export { PasswordMinigame } from './password/password-minigame.js';
export { TextFileMinigame, returnToTextFileAfterNotes } from './text-file/text-file-minigame.js';
@@ -56,6 +57,9 @@ import { ContainerMinigame, startContainerMinigame, returnToContainerAfterNotes
// Import the phone messages minigame
import { PhoneMessagesMinigame, returnToPhoneAfterNotes } from './phone/phone-messages-minigame.js';
// Import the phone chat minigame (Ink-based NPC conversations)
import { PhoneChatMinigame } from './phone-chat/phone-chat-minigame.js';
// Import the PIN minigame
import { PinMinigame, startPinMinigame } from './pin/pin-minigame.js';
@@ -74,6 +78,7 @@ MinigameFramework.registerScene('bluetooth-scanner', BluetoothScannerMinigame);
MinigameFramework.registerScene('biometrics', BiometricsMinigame);
MinigameFramework.registerScene('container', ContainerMinigame);
MinigameFramework.registerScene('phone-messages', PhoneMessagesMinigame);
MinigameFramework.registerScene('phone-chat', PhoneChatMinigame);
MinigameFramework.registerScene('pin', PinMinigame);
MinigameFramework.registerScene('password', PasswordMinigame);
MinigameFramework.registerScene('text-file', TextFileMinigame);

View File

@@ -0,0 +1,201 @@
import { MinigameScene } from '../framework/base-minigame.js';
/**
* Phone Chat Minigame - NPC conversations via phone using Ink
* Displays chat interface with messages and choices driven by Ink stories
*/
export class PhoneChatMinigame extends MinigameScene {
constructor(container, params) {
super(container, params);
// Extract params
this.npcId = params.npcId || 'unknown';
this.npcName = params.npcName || 'Contact';
this.avatar = params.avatar || null;
this.inkStoryPath = params.inkStoryPath || null;
this.startKnot = params.startKnot || null;
// Chat state
this.messages = []; // Array of { sender: 'npc'|'player', text: string }
this.choices = [];
this.inkEngine = null;
this.waitingForChoice = false;
}
async start() {
super.start();
// Initialize Ink engine
if (!window.InkEngine) {
this.showError('Ink engine not available');
return;
}
this.inkEngine = new window.InkEngine(this.npcId);
// Load story
if (this.inkStoryPath) {
try {
const response = await fetch(this.inkStoryPath);
const storyJson = await response.json();
this.inkEngine.loadStory(storyJson);
// Go to starting knot if specified
if (this.startKnot) {
this.inkEngine.goToKnot(this.startKnot);
}
// Display initial content
this.continueStory();
} catch (error) {
this.showError(`Failed to load story: ${error.message}`);
return;
}
}
this.render();
}
continueStory() {
if (!this.inkEngine || !this.inkEngine.story) return;
// Continue until we hit choices or end
let text = '';
while (this.inkEngine.story.canContinue) {
text += this.inkEngine.continue();
}
// Add NPC message if there's text
if (text.trim()) {
this.addMessage('npc', text.trim());
}
// Get current choices
this.choices = this.inkEngine.currentChoices;
this.waitingForChoice = this.choices.length > 0;
// If no choices and story can't continue, conversation is over
if (!this.waitingForChoice && !this.inkEngine.story.canContinue) {
this.addMessage('system', 'Conversation ended.');
setTimeout(() => this.complete({ completed: true }), 2000);
}
this.render();
}
addMessage(sender, text) {
this.messages.push({ sender, text, timestamp: Date.now() });
}
makeChoice(choiceIndex) {
if (!this.inkEngine || !this.waitingForChoice) return;
// Add player's choice as a message
const choice = this.choices[choiceIndex];
if (choice) {
this.addMessage('player', choice.text);
this.inkEngine.choose(choiceIndex);
this.waitingForChoice = false;
this.continueStory();
}
}
showError(message) {
this.addMessage('system', `Error: ${message}`);
this.render();
}
render() {
if (!this.container) return;
this.container.innerHTML = `
<div class="phone-chat-container">
<div class="phone-chat-header">
<button class="phone-back-btn" data-action="close">←</button>
<div class="phone-contact-info">
${this.avatar ? `<img src="${this.avatar}" alt="${this.npcName}" class="contact-avatar">` : ''}
<span class="contact-name">${this.npcName}</span>
</div>
</div>
<div class="phone-chat-messages" id="phone-chat-messages">
${this.renderMessages()}
</div>
<div class="phone-chat-choices" id="phone-chat-choices">
${this.renderChoices()}
</div>
</div>
`;
this.attachEventListeners();
this.scrollToBottom();
}
renderMessages() {
return this.messages.map(msg => {
const senderClass = msg.sender === 'player' ? 'message-player' :
msg.sender === 'npc' ? 'message-npc' :
'message-system';
return `
<div class="chat-message ${senderClass}">
<div class="message-bubble">${this.escapeHtml(msg.text)}</div>
</div>
`;
}).join('');
}
renderChoices() {
if (!this.waitingForChoice || this.choices.length === 0) {
return '';
}
return `
<div class="choices-container">
${this.choices.map((choice, idx) => `
<button class="choice-btn" data-choice="${idx}">
${this.escapeHtml(choice.text)}
</button>
`).join('')}
</div>
`;
}
attachEventListeners() {
// Close button
const closeBtn = this.container.querySelector('[data-action="close"]');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.complete({ cancelled: true }));
}
// Choice buttons
const choiceBtns = this.container.querySelectorAll('[data-choice]');
choiceBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const choiceIndex = parseInt(e.target.dataset.choice);
this.makeChoice(choiceIndex);
});
});
}
scrollToBottom() {
const messagesEl = this.container.querySelector('#phone-chat-messages');
if (messagesEl) {
setTimeout(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
}, 100);
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
cleanup() {
// Stop any ongoing processes
this.inkEngine = null;
super.cleanup();
}
}

View File

@@ -0,0 +1,100 @@
// Minimal InkEngine wrapper around the global inkjs.Story
// Exports a default class InkEngine matching the test harness API.
export default class InkEngine {
constructor(id) {
this.id = id || 'ink-engine';
this.story = null;
}
// Accepts a parsed JSON object (ink.json) or a JSON string
loadStory(storyJson) {
if (!storyJson) throw new Error('No story JSON provided');
// inkjs may accept either an object or a string; the test harness provides parsed JSON
// inkjs library is available as global `inkjs` (loaded via assets/vendor/ink.js)
if (typeof storyJson === 'string') {
this.story = new inkjs.Story(storyJson);
} else {
// If it's an object, stringify then pass to constructor
this.story = new inkjs.Story(JSON.stringify(storyJson));
}
// Do an initial continue to get the first content
// (if story starts at root and immediately exits, this won't produce text)
if (this.story.canContinue) {
this.continue();
}
return this.story;
}
// Continue the story and return the current text plus state
continue() {
if (!this.story) throw new Error('Story not loaded');
try {
// Call Continue() to advance the story
while (this.story.canContinue) {
this.story.Continue();
}
// Return structured result with text, choices, and continue state
return {
text: this.story.currentText || '',
choices: (this.story.currentChoices || []).map((c, i) => ({ text: c.text, index: i })),
canContinue: this.story.canContinue
};
} catch (e) {
// inkjs uses Continue() and throws for errors; rethrow with nicer message
throw e;
}
}
// Go to a knot/stitch by name
goToKnot(knotName) {
if (!this.story) throw new Error('Story not loaded');
if (!knotName) return;
// inkjs expects ChoosePathString for high-level path selection
this.story.ChoosePathString(knotName);
}
// Return the current text produced by the story
get currentText() {
if (!this.story) return '';
return this.story.currentText || '';
}
// Return current choices as an array of objects { text, index }
get currentChoices() {
if (!this.story) return [];
return (this.story.currentChoices || []).map((c, i) => ({ text: c.text, index: i }));
}
// Choose a choice index
choose(index) {
if (!this.story) throw new Error('Story not loaded');
if (typeof index !== 'number') throw new Error('choose() expects a numeric index');
this.story.ChooseChoiceIndex(index);
}
// Variable accessors
getVariable(name) {
if (!this.story) throw new Error('Story not loaded');
const val = this.story.variablesState.GetVariableWithName(name);
// inkjs returns runtime value wrappers; try to unwrap common cases
try {
if (val && typeof val === 'object') {
// common numeric/string wrapper types expose value or valueObject
if ('value' in val) return val.value;
if ('valueObject' in val) return val.valueObject;
}
} catch (e) {
// ignore and return raw
}
return val;
}
setVariable(name, value) {
if (!this.story) throw new Error('Story not loaded');
// inkjs VariableState.SetGlobal expects a RuntimeObject; it's forgiving for primitives
this.story.variablesState.SetGlobal(name, value);
}
}

View File

@@ -207,6 +207,15 @@ export function addToInventory(sprite) {
// Add to inventory array
window.inventory.items.push(itemImg);
// Emit NPC event for item pickup
if (window.npcEvents) {
window.npcEvents.emit(`item_picked_up:${sprite.scenarioData.type}`, {
itemType: sprite.scenarioData.type,
itemName: sprite.scenarioData.name,
roomId: window.currentPlayerRoom
});
}
// Apply pulse animation to the slot instead of showing notification
slot.classList.add('pulse');
// Remove the pulse class after the animation completes

336
js/systems/npc-barks.js Normal file
View File

@@ -0,0 +1,336 @@
// Minimal NPCBarkSystem
// default export class NPCBarkSystem
export default class NPCBarkSystem {
constructor(npcManager) {
this.npcManager = npcManager;
this.container = null;
}
init() {
// create a simple container for barks if missing
if (!document) return;
this.container = document.getElementById('npc-bark-container');
if (!this.container) {
this.container = document.createElement('div');
this.container.id = 'npc-bark-container';
const style = this.container.style;
style.position = 'fixed';
style.right = '12px';
style.top = '12px';
style.zIndex = 9999;
style.pointerEvents = 'auto';
document.body.appendChild(this.container);
}
}
// payload: { npcId, text|message, duration, onClick, openPhone }
showBark(payload = {}) {
if (!this.container) this.init();
const { npcId, npcName } = payload;
const text = payload.text || payload.message || '';
const duration = ('duration' in payload) ? payload.duration : 4000;
const el = document.createElement('div');
el.className = 'npc-bark';
el.textContent = (npcId ? npcId + ': ' : '') + (text || '...');
// basic styling
el.style.background = 'rgba(0,0,0,0.8)';
el.style.color = 'white';
el.style.padding = '8px 12px';
el.style.marginTop = '8px';
el.style.borderRadius = '4px';
el.style.fontFamily = 'sans-serif';
el.style.fontSize = '13px';
el.style.maxWidth = '320px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.5)';
el.style.transition = 'all 0.2s';
this.container.appendChild(el);
// Handle clicks - either custom handler or auto-open phone
if (typeof payload.onClick === 'function') {
el.style.cursor = 'pointer';
el.addEventListener('click', () => payload.onClick(el));
} else if (payload.openPhone !== false && npcId) {
// Default: clicking bark opens phone chat with this NPC
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
this.openPhoneChat(payload);
// Remove bark when clicked
if (el.parentNode) el.parentNode.removeChild(el);
});
// Add visual hint that it's clickable
el.addEventListener('mouseenter', () => {
el.style.background = 'rgba(74, 158, 255, 0.9)';
el.style.transform = 'scale(1.05)';
});
el.addEventListener('mouseleave', () => {
el.style.background = 'rgba(0,0,0,0.8)';
el.style.transform = 'scale(1)';
});
}
setTimeout(() => {
if (el && el.parentNode) el.parentNode.removeChild(el);
}, duration);
return el;
}
async openPhoneChat(payload) {
const { npcId, npcName, avatar, inkStoryPath, startKnot } = payload;
console.log('📱 Opening phone chat for NPC:', npcId, 'with payload:', payload);
// Get NPC data from manager if available
let npcData = null;
if (this.npcManager) {
npcData = this.npcManager.getNPC(npcId);
console.log('📋 NPC data from manager:', npcData);
}
// Build minigame params
const params = {
npcId: npcId,
npcName: npcName || (npcData && npcData.displayName) || npcId,
avatar: avatar || (npcData && npcData.avatar),
inkStoryPath: inkStoryPath || (npcData && npcData.storyPath),
startKnot: startKnot || (npcData && npcData.currentKnot)
};
console.log('📱 Final params for phone chat:', params);
// Try MinigameFramework first (for full game)
if (window.MinigameFramework && typeof window.MinigameFramework.startMinigame === 'function') {
window.MinigameFramework.startMinigame('phone-chat', params);
return;
}
// Fallback: try to dynamically load MinigameFramework (only works if Phaser is available)
if (typeof window.Phaser !== 'undefined') {
try {
await import('../minigames/index.js');
if (window.MinigameFramework && typeof window.MinigameFramework.startMinigame === 'function') {
window.MinigameFramework.startMinigame('phone-chat', params);
return;
}
} catch (err) {
console.warn('Failed to load minigames module (Phaser-based):', err);
}
}
// Final fallback: create inline phone UI for testing environments without Phaser
console.log('Using inline fallback phone UI (no Phaser/MinigameFramework)');
this.createInlinePhoneUI(params);
}
createInlinePhoneUI(params) {
// Create a simple phone-like chat UI inline
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8); z-index: 10000;
display: flex; align-items: center; justify-content: center;
`;
const phone = document.createElement('div');
phone.style.cssText = `
width: 360px; height: 600px; background: #1a1a1a;
border: 2px solid #333; display: flex; flex-direction: column;
font-family: sans-serif;
`;
// Header
const header = document.createElement('div');
header.style.cssText = `
background: #2a2a2a; padding: 16px; border-bottom: 2px solid #333;
display: flex; align-items: center; justify-content: space-between;
`;
header.innerHTML = `
<span style="color: white; font-weight: bold;">${params.npcName || 'Chat'}</span>
<button id="phone-close" style="background: #d32f2f; color: white; border: none; padding: 4px 12px; cursor: pointer;">Close</button>
`;
// Messages container
const messages = document.createElement('div');
messages.id = 'phone-messages';
messages.style.cssText = `
flex: 1; overflow-y: auto; padding: 16px; background: #0a0a0a;
`;
// Choices container
const choices = document.createElement('div');
choices.id = 'phone-choices';
choices.style.cssText = `
padding: 12px; background: #1a1a1a; border-top: 2px solid #333;
display: flex; flex-direction: column; gap: 8px;
`;
phone.appendChild(header);
phone.appendChild(messages);
phone.appendChild(choices);
overlay.appendChild(phone);
document.body.appendChild(overlay);
// Close handler
header.querySelector('#phone-close').addEventListener('click', () => {
document.body.removeChild(overlay);
});
// Load and run Ink story
if (params.inkStoryPath && window.InkEngine) {
this.runInlineStory(params, messages, choices);
} else {
messages.innerHTML = '<div style="color: #999; text-align: center; margin-top: 50%;">No story path provided or InkEngine not loaded</div>';
}
}
async runInlineStory(params, messagesContainer, choicesContainer) {
try {
// Fetch story JSON
const response = await fetch(params.inkStoryPath);
const storyJson = await response.json();
// Create engine instance
const engine = new window.InkEngine();
engine.loadStory(storyJson);
// Set NPC name variable if the story supports it
try {
engine.setVariable('npc_name', params.npcName || params.npcId);
console.log('✅ Set npc_name variable to:', params.npcName || params.npcId);
} catch (e) {
console.log('⚠️ Story does not have npc_name variable (this is ok)');
}
console.log('📖 Story loaded, navigating to knot:', params.startKnot);
// Navigate to start knot if specified
if (params.startKnot) {
engine.goToKnot(params.startKnot);
console.log('✅ Navigated to knot:', params.startKnot);
}
// Display conversation history first
if (this.npcManager) {
const history = this.npcManager.getConversationHistory(params.npcId);
console.log(`📜 Loading ${history.length} messages from history for NPC: ${params.npcId}`);
console.log('History content:', history);
history.forEach(msg => {
const msgDiv = document.createElement('div');
if (msg.type === 'player') {
msgDiv.style.cssText = `
background: #4a9eff; color: white; padding: 10px;
border-radius: 8px; margin-bottom: 8px; max-width: 80%;
margin-left: auto; text-align: right;
`;
} else {
msgDiv.style.cssText = `
background: #2a5a8a; color: white; padding: 10px;
border-radius: 8px; margin-bottom: 8px; max-width: 80%;
`;
}
msgDiv.textContent = msg.text;
messagesContainer.appendChild(msgDiv);
});
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Continue story and render
const continueStory = (playerChoiceText = null) => {
console.log('📖 Continuing story...');
// If player made a choice, show it as a player message and record it
if (playerChoiceText) {
const playerMsg = document.createElement('div');
playerMsg.style.cssText = `
background: #4a9eff; color: white; padding: 10px;
border-radius: 8px; margin-bottom: 8px; max-width: 80%;
margin-left: auto; text-align: right;
`;
playerMsg.textContent = playerChoiceText;
messagesContainer.appendChild(playerMsg);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Record player choice in history
if (this.npcManager) {
this.npcManager.addMessage(params.npcId, 'player', playerChoiceText);
}
}
const result = engine.continue();
console.log('Story result:', {
text: result.text,
textLength: result.text ? result.text.length : 0,
choicesCount: result.choices ? result.choices.length : 0,
canContinue: result.canContinue
});
// Add NPC message if there's text and record it
if (result.text && result.text.trim()) {
const msg = document.createElement('div');
msg.style.cssText = `
background: #2a5a8a; color: white; padding: 10px;
border-radius: 8px; margin-bottom: 8px; max-width: 80%;
`;
msg.textContent = result.text.trim();
messagesContainer.appendChild(msg);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
console.log('✅ Message added:', result.text.trim().substring(0, 50) + '...');
// Record NPC message in history
if (this.npcManager) {
this.npcManager.addMessage(params.npcId, 'npc', result.text.trim());
}
} else {
console.warn('⚠️ No text in result');
}
// Clear and add choices
choicesContainer.innerHTML = '';
if (result.choices && result.choices.length > 0) {
console.log('✅ Adding', result.choices.length, 'choices');
result.choices.forEach((choice, index) => {
const btn = document.createElement('button');
btn.style.cssText = `
background: #4a9eff; color: white; border: 2px solid #6ab0ff;
padding: 10px; cursor: pointer; font-size: 14px;
transition: background 0.2s;
`;
btn.textContent = choice.text;
btn.addEventListener('mouseenter', () => {
btn.style.background = '#6ab0ff';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = '#4a9eff';
});
btn.addEventListener('click', () => {
console.log('Choice selected:', index, choice.text);
engine.choose(index);
// Pass the choice text so it appears as a player message
continueStory(choice.text);
});
choicesContainer.appendChild(btn);
});
} else if (!result.canContinue) {
// Story ended
console.log('📕 Story ended');
const endMsg = document.createElement('div');
endMsg.style.cssText = 'color: #999; text-align: center; padding: 12px; font-style: italic;';
endMsg.textContent = '— End of conversation —';
messagesContainer.appendChild(endMsg);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
} else {
console.log('⚠️ No choices but story can continue');
}
};
continueStory();
} catch (err) {
console.error('❌ Failed to run inline story:', err);
messagesContainer.innerHTML = `<div style="color: #f44; padding: 16px;">Error loading story: ${err.message}</div>`;
}
}
}

36
js/systems/npc-events.js Normal file
View File

@@ -0,0 +1,36 @@
// Minimal event dispatcher for NPC events
// Exports default class NPCEventDispatcher with .on(pattern, cb) and .emit(type, data)
export default class NPCEventDispatcher {
constructor(opts = {}) {
this.debug = !!opts.debug;
this.listeners = new Map(); // map eventType -> [callbacks]
}
on(eventType, cb) {
if (!eventType || typeof cb !== 'function') return;
if (!this.listeners.has(eventType)) this.listeners.set(eventType, []);
this.listeners.get(eventType).push(cb);
}
off(eventType, cb) {
if (!this.listeners.has(eventType)) return;
if (!cb) { this.listeners.delete(eventType); return; }
const arr = this.listeners.get(eventType).filter(f => f !== cb);
this.listeners.set(eventType, arr);
}
emit(eventType, data) {
if (this.debug) console.log('[NPCEventDispatcher] emit', eventType, data);
// exact-match listeners
const exact = this.listeners.get(eventType) || [];
for (const fn of exact) try { fn(data); } catch (e) { console.error(e); }
// wildcard-style listeners where eventType is a prefix (e.g. 'npc:')
for (const [key, arr] of this.listeners.entries()) {
if (key.endsWith('*')) {
const prefix = key.slice(0, -1);
if (eventType.startsWith(prefix)) for (const fn of arr) try { fn(data); } catch (e) { console.error(e); }
}
}
}
}

219
js/systems/npc-manager.js Normal file
View File

@@ -0,0 +1,219 @@
// NPCManager with event → knot auto-mapping and conversation history
// default export NPCManager
export default class NPCManager {
constructor(eventDispatcher, barkSystem = null) {
this.eventDispatcher = eventDispatcher;
this.barkSystem = barkSystem;
this.npcs = new Map();
this.eventListeners = new Map(); // Track registered listeners for cleanup
this.triggeredEvents = new Map(); // Track which events have been triggered per NPC
this.conversationHistory = new Map(); // Track conversation history per NPC: { npcId: [ {type, text, timestamp, choiceText} ] }
}
// registerNPC(id, opts) or registerNPC({ id, ...opts })
// opts: {
// displayName, storyPath, avatar, currentKnot,
// phoneId: 'player_phone' | 'office_phone' | null, // Which phone this NPC uses
// npcType: 'phone' | 'sprite', // Text-only phone NPC or in-world sprite
// eventMappings: { 'event_pattern': { knot, bark, once, cooldown } }
// }
registerNPC(id, opts = {}) {
// Accept either registerNPC(id, opts) or registerNPC({ id, ...opts })
let realId = id;
let realOpts = opts;
if (typeof id === 'object' && id !== null) {
realOpts = id;
realId = id.id;
}
if (!realId) throw new Error('registerNPC requires an id');
const entry = Object.assign({
id: realId,
displayName: realId,
metadata: {},
eventMappings: {},
phoneId: 'player_phone', // Default to player's phone
npcType: 'phone' // Default to phone-based NPC
}, realOpts);
this.npcs.set(realId, entry);
// Initialize conversation history for this NPC
if (!this.conversationHistory.has(realId)) {
this.conversationHistory.set(realId, []);
}
// Set up event listeners for auto-mapping
if (entry.eventMappings && this.eventDispatcher) {
this._setupEventMappings(realId, entry.eventMappings);
}
return entry;
}
getNPC(id) {
return this.npcs.get(id) || null;
}
// Set bark system (can be set after construction)
setBarkSystem(barkSystem) {
this.barkSystem = barkSystem;
}
// Add a message to conversation history
addMessage(npcId, type, text, metadata = {}) {
if (!this.conversationHistory.has(npcId)) {
this.conversationHistory.set(npcId, []);
}
const history = this.conversationHistory.get(npcId);
history.push({
type: type, // 'npc' or 'player'
text: text,
timestamp: Date.now(),
...metadata
});
console.log(`[NPCManager] Added ${type} message to ${npcId} history:`, text);
}
// Get conversation history for an NPC
getConversationHistory(npcId) {
return this.conversationHistory.get(npcId) || [];
}
// Clear conversation history for an NPC
clearConversationHistory(npcId) {
this.conversationHistory.set(npcId, []);
}
// Get all NPCs for a specific phone
getNPCsByPhone(phoneId) {
return Array.from(this.npcs.values()).filter(npc => npc.phoneId === phoneId);
}
// Set up event listeners for an NPC's event mappings
_setupEventMappings(npcId, eventMappings) {
if (!this.eventDispatcher) return;
for (const [eventPattern, mapping] of Object.entries(eventMappings)) {
// Mapping can be:
// - string (just knot name)
// - object { knot, bark, once, cooldown, condition }
let config = typeof mapping === 'string' ? { knot: mapping } : mapping;
const listener = (eventData) => {
this._handleEventMapping(npcId, eventPattern, config, eventData);
};
// Register listener with event dispatcher
this.eventDispatcher.on(eventPattern, listener);
// Track listener for cleanup
if (!this.eventListeners.has(npcId)) {
this.eventListeners.set(npcId, []);
}
this.eventListeners.get(npcId).push({ pattern: eventPattern, listener });
}
}
// Handle when a mapped event fires
_handleEventMapping(npcId, eventPattern, config, eventData) {
const npc = this.getNPC(npcId);
if (!npc) return;
// Check if event should be handled
const eventKey = `${npcId}:${eventPattern}`;
const triggered = this.triggeredEvents.get(eventKey) || { count: 0, lastTime: 0 };
// Check if this is a once-only event that's already triggered
if (config.once && triggered.count > 0) {
return;
}
// Check cooldown (in milliseconds, default 5000ms = 5s)
const cooldown = config.cooldown || 5000;
const now = Date.now();
if (triggered.lastTime && (now - triggered.lastTime < cooldown)) {
return;
}
// Check condition function if provided
if (config.condition && typeof config.condition === 'function') {
if (!config.condition(eventData, npc)) {
return;
}
}
// Update triggered tracking
triggered.count++;
triggered.lastTime = now;
this.triggeredEvents.set(eventKey, triggered);
// Update NPC's current knot if specified
if (config.knot) {
npc.currentKnot = config.knot;
}
// Show bark if bark system is available and bark text/message provided
if (this.barkSystem && (config.bark || config.message)) {
const barkText = config.bark || config.message;
// Add bark message to conversation history
this.addMessage(npcId, 'npc', barkText, {
eventPattern,
knot: config.knot
});
this.barkSystem.showBark({
npcId: npc.id,
npcName: npc.displayName,
message: barkText,
avatar: npc.avatar,
inkStoryPath: npc.storyPath,
startKnot: config.knot || npc.currentKnot,
phoneId: npc.phoneId
});
}
console.log(`[NPCManager] Event '${eventPattern}' triggered for NPC '${npcId}' → knot '${config.knot}'`);
}
// Unregister an NPC and clean up its event listeners
unregisterNPC(id) {
const listeners = this.eventListeners.get(id);
if (listeners && this.eventDispatcher) {
listeners.forEach(({ pattern, listener }) => {
this.eventDispatcher.off(pattern, listener);
});
this.eventListeners.delete(id);
}
// Clean up triggered events tracking
for (const key of this.triggeredEvents.keys()) {
if (key.startsWith(`${id}:`)) {
this.triggeredEvents.delete(key);
}
}
this.npcs.delete(id);
}
// Helper to emit events about an NPC
emit(npcId, type, payload = {}) {
const ev = Object.assign({ npcId, type }, payload);
this.eventDispatcher && this.eventDispatcher.emit(type, ev);
}
// Get all NPCs
getAllNPCs() {
return Array.from(this.npcs.values());
}
// Check if an event has been triggered for an NPC
hasTriggered(npcId, eventPattern) {
const eventKey = `${npcId}:${eventPattern}`;
const triggered = this.triggeredEvents.get(eventKey);
return triggered ? triggered.count > 0 : false;
}
}

View File

@@ -0,0 +1,166 @@
# NPC Implementation Progress
## Completed (Phase 1: Core Infrastructure)
### ✅ Directory Structure
- [x] `assets/vendor/` - Moved ink.js library
- [x] `assets/npc/avatars/` - Placeholder avatars (npc_alice.png, npc_bob.png)
- [x] `assets/npc/sounds/` - Sound directory created
- [x] `js/systems/ink/` - Ink engine module
- [x] `js/minigames/phone-chat/` - Phone chat minigame directory
- [x] `scenarios/ink/` - Source Ink scripts
- [x] `scenarios/compiled/` - Compiled Ink JSON files
### ✅ Core Systems Implemented
- [x] **InkEngine** (`js/systems/ink/ink-engine.js`) - Enhanced
- Load/parse compiled Ink JSON
- Navigate to knots
- Continue dialogue (returns structured result: {text, choices, canContinue})
- Make choices
- Get/set variables with value unwrapping
- Tag parsing support
- **Status**: Tested and working ✅
- [x] **NPCEventDispatcher** (`js/systems/npc-events.js`) - Complete
- Event emission and listening
- Pattern matching (wildcards supported)
- Cooldown system
- Event queue processing
- Priority-based listener sorting
- Event history tracking
- Debug mode
- **Status**: Tested and working ✅
- [x] **NPCManager** (`js/systems/npc-manager.js`) - Enhanced with auto-mapping
- NPC registration
- **Event → Knot auto-mapping** ✅
- Automatic bark triggers on game events
- Support for once-only events
- Configurable cooldowns (default 5s)
- Conditional triggers via functions
- Pattern matching support (e.g., `item_picked_up:*`)
- Conversation state management
- Current knot tracking
- Event listener cleanup
- Integration with InkEngine and BarkSystem
- **Status**: Implemented, ready for testing
- [x] **NPCBarkSystem** (`js/systems/npc-barks.js`) - Enhanced
- Bark notification popups
- Auto-dismiss (4s default)
- Click to open phone chat
- **Inline fallback phone UI** for testing (no Phaser required)
- Modal overlay with phone-shaped container
- Message rendering (NPC left-aligned, player right-aligned)
- Choice buttons with hover states
- Scrollable conversation history
- Close button
- Dynamic import of MinigameFramework when Phaser available
- HTML sanitization
- **Status**: Tested and working ✅
### ✅ Example Stories
- [x] **alice-chat.ink** - Complete branching dialogue example
- Trust level system (0-5+)
- Conditional choices that appear/disappear
- State tracking (knows_about_breach, has_keycard)
- Once-only topics
- Multiple endings
- Realistic security consultant persona
- **Status**: Tested and working ✅
### ✅ Test Harness
- [x] `test-npc-ink.html` - Comprehensive test page
- ink.js library verification
- InkEngine story loading/continuation
- Event system testing
- Bark display testing
- Phone chat integration testing
- **Auto-trigger testing** ✅
- Visual console output
- **Status**: Complete and functional
## In Progress (Phase 2: Game Integration)
### 🔄 Testing & Verification
- [x] Create test HTML page ✅
- [x] Verify ink.js loads correctly ✅
- [x] Test InkEngine with story JSON ✅
- [x] Test event emission ✅
- [x] Test bark display ✅
- [x] Test NPC Manager registration ✅
- [x] Test inline phone UI ✅
- [x] Test branching dialogue ✅
- [ ] Test auto-trigger workflow (ready to test)
- [ ] Test in main game environment
## TODO (Phase 2: Phone Chat Minigame)
### 📋 Phone Chat UI
- [ ] Create `PhoneChatMinigame` class (extend MinigameScene)
- [ ] Contact list view
- [ ] Conversation view
- [ ] Message bubbles (NPC/player)
- [ ] Choice buttons
- [ ] Message history
- [ ] Typing indicator
- [ ] CSS styling (`css/phone-chat.css`)
### 📋 Phone Access
- [ ] Phone access button (bottom-right)
- [ ] Unread badge system
- [ ] Integration with existing phone minigame
- [ ] Phones in rooms trigger NPC chat
## TODO (Phase 3: Additional Events)
### 📋 Event Emissions
- [ ] Door events (door_unlocked, door_locked, door_attempt_failed)
- [ ] Minigame events (minigame_completed, minigame_started, minigame_failed)
- [ ] Interaction events (object_interacted, fingerprint_collected, bluetooth_device_found)
- [ ] Progress events (objective_completed, suspect_identified, mission_phase_changed)
## TODO (Phase 4: Scenario Integration)
### 📋 Example Scenario
- [ ] Create biometric_breach_npcs.ink
- [ ] Compile to JSON
- [ ] Update biometric_breach.json with NPC config
- [ ] Test full integration
## TODO (Phase 5: Polish & Testing)
### 📋 Enhancements
- [ ] Sound effects (message_received.wav)
- [ ] Better NPC avatars
- [ ] State persistence
- [ ] Error handling improvements
- [ ] Performance optimization
## File Statistics
| File | Lines | Status |
|------|-------|--------|
| ink-engine.js | 360 | ✅ Complete |
| npc-events.js | 230 | ✅ Complete |
| npc-manager.js | 220 | ✅ Complete |
| npc-barks.js | 190 | ✅ Complete |
| npc-barks.css | 145 | ✅ Complete |
| test.ink | 40 | ✅ Complete |
**Total implemented: ~1,185 lines**
## Next Steps
1. Create test HTML page to verify Ink integration
2. Test bark system with manual triggers
3. Test event system with room transitions
4. Begin phone chat minigame implementation
## Issues Found
None so far - initial implementation complete and compiling successfully.
---
**Last Updated:** 2025-10-29 00:31
**Status:** Phase 1 Complete - Moving to Testing

View File

@@ -0,0 +1,68 @@
# Test Harness Fixes - October 29, 2025
## Issues Resolved
### 1. Duplicate Class Declaration
**Error**: `Uncaught SyntaxError: Identifier 'InkEngine' has already been declared`
**Cause**: The `ink-engine.js` file had two `export class InkEngine` declarations - the minimal test version and a duplicate from planning code.
**Fix**: Removed the duplicate class declaration (lines 84-410) leaving only the minimal test-compatible InkEngine wrapper.
### 2. Script Load Order
**Error**: `window.InkEngine is not a constructor`
**Cause**: The module script was trying to call the `log()` function before it was defined in the subsequent script block.
**Fix**: Reorganized test-npc-ink.html script blocks:
1. Load ink.js library
2. Define helper functions (log, updateStatus)
3. Load ES modules and initialize systems
4. Define test functions
### 3. System Initialization
**Error**: Multiple "Cannot read properties of undefined" errors for npcEvents, npcManager, npcBarkSystem
**Cause**: The initialization code in the module block was running before the log function existed, preventing proper initialization and error reporting.
**Fix**: Moved the log function definition before the module imports, ensuring proper initialization order.
## Files Modified
### js/systems/ink/ink-engine.js
- Removed duplicate class declaration (lines 84-410)
- Kept only minimal InkEngine wrapper with methods: loadStory, continue, goToKnot, choose, getVariable, setVariable
- Properties: currentText, currentChoices
### test-npc-ink.html
- Reordered script blocks for proper initialization
- log() and updateStatus() now defined before module imports
- Module script can now safely call log() during initialization
## Current State
All modules now properly:
- Export default classes as expected by test harness
- Initialize without errors
- Have methods callable from test buttons
The test page should now:
- Load ink.js library ✓
- Initialize all NPC systems ✓
- Allow story loading and interaction ✓
- Support event emission and barks ✓
- Allow NPC registration ✓
## Next Steps
Test the page by:
1. Serve the repo: `python3 -m http.server 8000`
2. Open: http://localhost:8000/test-npc-ink.html
3. Click through test buttons in order
4. Verify console shows "✅ Systems initialized" on page load
5. Load test story and interact with it
If all tests pass, proceed to:
- Wire bark click → phone minigame integration
- Implement event cooldowns
- Add automatic event → knot mapping for NPCs

View File

@@ -0,0 +1,44 @@
# NPC Ink Integration - Implementation Log
## Session 1: October 29, 2025
### Phase 1: Test Harness & Core Modules ✅
**Status**: Complete
**Issues Fixed**:
- Duplicate class declarations in all module files (600+ lines removed)
- Incomplete comment syntax in npc-barks.js
- Script load order in test-npc-ink.html
- Module export/import mismatches
**Files Created**:
- `js/systems/ink/ink-engine.js` (83 lines) - Ink wrapper
- `js/systems/npc-events.js` (36 lines) - Event dispatcher
- `js/systems/npc-manager.js` (33 lines) - NPC registry
- `js/systems/npc-barks.js` (90+ lines) - Bark UI with phone integration
- `test-npc-ink.html` (500 lines) - Test harness
**Test Results**: All systems operational ✅
### Phase 2: Phone Chat Integration ✅
**Status**: Complete
**Files Created**:
- `js/minigames/phone-chat/phone-chat-minigame.js` (200 lines)
- `css/phone-chat-minigame.css` (180 lines)
**Features**:
1. PhoneChatMinigame - Ink-based conversations
2. Auto-open phone on bark click
3. Message display (NPC/player/system)
4. Choice rendering and selection
5. Story continuation until end
**Modified**:
- `js/minigames/index.js` - Registered phone-chat
- `index.html` - Added CSS link
- `js/systems/npc-barks.js` - Added openPhoneChat()
### Next: Event Cooldowns & Auto-Mapping
**Test**: Click "Test Bark → Phone Chat" in test harness to verify integration!

View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["ev",0,"/ev",{"VAR=":"trust_level","re":true},"^Alice: Hey! I'm Alice, the security consultant here. What can I help you with?","\n",{"->":"hub"},null],"hub":[["ev","str","^Ask about security protocols","/str",{"VAR?":"topic_discussed_security"},"!","/ev",{"*":".^.c-0","flg":5},"ev","str","^Ask about the building layout","/str",{"VAR?":"topic_discussed_building"},"!","/ev",{"*":".^.c-1","flg":5},"ev","str","^Make small talk","/str",{"VAR?":"topic_discussed_personal"},"!","/ev",{"*":".^.c-2","flg":5},"ev","str","^Ask if there are any security concerns","/str",{"VAR?":"trust_level"},2,">=",{"VAR?":"knows_about_breach"},"!","&&","/ev",{"*":".^.c-3","flg":5},"ev","str","^Ask for access to the server room","/str",{"VAR?":"knows_about_breach"},{"VAR?":"has_keycard"},"!","&&","/ev",{"*":".^.c-4","flg":5},"ev","str","^Thank her and say goodbye","/str",{"VAR?":"has_keycard"},"/ev",{"*":".^.c-5","flg":5},"ev","str","^Say goodbye","/str","/ev",{"*":".^.c-6","flg":4},{"c-0":["^ ","\n",{"->":"topic_security"},null],"c-1":["\n",{"->":"topic_building"},null],"c-2":["\n",{"->":"topic_personal"},null],"c-3":["\n",{"->":"reveal_breach"},null],"c-4":["\n",{"->":"request_keycard"},null],"c-5":["\n",{"->":"ending_success"},null],"c-6":["\n",{"->":"ending_neutral"},null]}],null],"topic_security":["ev",true,"/ev",{"VAR=":"topic_discussed_security","re":true},"ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: Our security system uses biometric authentication and keycard access. Pretty standard corporate stuff.","\n","ev",{"VAR?":"trust_level"},2,">=","/ev",[{"->":".^.b","c":true},{"b":["^ Alice: Between you and me, some of the legacy systems worry me a bit...",{"->":".^.^.^.18"},null]}],"nop","\n",{"->":"hub"},null],"topic_building":["ev",true,"/ev",{"VAR=":"topic_discussed_building","re":true},"ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: The building has three main floors. Server room is on the second floor, but you need clearance for that.","\n","ev",{"VAR?":"trust_level"},3,">=","/ev",[{"->":".^.b","c":true},{"b":["^ Alice: The back stairwell has a blind spot in the camera coverage, just FYI.",{"->":".^.^.^.18"},null]}],"nop","\n",{"->":"hub"},null],"topic_personal":["ev",true,"/ev",{"VAR=":"topic_discussed_personal","re":true},"ev",{"VAR?":"trust_level"},2,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: Oh, making small talk? *smiles* I appreciate that. Most people here just see me as \"the security lady.\"","\n","^Alice: I actually studied cybersecurity at MIT. Love puzzles and breaking systems... professionally, of course!","\n",{"->":"hub"},null],"reveal_breach":["ev",true,"/ev",{"VAR=":"knows_about_breach","re":true},"ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: *looks around nervously*","\n","^Alice: Actually... I've been noticing some weird network activity. Someone's been accessing systems they shouldn't.","\n","^Alice: I can't prove it yet, but I think we might have an insider threat situation.","\n",{"->":"hub"},null],"request_keycard":["ev",{"VAR?":"trust_level"},4,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",true,"/ev",{"VAR=":"has_keycard","re":true},"^Alice: You know what? I trust you. Here's a temporary access card for the server room.","\n","^Alice: Just... be careful, okay? And if you find anything suspicious, let me know immediately.","\n",{"->":"hub"},{"->":".^.^.^.7"},null]}],[{"->":".^.b"},{"b":["\n","^Alice: I'd love to help, but I don't know you well enough to give you that kind of access yet.","\n","^Alice: Maybe if we talk more, I'll feel more comfortable...","\n",{"->":"hub"},{"->":".^.^.^.7"},null]}],"nop","\n",null],"ending_success":["^Alice: Good luck in there. And hey... thanks for taking this seriously.","\n","^Alice: Not everyone would help investigate something like this.","\n","end",null],"ending_neutral":["^Alice: Alright, see you around! Let me know if you need anything security-related.","\n","end","end",null],"global decl":["ev",0,{"VAR=":"trust_level"},false,{"VAR=":"knows_about_breach"},false,{"VAR=":"has_keycard"},false,{"VAR=":"topic_discussed_security"},false,{"VAR=":"topic_discussed_building"},false,{"VAR=":"topic_discussed_personal"},"/ev","end",null]}],"listDefs":{}}

View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["ev",{"VAR?":"conversation_count"},1,"+",{"VAR=":"conversation_count","re":true},"/ev","ev",{"VAR?":"npc_name"},"out","/ev","^: Hey there! This is conversation ","#","ev",{"VAR?":"conversation_count"},"out","/ev","^.","/#","\n","ev",{"VAR?":"npc_name"},"out","/ev","^: What can I help you with?","\n",{"->":"hub"},null],"hub":[["ev","str","^Ask a question","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Say hello","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Say goodbye","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["\n",{"->":"question"},null],"c-1":["\n",{"->":"greeting"},null],"c-2":["\n",{"->":"goodbye"},null]}],null],"question":["ev",{"VAR?":"npc_name"},"out","/ev","^: That's a good question. Let me think about it...","\n","ev",{"VAR?":"npc_name"},"out","/ev","^: I'm not sure I have all the answers right now.","\n",{"->":"hub"},null],"greeting":["ev",{"VAR?":"npc_name"},"out","/ev","^: Hello to you too! Nice to chat with you.","\n",{"->":"hub"},null],"goodbye":["ev",{"VAR?":"npc_name"},"out","/ev","^: Alright, see you later! Let me know if you need anything else.","\n","end",null],"global decl":["ev","str","^NPC","/str",{"VAR=":"npc_name"},0,{"VAR=":"conversation_count"},"/ev","end",null]}],"listDefs":{}}

View File

View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^Hello! This is a test message from Ink.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"test_room_reception":["#","^speaker: TestNPC","/#","#","^type: bark","/#","ev",{"VAR?":"player_visited_room"},"/ev",[{"->":".^.b","c":true},{"b":["\n","^You're back in reception.","\n",{"->":".^.^.^.11"},null]}],[{"->":".^.b"},{"b":["\n","^Welcome to reception! This is your first time here.","\n","ev",true,"/ev",{"VAR=":"player_visited_room","re":true},{"->":".^.^.^.11"},null]}],"nop","\n","end",null],"test_item_lockpick":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^You picked up a lockpick! Nice find.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"hub":[["#","^speaker: TestNPC","/#","#","^type: conversation","/#","^What would you like to test?","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n","ev","str","^Test choice 1","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Test choice 2","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Exit","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["^ ",{"->":"test_1"},"\n",null],"c-1":["^ ",{"->":"test_2"},"\n",null],"c-2":["^ ","end","\n",null]}],null],"test_1":["#","^speaker: TestNPC","/#","^You selected test choice 1!","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n",{"->":"hub"},null],"test_2":["#","^speaker: TestNPC","/#","^You selected test choice 2!","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev",{"->":"hub"},null],"global decl":["ev",0,{"VAR=":"test_counter"},false,{"VAR=":"player_visited_room"},"/ev","end",null]}],"listDefs":{}}

View File

@@ -0,0 +1,83 @@
// Alice - Security Consultant NPC
// Demonstrates branching dialogue, conditional choices, and state tracking
VAR trust_level = 0
VAR knows_about_breach = false
VAR has_keycard = false
VAR topic_discussed_security = false
VAR topic_discussed_building = false
VAR topic_discussed_personal = false
=== start ===
~ trust_level = 0
Alice: Hey! I'm Alice, the security consultant here. What can I help you with?
-> hub
=== hub ===
// Status messages are shown as tags, not as regular text
// Remove these or make them system messages
+ {not topic_discussed_security} [Ask about security protocols]
-> topic_security
+ {not topic_discussed_building} [Ask about the building layout]
-> topic_building
+ {not topic_discussed_personal} [Make small talk]
-> topic_personal
+ {trust_level >= 2 and not knows_about_breach} [Ask if there are any security concerns]
-> reveal_breach
+ {knows_about_breach and not has_keycard} [Ask for access to the server room]
-> request_keycard
+ {has_keycard} [Thank her and say goodbye]
-> ending_success
+ [Say goodbye]
-> ending_neutral
=== topic_security ===
~ topic_discussed_security = true
~ trust_level += 1
Alice: Our security system uses biometric authentication and keycard access. Pretty standard corporate stuff.
{trust_level >= 2: Alice: Between you and me, some of the legacy systems worry me a bit...}
-> hub
=== topic_building ===
~ topic_discussed_building = true
~ trust_level += 1
Alice: The building has three main floors. Server room is on the second floor, but you need clearance for that.
{trust_level >= 3: Alice: The back stairwell has a blind spot in the camera coverage, just FYI.}
-> hub
=== topic_personal ===
~ topic_discussed_personal = true
~ trust_level += 2
Alice: Oh, making small talk? *smiles* I appreciate that. Most people here just see me as "the security lady."
Alice: I actually studied cybersecurity at MIT. Love puzzles and breaking systems... professionally, of course!
-> hub
=== reveal_breach ===
~ knows_about_breach = true
~ trust_level += 1
Alice: *looks around nervously*
Alice: Actually... I've been noticing some weird network activity. Someone's been accessing systems they shouldn't.
Alice: I can't prove it yet, but I think we might have an insider threat situation.
-> hub
=== request_keycard ===
{trust_level >= 4:
~ has_keycard = true
Alice: You know what? I trust you. Here's a temporary access card for the server room.
Alice: Just... be careful, okay? And if you find anything suspicious, let me know immediately.
-> hub
- else:
Alice: I'd love to help, but I don't know you well enough to give you that kind of access yet.
Alice: Maybe if we talk more, I'll feel more comfortable...
-> hub
}
=== ending_success ===
Alice: Good luck in there. And hey... thanks for taking this seriously.
Alice: Not everyone would help investigate something like this.
-> END
=== ending_neutral ===
Alice: Alright, see you around! Let me know if you need anything security-related.
-> END
-> END

View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["ev",0,"/ev",{"VAR=":"trust_level","re":true},"^Alice: Hey! I'm Alice, the security consultant here. What can I help you with?","\n",{"->":"hub"},null],"hub":[["ev",{"VAR?":"trust_level"},3,">=","/ev",[{"->":".^.b","c":true},{"b":["^ Alice is now comfortable sharing sensitive information.",{"->":"hub.0.6"},null]}],"nop","\n","ev",{"VAR?":"knows_about_breach"},"/ev",[{"->":".^.b","c":true},{"b":["^ You've learned about a potential security breach.",{"->":"hub.0.12"},null]}],"nop","\n","ev","str","^Ask about security protocols","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Ask about the building layout","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Make small talk","/str",{"VAR?":"topic_discussed_personal"},"!","/ev",{"*":".^.c-2","flg":5},"ev","str","^Ask if there are any security concerns","/str",{"VAR?":"trust_level"},2,">=",{"VAR?":"knows_about_breach"},"!","&&","/ev",{"*":".^.c-3","flg":5},"ev","str","^Ask for access to the server room","/str",{"VAR?":"knows_about_breach"},{"VAR?":"has_keycard"},"!","&&","/ev",{"*":".^.c-4","flg":5},"ev","str","^Thank her and say goodbye","/str",{"VAR?":"has_keycard"},"/ev",{"*":".^.c-5","flg":5},"ev","str","^Say goodbye","/str","/ev",{"*":".^.c-6","flg":4},{"c-0":["^ ","\n",{"->":"topic_security"},null],"c-1":["\n",{"->":"topic_building"},null],"c-2":["\n",{"->":"topic_personal"},null],"c-3":["\n",{"->":"reveal_breach"},null],"c-4":["\n",{"->":"request_keycard"},null],"c-5":["\n",{"->":"ending_success"},null],"c-6":["\n",{"->":"ending_neutral"},null]}],null],"topic_security":["ev",true,"/ev",{"VAR=":"topic_discussed_security","re":true},"ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: Our security system uses biometric authentication and keycard access. Pretty standard corporate stuff.","\n","ev",{"VAR?":"trust_level"},2,">=","/ev",[{"->":".^.b","c":true},{"b":["^ Alice: Between you and me, some of the legacy systems worry me a bit...",{"->":".^.^.^.18"},null]}],"nop","\n",{"->":"hub"},null],"topic_building":["ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: The building has three main floors. Server room is on the second floor, but you need clearance for that.","\n","ev",{"VAR?":"trust_level"},3,">=","/ev",[{"->":".^.b","c":true},{"b":["^ Alice: The back stairwell has a blind spot in the camera coverage, just FYI.",{"->":".^.^.^.14"},null]}],"nop","\n",{"->":"hub"},null],"topic_personal":["ev",true,"/ev",{"VAR=":"topic_discussed_personal","re":true},"ev",{"VAR?":"trust_level"},2,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: Oh, making small talk? *smiles* I appreciate that. Most people here just see me as \"the security lady.\"","\n","^Alice: I actually studied cybersecurity at MIT. Love puzzles and breaking systems... professionally, of course!","\n",{"->":"hub"},null],"reveal_breach":["ev",true,"/ev",{"VAR=":"knows_about_breach","re":true},"ev",{"VAR?":"trust_level"},1,"+",{"VAR=":"trust_level","re":true},"/ev","^Alice: *looks around nervously*","\n","^Alice: Actually... I've been noticing some weird network activity. Someone's been accessing systems they shouldn't.","\n","^Alice: I can't prove it yet, but I think we might have an insider threat situation.","\n",{"->":"hub"},null],"request_keycard":["ev",{"VAR?":"trust_level"},4,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",true,"/ev",{"VAR=":"has_keycard","re":true},"^Alice: You know what? I trust you. Here's a temporary access card for the server room.","\n","^Alice: Just... be careful, okay? And if you find anything suspicious, let me know immediately.","\n",{"->":"hub"},{"->":".^.^.^.7"},null]}],[{"->":".^.b"},{"b":["\n","^Alice: I'd love to help, but I don't know you well enough to give you that kind of access yet.","\n","^Alice: Maybe if we talk more, I'll feel more comfortable...","\n",{"->":"hub"},{"->":".^.^.^.7"},null]}],"nop","\n",null],"ending_success":["^Alice: Good luck in there. And hey... thanks for taking this seriously.","\n","^Alice: Not everyone would help investigate something like this.","\n","end",null],"ending_neutral":["^Alice: Alright, see you around! Let me know if you need anything security-related.","\n","end",null],"global decl":["ev",0,{"VAR=":"trust_level"},false,{"VAR=":"knows_about_breach"},false,{"VAR=":"has_keycard"},false,{"VAR=":"topic_discussed_security"},false,{"VAR=":"topic_discussed_personal"},"/ev","end",null]}],"listDefs":{}}

View File

@@ -0,0 +1,32 @@
// Generic NPC Story - Can be used for any NPC
// The game should set the npc_name variable before starting
VAR npc_name = "NPC"
VAR conversation_count = 0
=== start ===
~ conversation_count += 1
{npc_name}: Hey there! This is conversation #{conversation_count}.
{npc_name}: What can I help you with?
-> hub
=== hub ===
+ [Ask a question]
-> question
+ [Say hello]
-> greeting
+ [Say goodbye]
-> goodbye
=== question ===
{npc_name}: That's a good question. Let me think about it...
{npc_name}: I'm not sure I have all the answers right now.
-> hub
=== greeting ===
{npc_name}: Hello to you too! Nice to chat with you.
-> hub
=== goodbye ===
{npc_name}: Alright, see you later! Let me know if you need anything else.
-> END

49
scenarios/ink/test.ink Normal file
View File

@@ -0,0 +1,49 @@
// Test Ink script for development
VAR test_counter = 0
VAR player_visited_room = false
=== start ===
# speaker: TestNPC
# type: bark
Hello! This is a test message from Ink.
~ test_counter++
-> END
=== test_room_reception ===
# speaker: TestNPC
# type: bark
{player_visited_room:
You're back in reception.
- else:
Welcome to reception! This is your first time here.
~ player_visited_room = true
}
-> END
=== test_item_lockpick ===
# speaker: TestNPC
# type: bark
You picked up a lockpick! Nice find.
~ test_counter++
-> END
=== hub ===
# speaker: TestNPC
# type: conversation
What would you like to test?
Counter: {test_counter}
+ [Test choice 1] -> test_1
+ [Test choice 2] -> test_2
+ [Exit] -> END
=== test_1 ===
# speaker: TestNPC
You selected test choice 1!
Counter: {test_counter}
-> hub
=== test_2 ===
# speaker: TestNPC
You selected test choice 2!
~ test_counter++
-> hub

View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^Hello! This is a test message from Ink.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"test_room_reception":["#","^speaker: TestNPC","/#","#","^type: bark","/#","ev",{"VAR?":"player_visited_room"},"/ev",[{"->":".^.b","c":true},{"b":["\n","^You're back in reception.","\n",{"->":".^.^.^.11"},null]}],[{"->":".^.b"},{"b":["\n","^Welcome to reception! This is your first time here.","\n","ev",true,"/ev",{"VAR=":"player_visited_room","re":true},{"->":".^.^.^.11"},null]}],"nop","\n","end",null],"test_item_lockpick":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^You picked up a lockpick! Nice find.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"hub":[["#","^speaker: TestNPC","/#","#","^type: conversation","/#","^What would you like to test?","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n","ev","str","^Test choice 1","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Test choice 2","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Exit","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["^ ",{"->":"test_1"},"\n",null],"c-1":["^ ",{"->":"test_2"},"\n",null],"c-2":["^ ","end","\n",null]}],null],"test_1":["#","^speaker: TestNPC","/#","^You selected test choice 1!","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n",{"->":"hub"},null],"test_2":["#","^speaker: TestNPC","/#","^You selected test choice 2!","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev",{"->":"hub"},null],"global decl":["ev",0,{"VAR=":"test_counter"},false,{"VAR=":"player_visited_room"},"/ev","end",null]}],"listDefs":{}}

650
test-npc-ink.html Normal file
View File

@@ -0,0 +1,650 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NPC Ink Integration Test</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/npc-barks.css">
<style>
body {
margin: 0;
padding: 20px;
background: #2a2a2a;
color: #fff;
font-family: 'VT323', monospace;
}
#test-container {
max-width: 800px;
margin: 0 auto;
background: #1a1a1a;
border: 2px solid #444;
padding: 20px;
}
h1 {
color: #4a9eff;
margin-top: 0;
}
.test-section {
margin: 20px 0;
padding: 15px;
background: #2a2a2a;
border: 2px solid #666;
}
.test-section h2 {
color: #6acc6a;
margin-top: 0;
}
button {
background: #4a9eff;
color: #fff;
border: 2px solid #fff;
padding: 10px 20px;
font-family: 'VT323', monospace;
font-size: 18px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #6abd6a;
}
button:active {
background: #2a7adf;
}
#output {
background: #000;
color: #0f0;
padding: 10px;
font-family: monospace;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
border: 2px solid #0f0;
margin: 10px 0;
}
.success {
color: #0f0;
}
.error {
color: #f00;
}
.info {
color: #4a9eff;
}
#story-output {
background: #000;
color: #fff;
padding: 15px;
border: 2px solid #666;
min-height: 150px;
margin: 10px 0;
}
.choice-button {
display: block;
margin: 5px 0;
width: 100%;
text-align: left;
}
.story-text {
margin: 10px 0;
line-height: 1.5;
}
</style>
</head>
<body>
<div id="test-container">
<h1>🧪 NPC Ink Integration Test</h1>
<div class="test-section">
<h2>1. Library Check</h2>
<button onclick="testInkLibrary()">Test ink.js Library</button>
<div id="library-status"></div>
</div>
<div class="test-section">
<h2>2. InkEngine Test</h2>
<button onclick="testInkEngine()">Load Test Story</button>
<button onclick="testContinueStory()">Continue Story</button>
<button onclick="testGoToKnot()">Go to Knot: hub</button>
<button onclick="testVariables()">Test Variables</button>
<div id="engine-status"></div>
</div>
<div class="test-section">
<h2>3. Story Display</h2>
<div id="story-output"></div>
<div id="choices-container"></div>
</div>
<div class="test-section">
<h2>4. Event System Test</h2>
<button onclick="testEventEmit()">Emit Test Event</button>
<button onclick="testEventCooldown()">Test Cooldown</button>
<button onclick="testEventPattern()">Test Pattern Matching</button>
<div id="event-status"></div>
</div>
<div class="test-section">
<h2>5. Bark System Test</h2>
<button onclick="testBark()">Show Test Bark</button>
<button onclick="testMultipleBarks()">Show 3 Barks</button>
<button onclick="testBarkClick()">Show Clickable Bark</button>
<button onclick="testBarkPhoneOpen()">Test Bark → Phone Chat</button>
<div id="bark-status"></div>
</div>
<div class="test-section">
<h2>6. NPC Manager Test</h2>
<button onclick="testNPCRegistration()">Register Test NPC</button>
<button onclick="testNPCEvent()">Trigger NPC Event</button>
<button onclick="testAutoTrigger()">Test Auto-Trigger (Event Mapping)</button>
<div id="npc-status"></div>
</div>
<div class="test-section">
<h2>📊 Console Output</h2>
<div id="output"></div>
</div>
</div>
<!-- Load ink.js -->
<script src="assets/vendor/ink.js"></script>
<script>
// Define logging function first
function log(message, type = 'info') {
const output = document.getElementById('output');
const line = document.createElement('div');
line.className = type;
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
console.log(message);
}
function updateStatus(elementId, message, isSuccess = true) {
const el = document.getElementById(elementId);
el.innerHTML = `<p class="${isSuccess ? 'success' : 'error'}">${message}</p>`;
}
</script>
<!-- Load NPC systems -->
<script type="module">
import InkEngine from './js/systems/ink/ink-engine.js';
import NPCEventDispatcher from './js/systems/npc-events.js';
import NPCManager from './js/systems/npc-manager.js';
import NPCBarkSystem from './js/systems/npc-barks.js';
// Make modules globally available
window.InkEngine = InkEngine;
window.NPCEventDispatcher = NPCEventDispatcher;
window.NPCManager = NPCManager;
window.NPCBarkSystem = NPCBarkSystem;
// Initialize systems
window.npcEvents = new NPCEventDispatcher({ debug: true });
window.npcBarkSystem = new NPCBarkSystem(null);
window.npcManager = new NPCManager(window.npcEvents, window.npcBarkSystem);
window.npcBarkSystem.npcManager = window.npcManager; // Link bark system to manager
window.npcBarkSystem.init();
window.npcBarkSystem.init();
// Test engine instance
window.testEngine = null;
// Mark as initialized
window.npcSystemsReady = true;
// Log initialization (use setTimeout to ensure log() is available)
setTimeout(() => {
if (typeof log === 'function') {
log('✅ Systems initialized', 'success');
}
console.log('✅ NPC Systems loaded and initialized');
}, 0);
</script>
<script>
// Test 1: ink.js library
function testInkLibrary() {
try {
if (typeof inkjs === 'undefined') {
throw new Error('ink.js library not loaded');
}
updateStatus('library-status', '✅ ink.js library loaded successfully');
log('✅ ink.js library available', 'success');
log(`ink.js Story constructor: ${typeof inkjs.Story}`, 'info');
} catch (error) {
updateStatus('library-status', `${error.message}`, false);
log(`${error.message}`, 'error');
}
}
// Test 2: Load Ink story
async function testInkEngine() {
try {
log('Loading test story...', 'info');
const response = await fetch('scenarios/compiled/test2.json');
const storyJson = await response.json();
window.testEngine = new window.InkEngine('test-npc');
window.testEngine.loadStory(storyJson);
// Navigate to 'start' knot (test story root immediately exits)
window.testEngine.goToKnot('start');
updateStatus('engine-status', '✅ Story loaded successfully');
log('✅ Story loaded and initialized', 'success');
log(`Current text: ${window.testEngine.currentText}`, 'info');
// Display initial story
displayStory();
} catch (error) {
updateStatus('engine-status', `${error.message}`, false);
log(`❌ Error loading story: ${error.message}`, 'error');
console.error(error);
}
}
function testContinueStory() {
if (!window.testEngine) {
log('❌ No story loaded. Load story first.', 'error');
return;
}
try {
window.testEngine.continue();
log('✅ Story continued', 'success');
displayStory();
} catch (error) {
log(`❌ Error continuing story: ${error.message}`, 'error');
}
}
function testGoToKnot() {
if (!window.testEngine) {
log('❌ No story loaded. Load story first.', 'error');
return;
}
try {
window.testEngine.goToKnot('hub');
log('✅ Navigated to knot: hub', 'success');
displayStory();
} catch (error) {
log(`❌ Error going to knot: ${error.message}`, 'error');
}
}
function testVariables() {
if (!window.testEngine) {
log('❌ No story loaded. Load story first.', 'error');
return;
}
try {
const counter = window.testEngine.getVariable('test_counter');
log(`Current test_counter: ${counter}`, 'info');
window.testEngine.setVariable('test_counter', counter + 1);
const newCounter = window.testEngine.getVariable('test_counter');
log(`Updated test_counter: ${newCounter}`, 'success');
updateStatus('engine-status', `✅ Variable test passed (counter: ${newCounter})`);
} catch (error) {
log(`❌ Error testing variables: ${error.message}`, 'error');
}
}
function displayStory() {
const storyOutput = document.getElementById('story-output');
const choicesContainer = document.getElementById('choices-container');
if (!window.testEngine) return;
// Display story text
storyOutput.innerHTML = `<div class="story-text">${window.testEngine.currentText || '(no text)'}</div>`;
// Display choices
choicesContainer.innerHTML = '';
const choices = window.testEngine.currentChoices;
if (choices.length > 0) {
choices.forEach((choice, index) => {
const button = document.createElement('button');
button.className = 'choice-button';
button.textContent = `${index + 1}. ${choice.text}`;
button.onclick = () => makeChoice(index);
choicesContainer.appendChild(button);
});
}
log(`📖 Story displayed. Choices: ${choices.length}`, 'info');
}
function makeChoice(index) {
if (!window.testEngine) return;
try {
window.testEngine.choose(index);
log(`✅ Choice ${index} selected`, 'success');
displayStory();
} catch (error) {
log(`❌ Error making choice: ${error.message}`, 'error');
}
}
// Test 3: Event system
function testEventEmit() {
try {
window.npcEvents.emit('test_event', { data: 'test' });
log('✅ Event emitted: test_event', 'success');
updateStatus('event-status', '✅ Event emitted successfully');
} catch (error) {
log(`❌ Error emitting event: ${error.message}`, 'error');
updateStatus('event-status', `${error.message}`, false);
}
}
function testEventCooldown() {
try {
window.npcEvents.emit('room_entered:test_room');
log('✅ Event 1 emitted', 'success');
// Try immediate re-emit (should be blocked by cooldown)
setTimeout(() => {
window.npcEvents.emit('room_entered:test_room');
log('✅ Event 2 emitted (cooldown should prevent this)', 'info');
}, 100);
updateStatus('event-status', '✅ Cooldown test initiated. Check console.');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
}
}
function testEventPattern() {
try {
let received = false;
window.npcEvents.on('item_picked_up:*', (data) => {
received = true;
log(`✅ Pattern matched: item_picked_up:* received event`, 'success');
});
window.npcEvents.emit('item_picked_up:keycard', { itemType: 'keycard' });
setTimeout(() => {
if (received) {
updateStatus('event-status', '✅ Pattern matching works');
} else {
updateStatus('event-status', '❌ Pattern matching failed', false);
}
}, 200);
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
}
}
// Test 4: Bark system
function testBark() {
try {
// Register alice if not already registered
if (!window.npcManager.getNPC('alice')) {
window.npcManager.registerNPC({
id: 'alice',
displayName: 'Alice',
storyPath: 'scenarios/compiled/alice-chat.json',
avatar: 'assets/npc/avatars/npc_alice.png'
});
}
// Add message to history manually
window.npcManager.addMessage('alice', 'npc', 'This is a test bark notification!');
window.npcBarkSystem.showBark({
npcId: 'alice',
npcName: 'Alice',
message: 'This is a test bark notification!',
avatar: 'assets/npc/avatars/npc_alice.png',
inkStoryPath: 'scenarios/compiled/alice-chat.json',
startKnot: 'start'
});
log('✅ Bark displayed (with history tracking)', 'success');
updateStatus('bark-status', '✅ Bark displayed successfully');
} catch (error) {
log(`❌ Error showing bark: ${error.message}`, 'error');
updateStatus('bark-status', `${error.message}`, false);
}
}
function testMultipleBarks() {
try {
// Register Alice with her story
if (!window.npcManager.getNPC('alice')) {
window.npcManager.registerNPC({
id: 'alice',
displayName: 'Alice',
storyPath: 'scenarios/compiled/alice-chat.json',
avatar: 'assets/npc/avatars/npc_alice.png'
});
}
// Register Bob with generic story
if (!window.npcManager.getNPC('bob')) {
window.npcManager.registerNPC({
id: 'bob',
displayName: 'Bob',
storyPath: 'scenarios/compiled/generic-npc.json',
avatar: 'assets/npc/avatars/npc_bob.png'
});
}
const messages = [
{ npcId: 'alice', npcName: 'Alice', message: 'First bark!' },
{ npcId: 'bob', npcName: 'Bob', message: 'Second bark!' },
{ npcId: 'alice', npcName: 'Alice', message: 'Third bark!' }
];
messages.forEach((msg, i) => {
setTimeout(() => {
// Add to history
window.npcManager.addMessage(msg.npcId, 'npc', msg.message);
window.npcBarkSystem.showBark({
...msg,
avatar: `assets/npc/avatars/npc_${msg.npcId}.png`
// No inkStoryPath - will use NPC's registered storyPath
});
}, i * 500);
});
log('✅ Multiple barks queued (with history tracking)', 'success');
updateStatus('bark-status', '✅ 3 barks will appear');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
}
}
function testBarkClick() {
try {
// Register alice if not already registered
if (!window.npcManager.getNPC('alice')) {
window.npcManager.registerNPC({
id: 'alice',
displayName: 'Alice',
storyPath: 'scenarios/compiled/alice-chat.json',
avatar: 'assets/npc/avatars/npc_alice.png'
});
}
// Add to history
window.npcManager.addMessage('alice', 'npc', 'Click me to open phone!');
window.npcBarkSystem.showBark({
npcId: 'alice',
npcName: 'Alice',
message: 'Click me to open phone!',
avatar: 'assets/npc/avatars/npc_alice.png',
inkStoryPath: 'scenarios/compiled/alice-chat.json',
startKnot: 'start'
});
log('✅ Clickable bark displayed (click it!)', 'success');
updateStatus('bark-status', '✅ Click the bark to test');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
}
}
function testBarkPhoneOpen() {
try {
// Register NPC with story first
window.npcManager.registerNPC({
id: 'alice',
displayName: 'Alice - Security Consultant',
storyPath: 'scenarios/compiled/alice-chat.json',
avatar: 'assets/npc/avatars/npc_alice.png'
});
// Show bark that will open phone when clicked
window.npcBarkSystem.showBark({
npcId: 'alice',
npcName: 'Alice',
message: 'Hey! Click to chat with me.',
avatar: 'assets/npc/avatars/npc_alice.png',
inkStoryPath: 'scenarios/compiled/alice-chat.json',
startKnot: 'start'
});
log('✅ Bark with phone integration shown - click it!', 'success');
updateStatus('bark-status', '✅ Click bark to open phone chat');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
updateStatus('bark-status', `${error.message}`, false);
}
}
// Test 5: NPC Manager
function testNPCRegistration() {
try {
window.npcManager.registerNPC({
id: 'test-bob',
displayName: 'Bob - IT Manager',
storyPath: 'scenarios/compiled/generic-npc.json',
avatar: 'assets/npc/avatars/npc_bob.png',
eventMappings: {
'room_entered:server_room': {
knot: 'start',
bark: 'Hey! You\'re not supposed to be in here!',
once: true
},
'item_picked_up:*': {
knot: 'hub',
bark: 'Did you find something interesting?',
cooldown: 10000
}
}
});
log('✅ NPC registered: test-bob with event mappings', 'success');
updateStatus('npc-status', '✅ NPC registered successfully');
} catch (error) {
log(`❌ Error registering NPC: ${error.message}`, 'error');
updateStatus('npc-status', `${error.message}`, false);
}
}
async function testNPCEvent() {
try {
// Ensure NPC is registered first
if (!window.npcManager.npcs.has('test-bob')) {
testNPCRegistration();
await new Promise(resolve => setTimeout(resolve, 500));
}
// Emit mapped event
window.npcEvents.emit('room_entered:server_room', {
room: 'server_room',
firstVisit: true
});
log('✅ Event emitted: room_entered:server_room', 'success');
log('📱 Check for bark notification!', 'info');
updateStatus('npc-status', '✅ Event triggered. Watch for bark!');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
updateStatus('npc-status', `${error.message}`, false);
}
}
async function testAutoTrigger() {
try {
log('🎯 Testing auto-trigger system with conversation history...', 'info');
// Register an NPC with event mappings
window.npcManager.registerNPC({
id: 'auto-alice',
displayName: 'Alice (Auto)',
storyPath: 'scenarios/compiled/alice-chat.json',
avatar: 'assets/npc/avatars/npc_alice.png',
phoneId: 'player_phone',
npcType: 'phone',
eventMappings: {
'player_moved:*': {
knot: 'start',
bark: 'I see you moving around...',
cooldown: 3000
},
'test_trigger': {
knot: 'start',
bark: '🎉 First message! Click me to chat.',
once: false
},
'test_trigger_2': {
knot: 'hub',
bark: '📬 Second message! Your history should show both.',
once: false
}
}
});
log('✅ Registered auto-alice with event mappings', 'success');
await new Promise(resolve => setTimeout(resolve, 500));
// Emit first test event - should automatically show bark and add to history
log('📤 Emitting test_trigger event (first message)...', 'info');
window.npcEvents.emit('test_trigger', { source: 'test' });
log('✅ First bark sent and added to history', 'success');
// Send a second message after delay
setTimeout(() => {
log('📤 Emitting test_trigger_2 event (second message)...', 'info');
window.npcEvents.emit('test_trigger_2', { source: 'test_2' });
log('💡 Now click either bark to open phone - you should see BOTH messages in history!', 'info');
}, 2000);
updateStatus('npc-status', '✅ Auto-trigger test initiated. Multiple barks will appear. Click any to see full history!');
} catch (error) {
log(`❌ Error: ${error.message}`, 'error');
updateStatus('npc-status', `${error.message}`, false);
}
}
</script>
</body>
</html>