split up demo modules, improve death support

This commit is contained in:
Brian Beck 2026-03-01 08:33:38 -08:00
parent 359a036558
commit c5b43f2e55
39 changed files with 2269 additions and 3942 deletions

View file

@ -0,0 +1,166 @@
import { Component, Suspense } from "react";
import type { ErrorInfo, MutableRefObject, ReactNode } from "react";
import { entityTypeColor } from "../demo/demoPlaybackUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { DemoPlayerModel } from "./DemoPlayerModel";
import { DemoShapeModel, DemoWeaponModel } from "./DemoShapeModel";
import { DemoSpriteProjectile, DemoTracerProjectile } from "./DemoProjectiles";
import { PlayerNameplate } from "./PlayerNameplate";
import { useEngineStoreApi } from "../state";
import type { DemoEntity } from "../demo/types";
/**
* Renders a non-camera demo entity.
* The group name must match the entity ID so the AnimationMixer can target it.
* Player entities use DemoPlayerModel for skeletal animation; others use
* DemoShapeModel.
*/
export function DemoEntityGroup({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const name = String(entity.id);
if (entity.visual?.kind === "tracer") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "tracer" }}>
<Suspense fallback={null}>
<DemoTracerProjectile entity={entity} visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (entity.visual?.kind === "sprite") {
return (
<group name={name}>
<group name="model" userData={{ demoVisualKind: "sprite" }}>
<Suspense fallback={null}>
<DemoSpriteProjectile visual={entity.visual} />
</Suspense>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
if (!entity.dataBlock) {
return (
<group name={name}>
<group name="model">
<mesh>
<sphereGeometry args={[0.3, 6, 4]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
{debugMode ? <DemoMissingShapeLabel entity={entity} /> : null}
</group>
</group>
);
}
const fallback = (
<mesh>
<sphereGeometry args={[0.5, 8, 6]} />
<meshBasicMaterial color={entityTypeColor(entity.type)} wireframe />
</mesh>
);
// Player entities use skeleton-preserving DemoPlayerModel for animation.
if (entity.type === "Player") {
const isControlPlayer =
entity.id ===
engineStore.getState().playback.recording?.controlPlayerGhostId;
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoPlayerModel entity={entity} timeRef={timeRef} />
</Suspense>
</ShapeErrorBoundary>
{!isControlPlayer && (
<Suspense fallback={null}>
<PlayerNameplate entity={entity} timeRef={timeRef} />
</Suspense>
)}
</group>
</group>
);
}
return (
<group name={name}>
<group name="model">
<ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<DemoShapeModel shapeName={entity.dataBlock} entityId={entity.id} />
</Suspense>
</ShapeErrorBoundary>
</group>
{entity.weaponShape && (
<group name="weapon">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<DemoWeaponModel
shapeName={entity.weaponShape}
playerShapeName={entity.dataBlock}
/>
</Suspense>
</ShapeErrorBoundary>
</group>
)}
</group>
);
}
export function DemoMissingShapeLabel({ entity }: { entity: DemoEntity }) {
const id = String(entity.id);
const bits: string[] = [];
bits.push(`${id} (${entity.type})`);
if (entity.className) bits.push(`class ${entity.className}`);
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
bits.push(
entity.shapeHint
? `shapeHint ${entity.shapeHint}`
: "shapeHint <none resolved>",
);
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
}
/** Error boundary that renders a fallback when shape loading fails. */
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn(
"[demo] Shape load failed:",
error.message,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,549 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import {
Group,
Quaternion,
Raycaster,
Vector3,
} from "three";
import {
buildStreamDemoEntity,
CAMERA_COLLISION_RADIUS,
DEFAULT_EYE_HEIGHT,
hasAncestorNamed,
nextLifecycleInstanceId,
streamSnapshotSignature,
STREAM_TICK_SEC,
torqueHorizontalFovToThreeVerticalFov,
} from "../demo/demoPlaybackUtils";
import { shapeToUrl } from "../loaders";
import { TickProvider } from "./TickProvider";
import { DemoEntityGroup } from "./DemoEntities";
import { PlayerEyeOffset } from "./DemoPlayerModel";
import { useEngineStoreApi } from "../state";
import type { DemoEntity, DemoRecording, DemoStreamSnapshot } from "../demo/types";
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
const _hitNormal = new Vector3();
const _orbitRaycaster = new Raycaster();
let streamingDemoPlaybackMountCount = 0;
let streamingDemoPlaybackUnmountCount = 0;
export function StreamingDemoPlayback({ recording }: { recording: DemoRecording }) {
const engineStore = useEngineStoreApi();
const instanceIdRef = useRef<string | null>(null);
if (!instanceIdRef.current) {
instanceIdRef.current = nextLifecycleInstanceId("StreamingDemoPlayback");
}
const rootRef = useRef<Group>(null);
const timeRef = useRef(0);
const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const currentTickSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
const streamRef = useRef(recording.streamingPlayback ?? null);
const publishedSnapshotRef = useRef<DemoStreamSnapshot | null>(null);
const entitySignatureRef = useRef("");
const entityMapRef = useRef<Map<string, DemoEntity>>(new Map());
const lastEntityRebuildEventMsRef = useRef(0);
const exhaustedEventLoggedRef = useRef(false);
const [entities, setEntities] = useState<DemoEntity[]>([]);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
useEffect(() => {
streamingDemoPlaybackMountCount += 1;
const mountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback mounted",
meta: {
component: "StreamingDemoPlayback",
phase: "mount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
recordingDurationSec: Number(recording.duration.toFixed(3)),
ts: mountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback mounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
mountedAt,
});
return () => {
streamingDemoPlaybackUnmountCount += 1;
const unmountedAt = Date.now();
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "component.lifecycle",
message: "StreamingDemoPlayback unmounted",
meta: {
component: "StreamingDemoPlayback",
phase: "unmount",
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
ts: unmountedAt,
},
});
console.info("[demo diagnostics] StreamingDemoPlayback unmounted", {
instanceId: instanceIdRef.current,
mountCount: streamingDemoPlaybackMountCount,
unmountCount: streamingDemoPlaybackUnmountCount,
recordingMissionName: recording.missionName ?? null,
unmountedAt,
});
};
}, [engineStore]);
const syncRenderableEntities = useCallback((snapshot: DemoStreamSnapshot) => {
const previousEntityCount = entityMapRef.current.size;
const nextSignature = streamSnapshotSignature(snapshot);
const shouldRebuild = entitySignatureRef.current !== nextSignature;
const nextMap = new Map<string, DemoEntity>();
for (const entity of snapshot.entities) {
let renderEntity = entityMapRef.current.get(entity.id);
if (
!renderEntity ||
renderEntity.type !== entity.type ||
renderEntity.dataBlock !== entity.dataBlock ||
renderEntity.weaponShape !== entity.weaponShape ||
renderEntity.className !== entity.className ||
renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint
) {
renderEntity = buildStreamDemoEntity(
entity.id,
entity.type,
entity.dataBlock,
entity.visual,
entity.direction,
entity.weaponShape,
entity.playerName,
entity.className,
entity.ghostIndex,
entity.dataBlockId,
entity.shapeHint,
);
}
renderEntity.playerName = entity.playerName;
renderEntity.iffColor = entity.iffColor;
renderEntity.dataBlock = entity.dataBlock;
renderEntity.visual = entity.visual;
renderEntity.direction = entity.direction;
renderEntity.weaponShape = entity.weaponShape;
renderEntity.className = entity.className;
renderEntity.ghostIndex = entity.ghostIndex;
renderEntity.dataBlockId = entity.dataBlockId;
renderEntity.shapeHint = entity.shapeHint;
if (renderEntity.keyframes.length === 0) {
renderEntity.keyframes.push({
time: snapshot.timeSec,
position: entity.position ?? [0, 0, 0],
rotation: entity.rotation ?? [0, 0, 0, 1],
});
}
const kf = renderEntity.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;
nextMap.set(entity.id, renderEntity);
}
entityMapRef.current = nextMap;
if (shouldRebuild) {
entitySignatureRef.current = nextSignature;
setEntities(Array.from(nextMap.values()));
const now = Date.now();
if (now - lastEntityRebuildEventMsRef.current >= 500) {
lastEntityRebuildEventMsRef.current = now;
engineStore.getState().recordPlaybackDiagnosticEvent({
kind: "stream.entities.rebuild",
message: "Renderable demo entity list was rebuilt",
meta: {
previousEntityCount,
nextEntityCount: nextMap.size,
snapshotTimeSec: Number(snapshot.timeSec.toFixed(3)),
},
});
}
}
let nextFirstPersonShape: string | null = null;
if (snapshot.camera?.mode === "first-person" && snapshot.camera.controlEntityId) {
const entity = nextMap.get(snapshot.camera.controlEntityId);
if (entity?.dataBlock) {
nextFirstPersonShape = entity.dataBlock;
}
}
setFirstPersonShape((prev) =>
prev === nextFirstPersonShape ? prev : nextFirstPersonShape,
);
}, [engineStore]);
useEffect(() => {
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
entitySignatureRef.current = "";
publishedSnapshotRef.current = null;
timeRef.current = 0;
playbackClockRef.current = 0;
prevTickSnapshotRef.current = null;
currentTickSnapshotRef.current = null;
exhaustedEventLoggedRef.current = false;
const stream = streamRef.current;
if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null);
return;
}
stream.reset();
// Preload weapon effect shapes (explosions) so they're cached before
// the first projectile detonates — otherwise the GLB fetch latency
// causes the short-lived explosion entity to expire before it renders.
for (const shape of stream.getEffectShapes()) {
useGLTF.preload(shapeToUrl(shape));
}
const snapshot = stream.getSnapshot();
timeRef.current = snapshot.timeSec;
playbackClockRef.current = snapshot.timeSec;
prevTickSnapshotRef.current = snapshot;
currentTickSnapshotRef.current = snapshot;
syncRenderableEntities(snapshot);
engineStore.getState().setPlaybackStreamSnapshot(snapshot);
publishedSnapshotRef.current = snapshot;
return () => {
engineStore.getState().setPlaybackStreamSnapshot(null);
};
}, [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 - timeRef.current) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
if (isSeeking) {
// Sync stream cursor to UI/programmatic seek.
playbackClockRef.current = requestedTimeSec;
}
if (isPlaying) {
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),
);
timeRef.current = playbackClockRef.current;
if (snapshot.exhausted && isPlaying) {
playbackClockRef.current = Math.min(playbackClockRef.current, snapshot.timeSec);
}
syncRenderableEntities(renderCurrent);
const publishedSnapshot = publishedSnapshotRef.current;
const shouldPublish =
!publishedSnapshot ||
renderCurrent.timeSec !== publishedSnapshot.timeSec ||
renderCurrent.exhausted !== publishedSnapshot.exhausted ||
renderCurrent.status.health !== publishedSnapshot.status.health ||
renderCurrent.status.energy !== publishedSnapshot.status.energy ||
renderCurrent.camera?.mode !== publishedSnapshot.camera?.mode ||
renderCurrent.camera?.controlEntityId !==
publishedSnapshot.camera?.controlEntityId ||
renderCurrent.camera?.orbitTargetId !==
publishedSnapshot.camera?.orbitTargetId;
if (shouldPublish) {
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;
if (currentCamera) {
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();
}
}
}
const currentEntities = new Map(renderCurrent.entities.map((e) => [e.id, e]));
const previousEntities = new Map(renderPrev.entities.map((e) => [e.id, e]));
const root = rootRef.current;
if (root) {
for (const child of root.children) {
const entity = currentEntities.get(child.name);
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);
} 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;
if (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 (
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);
// Camera::validateEyePoint uses Camera::setPosition's column1 in
// Torque space as the orbit pull-back direction. Converted to Three,
// that target->camera vector is (-cx, -sz*sx, -cz*sx).
_orbitDir.set(-cx, -sz * sx, -cz * sx);
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);
// Mirror Camera::validateEyePoint: cast 2.5x desired distance toward
// the candidate and pull in if an obstacle blocks the orbit.
_orbitRaycaster.near = 0.001;
_orbitRaycaster.far = orbitDistance * 2.5;
_orbitRaycaster.camera = state.camera;
_orbitRaycaster.set(_orbitTarget, _orbitDir);
const hits = _orbitRaycaster.intersectObjects(state.scene.children, true);
for (const hit of hits) {
if (hit.distance <= 0.0001) continue;
if (hasAncestorNamed(hit.object, currentCamera.orbitTargetId)) continue;
if (!hit.face) break;
_hitNormal.copy(hit.face.normal).transformDirection(hit.object.matrixWorld);
const dot = -_orbitDir.dot(_hitNormal);
if (dot > 0.01) {
let colDist = hit.distance - CAMERA_COLLISION_RADIUS / dot;
if (colDist > orbitDistance) colDist = orbitDistance;
if (colDist < 0) colDist = 0;
_orbitCandidate
.copy(_orbitTarget)
.addScaledVector(_orbitDir, colDist);
}
break;
}
state.camera.position.copy(_orbitCandidate);
state.camera.lookAt(_orbitTarget);
}
}
}
if (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) {
if (!exhaustedEventLoggedRef.current) {
exhaustedEventLoggedRef.current = true;
storeState.recordPlaybackDiagnosticEvent({
kind: "stream.exhausted",
message: "Streaming playback reached end-of-stream while playing",
meta: {
streamTimeSec: Number(snapshot.timeSec.toFixed(3)),
requestedPlaybackSec: Number(playbackClockRef.current.toFixed(3)),
},
});
}
storeState.setPlaybackStatus("paused");
} else if (!snapshot.exhausted) {
exhaustedEventLoggedRef.current = false;
}
const timeMs = playbackClockRef.current * 1000;
if (Math.abs(timeMs - playback.timeMs) > 0.5) {
storeState.setPlaybackTime(timeMs);
}
});
return (
<TickProvider>
<group ref={rootRef}>
{entities.map((entity) => (
<DemoEntityGroup key={entity.id} entity={entity} timeRef={timeRef} />
))}
</group>
{firstPersonShape && (
<Suspense fallback={null}>
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} />
</Suspense>
)}
</TickProvider>
);
}

