feat: Implement phone badge for unread messages; add timed messages for NPCs and update inventory UI

This commit is contained in:
Z. Cliffe Schreuders
2025-10-30 23:35:13 +00:00
parent 5f6cc7e59d
commit b1843cd340
7 changed files with 274 additions and 128 deletions

View File

@@ -118,4 +118,29 @@
/* Hide count badge for single keys */
.inventory-item[data-type="key_ring"][data-key-count="1"]::after {
display: none;
}
}
/* Phone unread message badge */
.inventory-slot {
position: relative;
}
.inventory-slot .phone-badge {
display: block;
position: absolute;
top: -5px;
right: -5px;
background: #5fcf69; /* Green to match phone LCD screen */
color: #000;
border: 2px solid #000;
min-width: 20px;
height: 20px;
padding: 0 4px;
line-height: 16px; /* Center text vertically (20px - 2px border * 2 = 16px) */
text-align: center;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.8);
z-index: 10;
border-radius: 0; /* Maintain pixel-art aesthetic */
}

View File

@@ -1,110 +1,63 @@
/* NPC Bark Notifications */
@keyframes bark-slide-in {
/* Bark container positioned above inventory */
#npc-bark-container {
position: fixed;
bottom: 80px; /* Above inventory bar */
left: 20px;
z-index: 9999 !important;
pointer-events: none;
display: flex;
flex-direction: column-reverse; /* Stack upward */
gap: 8px;
max-width: 300px;
}
/* Individual bark notification styled like phone message */
.npc-bark {
background: #5fcf69; /* Phone screen green */
color: #000;
padding: 12px 15px;
border: 2px solid #000;
font-family: 'VT323', monospace;
font-size: 18px;
line-height: 1.4;
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.3);
pointer-events: auto;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
word-wrap: break-word;
animation: bark-slide-up 0.3s ease-out;
}
.npc-bark:hover {
transform: translate(-2px, -2px);
box-shadow: 5px 5px 0 rgba(0, 0, 0, 0.3);
background: #6fe079;
}
@keyframes bark-slide-up {
from {
transform: translateX(400px);
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateX(0);
transform: translateY(0);
opacity: 1;
}
}
@keyframes bark-slide-out {
from {
transform: translateX(0);
transform: translateY(0);
opacity: 1;
}
to {
transform: translateX(400px);
transform: translateY(20px);
opacity: 0;
}
}
.npc-bark-notification {
position: fixed;
right: 20px;
width: 320px;
background: #fff;
border: 2px solid #000;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.3);
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
animation: bark-slide-in 0.3s ease-out;
z-index: 9999;
font-family: 'VT323', monospace;
transition: transform 0.1s, box-shadow 0.1s;
}
.npc-bark-notification:hover {
background: #f0f0f0;
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0, 0, 0, 0.3);
}
.npc-bark-avatar {
width: 48px;
height: 48px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
border: 2px solid #000;
flex-shrink: 0;
}
.npc-bark-content {
flex: 1;
min-width: 0;
}
.npc-bark-name {
font-size: 14px;
font-weight: bold;
color: #000;
margin-bottom: 4px;
}
.npc-bark-message {
font-size: 12px;
color: #333;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.npc-bark-close {
width: 24px;
height: 24px;
background: #ff0000;
color: #fff;
border: 2px solid #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
font-weight: bold;
line-height: 1;
flex-shrink: 0;
transition: background-color 0.1s;
}
.npc-bark-close:hover {
background: #cc0000;
}
/* Ensure barks appear above inventory */
#npc-bark-container {
z-index: 9999 !important;
}
/* Phone access button (will be added later) */
.phone-access-button {
position: fixed;

View File

@@ -274,6 +274,11 @@ export class PhoneChatMinigame extends MinigameScene {
}
}
}
// Update phone badge after preloading messages
if (window.updatePhoneBadge && this.phoneId) {
window.updatePhoneBadge(this.phoneId);
}
}
/**
@@ -665,6 +670,11 @@ export class PhoneChatMinigame extends MinigameScene {
// Clean up conversation
this.isConversationActive = false;
// Update phone badge in inventory
if (window.updatePhoneBadge && this.phoneId) {
window.updatePhoneBadge(this.phoneId);
}
// Call parent complete
super.complete(success);
}

View File

@@ -2,6 +2,7 @@
// Handles inventory management and display
import { rooms } from '../core/rooms.js';
import InkEngine from './ink/ink-engine.js?v=1';
// Helper function to create a unique identifier for an item
export function createItemIdentifier(scenarioData) {
@@ -37,6 +38,71 @@ export function initializeInventory() {
console.log('INVENTORY INITIALIZED', window.inventory);
}
// Helper function to preload intro messages for a phone
async function preloadPhoneIntroMessages(phoneId) {
console.log(`📱 preloadPhoneIntroMessages called for ${phoneId}`);
if (!window.npcManager) {
console.warn('❌ npcManager not available');
return;
}
// Import PhoneChatConversation class (default export)
const PhoneChatConversation = (await import('../minigames/phone-chat/phone-chat-conversation.js')).default;
// Create a temporary ink engine for preloading
const tempEngine = new InkEngine();
const npcs = window.npcManager.getNPCsByPhone(phoneId);
console.log(`📱 Found ${npcs.length} NPCs on phone ${phoneId}:`, npcs.map(n => n.id));
for (const npc of npcs) {
const history = window.npcManager.getConversationHistory(npc.id);
console.log(`📱 NPC ${npc.id}: history length = ${history.length}, has story = ${!!(npc.storyPath || npc.storyJSON)}`);
// Only preload if no history exists and NPC has a story
if (history.length === 0 && (npc.storyPath || npc.storyJSON)) {
try {
console.log(`📱 Preloading intro for ${npc.id}...`);
const tempConversation = new PhoneChatConversation(npc.id, window.npcManager, tempEngine);
const storySource = npc.storyJSON || npc.storyPath;
const loaded = await tempConversation.loadStory(storySource);
if (loaded) {
const startKnot = npc.currentKnot || 'start';
tempConversation.goToKnot(startKnot);
const result = tempConversation.continue();
if (result.text && result.text.trim()) {
const messages = result.text.trim().split('\n').filter(line => line.trim());
console.log(`📱 Adding ${messages.length} preloaded messages for ${npc.id}`);
messages.forEach(message => {
if (message.trim()) {
window.npcManager.addMessage(npc.id, 'npc', message.trim(), {
preloaded: true,
timestamp: Date.now() - 3600000 // 1 hour ago
});
}
});
npc.storyState = tempConversation.saveState();
console.log(`✅ Preloaded intro for ${npc.id}`);
} else {
console.log(`⚠️ No intro text for ${npc.id}`);
}
} else {
console.log(`⚠️ Failed to load story for ${npc.id}`);
}
} catch (error) {
console.error(`❌ Error preloading intro for ${npc.id}:`, error);
}
} else {
console.log(`⏭️ Skipping ${npc.id} - history=${history.length}, story=${!!(npc.storyPath || npc.storyJSON)}`);
}
}
console.log(`📱 Finished preloading for phone ${phoneId}`);
}
// Process initial inventory items
export function processInitialInventoryItems() {
console.log('Processing initial inventory items');
@@ -190,6 +256,37 @@ export function addToInventory(sprite) {
itemImg.requires = sprite.scenarioData?.requires;
itemImg.difficulty = sprite.scenarioData?.difficulty;
// Add data-type attribute for CSS styling
itemImg.setAttribute('data-type', sprite.scenarioData?.type);
// For phones, add unread message count and badge
if (sprite.scenarioData?.type === 'phone' && sprite.scenarioData?.phoneId) {
const phoneId = sprite.scenarioData.phoneId;
itemImg.setAttribute('data-phone-id', phoneId);
if (window.npcManager) {
// Preload intro messages for all NPCs on this phone
preloadPhoneIntroMessages(phoneId).then(() => {
const unreadCount = window.npcManager.getTotalUnreadCount(phoneId);
console.log(`📱 Phone ${phoneId} added to inventory, unread count: ${unreadCount}`);
itemImg.setAttribute('data-unread-count', unreadCount);
// Create badge element if there are unread messages
if (unreadCount > 0) {
console.log(`✅ Creating badge for phone ${phoneId}`);
const badge = document.createElement('span');
badge.className = 'phone-badge';
badge.textContent = unreadCount;
itemImg.parentElement.appendChild(badge);
} else {
console.log(`❌ Not creating badge, count is ${unreadCount}`);
}
});
} else {
console.log('❌ npcManager not available when adding phone');
}
}
// Mark as non-takeable once in inventory (so it won't try to be picked up again)
itemImg.scenarioData.takeable = false;
@@ -482,6 +579,41 @@ export function removeFromInventory(item) {
}
}
// Update phone badge with unread count
export function updatePhoneBadge(phoneId) {
if (!window.npcManager) return;
// Find phone items in inventory
const phoneItems = window.inventory.items.filter(item =>
item.scenarioData?.type === 'phone' &&
item.getAttribute('data-phone-id') === phoneId
);
// Update badge for each phone with this ID
phoneItems.forEach(phoneItem => {
const unreadCount = window.npcManager.getTotalUnreadCount(phoneId);
phoneItem.setAttribute('data-unread-count', unreadCount);
// Get the inventory slot (parent element)
const inventorySlot = phoneItem.parentElement;
if (!inventorySlot) return;
// Remove existing badge if present
const existingBadge = inventorySlot.querySelector('.phone-badge');
if (existingBadge) {
existingBadge.remove();
}
// Create new badge if there are unread messages
if (unreadCount > 0) {
const badge = document.createElement('span');
badge.className = 'phone-badge';
badge.textContent = unreadCount;
inventorySlot.appendChild(badge);
}
});
}
// Export for global access
window.initializeInventory = initializeInventory;
window.processInitialInventoryItems = processInitialInventoryItems;
@@ -489,4 +621,5 @@ window.addToInventory = addToInventory;
window.removeFromInventory = removeFromInventory;
window.addNotepadToInventory = addNotepadToInventory;
window.createItemIdentifier = createItemIdentifier;
window.handleKeyRingInteraction = handleKeyRingInteraction;
window.handleKeyRingInteraction = handleKeyRingInteraction;
window.updatePhoneBadge = updatePhoneBadge;

View File

@@ -13,65 +13,48 @@ export default class NPCBarkSystem {
if (!this.container) {
this.container = document.createElement('div');
this.container.id = 'npc-bark-container';
const style = this.container.style;
style.position = 'fixed';
style.right = '12px';
style.top = '12px';
style.zIndex = 9999;
style.pointerEvents = 'auto';
document.body.appendChild(this.container);
}
}
// payload: { npcId, text|message, duration, onClick, openPhone }
// payload: { npcId, npcName, text|message, duration, onClick, openPhone }
showBark(payload = {}) {
if (!this.container) this.init();
const { npcId, npcName } = payload;
const text = payload.text || payload.message || '';
const duration = ('duration' in payload) ? payload.duration : 4000;
const duration = ('duration' in payload) ? payload.duration : 5000;
// Create bark element
const el = document.createElement('div');
el.className = 'npc-bark';
el.textContent = (npcId ? npcId + ': ' : '') + (text || '...');
// basic styling
el.style.background = 'rgba(0,0,0,0.8)';
el.style.color = 'white';
el.style.padding = '8px 12px';
el.style.marginTop = '8px';
el.style.borderRadius = '4px';
el.style.fontFamily = 'sans-serif';
el.style.fontSize = '13px';
el.style.maxWidth = '320px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.5)';
el.style.transition = 'all 0.2s';
// Format: "Name: message"
const displayName = npcName || npcId || 'NPC';
el.textContent = `${displayName}: ${text}`;
this.container.appendChild(el);
// Handle clicks - either custom handler or auto-open phone
if (typeof payload.onClick === 'function') {
el.style.cursor = 'pointer';
el.addEventListener('click', () => payload.onClick(el));
} else if (payload.openPhone !== false && npcId) {
// Default: clicking bark opens phone chat with this NPC
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
this.openPhoneChat(payload);
// Remove bark when clicked
if (el.parentNode) el.parentNode.removeChild(el);
});
// Add visual hint that it's clickable
el.addEventListener('mouseenter', () => {
el.style.background = 'rgba(74, 158, 255, 0.9)';
el.style.transform = 'scale(1.05)';
});
el.addEventListener('mouseleave', () => {
el.style.background = 'rgba(0,0,0,0.8)';
el.style.transform = 'scale(1)';
});
}
// Auto-remove after duration
setTimeout(() => {
if (el && el.parentNode) el.parentNode.removeChild(el);
if (el && el.parentNode) {
// Fade out animation
el.style.animation = 'bark-slide-out 0.3s ease-out';
setTimeout(() => {
if (el.parentNode) el.parentNode.removeChild(el);
}, 300);
}
}, duration);
return el;
}

View File

@@ -51,6 +51,19 @@ export default class NPCManager {
this._setupEventMappings(realId, entry.eventMappings);
}
// Schedule timed messages if any are defined
if (entry.timedMessages && Array.isArray(entry.timedMessages)) {
entry.timedMessages.forEach(msg => {
this.scheduleTimedMessage({
npcId: realId,
text: msg.message,
delay: msg.delay,
phoneId: entry.phoneId
});
});
console.log(`[NPCManager] Scheduled ${entry.timedMessages.length} timed messages for ${realId}`);
}
return entry;
}
@@ -95,6 +108,20 @@ export default class NPCManager {
return Array.from(this.npcs.values()).filter(npc => npc.phoneId === phoneId);
}
// Get total unread message count for a phone
getTotalUnreadCount(phoneId) {
const npcs = this.getNPCsByPhone(phoneId);
let totalUnread = 0;
for (const npc of npcs) {
const history = this.getConversationHistory(npc.id);
const unreadCount = history.filter(msg => !msg.read && msg.type === 'npc').length;
totalUnread += unreadCount;
}
return totalUnread;
}
// Set up event listeners for an NPC's event mappings
_setupEventMappings(npcId, eventMappings) {
if (!this.eventDispatcher) return;
@@ -221,24 +248,27 @@ export default class NPCManager {
}
// Schedule a timed message to be delivered after a delay
// opts: { npcId, text, triggerTime (ms from game start), phoneId }
// opts: { npcId, text, triggerTime (ms from game start) OR delay (ms from now), phoneId }
scheduleTimedMessage(opts) {
const { npcId, text, triggerTime = 0, phoneId } = opts;
const { npcId, text, triggerTime, delay, phoneId } = opts;
if (!npcId || !text) {
console.error('[NPCManager] scheduleTimedMessage requires npcId and text');
return;
}
// Use triggerTime if provided, otherwise use delay (defaults to 0)
const actualTriggerTime = triggerTime !== undefined ? triggerTime : (delay || 0);
this.timedMessages.push({
npcId,
text,
triggerTime, // milliseconds from game start
triggerTime: actualTriggerTime, // milliseconds from game start
phoneId: phoneId || 'player_phone',
delivered: false
});
console.log(`[NPCManager] Scheduled timed message from ${npcId} at ${triggerTime}ms:`, text);
console.log(`[NPCManager] Scheduled timed message from ${npcId} at ${actualTriggerTime}ms:`, text);
}
// Start checking for timed messages (call this when game starts)
@@ -292,6 +322,11 @@ export default class NPCManager {
phoneId: message.phoneId
});
// Update phone badge if updatePhoneBadge function exists
if (window.updatePhoneBadge && message.phoneId) {
window.updatePhoneBadge(message.phoneId);
}
// Show bark notification
if (this.barkSystem) {
this.barkSystem.showBark({

View File

@@ -18,7 +18,14 @@
"avatar": null,
"phoneId": "player_phone",
"currentKnot": "start",
"npcType": "phone"
"npcType": "phone",
"timedMessages": [
{
"delay": 5000,
"message": "Hey! 👋 Got any juicy gossip for me today?",
"type": "text"
}
]
}
],
"startItemsInInventory": [