From 99a631f42f3c5efdf8ff054dbb726b5fde979ca5 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Thu, 30 Oct 2025 01:31:37 +0000 Subject: [PATCH] feat: Implement timed messages system for NPC interactions; preload intro messages and enhance UI with avatars --- css/phone-chat-minigame.css | 465 ++++++++++++++---- .../phone-chat/phone-chat-conversation.js | 38 +- .../phone-chat/phone-chat-minigame.js | 130 ++++- js/minigames/phone-chat/phone-chat-ui.js | 46 +- js/systems/npc-manager.js | 103 ++++ .../progress/02_PHONE_CHAT_MINIGAME_PLAN.md | 116 ++++- scenarios/timed_messages_example.json | 48 ++ test-phone-chat-minigame.html | 28 ++ 8 files changed, 845 insertions(+), 129 deletions(-) create mode 100644 scenarios/timed_messages_example.json diff --git a/css/phone-chat-minigame.css b/css/phone-chat-minigame.css index 8bacf20..fa02d2d 100644 --- a/css/phone-chat-minigame.css +++ b/css/phone-chat-minigame.css @@ -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); } diff --git a/js/minigames/phone-chat/phone-chat-conversation.js b/js/minigames/phone-chat/phone-chat-conversation.js index 9ab39b3..9cd5f2c 100644 --- a/js/minigames/phone-chat/phone-chat-conversation.js +++ b/js/minigames/phone-chat/phone-chat-conversation.js @@ -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 diff --git a/js/minigames/phone-chat/phone-chat-minigame.js b/js/minigames/phone-chat/phone-chat-minigame.js index 443f5bf..fb46d78 100644 --- a/js/minigames/phone-chat/phone-chat-minigame.js +++ b/js/minigames/phone-chat/phone-chat-minigame.js @@ -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 */ diff --git a/js/minigames/phone-chat/phone-chat-ui.js b/js/minigames/phone-chat/phone-chat-ui.js index bc501b2..d623f9e 100644 --- a/js/minigames/phone-chat/phone-chat-ui.js +++ b/js/minigames/phone-chat/phone-chat-ui.js @@ -31,10 +31,11 @@ export default class PhoneChatUI { /** * Render the complete phone UI structure + * Matches phone-messages-minigame.js structure */ render() { this.container.innerHTML = ` -
+
@@ -43,7 +44,6 @@ export default class PhoneChatUI {
-
${this.getCurrentTime()}
85%
@@ -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; + } } /** diff --git a/js/systems/npc-manager.js b/js/systems/npc-manager.js index 6c85fa2..6b3db9e 100644 --- a/js/systems/npc-manager.js +++ b/js/systems/npc-manager.js @@ -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`); + } } diff --git a/planning_notes/npc/progress/02_PHONE_CHAT_MINIGAME_PLAN.md b/planning_notes/npc/progress/02_PHONE_CHAT_MINIGAME_PLAN.md index 4089800..42bb38f 100644 --- a/planning_notes/npc/progress/02_PHONE_CHAT_MINIGAME_PLAN.md +++ b/planning_notes/npc/progress/02_PHONE_CHAT_MINIGAME_PLAN.md @@ -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) + diff --git a/scenarios/timed_messages_example.json b/scenarios/timed_messages_example.json new file mode 100644 index 0000000..3984f7a --- /dev/null +++ b/scenarios/timed_messages_example.json @@ -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": [] + } + } +} diff --git a/test-phone-chat-minigame.html b/test-phone-chat-minigame.html index c8eb7dc..02f2182 100644 --- a/test-phone-chat-minigame.html +++ b/test-phone-chat-minigame.html @@ -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);