mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
add demo timeline
This commit is contained in:
parent
0de43ece22
commit
68f2c184da
67 changed files with 1420 additions and 621 deletions
|
|
@ -48,6 +48,9 @@
|
|||
padding: 16px 12px 10px 12px;
|
||||
}
|
||||
|
||||
.BodyNoPadding {
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
179
src/components/DemoTimeline.module.css
Normal file
179
src/components/DemoTimeline.module.css
Normal 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;
|
||||
}
|
||||
170
src/components/DemoTimeline.tsx
Normal file
170
src/components/DemoTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue