mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
feat: Implement timed messages system for NPC interactions; preload intro messages and enhance UI with avatars
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
48
scenarios/timed_messages_example.json
Normal file
48
scenarios/timed_messages_example.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user