t2-mapper/src/components/StreamingController.tsx

664 lines
25 KiB
TypeScript

import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { Quaternion, Vector3 } from "three";
import {
DEFAULT_EYE_HEIGHT,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../stream/playbackUtils";
import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter";
import { useEngineStoreApi, advanceEffectClock } from "../state/engineStore";
import { gameEntityStore } from "../state/gameEntityStore";
import {
streamPlaybackStore,
resetStreamPlayback,
} from "../state/streamPlaybackStore";
import { streamEntityToGameEntity } from "../stream/entityBridge";
import type {
StreamRecording,
StreamEntity,
StreamSnapshot,
} from "../stream/types";
import type { GameEntity } from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes";
type EntityById = Map<string, StreamEntity>;
/** Safely access a field that exists only on some GameEntity variants. */
function getField(entity: GameEntity, field: string): string | undefined {
return (entity as unknown as Record<string, unknown>)[field] as
| string
| undefined;
}
/** Mutate render-affecting fields on an entity in-place from stream data.
* Components read these fields imperatively in useFrame — no React
* re-render is needed. This is the key to avoiding Suspense starvation. */
function mutateRenderFields(
renderEntity: GameEntity,
stream: StreamEntity,
): void {
switch (renderEntity.renderType) {
case "Player": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.weaponShape = stream.weaponShape;
e.packShape = stream.packShape;
e.flagShape = stream.flagShape;
e.falling = stream.falling;
e.jetting = stream.jetting;
e.weaponImageState = stream.weaponImageState;
e.weaponImageStates = stream.weaponImageStates;
e.playerName = stream.playerName;
e.iffColor = stream.iffColor;
e.headPitch = stream.headPitch;
e.headYaw = stream.headYaw;
e.targetRenderFlags = stream.targetRenderFlags;
break;
}
case "Shape": {
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
break;
}
}
}
/** Cache entity-by-id Maps per snapshot so they're built once, not every frame. */
const _snapshotEntityCache = new WeakMap<StreamSnapshot, EntityById>();
function getEntityMap(snapshot: StreamSnapshot): EntityById {
let map = _snapshotEntityCache.get(snapshot);
if (!map) {
map = new Map(snapshot.entities.map((e) => [e.id, e]));
_snapshotEntityCache.set(snapshot, map);
}
return map;
}
/** Push the current entity map to the game entity store.
* Only triggers a version bump (and subscriber notifications) when the
* entity set changed (adds/removes). Render-field updates are mutated
* in-place on existing entity objects and read imperatively in useFrame. */
function pushEntitiesToStore(entityMap: Map<string, GameEntity>): void {
gameEntityStore
.getState()
.setAllStreamEntities(Array.from(entityMap.values()));
}
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
const _billboardFlip = new Quaternion(0, 1, 0, 0); // 180° around Y
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
export function StreamingController({
recording,
}: {
recording: StreamRecording;
}) {
const engineStore = useEngineStoreApi();
const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef(recording.streamingPlayback ?? null);
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
const syncRenderableEntities = useCallback((snapshot: StreamSnapshot) => {
if (snapshot === lastSyncedSnapshotRef.current) return;
lastSyncedSnapshotRef.current = snapshot;
const prevMap = entityMapRef.current;
const nextMap = new Map<string, GameEntity>();
for (const entity of snapshot.entities) {
let renderEntity = prevMap.get(entity.id);
// Identity change -> new component (unmount/remount).
// Compare fields that, when changed, require a full entity rebuild.
// Only compare shapeName for entity types that actually use it.
// Scene entities (Terrain, Interior, Sky, etc.), ForceFieldBare,
// AudioEmitter, WayPoint, and Camera don't have shapeName on their
// GameEntity, so comparing against entity.dataBlock would always
// mismatch and trigger a needless rebuild every frame.
const hasShapeName =
renderEntity &&
(renderEntity.renderType === "Shape" ||
renderEntity.renderType === "Player" ||
renderEntity.renderType === "Explosion");
const needsNewIdentity =
!renderEntity ||
renderEntity.className !== (entity.className ?? entity.type) ||
renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint ||
(hasShapeName &&
entity.dataBlock != null &&
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
// weaponShape changes only force rebuild for non-Player shapes
// (turrets, vehicles). Players handle weapon changes internally
// via PlayerModel's Mount0 bone, and rebuilding on weapon change
// would lose animation state (death animations, etc.).
(renderEntity.renderType !== "Player" &&
hasShapeName &&
getField(renderEntity, "weaponShape") !== entity.weaponShape);
if (needsNewIdentity) {
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
} else {
// Mutate render fields in-place on the existing entity object.
// Components read these imperatively in useFrame — no React
// re-render needed. This avoids store churn that starves Suspense.
mutateRenderFields(renderEntity, entity);
}
nextMap.set(entity.id, renderEntity);
// Keyframe update (mutable -- used for fallback position for
// retained explosion entities and per-frame reads in useFrame).
// Scene entities and None don't have keyframes.
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None")
continue;
const keyframes = renderEntity.keyframes!;
if (keyframes.length === 0) {
keyframes.push({
time: snapshot.timeSec,
position: entity.position ?? [0, 0, 0],
rotation: entity.rotation ?? [0, 0, 0, 1],
});
}
const kf = keyframes[0];
kf.time = snapshot.timeSec;
if (entity.position) kf.position = entity.position;
if (entity.rotation) kf.rotation = entity.rotation;
kf.velocity = entity.velocity;
kf.health = entity.health;
kf.energy = entity.energy;
kf.actionAnim = entity.actionAnim;
kf.actionAtEnd = entity.actionAtEnd;
kf.damageState = entity.damageState;
}
// Retain explosion entities with DTS shapes after they leave the snapshot.
// These entities are ephemeral (~1 tick) but the visual effect lasts seconds.
for (const [id, entity] of prevMap) {
if (nextMap.has(id)) continue;
if (
entity.renderType === "Explosion" &&
entity.shapeName &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
if (age < 5) {
nextMap.set(id, entity);
continue;
}
}
}
// Only push to store when the entity set changed (adds/removes).
const shouldRebuild =
nextMap.size !== prevMap.size ||
[...nextMap.keys()].some((id) => !prevMap.has(id));
entityMapRef.current = nextMap;
if (shouldRebuild) {
pushEntitiesToStore(nextMap);
}
let nextFirstPersonShape: string | null = null;
if (
snapshot.camera?.mode === "first-person" &&
snapshot.camera.controlEntityId
) {
const entity = nextMap.get(snapshot.camera.controlEntityId);
const sn = entity ? getField(entity, "shapeName") : undefined;
if (sn) {
nextFirstPersonShape = sn;
}
}
setFirstPersonShape((prev) =>
prev === nextFirstPersonShape ? prev : nextFirstPersonShape,
);
}, []);
useEffect(() => {
// Stop any lingering sounds from the previous recording before setting
// up the new one. One-shot sounds and looping projectile sounds survive
// across recording changes because ParticleEffects doesn't unmount.
stopAllTrackedSounds();
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
lastSyncedSnapshotRef.current = null;
publishedSnapshotRef.current = null;
resetStreamPlayback();
playbackClockRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
const stream = streamRef.current;
streamPlaybackStore.setState({ playback: stream });
gameEntityStore.getState().beginStreaming(recording.source);
if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null);
return;
}
// Update gameEntityStore when mission info arrives via server messages
// (MsgMissionDropInfo, MsgLoadInfo, MsgClientReady).
stream.onMissionInfoChange = () => {
gameEntityStore.getState().setMissionInfo({
missionDisplayName: stream.missionDisplayName ?? undefined,
missionTypeDisplayName: stream.missionTypeDisplayName ?? undefined,
gameClassName: stream.gameClassName ?? undefined,
recorderName: stream.connectedPlayerName ?? undefined,
});
};
// Save pre-populated mission info before reset clears it.
const savedMissionDisplayName = stream.missionDisplayName;
const savedMissionTypeDisplayName = stream.missionTypeDisplayName;
const savedGameClassName = stream.gameClassName;
const savedServerDisplayName = stream.serverDisplayName;
const savedConnectedPlayerName = stream.connectedPlayerName;
// Reset the stream cursor for demo playback (replay from the beginning).
// For live streams, skip reset — the adapter is already receiving packets
// and has accumulated protocol state (net strings, target info, sensor
// group colors) that the server won't re-send.
if (recording.source !== "live") {
stream.reset();
}
// Restore mission info fields that were parsed from the initial block
// (demoValues) — reset() clears them but they won't be re-sent.
stream.missionDisplayName = savedMissionDisplayName;
stream.missionTypeDisplayName = savedMissionTypeDisplayName;
stream.gameClassName = savedGameClassName;
stream.serverDisplayName = savedServerDisplayName;
stream.connectedPlayerName = savedConnectedPlayerName;
gameEntityStore.getState().setMissionInfo({
missionName: recording.missionName ?? undefined,
missionTypeDisplayName: recording.gameType ?? undefined,
missionDisplayName: savedMissionDisplayName ?? undefined,
gameClassName: savedGameClassName ?? undefined,
serverDisplayName:
savedServerDisplayName ?? recording.serverDisplayName ?? undefined,
recorderName:
savedConnectedPlayerName ?? recording.recorderName ?? undefined,
recordingDate: recording.recordingDate ?? undefined,
});
const snapshot = stream.getSnapshot();
streamPlaybackStore.setState({ time: snapshot.timeSec });
playbackClockRef.current = snapshot.timeSec;
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
syncRenderableEntities(snapshot);
engineStore.getState().setPlaybackStreamSnapshot(snapshot);
publishedSnapshotRef.current = snapshot;
return () => {
stopAllTrackedSounds();
// Null out streamRef so useFrame stops syncing entities.
streamRef.current = null;
// Don't call endStreaming() or clear the snapshot — leave entities,
// HUD, and chat in place as a frozen snapshot after disconnect.
resetStreamPlayback();
};
}, [recording, engineStore, syncRenderableEntities]);
useFrame((state, delta) => {
const stream = streamRef.current;
if (!stream) return;
const storeState = engineStore.getState();
const playback = storeState.playback;
const isPlaying = playback.status === "playing";
const requestedTimeSec = playback.timeMs / 1000;
const externalSeekWhilePaused =
!isPlaying &&
Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
const externalSeekWhilePlaying =
isPlaying &&
Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
if (isSeeking) {
// Sync stream cursor to UI/programmatic seek.
playbackClockRef.current = requestedTimeSec;
}
// Advance the shared effect clock so all effect timers (particles,
// explosions, shockwaves, shape animations) respect pause and rate.
if (isPlaying) {
advanceEffectClock(delta, playback.rate);
playbackClockRef.current += delta * playback.rate;
}
const moveTicksNeeded = Math.max(
1,
Math.ceil((delta * 1000 * Math.max(playback.rate, 0.01)) / 32) + 2,
);
// Torque interpolates backwards from the end of the current 32ms tick.
// We sample one tick ahead and blend previous->current for smooth render.
const sampleTimeSec = playbackClockRef.current + STREAM_TICK_SEC;
// During a seek, process all ticks to the target immediately so the world
// state is fully reconstructed. The per-frame tick limit only applies
// during normal playback advancement.
const snapshot = stream.stepToTime(
sampleTimeSec,
isPlaying && !isSeeking ? moveTicksNeeded : Number.POSITIVE_INFINITY,
);
const currentTick = currentTickSnapshotRef.current;
if (
!currentTick ||
snapshot.timeSec < currentTick.timeSec ||
snapshot.timeSec - currentTick.timeSec > STREAM_TICK_SEC * 1.5
) {
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
} else if (snapshot.timeSec !== currentTick.timeSec) {
prevTickSnapshotRef.current = currentTick;
currentTickSnapshotRef.current = snapshot;
}
const renderCurrent = currentTickSnapshotRef.current ?? snapshot;
const renderPrev = prevTickSnapshotRef.current ?? renderCurrent;
const tickStartTime = renderCurrent.timeSec - STREAM_TICK_SEC;
const interpT = Math.max(
0,
Math.min(1, (playbackClockRef.current - tickStartTime) / STREAM_TICK_SEC),
);
streamPlaybackStore.setState({ time: playbackClockRef.current });
if (snapshot.exhausted && isPlaying) {
playbackClockRef.current = Math.min(
playbackClockRef.current,
snapshot.timeSec,
);
}
syncRenderableEntities(renderCurrent);
// Publish the entity map for imperative reads by components in useFrame.
// This is a plain object assignment — no React re-renders triggered.
streamPlaybackStore.getState().entities = entityMapRef.current;
// Publish snapshot when it changed.
if (renderCurrent !== publishedSnapshotRef.current) {
publishedSnapshotRef.current = renderCurrent;
storeState.setPlaybackStreamSnapshot(renderCurrent);
}
const currentCamera = renderCurrent.camera;
const previousCamera =
currentCamera &&
renderPrev.camera &&
renderPrev.camera.mode === currentCamera.mode &&
renderPrev.camera.controlEntityId === currentCamera.controlEntityId &&
renderPrev.camera.orbitTargetId === currentCamera.orbitTargetId
? renderPrev.camera
: null;
// When freeFlyCamera is active, skip stream camera positioning so
// ObserverControls drives the camera instead.
const freeFly = streamPlaybackStore.getState().freeFlyCamera;
// In live mode, InputConsumer owns camera position and rotation
// (moves are applied locally, matching how the real Tribes 2 client
// handles its control Camera). StreamingController still handles
// entity interpolation, FOV, and orbit target positioning.
const isLive = recording.source === "live";
if (currentCamera && !freeFly) {
// In live mode, InputConsumer owns both camera position and rotation
// (client-side prediction with server reconciliation + interpolateTick,
// matching Tribes 2's Camera behavior). StreamingController only
// handles entity interpolation, FOV, and orbit target positioning.
if (!isLive) {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
const pz = previousCamera.position[2];
const cx = currentCamera.position[0];
const cy = currentCamera.position[1];
const cz = currentCamera.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
state.camera.position.set(iy, iz, ix);
_interpQuatA.set(...previousCamera.rotation);
_interpQuatB.set(...currentCamera.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
state.camera.quaternion.copy(_interpQuatA);
} else {
state.camera.position.set(
currentCamera.position[1],
currentCamera.position[2],
currentCamera.position[0],
);
state.camera.quaternion.set(...currentCamera.rotation);
}
}
if (
Number.isFinite(currentCamera.fov) &&
"isPerspectiveCamera" in state.camera &&
(state.camera as any).isPerspectiveCamera
) {
const perspectiveCamera = state.camera as any;
const fovValue =
previousCamera && Number.isFinite(previousCamera.fov)
? previousCamera.fov +
(currentCamera.fov - previousCamera.fov) * interpT
: currentCamera.fov;
const verticalFov = torqueHorizontalFovToThreeVerticalFov(
fovValue,
perspectiveCamera.aspect,
);
if (Math.abs(perspectiveCamera.fov - verticalFov) > 0.01) {
perspectiveCamera.fov = verticalFov;
perspectiveCamera.updateProjectionMatrix();
}
}
}
// Imperative position interpolation via the shared entity root.
const currentEntities = getEntityMap(renderCurrent);
const previousEntities = getEntityMap(renderPrev);
const renderEntities = entityMapRef.current;
const root = streamPlaybackStore.getState().root;
if (root) {
for (const child of root.children) {
// Scene infrastructure (terrain, interiors, sky, etc.) handles its
// own positioning — skip interpolation and visibility management.
const renderEntity = renderEntities.get(child.name);
if (renderEntity && isSceneEntity(renderEntity)) {
continue;
}
const entity = currentEntities.get(child.name);
// Retained entities (e.g. explosion shapes kept alive past their
// snapshot lifetime) won't be in the snapshot entity map. Fall back
// to their last-known keyframe position from the render entity.
if (!entity) {
const kfs =
renderEntity && "keyframes" in renderEntity
? renderEntity.keyframes
: undefined;
if (kfs?.[0]?.position) {
const kf = kfs[0];
child.visible = true;
child.position.set(kf.position[1], kf.position[2], kf.position[0]);
continue;
}
}
if (!entity?.position) {
child.visible = false;
continue;
}
child.visible = true;
const previousEntity = previousEntities.get(child.name);
if (previousEntity?.position) {
const px = previousEntity.position[0];
const py = previousEntity.position[1];
const pz = previousEntity.position[2];
const cx = entity.position[0];
const cy = entity.position[1];
const cz = entity.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
child.position.set(iy, iz, ix);
} else {
child.position.set(
entity.position[1],
entity.position[2],
entity.position[0],
);
}
if (entity.faceViewer) {
child.quaternion
.copy(state.camera.quaternion)
.multiply(_billboardFlip);
} else if (entity.visual?.kind === "tracer") {
child.quaternion.identity();
} else if (entity.rotation) {
if (previousEntity?.rotation) {
_interpQuatA.set(...previousEntity.rotation);
_interpQuatB.set(...entity.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
child.quaternion.copy(_interpQuatA);
} else {
child.quaternion.set(...entity.rotation);
}
}
}
}
const mode = currentCamera?.mode;
// In live mode, InputConsumer handles orbit positioning from local rotation
// so the orbit responds at frame rate. Skip here to avoid fighting.
if (
!freeFly &&
!isLive &&
mode === "third-person" &&
root &&
currentCamera?.orbitTargetId
) {
const targetGroup = root.children.find(
(child) => child.name === currentCamera.orbitTargetId,
);
if (targetGroup) {
const orbitEntity = currentEntities.get(currentCamera.orbitTargetId);
_orbitTarget.copy(targetGroup.position);
// Torque orbits the target's render world-box center; player positions
// in our stream are feet-level, so lift to an approximate center.
if (orbitEntity?.type === "Player") {
_orbitTarget.y += 1.0;
}
let hasDirection = false;
if (currentCamera.orbitDirection) {
// Use explicit pullback direction (e.g. from full vehicle quaternion
// including roll) when available.
_orbitDir.set(
currentCamera.orbitDirection[0],
currentCamera.orbitDirection[1],
currentCamera.orbitDirection[2],
);
hasDirection = _orbitDir.lengthSq() > 1e-8;
} else if (
typeof currentCamera.yaw === "number" &&
typeof currentCamera.pitch === "number"
) {
const sx = Math.sin(currentCamera.pitch);
const cx = Math.cos(currentCamera.pitch);
const sz = Math.sin(currentCamera.yaw);
const cz = Math.cos(currentCamera.yaw);
// Pull back behind the model. playerYawToQuaternion uses Ry(-yaw),
// so model forward in Three.js is (cz, 0, sz) at pitch=0.
// Behind = (-cz*cx, -sx, -sz*cx).
_orbitDir.set(-cz * cx, -sx, -sz * cx);
hasDirection = _orbitDir.lengthSq() > 1e-8;
}
if (!hasDirection) {
_orbitDir.copy(state.camera.position).sub(_orbitTarget);
hasDirection = _orbitDir.lengthSq() > 1e-8;
}
if (hasDirection) {
_orbitDir.normalize();
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
_orbitCandidate
.copy(_orbitTarget)
.addScaledVector(_orbitDir, orbitDistance);
state.camera.position.copy(_orbitCandidate);
state.camera.lookAt(_orbitTarget);
}
}
}
if (
!freeFly &&
mode === "first-person" &&
root &&
currentCamera?.controlEntityId
) {
const playerGroup = root.children.find(
(child) => child.name === currentCamera.controlEntityId,
);
if (playerGroup) {
_tmpVec
.copy(eyeOffsetRef.current)
.applyQuaternion(playerGroup.quaternion);
state.camera.position.add(_tmpVec);
} else {
state.camera.position.y += eyeOffsetRef.current.y;
}
}
if (isPlaying && snapshot.exhausted) {
storeState.setPlaybackStatus("paused");
}
const timeMs = playbackClockRef.current * 1000;
if (Math.abs(timeMs - playback.timeMs) > 0.5) {
storeState.setPlaybackTime(timeMs);
}
});
return (
<>
<ParticleEffects
playback={recording.streamingPlayback}
snapshotRef={currentTickSnapshotRef}
/>
{firstPersonShape && (
<Suspense>
<PlayerEyeOffset
shapeName={firstPersonShape}
eyeOffsetRef={eyeOffsetRef}
/>
</Suspense>
)}
</>
);
}