mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
split up demo modules, improve death support
This commit is contained in:
parent
359a036558
commit
c5b43f2e55
39 changed files with 2269 additions and 3942 deletions
166
src/components/DemoEntities.tsx
Normal file
166
src/components/DemoEntities.tsx
Normal 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
549
src/components/DemoPlaybackStreaming.tsx
Normal file
549
src/components/DemoPlaybackStreaming.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/components/DemoPlayerModel.tsx
Normal file
263
src/components/DemoPlayerModel.tsx
Normal 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;
|
||||
}
|
||||
226
src/components/DemoProjectiles.tsx
Normal file
226
src/components/DemoProjectiles.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
125
src/components/DemoShapeModel.tsx
Normal file
125
src/components/DemoShapeModel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
168
src/components/PlayerNameplate.tsx
Normal file
168
src/components/PlayerNameplate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue