feat: refactor left panel into tabbed container with Stats + Events tabs

Extract SuperlativesPanel into content-only component, create LeftPanel
as the outer shell with two tabs (S for Stats, E for Events), and add
EventsFeed component for displaying narration events with clickable
NPC names and auto-scroll behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 18:19:20 +00:00
parent 57695043f2
commit 554b5a6f30
4 changed files with 574 additions and 0 deletions
+69
View File
@@ -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
};
+170
View File
@@ -0,0 +1,170 @@
import type { NarrationEvent } from '@dflike/shared';
export class EventsFeed {
private container: HTMLDivElement;
private events: NarrationEvent[] = [];
private eventElements: Map<number, HTMLDivElement> = 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;
}
}
+220
View File
@@ -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();
}
}
+115
View File
@@ -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<string, { nameEl: HTMLSpanElement; valueEl: HTMLSpanElement }> = 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 = '';
}
}
}
}