From e8723f0a064d4d6f56385dde4bc82c6f5b9aaea9 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 18:22:48 +0000 Subject: [PATCH] feat: add recent narration events to NPC info panel Show the last 3 narration events involving the followed NPC in the Status tab, between the activity label and the diamond separator. Events update live when new narrations arrive or LLM text replaces template text. Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 46 +++++++++++++++++++++++++++++--- client/src/ui/NpcInfoPanel.ts | 48 +++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index e52b0a6..038f581 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -8,7 +8,7 @@ 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 type { WorldState, StateUpdate, EntityState, Appearance, NarrationEvent } 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'; interface EntitySprite { @@ -40,6 +40,7 @@ export class GameScene extends Phaser.Scene { private highlightEnabled = false; private highlightedSpriteId: number | null = null; private dayNightOverlay!: Phaser.GameObjects.Graphics; + private narrationEvents: NarrationEvent[] = []; private currentGameTime = 0; private targetGameTime = 0; private lastPhaseKey = ''; @@ -173,9 +174,27 @@ export class GameScene extends Phaser.Scene { 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); + this.client.onNarrationEvent = (event) => { + this.eventsFeed.addEvent(event); + this.narrationEvents.push(event); + if (this.narrationEvents.length > 50) { + this.narrationEvents = this.narrationEvents.slice(-50); + } + this.refreshRecentEvents(); + }; + this.client.onNarrationUpdate = (data) => { + this.eventsFeed.updateEvent(data.id, data.narration); + const existing = this.narrationEvents.find(e => e.id === data.id); + if (existing) { + existing.narration = data.narration; + } + this.refreshRecentEvents(); + }; + this.client.onNarrationHistory = (events) => { + this.eventsFeed.loadHistory(events); + this.narrationEvents = events.slice(-50); + this.refreshRecentEvents(); + }; document.addEventListener('narration-follow', ((e: CustomEvent) => { const { entityId } = e.detail; @@ -590,6 +609,7 @@ export class GameScene extends Phaser.Scene { const followed = this.entitySprites.get(followedId); if (followed) { this.npcInfoPanel.updateInfo(followed.lastState); + this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(followedId)); } } } @@ -703,6 +723,7 @@ export class GameScene extends Phaser.Scene { const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance); this.npcInfoPanel.show(es.lastState, portraitUrl); + this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId)); } private async updateFollowPanel(): Promise { @@ -715,6 +736,7 @@ export class GameScene extends Phaser.Scene { const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance); this.npcInfoPanel.updatePortrait(portraitUrl); this.npcInfoPanel.updateInfo(es.lastState); + this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId)); } private getNpcIds(): number[] { @@ -754,6 +776,22 @@ export class GameScene extends Phaser.Scene { } } + private getEventsForEntity(entityId: number): NarrationEvent[] { + return this.narrationEvents + .filter(e => e.entityIds[0] === entityId || e.entityIds[1] === entityId) + .slice(-3); + } + + private refreshRecentEvents(): void { + if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) { + const npcIds = this.getNpcIds(); + const followedId = npcIds[this.followTargetIndex]; + if (followedId != null) { + this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(followedId)); + } + } + } + private getDayNightPhase(gameTime: number): { phase: 'day' | 'sunset' | 'night' | 'sunrise'; progress: number } { const hour = gameTime * TOTAL_HOURS; diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 7bd0817..82c059e 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -1,4 +1,4 @@ -import type { EntityState } from '@dflike/shared'; +import type { EntityState, NarrationEvent } from '@dflike/shared'; const ACTIVITY_LABELS: Record = { wander: 'Wandering', @@ -32,6 +32,7 @@ export class NpcInfoPanel { private portraitImg: HTMLImageElement; private nameEl: HTMLDivElement; private activityEl: HTMLDivElement; + private recentEventsEl: HTMLDivElement; private separatorEl: HTMLDivElement; private needsBarsContainer: HTMLDivElement; private needsBars: Map = new Map(); @@ -216,6 +217,14 @@ export class NpcInfoPanel { `; this.statusContent.appendChild(this.activityEl); + // Recent events section (hidden by default) + this.recentEventsEl = document.createElement('div'); + this.recentEventsEl.style.cssText = ` + display: none; + padding: 2px 0; + `; + this.statusContent.appendChild(this.recentEventsEl); + // Decorative separator (EarthBound diamond pattern) this.separatorEl = document.createElement('div'); this.separatorEl.style.cssText = ` @@ -319,6 +328,43 @@ export class NpcInfoPanel { this.setNeedBar('Energy', needs.energy); } + updateRecentEvents(events: NarrationEvent[]): void { + if (events.length === 0) { + this.recentEventsEl.style.display = 'none'; + return; + } + + this.recentEventsEl.style.display = ''; + this.recentEventsEl.innerHTML = ''; + + const label = document.createElement('div'); + label.style.cssText = ` + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: ${EB.textMuted}; + margin-bottom: 3px; + `; + label.textContent = 'Recent:'; + this.recentEventsEl.appendChild(label); + + const recent = events.slice(-3); + for (const event of recent) { + const line = document.createElement('div'); + line.style.cssText = ` + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: ${EB.textSecondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.6; + `; + line.textContent = event.narration; + line.title = event.narration; + this.recentEventsEl.appendChild(line); + } + } + updatePortrait(portraitDataUrl: string): void { this.portraitImg.style.opacity = '0'; setTimeout(() => {