Files
BreakEscape/planning_notes/npc/04_IMPLEMENTATION.md
Z. Cliffe Schreuders 887e5f6443 Add NPC integration planning documents and example scenario
- Created `05_EXAMPLE_SCENARIO.md` with a complete Ink script for the Biometric Breach scenario featuring NPCs Alice and Bob.
- Added `QUICK_REFERENCE.md` as a cheat sheet for NPC system components, event types, and common patterns.
- Introduced `README.md` as an index for NPC integration planning, outlining document structure and key concepts.
2025-10-29 00:18:22 +00:00

32 KiB
Raw Blame History

Implementation Plan: NPC Inkscript Integration

Development Roadmap

This document provides a step-by-step implementation plan for integrating Ink-based NPCs into Break Escape.

Phase 0: Preparation (Before Coding)

0.1 Install Ink Compiler

# Install inklecate (Ink compiler)
npm install -g inkle/ink

# Or download binary from:
# https://github.com/inkle/ink/releases

# Verify installation
inklecate --version

0.2 Set Up Directory Structure

mkdir -p assets/npc/avatars
mkdir -p assets/npc/sounds
mkdir -p scenarios/ink
mkdir -p scenarios/compiled
mkdir -p js/systems/ink
mkdir -p js/minigames/phone-chat

0.3 Download Dependencies

0.4 Create Placeholder Assets

# Placeholder avatars (copy existing assets temporarily)
cp assets/objects/pc.png assets/npc/avatars/npc_alice.png
cp assets/objects/phone.png assets/npc/avatars/npc_bob.png

# Sound effects (can use existing sounds as placeholders)
# or create silent placeholder files

Phase 1: Core Ink Integration (Days 1-3)

1.1 Add Ink.js Library

File: index.html

<!-- Add after Phaser script tag -->
<script src="https://cdn.jsdelivr.net/npm/inkjs@2.2.3/dist/ink.js"></script>

Verify: Open browser console, check window.inkjs exists

1.2 Create Ink Engine Wrapper

File: js/systems/ink/ink-engine.js

// Ink.js wrapper for Break Escape
export class InkEngine {
  constructor() {
    this.stories = new Map(); // npcId -> Story instance
    this.storyData = new Map(); // npcId -> compiled JSON
  }

  // Load compiled Ink JSON
  async loadStory(npcId, jsonPath) {
    try {
      const response = await fetch(jsonPath);
      const storyData = await response.json();
      
      // Create Ink story instance
      const story = new inkjs.Story(storyData);
      
      // Bind external functions
      this.bindExternalFunctions(story);
      
      // Store
      this.stories.set(npcId, story);
      this.storyData.set(npcId, storyData);
      
      console.log(`Loaded Ink story for ${npcId}`);
      return story;
    } catch (error) {
      console.error(`Failed to load Ink story for ${npcId}:`, error);
      return null;
    }
  }

  // Get story instance for NPC
  getStory(npcId) {
    return this.stories.get(npcId);
  }

  // Navigate to a specific knot
  goToKnot(npcId, knotName) {
    const story = this.getStory(npcId);
    if (!story) {
      console.error(`No story found for ${npcId}`);
      return null;
    }

    try {
      story.ChoosePathString(knotName);
      return this.continue(npcId);
    } catch (error) {
      console.error(`Failed to go to knot ${knotName}:`, error);
      return null;
    }
  }

  // Continue story (get next text)
  continue(npcId) {
    const story = this.getStory(npcId);
    if (!story) return null;

    const result = {
      text: '',
      choices: [],
      tags: []
    };

    // Continue story
    while (story.canContinue) {
      const line = story.Continue();
      result.text += line;
      
      // Collect tags from this line
      if (story.currentTags.length > 0) {
        result.tags.push(...story.currentTags);
      }
    }

    // Get available choices
    if (story.currentChoices.length > 0) {
      result.choices = story.currentChoices.map(choice => ({
        index: choice.index,
        text: choice.text
      }));
    }

    return result;
  }

