feat: Implement timed messages system for NPC interactions; preload intro messages and enhance UI with avatars

This commit is contained in:
Z. Cliffe Schreuders
2025-10-30 01:31:37 +00:00
parent ef5d85b744
commit 99a631f42f
8 changed files with 845 additions and 129 deletions

View File

@@ -1,77 +1,355 @@
/* Phone Chat Minigame - Ink-based NPC conversations */
/* Includes all necessary phone structure styles */
.phone-chat-container {
width: 100%;
height: 100%;
/* Phone Container (outer shell) */
.phone-messages-container {
display: flex;
flex-direction: column;
background: #1a1a1a;
height: 70vh;
max-height: 700px;
width: 100%;
max-width: 400px;
margin: 0 auto;
background: #a0a0ad;
clip-path: polygon(
0px calc(100% - 10px),
2px calc(100% - 10px),
2px calc(100% - 6px),
4px calc(100% - 6px),
4px calc(100% - 4px),
6px calc(100% - 4px),
6px calc(100% - 2px),
10px calc(100% - 2px),
10px 100%,
calc(100% - 10px) 100%,
calc(100% - 10px) calc(100% - 2px),
calc(100% - 6px) calc(100% - 2px),
calc(100% - 6px) calc(100% - 4px),
calc(100% - 4px) calc(100% - 4px),
calc(100% - 4px) calc(100% - 6px),
calc(100% - 2px) calc(100% - 6px),
calc(100% - 2px) calc(100% - 10px),
100% calc(100% - 10px),
100% 10px,
calc(100% - 2px) 10px,
calc(100% - 2px) 6px,
calc(100% - 4px) 6px,
calc(100% - 4px) 4px,
calc(100% - 6px) 4px,
calc(100% - 6px) 2px,
calc(100% - 10px) 2px,
calc(100% - 10px) 0px,
10px 0px,
10px 2px,
6px 2px,
6px 4px,
4px 4px,
4px 6px,
2px 6px,
2px 10px,
0px 10px
);
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
font-family: 'VT323', monospace;
color: #fff;
}
.phone-chat-header {
/* Phone Screen (green LCD display) */
.phone-screen {
flex: 1;
background: #5fcf69;
display: flex;
flex-direction: column;
position: relative;
color: #000;
margin: 10px;
overflow: hidden;
clip-path: polygon(0px calc(100% - 10px), 2px calc(100% - 10px), 2px calc(100% - 6px), 4px calc(100% - 6px), 4px calc(100% - 4px), 6px calc(100% - 4px), 6px calc(100% - 2px), 10px calc(100% - 2px), 10px 100%, calc(100% - 10px) 100%, calc(100% - 10px) calc(100% - 2px), calc(100% - 6px) calc(100% - 2px), calc(100% - 6px) calc(100% - 4px), calc(100% - 4px) calc(100% - 4px), calc(100% - 4px) calc(100% - 6px), calc(100% - 2px) calc(100% - 6px), calc(100% - 2px) calc(100% - 10px), 100% calc(100% - 10px), 100% 10px, calc(100% - 2px) 10px, calc(100% - 2px) 6px, calc(100% - 4px) 6px, calc(100% - 4px) 4px, calc(100% - 6px) 4px, calc(100% - 6px) 2px, calc(100% - 10px) 2px, calc(100% - 10px) 0px, 10px 0px, 10px 2px, 6px 2px, 6px 4px, 4px 4px, 4px 6px, 2px 6px, 2px 10px, 0px 10px) !important;
}
/* Phone Header (signal, battery) */
.phone-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: rgba(0, 0, 0, 0.1);
border-bottom: 2px solid #333;
color: #000;
flex-shrink: 0;
}
.signal-bars {
display: flex;
gap: 2px;
align-items: end;
}
.signal-bars .bar {
width: 3px;
background: #000;
}
.signal-bars .bar:nth-child(1) { height: 4px; }
.signal-bars .bar:nth-child(2) { height: 6px; }
.signal-bars .bar:nth-child(3) { height: 8px; }
.signal-bars .bar:nth-child(4) { height: 10px; }
.battery {
color: #000;
font-family: 'VT323', monospace;
font-weight: bold;
}
/* Contact List View */
.contact-list-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.contact-list-header {
padding: 12px 15px;
background: rgba(0, 0, 0, 0.1);
border-bottom: 2px solid #333;
}
.contact-list-header h3 {
margin: 0;
font-family: 'VT323', monospace;
font-size: 20px;
color: #000;
font-weight: normal;
}
.contact-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #000 rgba(0, 0, 0, 0.1);
}
.contact-list::-webkit-scrollbar {
width: 8px;
}
.contact-list::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-left: 2px solid #333;
}
.contact-list::-webkit-scrollbar-thumb {
background: #000;
border: 2px solid #5fcf69;
}
.contact-list::-webkit-scrollbar-thumb:hover {
background: #333;
}
.contact-item {
display: flex;
align-items: center;
padding: 12px;
background: #2a2a2a;
border-bottom: 2px solid #4a9eff;
padding: 12px 15px;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: background 0.1s;
position: relative;
}
.contact-item:hover {
background: rgba(0, 0, 0, 0.05);
}
.contact-item:active {
background: rgba(0, 0, 0, 0.1);
}
.contact-avatar {
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.2);
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-right: 12px;
image-rendering: pixelated;
}
.contact-details {
flex: 1;
min-width: 0;
}
.contact-name {
font-family: 'VT323', monospace;
font-size: 18px;
color: #000;
font-weight: bold;
margin-bottom: 4px;
}
.contact-preview {
font-family: 'VT323', monospace;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-time {
font-family: 'VT323', monospace;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
margin-left: 8px;
}
.unread-badge {
background: #e74c3c;
color: #fff;
font-family: 'VT323', monospace;
font-size: 12px;
padding: 2px 6px;
border: 2px solid #000;
min-width: 20px;
text-align: center;
font-weight: bold;
}
.no-contacts {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.5);
font-family: 'VT323', monospace;
font-size: 16px;
padding: 20px;
text-align: center;
}
/* Conversation View */
.conversation-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
align-items: center;
padding: 10px 15px;
background: rgba(0, 0, 0, 0.1);
border-bottom: 2px solid #333;
gap: 12px;
}
.phone-back-btn {
.back-button {
background: transparent;
border: 2px solid #fff;
color: #fff;
border: 2px solid #000;
color: #000;
font-family: 'VT323', monospace;
font-size: 24px;
padding: 4px 12px;
cursor: pointer;
line-height: 1;
transition: background 0.1s;
}
.phone-back-btn:hover {
background: #4a9eff;
.back-button:hover {
background: rgba(0, 0, 0, 0.1);
}
.phone-contact-info {
.back-button:active {
background: rgba(0, 0, 0, 0.2);
}
.conversation-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.contact-avatar {
.conversation-avatar,
.conversation-avatar-placeholder {
width: 32px;
height: 32px;
border: 2px solid #000;
image-rendering: pixelated;
border: 2px solid #fff;
flex-shrink: 0;
}
.contact-name {
.conversation-avatar {
object-fit: cover;
}
.conversation-avatar-placeholder {
background: rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #4a9eff;
}
.phone-chat-messages {
.npc-name {
font-family: 'VT323', monospace;
font-size: 18px;
color: #000;
font-weight: bold;
}
/* Messages Container */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: #000 rgba(0, 0, 0, 0.1);
}
.chat-message {
display: flex;
max-width: 80%;
animation: messageSlideIn 0.3s ease-out;
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-left: 2px solid #333;
}
.messages-container::-webkit-scrollbar-thumb {
background: #000;
border: 2px solid #5fcf69;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: #333;
}
.message-bubble {
padding: 10px 14px;
border: 2px solid #000;
font-family: 'VT323', monospace;
font-size: 16px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 75%;
animation: messageSlideIn 0.2s ease-out;
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(5px);
}
to {
opacity: 1;
@@ -79,97 +357,114 @@
}
}
.message-npc {
.message-bubble.npc {
align-self: flex-start;
background: rgba(0, 0, 0, 0.2);
color: #000;
}
.message-player {
.message-bubble.player {
align-self: flex-end;
background: rgba(0, 0, 0, 0.3);
color: #000;
font-weight: bold;
}
.message-system {
align-self: center;
.message-time {
font-size: 10px;
color: rgba(0, 0, 0, 0.5);
margin-top: 4px;
font-family: 'VT323', monospace;
}
.message-bubble {
/* Typing Indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 10px 14px;
border: 2px solid #666;
background: #2a2a2a;
font-size: 16px;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
align-self: flex-start;
max-width: 60px;
}
.message-npc .message-bubble {
border-color: #4a9eff;
color: #fff;
.typing-indicator span {
width: 8px;
height: 8px;
background: rgba(0, 0, 0, 0.4);
border: 2px solid #000;
animation: typingBounce 1.4s infinite;
}
.message-player .message-bubble {
border-color: #6acc6a;
background: #1a3a1a;
color: #6acc6a;
.typing-indicator span:nth-child(1) {
animation-delay: 0s;
}
.message-system .message-bubble {
border-color: #999;
background: #1a1a1a;
color: #999;
font-style: italic;
text-align: center;
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.phone-chat-choices {
padding: 12px;
background: #2a2a2a;
border-top: 2px solid #666;
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingBounce {
0%, 60%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
/* Choices Container */
.choices-container {
padding: 12px;
background: rgba(0, 0, 0, 0.05);
border-top: 2px solid rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #000 rgba(0, 0, 0, 0.1);
}
.choice-btn {
background: #1a1a1a;
color: #fff;
border: 2px solid #4a9eff;
.choices-container::-webkit-scrollbar {
width: 8px;
}
.choices-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-left: 2px solid rgba(0, 0, 0, 0.3);
}
.choices-container::-webkit-scrollbar-thumb {
background: #000;
border: 2px solid #5fcf69;
}
.choices-container::-webkit-scrollbar-thumb:hover {
background: #333;
}
.choice-button {
background: rgba(0, 0, 0, 0.1);
color: #000;
border: 2px solid #000;
padding: 10px 14px;
font-family: 'VT323', monospace;
font-size: 16px;
text-align: left;
cursor: pointer;
transition: all 0.1s;
line-height: 1.4;
}
.choice-btn:hover {
background: #4a9eff;
color: #000;
transform: translateX(4px);
.choice-button:hover {
background: rgba(0, 0, 0, 0.2);
transform: translateX(2px);
}
.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;
.choice-button:active {
background: rgba(0, 0, 0, 0.3);
}

View File

@@ -61,16 +61,8 @@ export default class PhoneChatConversation {
// Load into InkEngine
this.engine.loadStory(storyJson);
// Set NPC name variable if story supports it
const npc = this.npcManager.getNPC(this.npcId);
if (npc?.displayName) {
try {
this.engine.setVariable('npc_name', npc.displayName);
console.log(`✅ Set npc_name variable to: ${npc.displayName}`);
} catch (error) {
console.log(' Story does not have npc_name variable (this is ok)');
}
}
// Note: We don't set npc_name variable here because it causes issues with state serialization.
// The NPC display name is handled in the UI layer instead.
this.storyLoaded = true;
this.storyEnded = false;
@@ -180,6 +172,32 @@ export default class PhoneChatConversation {
}
}
/**
* Get current state without continuing (for reopening conversations)
* @returns {Object} Current story state { choices, hasEnded }
*/
getCurrentState() {
if (!this.storyLoaded) {
console.error('❌ Cannot get state: story not loaded');
return { choices: [], hasEnded: true };
}
if (this.storyEnded) {
return { choices: [], hasEnded: true };
}
try {
// Get current choices without continuing
const choices = this.engine.currentChoices || [];
const hasEnded = !this.engine.story?.canContinue && choices.length === 0;
return { choices, hasEnded };
} catch (error) {
console.error('❌ Error getting current state:', error);
return { choices: [], hasEnded: true };
}
}
/**
* Get an Ink variable value
* @param {string} name - Variable name

View File

@@ -159,9 +159,12 @@ export class PhoneChatMinigame extends MinigameScene {
/**
* Start the minigame
*/
start() {
async start() {
super.start();
// Preload intro messages for NPCs without history
await this.preloadIntroMessages();
// If NPC ID provided, open that conversation directly
if (this.currentNPCId) {
this.openConversation(this.currentNPCId);
@@ -173,6 +176,60 @@ export class PhoneChatMinigame extends MinigameScene {
console.log('✅ PhoneChatMinigame started');
}
/**
* Preload intro messages for NPCs that have no conversation history
* This makes it look like messages exist before opening the conversation
*/
async preloadIntroMessages() {
// Get all NPCs for this phone
const npcs = this.phoneId
? this.npcManager.getNPCsByPhone(this.phoneId)
: Array.from(this.npcManager.npcs.values());
for (const npc of npcs) {
const history = this.npcManager.getConversationHistory(npc.id);
// Only preload if no history exists
if (history.length === 0 && npc.storyPath) {
try {
// Create temporary conversation to get intro message
const tempConversation = new PhoneChatConversation(npc.id, this.npcManager, this.inkEngine);
const loaded = await tempConversation.loadStory(npc.storyPath);
if (loaded) {
// Navigate to start
const startKnot = npc.currentKnot || 'start';
tempConversation.goToKnot(startKnot);
// Get intro message
const result = tempConversation.continue();
if (result.text && result.text.trim()) {
// Add intro message(s) to history
const messages = result.text.trim().split('\n').filter(line => line.trim());
messages.forEach(message => {
if (message.trim()) {
this.npcManager.addMessage(npc.id, 'npc', message.trim(), {
preloaded: true,
timestamp: Date.now() - 3600000 // 1 hour ago
});
}
});
// Save the story state after preloading
// This prevents the intro from replaying when conversation is opened
npc.storyState = tempConversation.saveState();
console.log(`📝 Preloaded intro message for ${npc.id} and saved state`);
}
}
} catch (error) {
console.warn(`⚠️ Could not preload intro for ${npc.id}:`, error);
}
}
}
}
/**
* Open a conversation with an NPC
* @param {string} npcId - NPC identifier
@@ -199,7 +256,9 @@ export class PhoneChatMinigame extends MinigameScene {
// Load conversation history
const history = this.history.loadHistory();
if (history.length > 0) {
const hasHistory = history.length > 0;
if (hasHistory) {
this.ui.addMessages(history);
// Mark messages as read
this.history.markAllRead();
@@ -219,15 +278,44 @@ export class PhoneChatMinigame extends MinigameScene {
return;
}
// Navigate to starting knot
// Always navigate to a knot since some Ink stories don't start at root properly
const safeParams = this.params || {};
const startKnot = safeParams.startKnot || npc.currentKnot || 'start';
this.conversation.goToKnot(startKnot);
// Continue story and show new content
// Set conversation as active
this.isConversationActive = true;
this.continueStory();
// Check if we have saved story state to restore
if (hasHistory && npc.storyState) {
// Restore previous story state
console.log('📚 Restoring story state from previous conversation');
this.conversation.restoreState(npc.storyState);
// Show current choices without continuing
this.showCurrentChoices();
} else {
// Navigate to starting knot for first time
const safeParams = this.params || {};
const startKnot = safeParams.startKnot || npc.currentKnot || 'start';
this.conversation.goToKnot(startKnot);
// First time opening - show intro message and choices
this.continueStory();
}
}
/**
* Show current choices without continuing story (for reopening conversations)
*/
showCurrentChoices() {
if (!this.conversation || !this.isConversationActive) {
return;
}
// Get current state without continuing
const result = this.conversation.getCurrentState();
if (result.choices && result.choices.length > 0) {
this.ui.addChoices(result.choices);
} else {
console.log(' No choices available in current state');
}
}
/**
@@ -278,6 +366,9 @@ export class PhoneChatMinigame extends MinigameScene {
console.log('🏁 No more choices available');
this.isConversationActive = false;
}
// Save story state after initial load
this.saveStoryState();
}, 500); // Brief delay for typing effect
}
@@ -346,9 +437,28 @@ export class PhoneChatMinigame extends MinigameScene {
console.log('🏁 No more choices available');
this.isConversationActive = false;
}
// Save story state for resuming later
this.saveStoryState();
}, 500); // Brief delay for typing effect
}
/**
* Save the current Ink story state to NPC data
*/
saveStoryState() {
if (!this.conversation || !this.currentNPCId) {
return;
}
const npc = this.npcManager.getNPC(this.currentNPCId);
if (npc) {
const state = this.conversation.saveState();
npc.storyState = state;
console.log('💾 Saved story state for', this.currentNPCId);
}
}
/**
* Close the current conversation and return to contact list
*/

View File

@@ -31,10 +31,11 @@ export default class PhoneChatUI {
/**
* Render the complete phone UI structure
* Matches phone-messages-minigame.js structure
*/
render() {
this.container.innerHTML = `
<div class="phone-chat-container">
<div class="phone-messages-container">
<div class="phone-screen">
<div class="phone-header">
<div class="signal-bars">
@@ -43,7 +44,6 @@ export default class PhoneChatUI {
<span class="bar"></span>
<span class="bar"></span>
</div>
<div class="phone-time">${this.getCurrentTime()}</div>
<div class="battery">85%</div>
</div>
@@ -216,8 +216,8 @@ export default class PhoneChatUI {
this.elements.contactListView.style.display = 'none';
this.elements.conversationView.style.display = 'flex';
// Update header
this.updateHeader(npc.displayName || npc.id);
// Update header with avatar
this.updateHeader(npc.displayName || npc.id, npc.id);
// Clear messages and choices
this.elements.messagesContainer.innerHTML = '';
@@ -229,9 +229,43 @@ export default class PhoneChatUI {
/**
* Update the conversation header
* @param {string} npcName - NPC display name
* @param {string} npcId - NPC identifier
*/
updateHeader(npcName) {
this.elements.npcName.textContent = npcName;
updateHeader(npcName, npcId) {
const npc = this.npcManager.getNPC(npcId);
// Clear and rebuild header content
const conversationInfo = this.elements.conversationHeader.querySelector('.conversation-info');
if (conversationInfo) {
conversationInfo.innerHTML = '';
// Add avatar if available
if (npc?.avatar) {
const avatarImg = document.createElement('img');
avatarImg.src = npc.avatar;
avatarImg.alt = npcName;
avatarImg.className = 'conversation-avatar';
conversationInfo.appendChild(avatarImg);
} else {
// Placeholder avatar
const avatarPlaceholder = document.createElement('div');
avatarPlaceholder.className = 'conversation-avatar-placeholder';
avatarPlaceholder.textContent = '👤';
conversationInfo.appendChild(avatarPlaceholder);
}
// Add name
const nameSpan = document.createElement('span');
nameSpan.className = 'npc-name';
nameSpan.textContent = npcName;
conversationInfo.appendChild(nameSpan);
// Update reference
this.elements.npcName = nameSpan;
} else {
// Fallback to old method
this.elements.npcName.textContent = npcName;
}
}
/**

View File

@@ -8,6 +8,9 @@ export default class NPCManager {
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} ] }
this.timedMessages = []; // Scheduled messages: { npcId, text, triggerTime, delivered, phoneId }
this.gameStartTime = Date.now(); // Track when game started for timed messages
this.timerInterval = null; // Timer for checking timed messages
}
// registerNPC(id, opts) or registerNPC({ id, ...opts })
@@ -216,4 +219,104 @@ export default class NPCManager {
const triggered = this.triggeredEvents.get(eventKey);
return triggered ? triggered.count > 0 : false;
}
// Schedule a timed message to be delivered after a delay
// opts: { npcId, text, triggerTime (ms from game start), phoneId }
scheduleTimedMessage(opts) {
const { npcId, text, triggerTime = 0, phoneId } = opts;
if (!npcId || !text) {
console.error('[NPCManager] scheduleTimedMessage requires npcId and text');
return;
}
this.timedMessages.push({
npcId,
text,
triggerTime, // milliseconds from game start
phoneId: phoneId || 'player_phone',
delivered: false
});
console.log(`[NPCManager] Scheduled timed message from ${npcId} at ${triggerTime}ms:`, text);
}
// Start checking for timed messages (call this when game starts)
startTimedMessages() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
this.gameStartTime = Date.now();
// Check every second for messages that need to be delivered
this.timerInterval = setInterval(() => {
this._checkTimedMessages();
}, 1000);
console.log('[NPCManager] Started timed messages system');
}
// Stop checking for timed messages (cleanup)
stopTimedMessages() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
// Check if any timed messages need to be delivered
_checkTimedMessages() {
const now = Date.now();
const elapsed = now - this.gameStartTime;
for (const message of this.timedMessages) {
if (!message.delivered && elapsed >= message.triggerTime) {
this._deliverTimedMessage(message);
message.delivered = true;
}
}
}
// Deliver a timed message (add to history and show bark)
_deliverTimedMessage(message) {
const npc = this.getNPC(message.npcId);
if (!npc) {
console.warn(`[NPCManager] Cannot deliver timed message: NPC ${message.npcId} not found`);
return;
}
// Add message to conversation history
this.addMessage(message.npcId, 'npc', message.text, {
timed: true,
phoneId: message.phoneId
});
// Show bark notification
if (this.barkSystem) {
this.barkSystem.showBark({
npcId: npc.id,
npcName: npc.displayName,
message: message.text,
avatar: npc.avatar,
inkStoryPath: npc.storyPath,
startKnot: npc.currentKnot,
phoneId: message.phoneId
});
}
console.log(`[NPCManager] Delivered timed message from ${message.npcId}:`, message.text);
}
// Load timed messages from scenario data
// timedMessages: [ { npcId, text, triggerTime, phoneId } ]
loadTimedMessages(timedMessages) {
if (!Array.isArray(timedMessages)) return;
timedMessages.forEach(msg => {
this.scheduleTimedMessage(msg);
});
console.log(`[NPCManager] Loaded ${timedMessages.length} timed messages`);
}
}

View File

@@ -403,6 +403,12 @@ phoneChat.start();
- ✅ Can switch between NPCs
- ✅ Can close and reopen without losing history
- ✅ Works with both Alice's complex story and Bob's generic story
- ✅ UI matches phone-messages aesthetic (green screen, pixel-art borders)
- ✅ Styled scrollbars (visible, 8px, black with green border)
- ✅ Intro messages preload when phone opens (appear as pre-existing)
- ✅ Avatar display in conversation header
- ✅ Story state persists across reopening conversations
- ✅ Timed messages system (scenarios can schedule message arrivals)
### Ready for Game Integration When:
- ✅ All core features working
@@ -414,26 +420,97 @@ phoneChat.start();
---
## Timed Messages System
### Overview
Scenarios can specify messages that arrive after a specified time. When the trigger time is reached, the message will:
1. Be added to the NPC's conversation history
2. Show as a bark notification with the message text
3. Appear in the phone contact list preview
4. Be available in the conversation history when opened
### Scenario JSON Structure
```json
{
"timedMessages": [
{
"npcId": "alice",
"text": "Hey! I found something interesting in the security logs.",
"triggerTime": 30000,
"phoneId": "player_phone"
},
{
"npcId": "bob",
"text": "Server maintenance scheduled for 10 AM.",
"triggerTime": 60000,
"phoneId": "player_phone"
}
]
}
```
### Fields
- **npcId**: ID of the NPC sending the message (must be registered)
- **text**: Message text that will appear in bark and conversation history
- **triggerTime**: Time in milliseconds from game start when message should arrive (0 = immediate, 5000 = 5 seconds, 60000 = 1 minute)
- **phoneId**: Which phone this message should appear on (default: 'player_phone')
### Implementation
The NPCManager handles timed messages:
```javascript
// Load timed messages from scenario
npcManager.loadTimedMessages(scenarioData.timedMessages);
// Start the timer system (checks every 1 second)
npcManager.startTimedMessages();
// Manually schedule a message
npcManager.scheduleTimedMessage({
npcId: 'alice',
text: 'This is a timed message!',
triggerTime: 10000, // 10 seconds
phoneId: 'player_phone'
});
// Stop the timer system (cleanup)
npcManager.stopTimedMessages();
```
### Example Usage
See `scenarios/timed_messages_example.json` for a complete working example with 5 timed messages arriving at different intervals (0s, 30s, 1min, 2min, 3min).
---
## Timeline
**Day 1 (Today):**
- Create module files and basic structure
- Implement PhoneChatUI
- Implement PhoneChatConversation
- Wire up basic flow
**Day 1:**
- Create module files and basic structure
- Implement PhoneChatUI
- Implement PhoneChatConversation
- Wire up basic flow
**Day 2:**
- Implement PhoneChatHistory
- Complete main controller
- Add CSS styling
- Test with existing stories
- Register with MinigameFramework
- Implement PhoneChatHistory
- Complete main controller
- Add CSS styling
- Test with existing stories
- Register with MinigameFramework
**Day 3:**
- Polish and animations
- Edge case testing
- Documentation
- Game integration prep
- Polish and animations
- ✅ UI improvements (match phone-messages aesthetic)
- ✅ Styled scrollbars
- ✅ Avatar display
- ✅ Edge case testing
- ✅ Documentation
**Day 4:**
- ✅ State persistence system
- ✅ Preload intro messages
- ✅ Prevent intro replay on reopen
- ✅ Timed messages system
- ✅ Game integration prep
---
@@ -442,12 +519,15 @@ phoneChat.start();
- Reuse CSS patterns from `phone-messages-minigame.css`
- Maintain 2px borders (pixel-art aesthetic)
- No border-radius (sharp corners only)
- Use existing color scheme from phone minigame
- Use existing color scheme from phone minigame (#5fcf69 green, #a0a0ad gray)
- Test on both Phaser and inline fallback paths
- Keep modules loosely coupled for future refactoring
- Story state saves automatically after each choice and initial load
- Timed messages bark automatically and add to history
---
**Status:** 📋 Planning Complete - Ready for Implementation
**Next Step:** Create module files and begin Phase 1
**Estimated Total Lines:** ~1400-1700 (split across 4 modules)
**Status:** ✅ Implementation Complete - Ready for Game Integration
**Next Step:** Integrate into main game, test with real scenarios
**Estimated Total Lines:** ~2000+ (split across 4+ modules + NPCManager enhancements)

View File

@@ -0,0 +1,48 @@
{
"scenario_brief": "Example scenario demonstrating timed messages",
"endGoal": "Test timed message arrivals",
"startRoom": "reception",
"timedMessages": [
{
"npcId": "alice",
"text": "Hey, I just got into the office. How's it going?",
"triggerTime": 0,
"phoneId": "player_phone"
},
{
"npcId": "alice",
"text": "BTW, I found something interesting in the security logs...",
"triggerTime": 30000,
"phoneId": "player_phone"
},
{
"npcId": "bob",
"text": "Morning! Server maintenance is scheduled for 10 AM.",
"triggerTime": 60000,
"phoneId": "player_phone"
},
{
"npcId": "alice",
"text": "Can you check the biometrics lab? Something seems off.",
"triggerTime": 120000,
"phoneId": "player_phone"
},
{
"npcId": "bob",
"text": "Heads up - I'm seeing unusual network traffic from Lab 2.",
"triggerTime": 180000,
"phoneId": "player_phone"
}
],
"rooms": {
"reception": {
"type": "room_reception",
"connections": {
"north": "office1"
},
"objects": []
}
}
}

View File

@@ -261,6 +261,34 @@
log('✅ Registered Charlie', 'success');
log('✅ All NPCs registered!', 'success');
// Start timed messages system
window.npcManager.startTimedMessages();
log('✅ Timed messages system started!', 'success');
// Schedule some timed messages for testing
window.npcManager.scheduleTimedMessage({
npcId: 'alice',
text: '⏰ Hey! This is a timed message arriving 5 seconds after game start.',
triggerTime: 5000, // 5 seconds
phoneId: 'player_phone'
});
window.npcManager.scheduleTimedMessage({
npcId: 'bob',
text: '⏰ Bob here! This message arrives 10 seconds in.',
triggerTime: 10000, // 10 seconds
phoneId: 'player_phone'
});
window.npcManager.scheduleTimedMessage({
npcId: 'alice',
text: '⏰ Follow-up from Alice at 15 seconds!',
triggerTime: 15000, // 15 seconds
phoneId: 'player_phone'
});
log('✅ Scheduled 3 timed messages (5s, 10s, 15s)', 'success');
} catch (error) {
log(`❌ Error registering NPCs: ${error.message}`, 'error');
console.error(error);