diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 5f543ec..e52b0a6 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -4,6 +4,9 @@ import { CharacterCompositor } from '../sprites/CharacterCompositor.js'; import { PortraitCompositor } from '../sprites/PortraitCompositor.js'; import { NpcInfoPanel } from '../ui/NpcInfoPanel.js'; import { CommandPanel } from '../ui/CommandPanel.js'; +import { SuperlativesPanel } from '../ui/SuperlativesPanel.js'; +import { LeftPanel } from '../ui/LeftPanel.js'; +import { EventsFeed } from '../ui/EventsFeed.js'; import { InteractionEmojiManager } from '../ui/InteractionEmoji.js'; import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared'; import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared'; @@ -31,6 +34,9 @@ export class GameScene extends Phaser.Scene { private emojiManager!: InteractionEmojiManager; private commandPanel!: CommandPanel; private followCommandPanel!: CommandPanel; + private superlativesPanel!: SuperlativesPanel; + private leftPanel!: LeftPanel; + private eventsFeed!: EventsFeed; private highlightEnabled = false; private highlightedSpriteId: number | null = null; private dayNightOverlay!: Phaser.GameObjects.Graphics; @@ -60,6 +66,23 @@ export class GameScene extends Phaser.Scene { { key: 'TAB', label: 'Camera Mode' }, ]); + this.superlativesPanel = new SuperlativesPanel(); + this.eventsFeed = new EventsFeed(); + this.leftPanel = new LeftPanel( + this.superlativesPanel.getElement(), + this.eventsFeed.getElement(), + ); + + document.addEventListener('left-panel-opened', ((e: CustomEvent) => { + if (e.detail.tab === 'stats') { + this.client.subscribeSuperlatives(); + } + }) as EventListener); + + document.addEventListener('left-panel-closed', () => { + this.client.unsubscribeSuperlatives(); + }); + // Draw tile grid this.drawWorld(); @@ -146,6 +169,52 @@ export class GameScene extends Phaser.Scene { }; }); + this.client.onSuperlativesUpdate = (data) => { + this.superlativesPanel.update(data); + }; + + this.client.onNarrationEvent = (event) => this.eventsFeed.addEvent(event); + this.client.onNarrationUpdate = (data) => this.eventsFeed.updateEvent(data.id, data.narration); + this.client.onNarrationHistory = (events) => this.eventsFeed.loadHistory(events); + + document.addEventListener('narration-follow', ((e: CustomEvent) => { + const { entityId } = e.detail; + const npcIds = this.getNpcIds(); + const idx = npcIds.indexOf(entityId); + if (idx === -1) return; + + this.followTargetIndex = idx; + if (this.mode !== 'follow') { + this.mode = 'follow'; + this.showFollowPanel(); + this.followCommandPanel.show(); + this.commandPanel.hide(); + } else { + this.updateFollowPanel(); + } + this.updateModeText(); + this.leftPanel.collapse(); + }) as EventListener); + + document.addEventListener('superlative-follow', ((e: CustomEvent) => { + const { entityId } = e.detail; + const npcIds = this.getNpcIds(); + const idx = npcIds.indexOf(entityId); + if (idx === -1) return; + + this.followTargetIndex = idx; + if (this.mode !== 'follow') { + this.mode = 'follow'; + this.showFollowPanel(); + this.followCommandPanel.show(); + this.commandPanel.hide(); + } else { + this.updateFollowPanel(); + } + this.updateModeText(); + this.leftPanel.collapse(); + }) as EventListener); + this.client.onPlayerLeft = (_data) => { // Entity removal will be handled by next state update }; diff --git a/client/src/ui/EventsFeed.ts b/client/src/ui/EventsFeed.ts new file mode 100644 index 0000000..dd77049 --- /dev/null +++ b/client/src/ui/EventsFeed.ts @@ -0,0 +1,170 @@ +import type { NarrationEvent } from '@dflike/shared'; + +export class EventsFeed { + private container: HTMLDivElement; + private events: NarrationEvent[] = []; + private eventElements: Map = new Map(); + private maxEvents = 30; + private autoScroll = true; + + constructor() { + this.container = document.createElement('div'); + this.container.style.cssText = ` + overflow-y: auto; + flex: 1; + `; + + this.container.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + this.autoScroll = scrollHeight - scrollTop - clientHeight < 20; + }); + } + + getElement(): HTMLDivElement { + return this.container; + } + + addEvent(event: NarrationEvent): void { + this.events.push(event); + const el = this.createEventElement(event); + this.eventElements.set(event.id, el); + this.container.appendChild(el); + + // Trim oldest if over limit + while (this.events.length > this.maxEvents) { + const removed = this.events.shift()!; + const removedEl = this.eventElements.get(removed.id); + if (removedEl) { + removedEl.remove(); + this.eventElements.delete(removed.id); + } + } + + if (this.autoScroll) { + this.container.scrollTop = this.container.scrollHeight; + } + } + + updateEvent(id: number, narration: string): void { + const el = this.eventElements.get(id); + if (!el) return; + + // Update the narration text + const textSpan = el.querySelector('[data-narration]') as HTMLSpanElement; + if (textSpan) { + textSpan.textContent = narration; + } + + // Update styling to LLM-narrated + el.style.color = '#d0d0f0'; + + // Update the stored event + const event = this.events.find((e) => e.id === id); + if (event) { + event.narration = narration; + event.isLlmGenerated = true; + } + } + + loadHistory(events: NarrationEvent[]): void { + this.clear(); + for (const event of events) { + this.events.push(event); + const el = this.createEventElement(event); + this.eventElements.set(event.id, el); + this.container.appendChild(el); + } + + // Scroll to bottom + this.container.scrollTop = this.container.scrollHeight; + } + + clear(): void { + this.events = []; + this.eventElements.clear(); + this.container.innerHTML = ''; + } + + private createEventElement(event: NarrationEvent): HTMLDivElement { + const el = document.createElement('div'); + el.style.cssText = ` + font-size: 11px; + color: ${event.isLlmGenerated ? '#d0d0f0' : '#b0b0d0'}; + padding: 4px 0; + border-bottom: 1px solid #2a2a4e; + line-height: 1.5; + font-family: 'Press Start 2P', monospace; + `; + + // Build narration with clickable NPC names + const narrationText = event.narration; + const fragment = document.createDocumentFragment(); + + // Try to make NPC names clickable by finding them in the narration + let remaining = narrationText; + let builtSomething = false; + + for (let i = 0; i < event.names.length; i++) { + const name = event.names[i]; + const entityId = event.entityIds[i]; + const idx = remaining.indexOf(name); + if (idx !== -1) { + builtSomething = true; + // Text before the name + if (idx > 0) { + fragment.appendChild(document.createTextNode(remaining.substring(0, idx))); + } + // Clickable name + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + nameSpan.style.cssText = ` + color: #58d858; + cursor: pointer; + text-decoration: none; + `; + nameSpan.addEventListener('mouseenter', () => { + nameSpan.style.color = '#88ff88'; + nameSpan.style.textDecoration = 'underline'; + }); + nameSpan.addEventListener('mouseleave', () => { + nameSpan.style.color = '#58d858'; + nameSpan.style.textDecoration = 'none'; + }); + nameSpan.addEventListener('click', () => { + document.dispatchEvent( + new CustomEvent('narration-follow', { + detail: { entityId }, + }), + ); + }); + fragment.appendChild(nameSpan); + remaining = remaining.substring(idx + name.length); + } + } + + if (builtSomething) { + // Append any remaining text + if (remaining) { + const textSpan = document.createElement('span'); + textSpan.setAttribute('data-narration', ''); + textSpan.textContent = remaining; + fragment.appendChild(textSpan); + } else { + // Mark the last text node for updateEvent + const lastChild = fragment.lastChild as HTMLElement; + if (lastChild && lastChild.nodeType === Node.ELEMENT_NODE) { + lastChild.setAttribute('data-narration', ''); + } + } + el.appendChild(fragment); + } else { + // No names found in text, just show as plain text + const textSpan = document.createElement('span'); + textSpan.setAttribute('data-narration', ''); + textSpan.textContent = narrationText; + el.appendChild(textSpan); + } + + return el; + } +} diff --git a/client/src/ui/LeftPanel.ts b/client/src/ui/LeftPanel.ts new file mode 100644 index 0000000..f6333ea --- /dev/null +++ b/client/src/ui/LeftPanel.ts @@ -0,0 +1,220 @@ +export class LeftPanel { + private container: HTMLDivElement; + private panel: HTMLDivElement; + private headerEl: HTMLDivElement; + private contentArea: HTMLDivElement; + private statsTab: HTMLDivElement; + private eventsTab: HTMLDivElement; + private expanded = false; + private activeTab: 'stats' | 'events' | null = null; + private superlativesContent: HTMLDivElement; + private eventsFeedContent: HTMLDivElement; + + constructor(superlativesContent: HTMLDivElement, eventsFeedContent: HTMLDivElement) { + this.superlativesContent = superlativesContent; + this.eventsFeedContent = eventsFeedContent; + + // Outer container for positioning + this.container = document.createElement('div'); + this.container.style.cssText = ` + position: fixed; + left: 0; + top: 50%; + transform: translateY(-50%); + z-index: 1000; + display: flex; + flex-direction: row; + font-family: 'Press Start 2P', monospace; + transition: transform 0.35s ease; + `; + + // Main panel + this.panel = document.createElement('div'); + this.panel.style.cssText = ` + width: 340px; + background: #1a1a2e; + border: 3px solid #e0d0b0; + border-left: none; + padding: 0; + box-sizing: border-box; + max-height: 80vh; + display: flex; + flex-direction: column; + `; + + // Inner frame (EarthBound double-border) + const innerFrame = document.createElement('div'); + innerFrame.style.cssText = ` + border: 2px solid #8878a8; + margin: 4px; + padding: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + `; + + // Header + this.headerEl = document.createElement('div'); + this.headerEl.style.cssText = ` + text-align: center; + color: #e0d0b0; + font-size: 16px; + padding: 4px 0 8px 0; + border-bottom: 1px solid #8878a8; + margin-bottom: 8px; + flex-shrink: 0; + `; + this.headerEl.textContent = 'SUPERLATIVES'; + + // Content area (swaps between superlatives and events) + this.contentArea = document.createElement('div'); + this.contentArea.style.cssText = ` + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + `; + + innerFrame.appendChild(this.headerEl); + innerFrame.appendChild(this.contentArea); + this.panel.appendChild(innerFrame); + + // Tab buttons container (stacked vertically on right edge) + const tabContainer = document.createElement('div'); + tabContainer.style.cssText = ` + display: flex; + flex-direction: column; + align-self: center; + gap: 2px; + flex-shrink: 0; + `; + + // Stats tab ("S") + this.statsTab = this.createTab('S'); + this.statsTab.addEventListener('click', () => { + if (this.activeTab === 'stats' && this.expanded) { + this.collapse(); + } else { + this.expand('stats'); + } + }); + + // Events tab ("E") + this.eventsTab = this.createTab('E'); + this.eventsTab.addEventListener('click', () => { + if (this.activeTab === 'events' && this.expanded) { + this.collapse(); + } else { + this.expand('events'); + } + }); + + tabContainer.appendChild(this.statsTab); + tabContainer.appendChild(this.eventsTab); + + this.container.appendChild(this.panel); + this.container.appendChild(tabContainer); + + // Start collapsed + this.container.style.transform = 'translateY(-50%) translateX(-340px)'; + + document.body.appendChild(this.container); + } + + private createTab(label: string): HTMLDivElement { + const tab = document.createElement('div'); + tab.style.cssText = ` + width: 32px; + height: 56px; + background: #1a1a2e; + border: 3px solid #e0d0b0; + border-left: none; + border-radius: 0 6px 6px 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s; + `; + + const tabInner = document.createElement('div'); + tabInner.style.cssText = ` + color: #e0d0b0; + font-size: 20px; + `; + tabInner.textContent = label; + tab.appendChild(tabInner); + + tab.addEventListener('mouseenter', () => { + if (tab.dataset.active !== 'true') { + tab.style.background = '#2a2a4e'; + } + }); + tab.addEventListener('mouseleave', () => { + if (tab.dataset.active !== 'true') { + tab.style.background = '#1a1a2e'; + } + }); + + return tab; + } + + private updateTabs(): void { + this.statsTab.style.background = + this.activeTab === 'stats' && this.expanded ? '#2a2a4e' : '#1a1a2e'; + this.statsTab.dataset.active = + this.activeTab === 'stats' && this.expanded ? 'true' : 'false'; + + this.eventsTab.style.background = + this.activeTab === 'events' && this.expanded ? '#2a2a4e' : '#1a1a2e'; + this.eventsTab.dataset.active = + this.activeTab === 'events' && this.expanded ? 'true' : 'false'; + } + + private showContent(tab: 'stats' | 'events'): void { + // Remove current content + while (this.contentArea.firstChild) { + this.contentArea.removeChild(this.contentArea.firstChild); + } + + if (tab === 'stats') { + this.headerEl.textContent = 'SUPERLATIVES'; + this.contentArea.appendChild(this.superlativesContent); + } else { + this.headerEl.textContent = 'EVENTS'; + this.contentArea.appendChild(this.eventsFeedContent); + } + } + + expand(tab: 'stats' | 'events'): void { + this.activeTab = tab; + this.expanded = true; + this.showContent(tab); + this.updateTabs(); + this.container.style.transform = 'translateY(-50%) translateX(0)'; + document.dispatchEvent( + new CustomEvent('left-panel-opened', { detail: { tab } }), + ); + } + + collapse(): void { + this.expanded = false; + this.container.style.transform = 'translateY(-50%) translateX(-340px)'; + this.updateTabs(); + document.dispatchEvent(new CustomEvent('left-panel-closed')); + } + + isExpanded(): boolean { + return this.expanded; + } + + getActiveTab(): 'stats' | 'events' | null { + return this.expanded ? this.activeTab : null; + } + + destroy(): void { + this.container.remove(); + } +} diff --git a/client/src/ui/SuperlativesPanel.ts b/client/src/ui/SuperlativesPanel.ts new file mode 100644 index 0000000..664e795 --- /dev/null +++ b/client/src/ui/SuperlativesPanel.ts @@ -0,0 +1,115 @@ +import type { SuperlativesData } from '@dflike/shared'; + +type SuperlativeCategory = { + key: keyof SuperlativesData; + label: string; +}; + +const CATEGORIES: SuperlativeCategory[] = [ + { key: 'mostLoved', label: 'Most Loved' }, + { key: 'mostReviled', label: 'Most Reviled' }, + { key: 'mostPopular', label: 'Most Popular' }, + { key: 'mostAnnoying', label: 'Most Annoying' }, + { key: 'mostOutgoing', label: 'Most Outgoing' }, + { key: 'shyest', label: 'Shyest' }, + { key: 'socialButterfly', label: 'Social Butterfly' }, + { key: 'loneliest', label: 'Loneliest' }, + { key: 'mostDevoted', label: 'Most Devoted' }, + { key: 'mostPolarizing', label: 'Most Polarizing' }, + { key: 'biggestHeartbreaker', label: 'Heartbreaker' }, +]; + +export class SuperlativesPanel { + private contentEl: HTMLDivElement; + private categoryElements: Map = new Map(); + + constructor() { + // Content area (no outer container, tab, or slide logic) + this.contentEl = document.createElement('div'); + + for (const cat of CATEGORIES) { + const row = document.createElement('div'); + row.style.cssText = 'margin-bottom: 10px;'; + + const label = document.createElement('div'); + label.style.cssText = ` + color: #8878a8; + font-size: 12px; + margin-bottom: 2px; + text-transform: uppercase; + `; + label.textContent = cat.label; + + const entryRow = document.createElement('div'); + entryRow.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: baseline; + `; + + const nameEl = document.createElement('span'); + nameEl.style.cssText = ` + color: #58d858; + font-size: 14px; + cursor: pointer; + text-decoration: none; + `; + nameEl.textContent = '\u2014'; + nameEl.addEventListener('mouseenter', () => { + if (nameEl.dataset.entityId) { + nameEl.style.color = '#88ff88'; + nameEl.style.textDecoration = 'underline'; + } + }); + nameEl.addEventListener('mouseleave', () => { + nameEl.style.color = '#58d858'; + nameEl.style.textDecoration = 'none'; + }); + nameEl.addEventListener('click', () => { + const entityId = nameEl.dataset.entityId; + if (entityId) { + document.dispatchEvent(new CustomEvent('superlative-follow', { + detail: { entityId: parseInt(entityId, 10) }, + })); + } + }); + + const valueEl = document.createElement('span'); + valueEl.style.cssText = ` + color: #a0a0c0; + font-size: 12px; + margin-left: 4px; + flex-shrink: 0; + `; + + entryRow.appendChild(nameEl); + entryRow.appendChild(valueEl); + row.appendChild(label); + row.appendChild(entryRow); + this.contentEl.appendChild(row); + + this.categoryElements.set(cat.key, { nameEl, valueEl }); + } + } + + getElement(): HTMLDivElement { + return this.contentEl; + } + + update(data: SuperlativesData): void { + for (const cat of CATEGORIES) { + const els = this.categoryElements.get(cat.key); + if (!els) continue; + const entry = data[cat.key]; + if (entry) { + els.nameEl.textContent = entry.name; + els.nameEl.dataset.entityId = String(entry.entityId); + els.valueEl.textContent = `(${entry.value})`; + } else { + els.nameEl.textContent = '\u2014'; + delete els.nameEl.dataset.entityId; + els.valueEl.textContent = ''; + } + } + } +}