  // Make a choice
  choose(npcId, choiceIndex) {
    const story = this.getStory(npcId);
    if (!story) return null;

    try {
      story.ChooseChoiceIndex(choiceIndex);
      return this.continue(npcId);
    } catch (error) {
      console.error(`Failed to choose option ${choiceIndex}:`, error);
      return null;
    }
  }

  // Get/set Ink variables
  getVariable(npcId, varName) {
    const story = this.getStory(npcId);
    if (!story) return null;
    return story.variablesState[varName];
  }

  setVariable(npcId, varName, value) {
    const story = this.getStory(npcId);
    if (!story) return;
    story.variablesState[varName] = value;
  }

  // Save/restore story state
  saveState(npcId) {
    const story = this.getStory(npcId);
    if (!story) return null;
    return story.state.ToJson();
  }

  restoreState(npcId, stateJson) {
    const story = this.getStory(npcId);
    if (!story) return;
    story.state.LoadJson(stateJson);
  }

  // Bind external functions that Ink can call
  bindExternalFunctions(story) {
    // Items
    story.BindExternalFunction('give_item', (itemType) => {
      console.log(`[Ink] give_item: ${itemType}`);
      if (window.addToInventory) {
        // TODO: Create item object and add to inventory
      }
    });

    story.BindExternalFunction('remove_item', (itemType) => {
      console.log(`[Ink] remove_item: ${itemType}`);
      // TODO: Implement
    });

    // Doors
    story.BindExternalFunction('unlock_door', (doorId) => {
      console.log(`[Ink] unlock_door: ${doorId}`);
      // TODO: Implement door unlocking
    });

    // UI
    story.BindExternalFunction('show_notification', (message) => {
      if (window.showNotification) {
        window.showNotification(message, 'info', 'NPC');
      }
    });

    // Game state queries
    story.BindExternalFunction('get_current_room', () => {
      return window.currentPlayerRoom || '';
    }, true); // true = returns value

    story.BindExternalFunction('has_item', (itemType) => {
      if (!window.inventory) return false;
      return window.inventory.items.some(item => item.type === itemType);
    }, true);
  }

  // Parse tags into structured data
  parseTags(tags) {
    const parsed = {};
    
    tags.forEach(tag => {
      const [key, ...valueParts] = tag.split(':');
      const value = valueParts.join(':').trim();
      parsed[key.trim()] = value;
    });

    return parsed;
  }
}

// Global instance
window.inkEngine = new InkEngine();

Test:

// In browser console
console.log(window.inkEngine);

1.3 Create Simple Test Ink Script

File: scenarios/ink/test.ink

// Test Ink script for development
VAR test_counter = 0

=== start ===
# speaker: TestNPC
# type: bark
Hello! This is a test message from Ink.
~ test_counter++
-> hub