View file

@ -0,0 +1,263 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
AnimationMixer,
Group,
LoopOnce,
LoopRepeat,
Object3D,
Vector3,
} from "three";
import type { AnimationAction } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
DEFAULT_EYE_HEIGHT,
getKeyframeAtTime,
getPosedNodeTransform,
processShapeScene,
} from "../demo/demoPlaybackUtils";
import { pickMoveAnimation } from "../demo/playerAnimation";
import { useStaticShape } from "./GenericShape";
import { ShapeErrorBoundary } from "./DemoEntities";
import { useEngineStoreApi } from "../state";
import type { DemoEntity } from "../demo/types";
/**
* Renders a player model with skeleton-preserving animation.
*
* Uses SkeletonUtils.clone to deep-clone the GLTF scene with skeleton bindings
* intact, then drives a per-entity AnimationMixer to play movement animations
* (Root, Forward, Back, Side, Fall) selected from the keyframe velocity data.
* Weapon is attached to the animated Mount0 bone.
*/
export function DemoPlayerModel({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const engineStore = useEngineStoreApi();
const gltf = useStaticShape(entity.dataBlock!);
// Clone scene preserving skeleton bindings, create mixer, find Mount0 bone.
const { clonedScene, mixer, mount0 } = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
processShapeScene(scene);
const mix = new AnimationMixer(scene);
let m0: Object3D | null = null;
scene.traverse((n) => {
if (!m0 && n.name === "Mount0") m0 = n;
});
return { clonedScene: scene, mixer: mix, mount0: m0 };
}, [gltf]);
// Build case-insensitive clip lookup and start with Root animation.
const animActionsRef = useRef(new Map<string, AnimationAction>());
const currentAnimRef = useRef({ name: "Root", timeScale: 1 });
const isDeadRef = useRef(false);
useEffect(() => {
const actions = new Map<string, AnimationAction>();
for (const clip of gltf.animations) {
const action = mixer.clipAction(clip);
actions.set(clip.name.toLowerCase(), action);
}
animActionsRef.current = actions;
// Start with Root (idle) animation.
const rootAction = actions.get("root");
if (rootAction) {
rootAction.play();
}
currentAnimRef.current = { name: "Root", timeScale: 1 };
// Force initial pose evaluation.
mixer.update(0);
return () => {
mixer.stopAllAction();
animActionsRef.current = new Map();
};
}, [mixer, gltf.animations]);
// Per-frame animation selection and mixer update.
useFrame((_, delta) => {
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
const time = timeRef.current;
// Resolve velocity at current playback time.
const kf = getKeyframeAtTime(entity.keyframes, time);
const isDead = kf?.damageState != null && kf.damageState >= 1;
const actions = animActionsRef.current;
// Alive→Dead transition: play a random death animation.
if (isDead && !isDeadRef.current) {
isDeadRef.current = true;
const deathClips = [...actions.keys()].filter((k) =>
k.startsWith("die"),
);
if (deathClips.length > 0) {
const pick = deathClips[Math.floor(Math.random() * deathClips.length)];
const prevAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
const deathAction = actions.get(pick)!;
deathAction.setLoop(LoopOnce, 1);
deathAction.clampWhenFinished = true;
deathAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
currentAnimRef.current = { name: pick, timeScale: 1 };
}
}
// Dead→Alive transition: stop death animation, let movement resume.
if (!isDead && isDeadRef.current) {
isDeadRef.current = false;
const deathAction = actions.get(currentAnimRef.current.name.toLowerCase());
if (deathAction) {
deathAction.stop();
deathAction.setLoop(LoopRepeat, Infinity);
deathAction.clampWhenFinished = false;
}
// Reset to root so movement selection picks up on next iteration.
currentAnimRef.current = { name: "Root", timeScale: 1 };
const rootAction = actions.get("root");
if (rootAction) rootAction.reset().play();
}
// Movement animation selection (skip while dead).
if (!isDeadRef.current) {
const anim = pickMoveAnimation(
kf?.velocity,
kf?.rotation ?? [0, 0, 0, 1],
);
const prev = currentAnimRef.current;
if (anim.animation !== prev.name || anim.timeScale !== prev.timeScale) {
const prevAction = actions.get(prev.name.toLowerCase());
const nextAction = actions.get(anim.animation.toLowerCase());
if (nextAction) {
if (isPlaying && prevAction && prevAction !== nextAction) {
prevAction.fadeOut(ANIM_TRANSITION_TIME);
nextAction.reset().fadeIn(ANIM_TRANSITION_TIME).play();
} else {
if (prevAction && prevAction !== nextAction) prevAction.stop();
nextAction.reset().play();
}
nextAction.timeScale = anim.timeScale;
currentAnimRef.current = {
name: anim.animation,
timeScale: anim.timeScale,
};
}
}
}
// Advance or evaluate the body animation mixer.
if (isPlaying) {
mixer.update(delta * playback.rate);
} else {
mixer.update(0);
}
});
return (
<>
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
</group>
{entity.weaponShape && mount0 && (
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<AnimatedWeaponMount
weaponShape={entity.weaponShape}
mount0={mount0}
/>
</Suspense>
</ShapeErrorBoundary>
)}
</>
);
}
/**
* Imperatively attaches a weapon model to the animated Mount0 bone.
* Computes the Mountpoint inverse offset so the weapon's grip aligns with
* the player's hand. The weapon follows the animated skeleton automatically.
*/
export function AnimatedWeaponMount({
weaponShape,
mount0,
}: {
weaponShape: string;
mount0: Object3D;
}) {
const weaponGltf = useStaticShape(weaponShape);
useEffect(() => {
const weaponClone = weaponGltf.scene.clone(true);
processShapeScene(weaponClone);
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
if (mp) {
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
weaponClone.position.copy(invPos);
weaponClone.quaternion.copy(invQuat);
}
mount0.add(weaponClone);
return () => {
mount0.remove(weaponClone);
};
}, [weaponGltf, mount0]);
return null;
}
/**
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton
* hierarchy. Its world Y in GLB Y-up space gives the height above the player's
* feet, which we use as the first-person camera offset.
*/
export function PlayerEyeOffset({
shapeName,
eyeOffsetRef,
}: {
shapeName: string;
eyeOffsetRef: MutableRefObject<Vector3>;
}) {
const gltf = useStaticShape(shapeName);
useEffect(() => {
// Get Eye node position from the posed (Root animation) skeleton.
const eye = getPosedNodeTransform(gltf.scene, gltf.animations, "Eye");
if (eye) {
// Convert from GLB space to entity space via ShapeRenderer's R90:
// R90 maps GLB (x,y,z) → entity (z, y, -x).
// This gives ~(0.169, 2.122, 0.0) — 17cm forward and 2.12m up.
eyeOffsetRef.current.set(eye.position.z, eye.position.y, -eye.position.x);
} else {
eyeOffsetRef.current.set(0, DEFAULT_EYE_HEIGHT, 0);
}
}, [gltf, eyeOffsetRef]);
return null;
}

