mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-12 23:14:58 +00:00
initial demo support
This commit is contained in:
parent
0f2e103294
commit
359a036558
406 changed files with 10513 additions and 1158 deletions
3
app/global.d.ts
vendored
3
app/global.d.ts
vendored
|
|
@ -7,5 +7,8 @@ declare global {
|
|||
getMissionList?: typeof getMissionList;
|
||||
getMissionInfo?: typeof getMissionInfo;
|
||||
loadDemoRecording?: (recording: DemoRecording) => void;
|
||||
getDemoDiagnostics?: () => unknown;
|
||||
getDemoDiagnosticsJson?: () => string;
|
||||
clearDemoDiagnostics?: () => void;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
app/page.tsx
133
app/page.tsx
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
142
app/style.css
142
app/style.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue