add Deaths, Grabs, Returns to demo timeline, improve mobile layout

This commit is contained in:
Brian Beck 2026-03-23 12:09:56 -07:00
parent 90ec7cbae2
commit c15bce10f3
41 changed files with 374 additions and 90 deletions

View file

@ -36,8 +36,9 @@
}
.Seek[type="range"] {
flex: 1 1 0;
min-width: 0;
flex: 1 0 20px;
width: 20px;
min-width: 20px;
max-width: none;
}

View file

@ -26,7 +26,7 @@
.ProgressFill {
height: 100%;
background: rgba(100, 180, 255, 0.7);
background: rgba(10, 132, 255, 0.8);
transition: width 0.15s;
}
@ -134,7 +134,8 @@
margin: 0 1px;
}
.EventIcon[data-type="kill"] {
.EventIcon[data-type="kill"],
.EventIcon[data-type="death"] {
color: #8a8380;
}
@ -144,6 +145,12 @@
margin: 0;
}
.EventIcon[data-type="flag-grab"],
.EventIcon[data-type="flag-return"] {
font-size: 15px;
margin: 0;
}
.EventIcon[data-type="flag-cap"][data-affinity="enemy"] {
color: #ff6b6b;
}

View file

@ -9,6 +9,7 @@ import type {
import { usePlaybackActions } from "./RecordingProvider";
import { BsPlayFill } from "react-icons/bs";
import { AiFillStop } from "react-icons/ai";
import { LuCrosshair } from "react-icons/lu";
import styles from "./DemoTimeline.module.css";
function formatTime(seconds: number): string {
@ -18,7 +19,10 @@ function formatTime(seconds: number): string {
}
const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
kill: <IoSkullSharp />,
kill: <LuCrosshair />,
death: <IoSkullSharp />,
"flag-grab": <PiFlagBannerFill />,
"flag-return": <PiFlagBannerFill />,
"flag-cap": <PiFlagBannerFill />,
"match-start": <BsPlayFill />,
"match-end": <AiFillStop />,
@ -26,6 +30,7 @@ const EVENT_ICON: Record<TimelineEventType, React.ReactNode> = {
const WEAPONS_PAST_TENSE: Record<string, string> = {
chaingun: "chaingunned",
plasma: "plasma rifled",
};
function renderEventDescription(event: TimelineEvent): React.ReactNode {
@ -45,6 +50,34 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
</>
);
}
if (event.type === "death") {
if (event.killer) {
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} title={event.victim}>
you
</span>
</>
);
}
return <>{event.description}</>;
}
if (event.type === "flag-grab") {
const flagLabel = event.flagTeamName
? `the ${event.flagTeamName} flag`
: "the enemy flag";
return <>You grabbed {flagLabel}</>;
}
if (event.type === "flag-return") {
return <>You returned your flag</>;
}
if (event.type === "flag-cap" && event.capturer) {
const flagLabel =
event.teamAffinity === "friendly"
@ -69,7 +102,13 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
return event.description;
}
type Filter = "all" | "kill" | "flag-cap";
type Filter =
| "all"
| "kill"
| "death"
| "flag-grab"
| "flag-return"
| "flag-cap";
export function DemoTimeline() {
const events = useDemoTimeline((s) => s.events);
@ -83,6 +122,11 @@ export function DemoTimeline() {
const handleClick = useCallback(
(timeSec: number) => {
seek(Math.max(0, timeSec - 3));
// Blur so focus returns to body — allows spacebar to toggle
// play/pause instead of re-activating the timeline button.
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
},
[seek],
);
@ -109,7 +153,10 @@ export function DemoTimeline() {
if (!events) return null;
const killCount = events.filter((e) => e.type === "kill").length;
const flagCount = events.filter((e) => e.type === "flag-cap").length;
const deathCount = events.filter((e) => e.type === "death").length;
const grabCount = events.filter((e) => e.type === "flag-grab").length;
const returnCount = events.filter((e) => e.type === "flag-return").length;
const capCount = events.filter((e) => e.type === "flag-cap").length;
return (
<div className={styles.Root}>
@ -130,13 +177,37 @@ export function DemoTimeline() {
>
Kills ({killCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "death"}
onClick={() => setFilter("death")}
>
Deaths ({deathCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "flag-grab"}
onClick={() => setFilter("flag-grab")}
>
Grabs ({grabCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "flag-return"}
onClick={() => setFilter("flag-return")}
>
Returns ({returnCount})
</button>
<button
type="button"
className={styles.FilterButton}
data-active={filter === "flag-cap"}
onClick={() => setFilter("flag-cap")}
>
Flags ({flagCount})
Caps ({capCount})
</button>
</div>
{filtered.length === 0 ? (

View file

@ -13,7 +13,6 @@ import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { LoadDemoButton } from "./LoadDemoButton";
import { JoinServerButton } from "./JoinServerButton";
import { Accordion, AccordionGroup } from "./Accordion";
import styles from "./InspectorControls.module.css";
import { useTouchDevice } from "./useTouchDevice";
import { DemoTimeline } from "./DemoTimeline";
import { MapTourPanel } from "./MapTourPanel";
@ -21,6 +20,7 @@ import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
import styles from "./InspectorControls.module.css";
const DEFAULT_PANELS = ["controls", "preferences", "audio", "timeline"];

View file

@ -131,6 +131,23 @@
display: none;
}
.CloseSidebarButton {
composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css";
width: calc(100% - 24px);
margin: 12px;
padding-top: 8px;
padding-bottom: 8px;
}
.ButtonLabel {
composes: ButtonLabel from "./InspectorControls.module.css";
}
.CloseSidebarButton .ButtonLabel {
font-size: 15px;
}
@media (max-width: 899px) {
.Frame {
display: grid;
@ -182,6 +199,12 @@
}
}
@media (min-width: 900px) {
.CloseSidebarButton {
display: none;
}
}
@media (pointer: coarse) {
.ToggleSidebarButton {
font-size: 28px;

View file

@ -310,6 +310,14 @@ export function MapInspector() {
onChooseMap={handleChooseMap}
onCancelChoosingMap={handleCancelChoosingMap}
/>
<button
className={styles.CloseSidebarButton}
onClick={(event) => {
setSidebarOpen(false);
}}
>
<span className={styles.ButtonLabel}>Close</span>
</button>
</div>
</Activity>
<InputProvider>

View file

@ -1,7 +1,14 @@
import { createStore } from "zustand/vanilla";
import { useStoreWithEqualityFn } from "zustand/traditional";
export type TimelineEventType = "match-start" | "match-end" | "kill" | "flag-cap";
export type TimelineEventType =
| "match-start"
| "match-end"
| "kill"
| "death"
| "flag-grab"
| "flag-return"
| "flag-cap";
/** Relationship of the event to the recorder's team. */
export type TeamAffinity = "friendly" | "enemy" | "neutral";
@ -10,17 +17,17 @@ export interface TimelineEvent {
timeSec: number;
type: TimelineEventType;
description: string;
/** For flag-cap: whether the recorder's team or enemy team scored. */
/** For flag events: whether the recorder's team or enemy team was involved. */
teamAffinity?: TeamAffinity;
/** For kill events: name of the killer. */
/** For kill/death events: name of the killer. */
killer?: string;
/** For kill events: name of the victim. */
/** For kill/death events: name of the victim. */
victim?: string;
/** For kill events: weapon or method of death (e.g. "disc", "mortar"). */
/** For kill/death events: weapon or damage type (e.g. "disc", "ground"). */
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. */
/** For flag-cap/flag-grab events: name of the flag's team. */
flagTeamName?: string;
}

View file

@ -10,21 +10,30 @@ const log = createLogger("demoTimelineScanner");
const YIELD_INTERVAL = 500;
/**
* Kill message types where args[5] is the killer name.
* Case-insensitive matching is used.
* All death message types where args[2]=victimName, args[5]=killerName,
* args[9]=DamageTypeText. Case-insensitive matching is used.
*
* Note: explicit suicide (Ctrl+K) uses `msgSuicide` which is NOT in
* this set we intentionally ignore those.
*/
const KILL_MSG_TYPES = new Set([
// Player-vs-player kills
"msglegitkill",
"msgheadshotkill",
"msgteamkill",
// Self-inflicted (own weapon damage, cratering)
"msgselfkill",
// Explosions (can be self or other)
"msgexplosionkill",
// Vehicle-related
"msgvehiclekill",
"msgvehiclecrash",
"msgvehiclespawnkill",
// Turret-related
"msgturretkill",
"msgcturretkill",
"msgturretselfkill",
// Environmental
"msgoobkill",
"msgcampkill",
"msgrogueminekill",
@ -32,16 +41,74 @@ const KILL_MSG_TYPES = new Set([
"msglightningkill",
]);
/** Suicide message types — we never attribute these as kills. */
const SUICIDE_MSG_TYPES = new Set([
"msgselfkill",
"msgturretselfkill",
"msgoobkill",
"msglavakill",
"msglightningkill",
"msgcampkill",
/**
* Death message types where the victim killed themselves (own weapon,
* cratering, environmental hazards). These are NOT credited as kills
* but ARE shown as deaths. Does NOT include explicit Ctrl+K suicide
* (which is `msgSuicide`, not in KILL_MSG_TYPES at all).
*/
const SELF_INFLICTED_MSG_TYPES = new Set([
"msgselfkill", // Own weapon damage or cratering ($DamageType::Ground)
"msgturretselfkill", // Own turret
"msgvehiclecrash", // Vehicle crash
"msgvehiclespawnkill", // Crushed by vehicle spawning
"msgoobkill", // Out of bounds
"msglavakill", // Lava
"msglightningkill", // Lightning
"msgcampkill", // Nexus camping
]);
/** Descriptions for self-inflicted deaths by message type.
* Used for environmental deaths where the msg type alone is sufficient. */
const SELF_INFLICTED_DESCRIPTIONS: Record<string, string> = {
msgoobkill: "Out of bounds",
msglavakill: "Killed by lava",
msglightningkill: "Struck by lightning",
msgcampkill: "Nexus camping",
msgturretselfkill: "Killed by own turret",
msgvehiclecrash: "Vehicle crash",
msgvehiclespawnkill: "Crushed by vehicle",
};
/** Display names for $DamageTypeText values that need formatting. */
const WEAPON_DISPLAY_NAMES: Record<string, string> = {
turret: "base turret",
"plasma turret": "plasma turret",
"aa turret": "AA turret",
"elf turret": "ELF turret",
"mortar turret": "mortar turret",
"missile turret": "missile turret",
"clamp turret": "indoor deployable turret",
"spike turret": "outdoor deployable turret",
"sentry turret": "sentry turret",
"shrike blaster": "Shrike",
"belly turret": "Havoc belly turret",
"bomber bomb": "bomber",
"tank chaingun": "tank chaingun",
"tank mortar": "tank mortar",
"mpb missile": "MPB missile",
forcefield: "force field",
impact: "vehicle impact",
crash: "vehicle crash",
explosion: "explosion",
};
/** Descriptions for msgselfkill deaths by $DamageTypeText (args[9]).
* The msg type is the same for cratering AND own-weapon deaths, so
* the damage type text is needed to distinguish them. */
const SELF_KILL_BY_WEAPON: Record<string, string> = {
ground: "Cratered",
mine: "Killed by own mine",
satchelcharge: "Killed by own satchel",
grenade: "Killed by own grenade",
mortar: "Killed by own mortar",
disc: "Killed by own disc",
plasma: "Killed by own plasma",
blaster: "Killed by own blaster",
missile: "Killed by own missile",
explosion: "Killed by explosion",
};
function resolveNetString(s: string, netStrings: Map<number, string>): string {
if (s.length >= 2 && s.charCodeAt(0) === 1) {
const id = parseInt(s.slice(1), 10);
@ -236,6 +303,55 @@ export async function scanDemoTimeline(
continue;
}
// Flag grab (taken from base).
// Wire: args[2]=playerName, args[3]=teamName, args[4]=flag.team, args[5]=playerNameBase
if (msgTypeLower === "msgctfflagtaken" && args.length >= 3) {
const playerName = stripTaggedStringMarkup(
resolveNetString(args[2], netStrings),
).trim();
// Only include grabs by the control player (recorder).
if (
normalizedRecorder &&
playerName.toLowerCase() === normalizedRecorder
) {
const flagTeamName =
args.length >= 4
? stripTaggedStringMarkup(
resolveNetString(args[3], netStrings),
).trim()
: undefined;
events.push({
timeSec,
type: "flag-grab",
description: `You took the ${flagTeamName ?? "enemy"} flag`,
teamAffinity: "friendly",
flagTeamName: flagTeamName || undefined,
});
}
continue;
}
// Flag return (returned to base).
// Wire: args[2]=playerName, args[3]=teamName, args[4]=flag.team
if (msgTypeLower === "msgctfflagreturned" && args.length >= 3) {
const playerName = stripTaggedStringMarkup(
resolveNetString(args[2], netStrings),
).trim();
// Only include returns by the control player (recorder).
if (
normalizedRecorder &&
playerName.toLowerCase() === normalizedRecorder
) {
events.push({
timeSec,
type: "flag-return",
description: "You returned your flag",
teamAffinity: "friendly",
});
}
continue;
}
// Flag capture.
// Wire: args[2]=capturerName, args[3]=teamName, args[4]=flag.team, args[5]=capturer.team
if (msgTypeLower === "msgctfflagcapped" && args.length >= 2) {
@ -283,11 +399,8 @@ export async function scanDemoTimeline(
continue;
}
// Kill messages.
// Kill/death 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),
@ -302,24 +415,78 @@ export async function scanDemoTimeline(
).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,
});
if (normalizedRecorder) {
const normalizedKiller = killerName.toLowerCase();
const normalizedVictim = victimName.toLowerCase();
const isSelfInflicted = SELF_INFLICTED_MSG_TYPES.has(msgTypeLower);
// Kills: recorder killed someone else (not self-inflicted).
if (
!isSelfInflicted &&
normalizedKiller === normalizedRecorder &&
normalizedVictim !== 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,
});
}
// Deaths: recorder is the victim.
if (normalizedVictim === normalizedRecorder) {
if (isSelfInflicted) {
// Self-inflicted: cratering, own weapon, environmental.
// For msgselfkill, use the weapon text to distinguish
// (e.g. "ground" = cratered, "mine" = own mine).
const msgDescription =
SELF_INFLICTED_DESCRIPTIONS[msgTypeLower];
const weaponDescription =
msgTypeLower === "msgselfkill" && weapon
? SELF_KILL_BY_WEAPON[weapon.toLowerCase()]
: undefined;
events.push({
timeSec,
type: "death",
description: msgDescription ?? weaponDescription ?? "Died",
weapon: weapon || undefined,
});
} else if (
normalizedKiller !== normalizedRecorder &&
killerName
) {
// Killed by another player or controlled turret.
events.push({
timeSec,
type: "death",
description: `Killed by ${killerName}`,
killer: killerName,
victim: victimName,
weapon: weapon || undefined,
});
} else {
// No killer name (base turret, explosion, etc.) or
// self-inflicted on a non-self-inflicted msg type
// (e.g. msgexplosionkill where killer === victim).
const weaponLower = weapon?.toLowerCase();
events.push({
timeSec,
type: "death",
description: weapon
? `Killed by ${WEAPON_DISPLAY_NAMES[weaponLower!] ?? weapon}`
: "Died",
weapon: weapon || undefined,
});
}
}
}
}
} catch (err) {