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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 18:22:48 +00:00
parent 554b5a6f30
commit e8723f0a06
2 changed files with 89 additions and 5 deletions
+42 -4
View File
@@ -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<void> {
@@ -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;
+47 -1
View File
@@ -1,4 +1,4 @@
import type { EntityState } from '@dflike/shared';
import type { EntityState, NarrationEvent } from '@dflike/shared';
const ACTIVITY_LABELS: Record<string, string> = {
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<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = 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(() => {