View file

@ -0,0 +1,226 @@
import { useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import {
AdditiveBlending,
Color,
DoubleSide,
Quaternion,
SRGBColorSpace,
Vector3,
} from "three";
import type { BufferAttribute, Mesh } from "three";
import {
setupEffectTexture,
torqueVecToThree,
setQuaternionFromDir,
} from "../demo/demoPlaybackUtils";
import { textureToUrl } from "../loaders";
import type { DemoEntity, DemoTracerVisual, DemoSpriteVisual } from "../demo/types";
const _tracerDir = new Vector3();
const _tracerDirFromCam = new Vector3();
const _tracerCross = new Vector3();
const _tracerStart = new Vector3();
const _tracerEnd = new Vector3();
const _tracerWorldPos = new Vector3();
const _upY = new Vector3(0, 1, 0);
export function DemoSpriteProjectile({ visual }: { visual: DemoSpriteVisual }) {
const url = textureToUrl(visual.texture);
const texture = useTexture(url, (tex) => {
const t = Array.isArray(tex) ? tex[0] : tex;
setupEffectTexture(t);
});
const map = Array.isArray(texture) ? texture[0] : texture;
// Convert sRGB datablock color to linear for Three.js material.
const color = useMemo(
() =>
new Color().setRGB(visual.color.r, visual.color.g, visual.color.b, SRGBColorSpace),
[visual.color.r, visual.color.g, visual.color.b],
);
return (
<sprite scale={[visual.size, visual.size, 1]}>
<spriteMaterial
map={map}
color={color}
transparent
blending={AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</sprite>
);
}
export function DemoTracerProjectile({
entity,
visual,
}: {
entity: DemoEntity;
visual: DemoTracerVisual;
}) {
const tracerRef = useRef<Mesh>(null);
const tracerPosRef = useRef<BufferAttribute>(null);
const crossRef = useRef<Mesh>(null);
const orientQuatRef = useRef(new Quaternion());
const tracerUrls = useMemo(
() => [
textureToUrl(visual.texture),
textureToUrl(visual.crossTexture ?? visual.texture),
],
[visual.texture, visual.crossTexture],
);
const textures = useTexture(tracerUrls, (loaded) => {
const list = Array.isArray(loaded) ? loaded : [loaded];
for (const tex of list) {
setupEffectTexture(tex);
}
});
const [tracerTexture, crossTexture] = Array.isArray(textures)
? textures
: [textures, textures];
useFrame(({ camera }) => {
const tracerMesh = tracerRef.current;
const posAttr = tracerPosRef.current;
if (!tracerMesh || !posAttr) return;
const kf = entity.keyframes[0];
const pos = kf?.position;
const direction = entity.direction ?? kf?.velocity;
if (!pos || !direction) {
tracerMesh.visible = false;
if (crossRef.current) crossRef.current.visible = false;
return;
}
torqueVecToThree(direction, _tracerDir);
if (_tracerDir.lengthSq() < 1e-8) {
tracerMesh.visible = false;
if (crossRef.current) crossRef.current.visible = false;
return;
}
_tracerDir.normalize();
tracerMesh.visible = true;
torqueVecToThree(pos, _tracerWorldPos);
_tracerDirFromCam.copy(_tracerWorldPos).sub(camera.position);
_tracerCross.crossVectors(_tracerDirFromCam, _tracerDir);
if (_tracerCross.lengthSq() < 1e-8) {
_tracerCross.crossVectors(_upY, _tracerDir);
if (_tracerCross.lengthSq() < 1e-8) {
_tracerCross.set(1, 0, 0);
}
}
_tracerCross.normalize().multiplyScalar(visual.tracerWidth);
const halfLength = visual.tracerLength * 0.5;
_tracerStart.copy(_tracerDir).multiplyScalar(-halfLength);
_tracerEnd.copy(_tracerDir).multiplyScalar(halfLength);
const posArray = posAttr.array as Float32Array;
posArray[0] = _tracerStart.x + _tracerCross.x;
posArray[1] = _tracerStart.y + _tracerCross.y;
posArray[2] = _tracerStart.z + _tracerCross.z;
posArray[3] = _tracerStart.x - _tracerCross.x;
posArray[4] = _tracerStart.y - _tracerCross.y;
posArray[5] = _tracerStart.z - _tracerCross.z;
posArray[6] = _tracerEnd.x - _tracerCross.x;
posArray[7] = _tracerEnd.y - _tracerCross.y;
posArray[8] = _tracerEnd.z - _tracerCross.z;
posArray[9] = _tracerEnd.x + _tracerCross.x;
posArray[10] = _tracerEnd.y + _tracerCross.y;
posArray[11] = _tracerEnd.z + _tracerCross.z;
posAttr.needsUpdate = true;
const crossMesh = crossRef.current;
if (!crossMesh) return;
if (!visual.renderCross) {
crossMesh.visible = false;
return;
}
_tracerDirFromCam.normalize();
const angle = _tracerDir.dot(_tracerDirFromCam);
if (angle > -visual.crossViewAng && angle < visual.crossViewAng) {
crossMesh.visible = false;
return;
}
crossMesh.visible = true;
setQuaternionFromDir(_tracerDir, orientQuatRef.current);
crossMesh.quaternion.copy(orientQuatRef.current);
crossMesh.scale.setScalar(visual.crossSize);
});
return (
<>
<mesh ref={tracerRef}>
<bufferGeometry>
<bufferAttribute
ref={tracerPosRef}
attach="attributes-position"
args={[new Float32Array(12), 3]}
/>
<bufferAttribute
attach="attributes-uv"
args={[
new Float32Array([
0, 0, 0, 1, 1, 1, 1, 0,
]),
2,
]}
/>
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry>
<meshBasicMaterial
map={tracerTexture}
transparent
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
toneMapped={false}
/>
</mesh>
{visual.renderCross ? (
<mesh ref={crossRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[
new Float32Array([
-0.5, 0, -0.5,
0.5, 0, -0.5,
0.5, 0, 0.5,
-0.5, 0, 0.5,
]),
3,
]}
/>
<bufferAttribute
attach="attributes-uv"
args={[
new Float32Array([
0, 0, 0, 1, 1, 1, 1, 0,
]),
2,
]}
/>
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry>
<meshBasicMaterial
map={crossTexture}
transparent
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
toneMapped={false}
/>
</mesh>
) : null}
</>
);
}

View file

@ -0,0 +1,125 @@
import { useMemo } from "react";
import { Quaternion, Vector3 } from "three";
import {
_r90,
_r90inv,
getPosedNodeTransform,
} from "../demo/demoPlaybackUtils";
import {
ShapeRenderer,
useStaticShape,
} from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
/** Renders a shape model for a demo entity using the existing shape pipeline. */
export function DemoShapeModel({
shapeName,
entityId,
}: {
shapeName: string;
entityId: number | string;
}) {
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "player",
_className: "Player",
_id: typeof entityId === "number" ? entityId : 0,
}),
[entityId],
);
return (
<ShapeInfoProvider
object={torqueObject}
shapeName={shapeName}
type="StaticShape"
>
<ShapeRenderer loadingColor="#00ff88" />
</ShapeInfoProvider>
);
}
/**
* Renders a mounted weapon using the Torque engine's mount system.
*
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
* animation) pose. The mount transform is conjugated by ShapeRenderer's 90° Y
* rotation: T_mount = R90 * M0 * MP^(-1) * R90^(-1).
*/
export function DemoWeaponModel({
shapeName,
playerShapeName,
}: {
shapeName: string;
playerShapeName: string;
}) {
const playerGltf = useStaticShape(playerShapeName);
const weaponGltf = useStaticShape(shapeName);
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed (Root animation) skeleton.
const m0 = getPosedNodeTransform(
playerGltf.scene,
playerGltf.animations,
"Mount0",
);
if (!m0) return { position: undefined, quaternion: undefined };
// Get Mountpoint from weapon (may not be animated).
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
// Compute T_mount = R90 * M0 * MP^(-1) * R90^(-1)
// This conjugates the GLB-space mount transform by ShapeRenderer's 90° Y
// rotation so the weapon is correctly oriented in entity space.
let combinedPos: Vector3;
let combinedQuat: Quaternion;
if (mp) {
// MP^(-1)
const mpInvQuat = mp.quaternion.clone().invert();
const mpInvPos = mp.position.clone().negate().applyQuaternion(mpInvQuat);
// M0 * MP^(-1)
combinedQuat = m0.quaternion.clone().multiply(mpInvQuat);
combinedPos = mpInvPos
.clone()
.applyQuaternion(m0.quaternion)
.add(m0.position);
} else {
combinedPos = m0.position.clone();
combinedQuat = m0.quaternion.clone();
}
// R90 * combined * R90^(-1)
const mountPos = combinedPos.applyQuaternion(_r90);
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
return { position: mountPos, quaternion: mountQuat };
}, [playerGltf, weaponGltf]);
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "weapon",
_className: "Weapon",
_id: 0,
}),
[],
);
return (
<ShapeInfoProvider object={torqueObject} shapeName={shapeName} type="Item">
<group
position={mountTransform.position}
quaternion={mountTransform.quaternion}
>
<ShapeRenderer loadingColor="#4488ff" />
</group>
</ShapeInfoProvider>
);
}

