initial demo support

This commit is contained in:
Brian Beck 2026-02-28 17:58:09 -08:00
parent 0f2e103294
commit 359a036558
406 changed files with 10513 additions and 1158 deletions

3
app/global.d.ts vendored
View file

@ -7,5 +7,8 @@ declare global {
getMissionList?: typeof getMissionList;
getMissionInfo?: typeof getMissionInfo;
loadDemoRecording?: (recording: DemoRecording) => void;
getDemoDiagnostics?: () => unknown;
getDemoDiagnosticsJson?: () => string;
clearDemoDiagnostics?: () => void;
}
}

View file

@ -29,10 +29,25 @@ import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements";
import { CamerasProvider } from "@/src/components/CamerasProvider";
import { DemoProvider, useDemo } from "@/src/components/DemoProvider";
import {
DemoProvider,
useDemoActions,
useDemoIsPlaying,
useDemoRecording,
} from "@/src/components/DemoProvider";
import { DemoPlayback } from "@/src/components/DemoPlayback";
import { DemoControls } from "@/src/components/DemoControls";
import { getMissionList, getMissionInfo } from "@/src/manifest";
import { PlayerHUD } from "@/src/components/PlayerHUD";
import {
buildSerializableDiagnosticsJson,
buildSerializableDiagnosticsSnapshot,
useEngineStoreApi,
} from "@/src/state";
import {
getMissionList,
getMissionInfo,
findMissionByDemoName,
} from "@/src/manifest";
import { createParser, parseAsBoolean, useQueryState } from "nuqs";
const MapInfoDialog = lazy(() =>
@ -53,6 +68,17 @@ const glSettings: GLProps = {
outputColorSpace: SRGBColorSpace,
};
function summarizeCallStack(skipFrames = 0): string | null {
const stack = new Error().stack;
if (!stack) return null;
const lines = stack
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const callsiteLines = lines.slice(1 + skipFrames, 9 + skipFrames);
return callsiteLines.length > 0 ? callsiteLines.join(" <= ") : null;
}
type CurrentMission = {
missionName: string;
missionType?: string;
@ -90,6 +116,7 @@ function MapInspector() {
"mission",
parseAsMissionWithType,
);
const engineStore = useEngineStoreApi();
const [fogEnabledOverride, setFogEnabledOverride] = useQueryState(
"fog",
parseAsBoolean,
@ -99,13 +126,34 @@ function MapInspector() {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const currentMissionRef = useRef(currentMission);
currentMissionRef.current = currentMission;
const changeMission = useCallback(
(mission: CurrentMission) => {
const previousMission = currentMissionRef.current;
const stack = summarizeCallStack(1);
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "mission.change.requested",
message: "changeMission invoked",
meta: {
previousMissionName: previousMission.missionName,
previousMissionType: previousMission.missionType ?? null,
nextMissionName: mission.missionName,
nextMissionType: mission.missionType ?? null,
stack: stack ?? "unavailable",
},
});
console.info("[mission trace] changeMission", {
previousMission,
nextMission: mission,
stack,
});
window.location.hash = "";
clearFogEnabledOverride();
setCurrentMission(mission);
},
[setCurrentMission, clearFogEnabledOverride],
[engineStore, setCurrentMission, clearFogEnabledOverride],
);
const isTouch = useTouchDevice();
@ -228,6 +276,7 @@ function MapInspector() {
</CamerasProvider>
</Canvas>
</div>
<PlayerHUD />
{isTouch && (
<TouchJoystick
joystickState={joystickStateRef}
@ -255,6 +304,7 @@ function MapInspector() {
/>
</Suspense>
)}
<DemoMissionSync changeMission={changeMission} currentMission={currentMission} />
<DemoControls />
<DemoWindowAPI />
</KeyboardControls>
@ -265,6 +315,64 @@ function MapInspector() {
);
}
/** Map from Tribes 2 game type display names to manifest mission type codes. */
const GAME_TYPE_TO_MISSION_TYPE: Record<string, string> = {
"Capture the Flag": "CTF",
"Capture and Hold": "CnH",
Deathmatch: "DM",
"Team Deathmatch": "TDM",
Siege: "Siege",
Bounty: "Bounty",
Rabbit: "Rabbit",
};
/**
* When a demo recording is loaded, switch to the mission it was recorded on.
*/
function DemoMissionSync({
changeMission,
currentMission,
}: {
changeMission: (mission: CurrentMission) => void;
currentMission: CurrentMission;
}) {
const recording = useDemoRecording();
useEffect(() => {
if (!recording?.missionName) return;
const missionName = findMissionByDemoName(recording.missionName);
if (!missionName) {
console.warn(
`Demo mission "${recording.missionName}" not found in manifest`,
);
return;
}
const info = getMissionInfo(missionName);
const missionTypeCode = recording.gameType
? GAME_TYPE_TO_MISSION_TYPE[recording.gameType]
: undefined;
const missionType =
missionTypeCode && info.missionTypes.includes(missionTypeCode)
? missionTypeCode
: info.missionTypes[0];
// Skip if we're already on the correct mission to avoid unnecessary
// remount cascades (e.g. after a Suspense boundary restores).
if (
currentMission.missionName === missionName &&
currentMission.missionType === missionType
) {
return;
}
changeMission({ missionName, missionType });
}, [recording, changeMission, currentMission]);
return null;
}
/**
* Disables observer/touch controls when a demo is playing so they don't
* fight the animated camera.
@ -282,7 +390,7 @@ function DemoAwareControls({
lookJoystickStateRef: React.RefObject<JoystickState>;
lookJoystickZoneRef: React.RefObject<HTMLDivElement | null>;
}) {
const { isPlaying } = useDemo();
const isPlaying = useDemoIsPlaying();
if (isPlaying) return null;
if (isTouch === null) return null;
if (isTouch) {
@ -300,14 +408,27 @@ function DemoAwareControls({
/** Exposes `window.loadDemoRecording` for automation/testing. */
function DemoWindowAPI() {
const { setRecording } = useDemo();
const { setRecording } = useDemoActions();
const engineStore = useEngineStoreApi();
useEffect(() => {
window.loadDemoRecording = setRecording;
window.getDemoDiagnostics = () => {
return buildSerializableDiagnosticsSnapshot(engineStore.getState());
};
window.getDemoDiagnosticsJson = () => {
return buildSerializableDiagnosticsJson(engineStore.getState());
};
window.clearDemoDiagnostics = () => {
engineStore.getState().clearPlaybackDiagnostics();
};
return () => {
delete window.loadDemoRecording;
delete window.getDemoDiagnostics;
delete window.getDemoDiagnosticsJson;
delete window.clearDemoDiagnostics;
};
}, [setRecording]);
}, [engineStore, setRecording]);
return null;
}

View file

@ -246,6 +246,32 @@ input[type="range"] {
text-align: center;
}
.PlayerNameplate {
pointer-events: none;
text-align: center;
white-space: nowrap;
}
.PlayerNameplate-name {
color: #fff;
font-size: 11px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 1px rgba(0, 0, 0, 0.7);
}
.PlayerNameplate-healthBar {
width: 60px;
height: 4px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
margin: 2px auto 0;
overflow: hidden;
}
.PlayerNameplate-healthFill {
height: 100%;
background: #2ecc40;
}
.StatsPanel {
left: auto !important;
top: auto !important;
@ -621,3 +647,119 @@ input[type="range"] {
inset 0 1px 0 rgba(255, 255, 255, 0.15),
inset 0 -1px 2px rgba(0, 0, 0, 0.3) !important;
}
.DemoControls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 13px;
z-index: 2;
}
.DemoControls-playPause {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
font-size: 14px;
cursor: pointer;
}
@media (hover: hover) {
.DemoControls-playPause:hover {
background: rgba(0, 98, 179, 0.8);
}
}
.DemoControls-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.DemoControls-seek[type="range"] {
flex: 1 1 0;
min-width: 0;
max-width: none;
}
.DemoControls-speed {
flex-shrink: 0;
padding: 2px 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
}
.DemoDiagnosticsPanel {
display: flex;
flex-direction: column;
gap: 3px;
margin-left: 8px;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
min-width: 320px;
}
.DemoDiagnosticsPanel[data-context-lost="true"] {
border-color: rgba(255, 90, 90, 0.8);
background: rgba(70, 0, 0, 0.45);
}
.DemoDiagnosticsPanel-status {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.DemoDiagnosticsPanel-metrics {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
opacity: 0.92;
}
.DemoDiagnosticsPanel-footer {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
align-items: center;
font-size: 11px;
}
.DemoDiagnosticsPanel-footer button {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(3, 82, 147, 0.6);
color: #fff;
padding: 1px 6px;
font-size: 11px;
cursor: pointer;
}
.DemoDiagnosticsPanel-footer button:hover {
background: rgba(0, 98, 179, 0.8);
}
.DemoIcon {
font-size: 19px;
}