=== hub ===
# speaker: TestNPC
# type: conversation
What would you like to test?
+ [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

Compile:

cd scenarios/ink
inklecate test.ink -o ../compiled/test.json

Verify: Check that scenarios/compiled/test.json exists

1.4 Test Ink Engine

Create test page: test-ink-engine.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Ink Engine Test</title>
</head>
<body>
    <h1>Ink Engine Test</h1>
    <div id="output"></div>
    <div id="choices"></div>

    <script src="https://cdn.jsdelivr.net/npm/inkjs@2.2.3/dist/ink.js"></script>
    <script type="module">
        import { InkEngine } from './js/systems/ink/ink-engine.js';

        const engine = new InkEngine();
        const output = document.getElementById('output');
        const choicesDiv = document.getElementById('choices');

        async function test() {
            // Load story
            await engine.loadStory('test', 'scenarios/compiled/test.json');

            // Go to start
            const result = engine.goToKnot('test', 'start');
            
            // Display result
            output.innerHTML = `<p>${result.text}</p>`;
            output.innerHTML += `<p>Tags: ${result.tags.join(', ')}</p>`;

            // Display choices
            result.choices.forEach(choice => {
                const btn = document.createElement('button');
                btn.textContent = choice.text;
                btn.onclick = () => {
                    const nextResult = engine.choose('test', choice.index);
                    output.innerHTML = `<p>${nextResult.text}</p>`;
                    
                    // Clear and redisplay choices
                    choicesDiv.innerHTML = '';
                    nextResult.choices.forEach(c => {
                        const b = document.createElement('button');
                        b.textContent = c.text;
                        b.onclick = () => test(); // Recursive for testing
                        choicesDiv.appendChild(b);
                    });
                };
                choicesDiv.appendChild(btn);
            });
        }

        test();
    </script>
</body>
</html>

Test: Open test-ink-engine.html in browser, verify dialogue and choices work


Phase 2: NPC Event System (Days 3-4)

2.1 Create Event Dispatcher

File: js/systems/npc-events.js

// NPC Event Dispatcher
export class NPCEventDispatcher {
  constructor() {
    this.listeners = [];
    this.eventQueue = [];
    this.cooldowns = new Map();
    this.isProcessing = false;
    this.debug = false;
  }

  // Register event listener
  on(eventPattern, callback) {
    this.listeners.push({ eventPattern, callback });
  }

  // Emit an event
  emit(eventType, eventData) {
    const event = {
      type: eventType,
      data: eventData,
      timestamp: Date.now()
    };

    if (this.debug) {
      console.log(`[NPC Event] ${eventType}`, eventData);
    }

    this.eventQueue.push(event);
    this.processQueue();
  }

  // Process queued events
  async processQueue() {
    if (this.isProcessing) return;
    this.isProcessing = true;

    while (this.eventQueue.length > 0) {
      const event = this.eventQueue.shift();

      // Check cooldown
      if (this.isOnCooldown(event)) {
        if (this.debug) {
          console.log(`[NPC Event] Cooldown: ${event.type}`);
        }
        continue;
      }

      // Notify listeners
      for (const listener of this.listeners) {
        if (this.matchesPattern(event.type, listener.eventPattern)) {
          await listener.callback(event);
        }
      }

      // Update cooldown
      this.updateCooldown(event);
    }

    this.isProcessing = false;
  }

  isOnCooldown(event) {
    const key = event.type;
    const lastTrigger = this.cooldowns.get(key);

    if (!lastTrigger) return false;

    const cooldownDuration = 5000; // 5 seconds default
    return (Date.now() - lastTrigger) < cooldownDuration;
  }

  updateCooldown(event) {
    this.cooldowns.set(event.type, Date.now());
  }

  matchesPattern(eventType, pattern) {
    if (pattern === '*') return true;
    if (pattern === eventType) return true;

    // Support wildcards
    const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
    return regex.test(eventType);
  }
}

// Global instance
window.npcEvents = new NPCEventDispatcher();
window.npcEvents.debug = true; // Enable debug logging initially

Import in: js/main.js

import './systems/npc-events.js';

2.2 Add Event Emission to Game Code

File: js/core/rooms.js (Room transitions)

// In updatePlayerRoom() function, after room change detected:
if (window.currentPlayerRoom !== previousRoom) {
  // ... existing code ...

  // Emit NPC event
  if (window.npcEvents) {
    window.npcEvents.emit('room_entered', {
      roomId: window.currentPlayerRoom,
      previousRoom: previousRoom,
      firstVisit: !window.discoveredRooms.has(window.currentPlayerRoom)
    });
  }
}

File: js/systems/inventory.js (Item pickup)

// In addToInventory() function:
export function addToInventory(item) {
  // ... existing code ...

  // Emit NPC event
  if (window.npcEvents) {
    window.npcEvents.emit('item_picked_up', {
      itemType: item.type,
      itemName: item.name,
      roomId: window.currentPlayerRoom
    });
  }
}

Test: Move between rooms and pick up items, check console for [NPC Event] logs

2.3 Create NPC Manager

File: js/systems/npc-manager.js

// NPC Manager - coordinates NPCs and events
export class NPCManager {
  constructor() {
    this.npcs = new Map(); // npcId -> NPC config
    this.eventMappings = new Map(); // npcId -> event mappings
  }

  // Register an NPC
  registerNPC(npcId, npcConfig) {
    this.npcs.set(npcId, npcConfig);
    this.eventMappings.set(npcId, npcConfig.eventMappings || {});

    // Set up event listeners for this NPC
    this.setupEventListeners(npcId);

    console.log(`Registered NPC: ${npcId}`);
  }

  // Set up event listeners for an NPC
  setupEventListeners(npcId) {
    const mappings = this.eventMappings.get(npcId);
    
    for (const [eventPattern, knotName] of Object.entries(mappings)) {
      window.npcEvents.on(eventPattern, async (event) => {
        console.log(`[NPC] ${npcId} triggered by ${event.type} -> ${knotName}`);
        await this.handleEvent(npcId, knotName, event);
      });
    }
  }

  // Handle event triggering Ink knot
  async handleEvent(npcId, knotName, event) {
    // Go to knot in Ink
    const result = window.inkEngine.goToKnot(npcId, knotName);
    
    if (!result) {
      console.error(`Failed to execute knot ${knotName} for ${npcId}`);
      return;
    }

    // Parse tags
    const tags = window.inkEngine.parseTags(result.tags);

    // Determine if bark or conversation
    if (tags.type === 'bark') {
      // Show bark notification
      if (window.npcBarkSystem) {
        window.npcBarkSystem.showBark(npcId, result.text, tags);
      }
    } else {
      // Update phone chat conversation
      // TODO: Implement phone chat update
      console.log(`[NPC] ${npcId} conversation updated`);
    }
  }

  // Get NPC config
  getNPC(npcId) {
    return this.npcs.get(npcId);
  }
}

// Global instance
window.npcManager = new NPCManager();

Import in: js/main.js

import './systems/npc-manager.js';

Phase 3: Bark Notification System (Days 4-5)

3.1 Create Bark CSS

File: css/npc-barks.css

/* NPC Bark Notifications */
.npc-bark-notification {
  position: fixed;
  bottom: 120px;
  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;
}

@keyframes bark-slide-in {
  from {
    transform: translateX(400px);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.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;
  border: 2px solid #000;
}

.npc-bark-content {
  flex: 1;
}

.npc-bark-name {
  font-size: 12pt;
  font-weight: bold;
  color: #000;
  margin-bottom: 4px;
}

.npc-bark-message {
  font-size: 10pt;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.npc-bark-close {
  width: 20px;
  height: 20px;
  background: #ff0000;
  color: #fff;
  border: 2px solid #000;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 12pt;
  font-weight: bold;
  line-height: 1;
}

.npc-bark-close:hover {
  background: #cc0000;
}

Add to: index.html

<link rel="stylesheet" href="css/npc-barks.css">

3.2 Create Bark System

File: js/systems/npc-barks.js

// NPC Bark System
export class NPCBarkSystem {
  constructor() {
    this.container = null;
    this.activeBarks = [];
    this.maxBarks = 3;
  }

  init() {
    // Create container for barks
    this.container = document.createElement('div');
    this.container.id = 'npc-bark-container';
    document.body.appendChild(this.container);
  }

  showBark(npcId, message, tags = {}) {
    const npc = window.npcManager.getNPC(npcId);
    if (!npc) return;

    // Create bark element
    const bark = document.createElement('div');
    bark.className = 'npc-bark-notification';
    bark.dataset.npcId = npcId;

    bark.innerHTML = `
      <img src="${npc.avatar}" alt="${npc.name}" class="npc-bark-avatar">
      <div class="npc-bark-content">
        <div class="npc-bark-name">${npc.name}</div>
        <div class="npc-bark-message">${message}</div>
      </div>
      <div class="npc-bark-close">×</div>
    `;

    // Click to open phone chat
    bark.addEventListener('click', (e) => {
      if (!e.target.classList.contains('npc-bark-close')) {
        this.openPhoneChat(npcId);
        this.dismissBark(bark);
      }
    });

    // Close button
    bark.querySelector('.npc-bark-close').addEventListener('click', (e) => {
      e.stopPropagation();
      this.dismissBark(bark);
    });

    // Add to DOM
    this.container.appendChild(bark);
    this.activeBarks.push(bark);

    // Auto-dismiss after 5 seconds
    setTimeout(() => {
      this.dismissBark(bark);
    }, 5000);

    // Remove excess barks
    while (this.activeBarks.length > this.maxBarks) {
      this.dismissBark(this.activeBarks[0]);
    }

    // Reposition barks
    this.repositionBarks();
  }

  dismissBark(bark) {
    if (!bark || !bark.parentNode) return;

    bark.style.animation = 'bark-slide-in 0.3s ease-out reverse';
    setTimeout(() => {
      if (bark.parentNode) {
        bark.parentNode.removeChild(bark);
      }
      this.activeBarks = this.activeBarks.filter(b => b !== bark);
      this.repositionBarks();
    }, 300);
  }

  repositionBarks() {
    this.activeBarks.forEach((bark, index) => {
      bark.style.bottom = `${120 + (index * 120)}px`;
    });
  }

  openPhoneChat(npcId) {
    // TODO: Implement phone chat opening
    console.log(`[Bark] Opening phone chat for ${npcId}`);
    if (window.MinigameFramework) {
      // window.MinigameFramework.startMinigame('phone-chat', null, { npcId });
    }
  }
}

// Global instance
window.npcBarkSystem = new NPCBarkSystem();

Import and init in: js/main.js

import './systems/npc-barks.js';

// In initializeGame():
window.npcBarkSystem.init();

Test: Manually trigger a bark from console:

window.npcBarkSystem.showBark('test', 'This is a test message!', {});

Phase 4: Phone Chat Minigame (Days 5-7)

4.1 Fork Phone Messages Minigame

cp js/minigames/phone/phone-messages-minigame.js js/minigames/phone-chat/phone-chat-minigame.js

4.2 Modify Phone Chat Minigame

File: js/minigames/phone-chat/phone-chat-minigame.js

Start with a simplified version that extends the existing phone minigame:

import { MinigameScene } from '../framework/base-minigame.js';

export class PhoneChatMinigame extends MinigameScene {
  constructor(container, params) {
    super(container, params);
    
    this.npcId = params.npcId || null;
    this.viewMode = 'contacts'; // 'contacts' or 'conversation'
    this.currentNPC = null;
    this.conversationHistory = [];
  }

  init() {
    super.init();
    
    // Set title
    this.headerElement.querySelector('.minigame-title').textContent = 
      this.npcId ? window.npcManager.getNPC(this.npcId).name : 'PHONE';

    // Create UI based on mode
    if (this.npcId) {
      this.viewMode = 'conversation';
      this.currentNPC = this.npcId;
      this.createConversationView();
    } else {
      this.viewMode = 'contacts';
      this.createContactsView();
    }
  }

  createContactsView() {
    this.gameContainer.innerHTML = '<div class="phone-chat-contacts"></div>';
    
    const contactsList = this.gameContainer.querySelector('.phone-chat-contacts');
    
    // Get all registered NPCs
    const npcs = Array.from(window.npcManager.npcs.values());
    
    npcs.forEach(npc => {
      const contactDiv = document.createElement('div');
      contactDiv.className = 'phone-chat-contact';
      contactDiv.innerHTML = `
        <img src="${npc.avatar}" class="phone-chat-contact-avatar">
        <div class="phone-chat-contact-info">
          <div class="phone-chat-contact-name">${npc.name}</div>
          <div class="phone-chat-contact-role">${npc.role}</div>
        </div>
      `;
      
      contactDiv.addEventListener('click', () => {
        this.openConversation(npc.id);
      });
      
      contactsList.appendChild(contactDiv);
    });
  }

  createConversationView() {
    this.gameContainer.innerHTML = `
      <div class="phone-chat-conversation">
        <div class="phone-chat-messages"></div>
        <div class="phone-chat-choices"></div>
      </div>
    `;
    
    this.messagesContainer = this.gameContainer.querySelector('.phone-chat-messages');
    this.choicesContainer = this.gameContainer.querySelector('.phone-chat-choices');
    
    // Load conversation from Ink
    this.loadConversation();
  }

  openConversation(npcId) {
    this.currentNPC = npcId;
    this.viewMode = 'conversation';
    this.createConversationView();
  }

  loadConversation() {
    // Get current knot from NPC
    const npc = window.npcManager.getNPC(this.currentNPC);
    const knotName = npc.currentKnot || npc.initialKnot;
    
    // Execute Ink
    const result = window.inkEngine.goToKnot(this.currentNPC, knotName);
    
    if (result) {
      this.displayMessage(result.text, 'npc');
      this.displayChoices(result.choices);
    }
  }

  displayMessage(text, sender) {
    const messageDiv = document.createElement('div');
    messageDiv.className = `phone-chat-message ${sender}`;
    messageDiv.innerHTML = `
      <div class="message-bubble">${text}</div>
      <div class="phone-chat-message-timestamp">${this.getTimestamp()}</div>
    `;
    
    this.messagesContainer.appendChild(messageDiv);
    this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
  }

  displayChoices(choices) {
    this.choicesContainer.innerHTML = '';
    
    choices.forEach(choice => {
      const btn = document.createElement('button');
      btn.className = 'phone-chat-choice-button';
      btn.textContent = choice.text;
      
      btn.addEventListener('click', () => {
        this.selectChoice(choice.index, choice.text);
      });
      
      this.choicesContainer.appendChild(btn);
    });
  }

  selectChoice(choiceIndex, choiceText) {
    // Show player's choice as a message
    this.displayMessage(choiceText, 'player');
    
    // Execute choice in Ink
    const result = window.inkEngine.choose(this.currentNPC, choiceIndex);
    
    if (result) {
      // Show NPC response
      setTimeout(() => {
        this.displayMessage(result.text, 'npc');
        this.displayChoices(result.choices);
      }, 500);
    }
  }

  getTimestamp() {
    const now = new Date();
    return now.toLocaleTimeString('en-US', { 
      hour: '2-digit', 
      minute: '2-digit' 
    });
  }

  start() {
    super.start();
    console.log('Phone chat minigame started');
  }

  cleanup() {
    super.cleanup();
  }
}

4.3 Register Phone Chat Minigame

File: js/minigames/index.js

export { PhoneChatMinigame } from './phone-chat/phone-chat-minigame.js';

// In MinigameFramework registration:
MinigameFramework.registerScene('phone-chat', PhoneChatMinigame);

4.4 Create Phone Chat CSS

File: css/phone-chat.css

/* Phone Chat Minigame */
.phone-chat-contacts {
  padding: 10px;
}

.phone-chat-contact {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 2px solid #000;
  cursor: pointer;
  background: #e0e0e0;
  margin-bottom: 5px;
}

.phone-chat-contact:hover {
  background: #d0d0d0;
}

.phone-chat-contact-avatar {
  width: 64px;
  height: 64px;
  image-rendering: pixelated;
  border: 2px solid #000;
  margin-right: 10px;
}

.phone-chat-contact-name {
  font-size: 14pt;
  font-weight: bold;
  color: #000;
}

.phone-chat-contact-role {
  font-size: 10pt;
  font-style: italic;
  color: #666;
}

.phone-chat-conversation {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.phone-chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
  background: #f5f5f5;
}

.phone-chat-message {
  display: flex;
  margin: 10px 0;
  animation: message-appear 0.3s ease-out;
}

@keyframes message-appear {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.phone-chat-message.npc {
  justify-content: flex-start;
}

.phone-chat-message.npc .message-bubble {
  background: #e0e0e0;
  color: #000;
  border: 2px solid #000;
  padding: 10px;
  max-width: 70%;
}

.phone-chat-message.player {
  justify-content: flex-end;
}

.phone-chat-message.player .message-bubble {
  background: #a0d0ff;
  color: #000;
  border: 2px solid #000;
  padding: 10px;
  max-width: 70%;
}

.phone-chat-choices {
  padding: 10px;
  border-top: 2px solid #000;
  background: #f5f5f5;
}

.phone-chat-choice-button {
  width: 100%;
  padding: 12px;
  margin-bottom: 8px;
  background: #fff;
  color: #000;
  border: 2px solid #000;
  cursor: pointer;
  font-family: 'VT323', monospace;
  font-size: 14pt;
  text-align: left;
}

.phone-chat-choice-button:hover {
  background: #e0e0e0;
  transform: translate(-2px, -2px);
  box-shadow: 2px 2px 0 #000;
}

.phone-chat-choice-button::before {
  content: '▶ ';
  color: #666;
}

Add to: index.html

<link rel="stylesheet" href="css/phone-chat.css">

Phase 5: Scenario Integration (Days 7-8)

5.1 Create Example Ink Script

File: scenarios/ink/biometric_breach_npcs.ink

// Biometric Breach - NPC Conversations
VAR player_in_reception = false
VAR player_in_lab = false
VAR fingerprint_collected = false

// Alice - Security Analyst
=== alice_intro ===
# speaker: Alice
# type: bark
# trigger: game_start
Hey! I'm Alice from security. We've got a major breach tonight.
-> END

=== alice_room_reception ===
# speaker: Alice
# type: bark
Start in reception. Look for fingerprints on the computer.
~ player_in_reception = true
-> END

=== alice_item_fingerprint_kit ===
# speaker: Alice
# type: bark
Good, you have the fingerprint kit. Use it on suspicious surfaces.
-> END

=== alice_hub ===
# speaker: Alice
# type: conversation
{fingerprint_collected: Great work on that fingerprint!|What can I help you with?}
+ [What happened?] -> alice_explain
+ [Where should I go?] -> alice_directions
+ [Goodbye] -> END

=== alice_explain ===
# speaker: Alice
Someone broke into the biometrics lab around 2 AM.
We need to find out who it was and what they took.
-> alice_hub

=== alice_directions ===
# speaker: Alice
{not player_in_reception: Check reception first.|Check the lab next. It's north of the main office.}
-> alice_hub

Compile:

inklecate scenarios/ink/biometric_breach_npcs.ink -o scenarios/compiled/biometric_breach_npcs.json

5.2 Update Scenario JSON

File: scenarios/biometric_breach.json

Add NPC configuration:

{
  "scenario_brief": "...",
  "npcs": {
    "alice": {
      "id": "alice",
      "name": "Alice Chen",
      "role": "Security Analyst",
      "phone": "555-0123",
      "avatar": "assets/npc/avatars/npc_alice.png",
      "inkFile": "scenarios/compiled/biometric_breach_npcs.json",
      "initialKnot": "alice_hub",
      "eventMappings": {
        "room_entered:reception": "alice_room_reception",
        "item_picked_up:fingerprint_kit": "alice_item_fingerprint_kit"
      }
    }
  },
  "rooms": {
    ...
  }
}

5.3 Load NPCs in Game Init

File: js/main.js

// In initializeGame(), after scenario loaded:
async function loadScenarioNPCs(scenario) {
  if (!scenario.npcs) return;

  for (const [npcId, npcConfig] of Object.entries(scenario.npcs)) {
    // Load Ink story
    await window.inkEngine.loadStory(npcId, npcConfig.inkFile);
    
    // Register NPC
    window.npcManager.registerNPC(npcId, npcConfig);
    
    // Trigger initial knot if specified
    if (npcConfig.initialKnot) {
      const result = window.inkEngine.goToKnot(npcId, npcConfig.initialKnot);
      console.log(`Initialized ${npcId} at ${npcConfig.initialKnot}`);
    }
  }
}

// Call after scenario loads:
if (window.gameScenario) {
  await loadScenarioNPCs(window.gameScenario);
}

Phase 6: Testing & Polish (Days 8-10)

6.1 Test Event Flow

  • Move between rooms
  • Pick up items
  • Complete minigames
  • Verify barks appear
  • Verify phone chat works

6.2 Add Phone Access Button

Create button in: js/ui/phone-button.js

export function createPhoneAccessButton() {
  const button = document.createElement('div');
  button.className = 'phone-access-button';
  button.innerHTML = `
    <img src="assets/objects/phone.png" class="phone-access-button-icon">
  `;

  button.addEventListener('click', () => {
    window.MinigameFramework.startMinigame('phone-chat', null, {});
  });

  document.body.appendChild(button);
}

Add CSS in: css/phone-chat.css

.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;
}

.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;
}

6.3 Add Sound Effects

// In NPCBarkSystem.showBark():
const audio = new Audio('assets/npc/sounds/message_received.wav');
audio.volume = 0.5;
audio.play().catch(e => console.log('Audio play failed:', e));

6.4 Persistence

Save NPC state:

// In game save function:
window.gameState.npcStates = {};
for (const [npcId, story] of window.inkEngine.stories) {
  window.gameState.npcStates[npcId] = window.inkEngine.saveState(npcId);
}

// In game load function:
for (const [npcId, stateJson] of Object.entries(window.gameState.npcStates)) {
  window.inkEngine.restoreState(npcId, stateJson);
}

Testing Checklist

  • Ink engine loads compiled JSON correctly
  • Events emit when player moves/acts
  • NPC manager maps events to knots
  • Barks appear and dismiss correctly
  • Barks stack properly (max 3)
  • Click bark opens phone chat
  • Phone chat shows contacts
  • Click contact opens conversation
  • Ink dialogue displays correctly
  • Choices appear and work
  • Choice selection updates conversation
  • Multiple NPCs work independently
  • Sound effects play on bark
  • Phone access button works
  • NPC state persists across sessions

Next Steps After MVP

  1. Multiple NPCs - Add more NPCs to test interactions
  2. Voice Synthesis - Add TTS for NPC dialogue
  3. Relationship System - Track trust/rapport with NPCs
  4. Time-Delayed Messages - NPCs send messages after delays
  5. Group Chats - Multiple NPCs in one conversation
  6. Emoji Support - Add reaction system
  7. Phone Calls - Audio dialogue feature
  8. Advanced Ink Features - Tunnels, threads, etc.

Troubleshooting

Ink not loading

  • Check console for errors
  • Verify compiled JSON exists
  • Check file paths are correct
  • Ensure ink-js library loaded

Events not firing

  • Enable debug mode: window.npcEvents.debug = true
  • Check cooldown settings
  • Verify event emission points in code
  • Check event pattern matching

Barks not appearing

  • Check window.npcBarkSystem initialized
  • Verify CSS loaded
  • Check z-index conflicts
  • Verify NPC avatar paths

Phone chat not working

  • Check minigame registered
  • Verify MinigameFramework available
  • Check CSS classes match
  • Verify Ink story executing

This implementation plan provides a complete roadmap from setup to MVP. Each phase builds on the previous, with testing points throughout.