View file

@ -1,10 +1,22 @@
import { memo, ReactNode, useEffect, useRef, useState } from "react";
import { Object3D } from "three";
import { useDistanceFromCamera } from "./useDistanceFromCamera";
import { memo, ReactNode, useRef, useState } from "react";
import { Object3D, Vector3 } from "three";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
const DEFAULT_POSITION = [0, 0, 0] as [x: number, y: number, z: number];
const _worldPos = new Vector3();
/** Check if a world position is behind the camera using only scalar math. */
function isBehindCamera(
camera: { matrixWorld: { elements: number[] } },
wx: number,
wy: number,
wz: number,
): boolean {
const e = camera.matrixWorld.elements;
// Dot product of (objectPos - cameraPos) with camera forward (-Z column).
return (wx - e[12]) * -e[8] + (wy - e[13]) * -e[9] + (wz - e[14]) * -e[10] < 0;
}
export const FloatingLabel = memo(function FloatingLabel({
children,
@ -19,39 +31,36 @@ export const FloatingLabel = memo(function FloatingLabel({
}) {
const fadeWithDistance = opacity === "fadeWithDistance";
const groupRef = useRef<Object3D>(null);
const distanceRef = useDistanceFromCamera(groupRef);
const [isVisible, setIsVisible] = useState(opacity !== 0);
const labelRef = useRef<HTMLDivElement>(null);
// Initialize opacity when label ref is attached
useEffect(() => {
if (fadeWithDistance) {
if (labelRef.current && distanceRef.current != null) {
const opacity = Math.max(0, Math.min(1, 1 - distanceRef.current / 200));
labelRef.current.style.opacity = opacity.toString();
}
}
}, [isVisible, fadeWithDistance, distanceRef]);
useFrame(({ camera }) => {
const group = groupRef.current;
if (!group) return;
useFrame(() => {
if (fadeWithDistance) {
const distance = distanceRef.current;
const shouldBeVisible = distance != null && distance < 200;
group.getWorldPosition(_worldPos);
const behind = isBehindCamera(camera, _worldPos.x, _worldPos.y, _worldPos.z);
if (fadeWithDistance) {
const distance = behind ? Infinity : camera.position.distanceTo(_worldPos);
const shouldBeVisible = distance < 200;
// Update visibility state only when crossing threshold
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
// Update opacity directly on DOM element (no re-render)
// Update opacity directly on DOM element (no re-render).
if (labelRef.current && shouldBeVisible) {
const opacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = opacity.toString();
const fadeOpacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = fadeOpacity.toString();
}
} else {
setIsVisible(opacity !== 0);
const shouldBeVisible = !behind && opacity !== 0;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (labelRef.current) {
labelRef.current.style.opacity = opacity.toString();
labelRef.current.style.opacity = (opacity as number).toString();
}
}
});

View file

@ -0,0 +1,168 @@
import { useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Box3, Object3D, Vector3 } from "three";
import { getKeyframeAtTime } from "../demo/demoPlaybackUtils";
import { textureToUrl } from "../loaders";
import { useStaticShape } from "./GenericShape";
import type { DemoEntity } from "../demo/types";
/** Max distance at which nameplates are visible. */
const NAMEPLATE_FADE_DISTANCE = 150;
/** Padding above the shape's bounding box top for the IFF arrow. */
const IFF_PADDING = 0.1;
/** Height for the name + health label (slightly below the player's feet). */
const NAME_HEIGHT = -0.2;
const IFF_FRIENDLY_URL = textureToUrl("gui/hud_alliedtriangle");
const IFF_ENEMY_URL = textureToUrl("gui/hud_enemytriangle");
const _tmpVec = new Vector3();
/**
* Floating nameplate above a player model showing the entity name and a health
* bar. Fades out with distance.
*/
export function PlayerNameplate({
entity,
timeRef,
}: {
entity: DemoEntity;
timeRef: MutableRefObject<number>;
}) {
const gltf = useStaticShape(entity.dataBlock!);
const { camera } = useThree();
const groupRef = useRef<Object3D>(null);
const iffContainerRef = useRef<HTMLDivElement>(null);
const nameContainerRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const iffImgRef = useRef<HTMLImageElement>(null);
const [isVisible, setIsVisible] = useState(true);
const displayName = useMemo(() => {
if (entity.playerName) return entity.playerName;
if (typeof entity.id === "string") {
return entity.id.replace(/^player_/, "Player ");
}
return `Player ${entity.id}`;
}, [entity.id, entity.playerName]);
// Derive IFF height from the shape's bounding box.
const iffHeight = useMemo(() => {
const box = new Box3().setFromObject(gltf.scene);
return box.max.y + IFF_PADDING;
}, [gltf.scene]);
// Check whether this entity has any health data at all.
const hasHealthData = useMemo(
() => entity.keyframes.some((kf) => kf.health != null),
[entity.keyframes],
);
useFrame(() => {
const group = groupRef.current;
if (!group) return;
// Compute world-space distance to camera.
group.getWorldPosition(_tmpVec);
const distance = camera.position.distanceTo(_tmpVec);
// Check if behind camera using dot product with camera forward (-Z column).
const e = camera.matrixWorld.elements;
const behind =
(_tmpVec.x - e[12]) * -e[8] +
(_tmpVec.y - e[13]) * -e[9] +
(_tmpVec.z - e[14]) * -e[10] <
0;
const shouldBeVisible = !behind && distance < NAMEPLATE_FADE_DISTANCE;
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
if (!shouldBeVisible) return;
// Hide nameplate when player is dead.
const kf = getKeyframeAtTime(entity.keyframes, timeRef.current);
const health = kf?.health ?? 1;
if (kf?.damageState != null && kf.damageState >= 1) {
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";
if (nameContainerRef.current)
nameContainerRef.current.style.opacity = "0";
return;
}
// Update opacity on both label containers.
const opacity = Math.max(
0,
Math.min(1, 1 - distance / NAMEPLATE_FADE_DISTANCE),
);
const opacityStr = opacity.toString();
if (iffContainerRef.current) {
iffContainerRef.current.style.opacity = opacityStr;
}
if (nameContainerRef.current) {
nameContainerRef.current.style.opacity = opacityStr;
}
// Update IFF arrow image imperatively — entity.iffColor is mutated in-place
// by streaming playback without triggering re-renders.
if (iffImgRef.current && entity.iffColor) {
const url =
entity.iffColor.r > entity.iffColor.g
? IFF_ENEMY_URL
: IFF_FRIENDLY_URL;
if (iffImgRef.current.src !== url) {
iffImgRef.current.src = url;
}
}
// Update health bar fill.
if (fillRef.current && hasHealthData) {
fillRef.current.style.width = `${Math.max(0, Math.min(100, health * 100))}%`;
fillRef.current.style.background = entity.iffColor
? `rgb(${entity.iffColor.r}, ${entity.iffColor.g}, ${entity.iffColor.b})`
: "";
}
});
const iffMarkerUrl =
entity.iffColor && entity.iffColor.r > entity.iffColor.g
? IFF_ENEMY_URL
: IFF_FRIENDLY_URL;
return (
<group ref={groupRef}>
{isVisible && (
<>
<Html position={[0, iffHeight, 0]} center>
<div ref={iffContainerRef} className="PlayerNameplate PlayerTop">
<img
ref={iffImgRef}
className="PlayerNameplate-iffArrow"
src={iffMarkerUrl}
alt=""
/>
</div>
</Html>
<Html position={[0, NAME_HEIGHT, 0]} center>
<div
ref={nameContainerRef}
className="PlayerNameplate PlayerBottom"
>
<div className="PlayerNameplate-name">{displayName}</div>
{hasHealthData && (
<div className="PlayerNameplate-healthBar">
<div ref={fillRef} className="PlayerNameplate-healthFill" />
</div>
)}
</div>
</Html>
</>
)}
</group>
);
}