add demo timeline

This commit is contained in:
Brian Beck 2026-03-17 15:21:14 -07:00
parent 0de43ece22
commit 68f2c184da
67 changed files with 1420 additions and 621 deletions

View file

@ -48,6 +48,9 @@
padding: 16px 12px 10px 12px;
}
.BodyNoPadding {
}
@keyframes slideDown {
from {
height: 0;

View file

@ -11,10 +11,12 @@ export function Accordion({
value,
label,
children,
noPadding = false,
}: {
value: string;
label: ReactNode;
children: ReactNode;
noPadding?: boolean;
}) {
return (
<RadixAccordion.Item value={value}>
@ -22,7 +24,9 @@ export function Accordion({
<IoCaretForward className={styles.TriggerIcon} /> {label}
</RadixAccordion.Trigger>
<RadixAccordion.Content className={styles.Content}>
<div className={styles.Body}>{children}</div>
<div className={noPadding ? styles.BodyNoPadding : styles.Body}>
{children}
</div>
</RadixAccordion.Content>
</RadixAccordion.Item>
);

View file

@ -0,0 +1,179 @@
.Root {
display: flex;
flex-direction: column;
}
.ProgressWrap {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 10px;
}
.ProgressLabel {
font-size: 11px;
opacity: 0.7;
text-align: center;
}
.ProgressBar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
}
.ProgressFill {
height: 100%;
background: rgba(100, 180, 255, 0.7);
transition: width 0.15s;
}
.Filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
padding: 10px;
}
.FilterButton {
padding: 2px 8px;
font-family: inherit;
font-size: 11px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.FilterButton[data-active="true"] {
background: rgba(0, 98, 179, 0.6);
border-color: rgba(100, 180, 255, 0.5);
color: #fff;
}
.EventList {
display: flex;
flex-direction: column;
padding: 2px 0 12px 0;
}
.EventRow {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px 3px 8px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
@media (hover: hover) {
.EventRow:hover {
background: rgba(255, 255, 255, 0.1);
}
.FilterButton:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
.FilterButton[data-active="true"]:hover {
background: rgba(0, 98, 179, 0.8);
border-color: rgba(100, 180, 255, 0.7);
}
}
@media (pointer: coarse) {
.Filters {
}
.FilterButton {
flex: 1 0 auto;
font-size: 13px;
padding: 4px 8px;
}
.EventList {
gap: 2px;
}
.EventRow {
padding-top: 5px;
padding-bottom: 5px;
font-size: 14px;
}
}
.EventTime {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
opacity: 0.6;
min-width: 3.5em;
font-size: 11px;
text-align: right;
}
.EventIcon {
flex-shrink: 0;
font-size: 13px;
display: flex;
align-items: center;
margin: 0 1px;
}
.EventIcon[data-type="kill"] {
color: #8a8380;
}
.EventIcon[data-type="flag-cap"] {
color: #69db7c;
font-size: 15px;
margin: 0;
}
.EventIcon[data-type="flag-cap"][data-affinity="enemy"] {
color: #ff6b6b;
}
.EventIcon[data-type="flag-cap"][data-affinity="neutral"] {
color: #adb5bd;
}
.EventIcon[data-type="match-start"] {
color: #74c0fc;
font-size: 15px;
margin: 0;
}
.EventIcon[data-type="match-end"] {
color: #74c0fc;
}
.EventDescription {
overflow: hidden;
text-overflow: ellipsis;
}
.Killer {
}
.Victim {
}
.DamageType {
}
.Empty {
font-size: 12px;
opacity: 0.5;
padding: 4px 10px 12px 10px;
text-align: center;
}

View file

@ -0,0 +1,170 @@
import { useState, useCallback } from "react";
import { PiFlagBannerFill } from "react-icons/pi";
import { IoSkullSharp } from "react-icons/io5";
import { useDemoTimeline } from "../state/demoTimelineStore";
import type {
TimelineEvent,
TimelineEventType,
} from "../state/demoTimelineStore";
import { usePlaybackActions } from "./RecordingProvider";
import { BsPlayFill } from "react-icons/bs";
import { AiFillStop } from "react-icons/ai";
import styles from "./DemoTimeline.module.css";
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
kill: <IoSkullSharp />,
"flag-cap": <PiFlagBannerFill />,
"match-start": <BsPlayFill />,
"match-end": <AiFillStop />,
};
const WEAPONS_PAST_TENSE: Record<string, string> = {
chaingun: "chaingunned",
};
function renderEventDescription(event: TimelineEvent): React.ReactNode {
if (event.type === "kill" && event.killer && event.victim) {
return (
<>
<span className={styles.Killer}>{event.killer}</span>{" "}
<span className={styles.DamageType}>
{event.weapon
? (WEAPONS_PAST_TENSE[event.weapon] ??
`${event.weapon}${event.weapon.endsWith("e") ? "d" : "ed"}`)
: "killed"}
</span>{" "}
<span className={styles.Victim}>{event.victim}</span>
</>
);
}
if (event.type === "flag-cap" && event.capturer) {
const flagLabel =
event.teamAffinity === "friendly"
? "the enemy flag"
: event.teamAffinity === "enemy"
? "your flag"
: event.flagTeamName
? `the ${event.flagTeamName} flag`
: "a flag";
return (
<>
{event.capturer} captured {flagLabel}
</>
);
}
if (event.type === "match-start") {
return "Match started";
}
if (event.type === "match-end") {
return "Match ended";
}
return event.description;
}
type Filter = "all" | "kill" | "flag-cap";
export function DemoTimeline() {
const events = useDemoTimeline((s) => s.events);
const scanProgress = useDemoTimeline((s) => s.scanProgress);
const { seek } = usePlaybackActions();
const [filter, setFilter] = useState<Filter>("all");
const filtered =
events?.filter((e) => filter === "all" || e.type === filter) ?? [];
const handleClick = useCallback(
(timeSec: number) => {
seek(Math.max(0, timeSec - 3));
},
[seek],
);
// Scanning in progress.
if (scanProgress != null && events == null) {
return (
<div className={styles.Root}>
<div className={styles.ProgressWrap}>
<span className={styles.ProgressLabel}>
Scanning {Math.round(scanProgress * 100)}%
</span>
<div className={styles.ProgressBar}>
<div
className={styles.ProgressFill}
style={{ width: `${scanProgress * 100}%` }}
/>
</div>
</div>
</div>
);
}
if (!events) return null;
const killCount = events.filter((e) => e.type === "kill").length;
const flagCount = events.filter((e) => e.type === "flag-cap").length;
return (
<div className={styles.Root}>
<div className={styles.Filters}>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "all"}
onClick={() => setFilter("all")}
>
All ({events.length})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "kill"}
onClick={() => setFilter("kill")}
>
Kills ({killCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "flag-cap"}
onClick={() => setFilter("flag-cap")}
>
Flags ({flagCount})
</button>
</div>
{filtered.length === 0 ? (
<div className={styles.Empty}>No events found.</div>
) : (
<div className={styles.EventList}>
{filtered.map((event, i) => (
<button
key={`${event.timeSec}-${event.type}-${i}`}
type="button"
className={styles.EventRow}
onClick={() => handleClick(event.timeSec)}
>
<span className={styles.EventTime}>
{formatTime(event.timeSec)}
</span>
<span
className={styles.EventIcon}
data-type={event.type}
data-affinity={event.teamAffinity}
>
{EVENT_ICON[event.type]}
</span>
<span className={styles.EventDescription}>
{renderEventDescription(event)}
</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -15,12 +15,13 @@ import { JoinServerButton } from "./JoinServerButton";
import { Accordion, AccordionGroup } from "./Accordion";
import styles from "./InspectorControls.module.css";
import { useTouchDevice } from "./useTouchDevice";
import { DemoTimeline } from "./DemoTimeline";
import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
const DEFAULT_PANELS = ["controls", "preferences", "audio"];
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];
export const InspectorControls = memo(function InspectorControls({
missionName,
@ -181,6 +182,11 @@ export const InspectorControls = memo(function InspectorControls({
</div>
<div className={styles.Accordions}>
<AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
{recording?.source === "demo" && (
<Accordion value="timeline" label="Timeline" noPadding>
<DemoTimeline />
</Accordion>
)}
<Accordion value="controls" label="Controls">
<div className={styles.Field}>
<label htmlFor="speedInput">Fly speed</label>

View file

@ -1,6 +1,7 @@
import { useCallback, useRef } from "react";
import { MdOndemandVideo } from "react-icons/md";
import { createLogger } from "../logger";
import { demoTimelineStore } from "../state/demoTimelineStore";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { usePlaybackActions, useRecording } from "./RecordingProvider";
import styles from "./LoadDemoButton.module.css";
@ -21,6 +22,7 @@ export function LoadDemoButton({
const { setRecording } = usePlaybackActions();
const inputRef = useRef<HTMLInputElement>(null);
const parseTokenRef = useRef(0);
const scanAbortRef = useRef<AbortController | null>(null);
const handleClick = useCallback(() => {
if (choosingMap && isDemoLoaded) {
@ -30,7 +32,10 @@ export function LoadDemoButton({
if (isDemoLoaded) {
// Unload the recording/parser but leave entities frozen in the store.
parseTokenRef.current += 1;
scanAbortRef.current?.abort();
scanAbortRef.current = null;
setRecording(null);
demoTimelineStore.getState().reset();
return;
}
inputRef.current?.click();
@ -57,6 +62,37 @@ export function LoadDemoButton({
liveState.disconnectServer();
// Metadata-first: mission/game-mode sync happens immediately.
setRecording(recording);
// Kick off background timeline scan.
scanAbortRef.current?.abort();
const abortController = new AbortController();
scanAbortRef.current = abortController;
const store = demoTimelineStore.getState();
store.reset();
store.setScanProgress(0);
import("../stream/demoTimelineScanner")
.then(({ scanDemoTimeline }) =>
scanDemoTimeline(
buffer,
recording.recorderName,
(p) => {
if (parseTokenRef.current !== parseToken) return;
demoTimelineStore.getState().setScanProgress(p);
},
abortController.signal,
),
)
.then((events) => {
if (parseTokenRef.current !== parseToken) return;
const s = demoTimelineStore.getState();
s.setEvents(events);
s.setScanProgress(null);
})
.catch((err: unknown) => {
if (parseTokenRef.current !== parseToken) return;
if (err instanceof Error && err.name === "AbortError") return;
log.error("Timeline scan failed: %o", err);
demoTimelineStore.getState().setScanProgress(null);
});
} catch (err) {
log.error("Failed to load demo: %o", err);
}

View file

@ -0,0 +1,54 @@
import { createStore } from "zustand/vanilla";
import { useStoreWithEqualityFn } from "zustand/traditional";
export type TimelineEventType = "match-start" | "match-end" | "kill" | "flag-cap";
/** Relationship of the event to the recorder's team. */
export type TeamAffinity = "friendly" | "enemy" | "neutral";
export interface TimelineEvent {
timeSec: number;
type: TimelineEventType;
description: string;
/** For flag-cap: whether the recorder's team or enemy team scored. */
teamAffinity?: TeamAffinity;
/** For kill events: name of the killer. */
killer?: string;
/** For kill events: name of the victim. */
victim?: string;
/** For kill events: weapon or method of death (e.g. "disc", "mortar"). */
weapon?: string;
/** For flag-cap events: name of the player who captured. */
capturer?: string;
/** For flag-cap events: name of the team whose flag was captured. */
flagTeamName?: string;
}
export interface DemoTimelineState {
events: TimelineEvent[] | null;
scanProgress: number | null;
setEvents(events: TimelineEvent[]): void;
setScanProgress(progress: number | null): void;
reset(): void;
}
export const demoTimelineStore = createStore<DemoTimelineState>((set) => ({
events: null,
scanProgress: null,
setEvents(events) {
set({ events });
},
setScanProgress(progress) {
set({ scanProgress: progress });
},
reset() {
set({ events: null, scanProgress: null });
},
}));
export function useDemoTimeline<T>(
selector: (state: DemoTimelineState) => T,
equality?: (a: T, b: T) => boolean,
): T {
return useStoreWithEqualityFn(demoTimelineStore, selector, equality);
}

View file

@ -0,0 +1,341 @@
import { BlockTypeMove, BlockTypePacket, DemoParser } from "t2-demo-parser";
import { TICK_DURATION_MS } from "./entityClassification";
import { stripTaggedStringMarkup } from "./streamHelpers";
import type { TimelineEvent } from "../state/demoTimelineStore";
import { createLogger } from "../logger";
const log = createLogger("demoTimelineScanner");
/** Yield control every N blocks to keep the UI responsive. */
const YIELD_INTERVAL = 500;
/**
* Kill message types where args[5] is the killer name.
* Case-insensitive matching is used.
*/
const KILL_MSG_TYPES = new Set([
"msglegitkill",
"msgheadshotkill",
"msgteamkill",
"msgselfkill",
"msgexplosionkill",
"msgvehiclekill",
"msgvehiclecrash",
"msgvehiclespawnkill",
"msgturretkill",
"msgcturretkill",
"msgturretselfkill",
"msgoobkill",
"msgcampkill",
"msgrogueminekill",
"msglavakill",
"msglightningkill",
]);
/** Suicide message types — we never attribute these as kills. */
const SUICIDE_MSG_TYPES = new Set([
"msgselfkill",
"msgturretselfkill",
"msgoobkill",
"msglavakill",
"msglightningkill",
"msgcampkill",
]);
function resolveNetString(s: string, netStrings: Map<number, string>): string {
if (s.length >= 2 && s.charCodeAt(0) === 1) {
const id = parseInt(s.slice(1), 10);
if (Number.isFinite(id)) return netStrings.get(id) ?? s;
}
return s;
}
function formatRemoteArgs(
template: string,
args: string[],
netStrings: Map<number, string>,
): string {
let resolved = resolveNetString(template, netStrings);
for (let i = 0; i < args.length; i++) {
const placeholder = `%${i + 1}`;
if (resolved.includes(placeholder)) {
resolved = resolved.replaceAll(
placeholder,
stripTaggedStringMarkup(resolveNetString(args[i], netStrings)),
);
}
}
resolved = resolved.replace(/%\d+/g, "");
return stripTaggedStringMarkup(resolved);
}
/**
* Scan an entire demo recording for timeline events (kills, flag caps,
* match start). Yields to the event loop periodically to stay responsive.
*/
export async function scanDemoTimeline(
buffer: ArrayBuffer,
recorderName: string | null,
onProgress?: (progress: number) => void,
signal?: AbortSignal,
): Promise<TimelineEvent[]> {
const parser = new DemoParser(new Uint8Array(buffer));
const { initialBlock } = await parser.load();
const netStrings = new Map<number, string>();
for (const [id, value] of initialBlock.taggedStrings) {
netStrings.set(id, value);
}
const registry = parser.getRegistry();
const normalizedRecorder = recorderName
? stripTaggedStringMarkup(recorderName).trim().toLowerCase()
: null;
// Extract recorder's clientId and initial team from demoValues.
let recorderClientId: number | null = null;
let recorderTeamId: number | null = null;
for (let i = 0; i < initialBlock.demoValues.length; i++) {
if (initialBlock.demoValues[i] !== "readplayerinfo") continue;
const value = initialBlock.demoValues[i + 1];
if (value?.startsWith("1\t")) {
const fields = value.split("\t");
const cid = parseInt(fields[1], 10);
if (Number.isFinite(cid)) recorderClientId = cid;
break;
}
}
// Get initial team from PLAYERLIST in demoValues.
if (recorderClientId != null) {
const dv = initialBlock.demoValues;
// PLAYERLIST starts after MISC (1 value): idx 1 is playerCount.
const playerCount =
parseInt(dv[1] === "<BLANK>" ? "0" : (dv[1] ?? "0"), 10) || 0;
for (let i = 0; i < playerCount; i++) {
const fields = (dv[2 + i] ?? "").split("\t");
const cid = parseInt(fields[2], 10);
if (cid === recorderClientId) {
const tid = parseInt(fields[4], 10);
if (!isNaN(tid)) recorderTeamId = tid;
break;
}
}
}
const events: TimelineEvent[] = [];
let moveTicks = 0;
let seenMatchStart = false;
let blockCount = 0;
const totalBlocks = parser.blockCount;
while (true) {
if (signal?.aborted) break;
let block;
try {
block = parser.nextBlock();
} catch (err) {
// Parser cursor state is unknown after a throw — stop scanning and
// return whatever events we've found rather than risking an infinite loop.
log.warn("Stopping scan at block %d due to read error: %o", blockCount, err);
break;
}
if (!block) break;
blockCount++;
if (block.type === BlockTypeMove) {
moveTicks++;
continue;
}
if (block.type !== BlockTypePacket || !block.parsed) continue;
const packet = block.parsed as {
events?: Array<{
classId: number;
parsedData?: Record<string, unknown>;
}>;
};
if (!packet.events) continue;
const timeSec = moveTicks * (TICK_DURATION_MS / 1000);
for (const evt of packet.events) {
try {
if (!evt.parsedData) continue;
const type = evt.parsedData.type as string | undefined;
if (type === "NetStringEvent") {
const id = evt.parsedData.id as number;
const value = evt.parsedData.value as string | undefined;
if (value != null) {
netStrings.set(id, value);
}
continue;
}
// Also check the registry name for RemoteCommandEvent identification.
const eventName = registry.getEventParser(evt.classId)?.name;
if (
type !== "RemoteCommandEvent" &&
eventName !== "RemoteCommandEvent"
) {
continue;
}
const funcName = resolveNetString(
evt.parsedData.funcName as string,
netStrings,
);
if (funcName !== "ServerMessage") continue;
const args = evt.parsedData.args as string[];
if (!args || args.length < 2) continue;
const msgType = resolveNetString(args[0], netStrings);
const msgTypeLower = msgType.toLowerCase();
// Track recorder's team changes.
if (
msgTypeLower === "msgclientjointeam" &&
recorderClientId != null &&
args.length >= 6
) {
const clientId = parseInt(
resolveNetString(args[4], netStrings),
10,
);
if (clientId === recorderClientId) {
const teamId = parseInt(
resolveNetString(args[5], netStrings),
10,
);
if (!isNaN(teamId)) recorderTeamId = teamId;
}
}
// Match start: MsgMissionStart is sent when the match actually begins
// (after the countdown). MsgSystemClock is just the countdown timer.
if (msgTypeLower === "msgmissionstart" && !seenMatchStart) {
seenMatchStart = true;
events.push({
timeSec,
type: "match-start",
description: "Match started",
});
continue;
}
// Match ended.
if (msgTypeLower === "msggameover") {
events.push({
timeSec,
type: "match-end",
description: "Match ended",
});
continue;
}
// Flag capture.
// Wire: args[2]=capturerName, args[3]=teamName, args[4]=flag.team, args[5]=capturer.team
if (msgTypeLower === "msgctfflagcapped" && args.length >= 2) {
const description = formatRemoteArgs(
args[1],
args.slice(2),
netStrings,
);
const capturerName =
args.length >= 3
? stripTaggedStringMarkup(
resolveNetString(args[2], netStrings),
).trim()
: undefined;
const flagTeamName =
args.length >= 4
? stripTaggedStringMarkup(
resolveNetString(args[3], netStrings),
).trim()
: undefined;
let teamAffinity: "friendly" | "enemy" | "neutral" = "neutral";
if (
recorderTeamId != null &&
recorderTeamId > 0 &&
args.length >= 6
) {
const capturerTeam = parseInt(
resolveNetString(args[5], netStrings),
10,
);
if (capturerTeam === recorderTeamId) {
teamAffinity = "friendly";
} else if (!isNaN(capturerTeam)) {
teamAffinity = "enemy";
}
}
events.push({
timeSec,
type: "flag-cap",
description: description || "Flag captured",
teamAffinity,
capturer: capturerName,
flagTeamName: flagTeamName || undefined,
});
continue;
}
// Kill messages.
if (KILL_MSG_TYPES.has(msgTypeLower) && args.length >= 6) {
// Exclude suicides — victim == killer, no distinct killer to credit.
if (SUICIDE_MSG_TYPES.has(msgTypeLower)) continue;
// args[2]=victimName, args[5]=killerName, args[9]=DamageTypeText
const killerName = stripTaggedStringMarkup(
resolveNetString(args[5], netStrings),
).trim();
const victimName = stripTaggedStringMarkup(
resolveNetString(args[2], netStrings),
).trim();
const weapon =
args.length >= 10
? stripTaggedStringMarkup(
resolveNetString(args[9], netStrings),
).trim()
: undefined;
// Only include kills where the recorder is the killer.
if (
normalizedRecorder &&
killerName.toLowerCase() === normalizedRecorder
) {
const description = formatRemoteArgs(
args[1],
args.slice(2),
netStrings,
);
events.push({
timeSec,
type: "kill",
description: description || `${killerName} got a kill`,
killer: killerName,
victim: victimName,
weapon: weapon || undefined,
});
}
}
} catch (err) {
log.warn("Skipping malformed event in block %d: %o", blockCount, err);
}
}
// Yield control periodically.
if (blockCount % YIELD_INTERVAL === 0) {
if (totalBlocks && onProgress) {
onProgress(Math.min(blockCount / totalBlocks, 1));
}
await new Promise<void>((r) => setTimeout(r, 0));
}
}
log.info("Scanned %d blocks, found %d events", blockCount, events.length);
return events;
}