world-simulator / frontend /src /components /EventFeedPanel.tsx
DeltaZN
feat: improved perception
54db442
Raw
History Blame Contribute Delete
3.31 kB
import { useEffect, useMemo, useRef } from "react";
import { eventIcon, severityClass } from "../eventDecor";
import type { CountrySnapshot, EntitySnapshot, GameLogEventSnapshot } from "../types";
type EventFeedPanelProps = {
events: GameLogEventSnapshot[];
countries?: CountrySnapshot[];
entities?: EntitySnapshot[];
};
export function EventFeedPanel({ events, countries = [], entities = [] }: EventFeedPanelProps) {
const listRef = useRef<HTMLDivElement>(null);
const isPinnedToBottomRef = useRef(true);
// Resolve a speaker's nation colour (matching the scoreboard: Nemotron blue,
// Qwen purple) so chronicle speech lines are tinted by who is talking.
const colorByCountry = useMemo(
() => new Map(countries.map((country) => [country.id, country.color])),
[countries],
);
const countryByActor = useMemo(
() => new Map(entities.map((entity) => [entity.id, entity.country_id ?? null])),
[entities],
);
const speechColor = (event: GameLogEventSnapshot): string | undefined => {
if (event.type !== "speech" || !event.actor_id) {
return undefined;
}
// Fall back to the id prefix when the speaker has since died/left the snapshot.
let countryId = countryByActor.get(event.actor_id) ?? null;
if (!countryId) {
if (event.actor_id.startsWith("qwen")) countryId = "qwen";
else if (event.actor_id.startsWith("npc")) countryId = "nemotron";
}
return countryId ? colorByCountry.get(countryId) : undefined;
};
useEffect(() => {
const list = listRef.current;
if (list && isPinnedToBottomRef.current) {
list.scrollTop = list.scrollHeight;
}
}, [events]);
return (
<aside className="eventFeed" aria-label="Event feed">
<div className="sidePanelHeader">
<span className="sidePanelTitle">📰 Village Chronicle</span>
<span className="sidePanelHint">{events.length} events</span>
</div>
<div
className="eventList"
ref={listRef}
onScroll={() => {
const list = listRef.current;
if (list) {
isPinnedToBottomRef.current =
list.scrollHeight - list.scrollTop - list.clientHeight < 24;
}
}}
>
{events.length === 0 ? (
<p className="eventEmpty">The village is quiet... for now.</p>
) : (
events.map((event, index) => {
const color = speechColor(event);
return (
<div
className={`eventLine ${severityClass(event.severity)}`}
key={`${event.tick}-${index}-${event.type}`}
// Tint a speaker's line with their nation colour as a small
// highlight (left bar + faint wash); the text itself stays light.
style={
color
? { borderLeftColor: color, background: `${color}14` }
: undefined
}
>
<span className="eventTick">{event.tick}</span>
<span className="eventIcon" aria-hidden="true">
{eventIcon(event.type)}
</span>
<span className="eventText">{event.summary}</span>
</div>
);
})
)}
</div>
</aside>
);
}