mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-23 22:29:31 +00:00
initial demo support
This commit is contained in:
parent
0f2e103294
commit
359a036558
406 changed files with 10513 additions and 1158 deletions
|
|
@ -1,5 +1,18 @@
|
|||
import { useCallback, type ChangeEvent } from "react";
|
||||
import { useDemo } from "./DemoProvider";
|
||||
import {
|
||||
useDemoActions,
|
||||
useDemoCurrentTime,
|
||||
useDemoDuration,
|
||||
useDemoIsPlaying,
|
||||
useDemoRecording,
|
||||
useDemoSpeed,
|
||||
} from "./DemoProvider";
|
||||
import {
|
||||
buildSerializableDiagnosticsJson,
|
||||
buildSerializableDiagnosticsSnapshot,
|
||||
useEngineSelector,
|
||||
useEngineStoreApi,
|
||||
} from "../state";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
|
||||
|
||||
|
|
@ -9,18 +22,41 @@ function formatTime(seconds: number): string {
|
|||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number | undefined): string {
|
||||
if (!Number.isFinite(value) || value == null) {
|
||||
return "n/a";
|
||||
}
|
||||
if (value < 1024) return `${Math.round(value)} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function DemoControls() {
|
||||
const {
|
||||
recording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setSpeed,
|
||||
} = useDemo();
|
||||
const recording = useDemoRecording();
|
||||
const isPlaying = useDemoIsPlaying();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const duration = useDemoDuration();
|
||||
const speed = useDemoSpeed();
|
||||
const { play, pause, seek, setSpeed } = useDemoActions();
|
||||
const engineStore = useEngineStoreApi();
|
||||
const webglContextLost = useEngineSelector(
|
||||
(state) => state.diagnostics.webglContextLost,
|
||||
);
|
||||
const rendererSampleCount = useEngineSelector(
|
||||
(state) => state.diagnostics.rendererSamples.length,
|
||||
);
|
||||
const latestRendererSample = useEngineSelector((state) => {
|
||||
const samples = state.diagnostics.rendererSamples;
|
||||
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||
});
|
||||
const playbackEventCount = useEngineSelector(
|
||||
(state) => state.diagnostics.playbackEvents.length,
|
||||
);
|
||||
const latestPlaybackEvent = useEngineSelector((state) => {
|
||||
const events = state.diagnostics.playbackEvents;
|
||||
return events.length > 0 ? events[events.length - 1] : null;
|
||||
});
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -36,6 +72,19 @@ export function DemoControls() {
|
|||
[setSpeed],
|
||||
);
|
||||
|
||||
const handleDumpDiagnostics = useCallback(() => {
|
||||
const state = engineStore.getState();
|
||||
const snapshot = buildSerializableDiagnosticsSnapshot(state);
|
||||
const json = buildSerializableDiagnosticsJson(state);
|
||||
console.log("[demo diagnostics dump]", snapshot);
|
||||
console.log("[demo diagnostics dump json]", json);
|
||||
}, [engineStore]);
|
||||
|
||||
const handleClearDiagnostics = useCallback(() => {
|
||||
engineStore.getState().clearPlaybackDiagnostics();
|
||||
console.info("[demo diagnostics] Cleared playback diagnostics");
|
||||
}, [engineStore]);
|
||||
|
||||
if (!recording) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +102,7 @@ export function DemoControls() {
|
|||
{isPlaying ? "\u275A\u275A" : "\u25B6"}
|
||||
</button>
|
||||
<span className="DemoControls-time">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
{`${formatTime(currentTime)} / ${formatTime(duration)}`}
|
||||
</span>
|
||||
<input
|
||||
className="DemoControls-seek"
|
||||
|
|
@ -75,6 +124,54 @@ export function DemoControls() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
className="DemoDiagnosticsPanel"
|
||||
data-context-lost={webglContextLost ? "true" : undefined}
|
||||
>
|
||||
<div className="DemoDiagnosticsPanel-status">
|
||||
{webglContextLost ? "WebGL context: LOST" : "WebGL context: ok"}
|
||||
</div>
|
||||
<div className="DemoDiagnosticsPanel-metrics">
|
||||
{latestRendererSample ? (
|
||||
<>
|
||||
<span>
|
||||
geom {latestRendererSample.geometries} tex{" "}
|
||||
{latestRendererSample.textures} prog{" "}
|
||||
{latestRendererSample.programs}
|
||||
</span>
|
||||
<span>
|
||||
draw {latestRendererSample.renderCalls} tri{" "}
|
||||
{latestRendererSample.renderTriangles}
|
||||
</span>
|
||||
<span>
|
||||
scene {latestRendererSample.visibleSceneObjects}/
|
||||
{latestRendererSample.sceneObjects}
|
||||
</span>
|
||||
<span>heap {formatBytes(latestRendererSample.jsHeapUsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>No renderer samples yet</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="DemoDiagnosticsPanel-footer">
|
||||
<span>
|
||||
samples {rendererSampleCount} events {playbackEventCount}
|
||||
</span>
|
||||
{latestPlaybackEvent ? (
|
||||
<span title={latestPlaybackEvent.message}>
|
||||
last event: {latestPlaybackEvent.kind}
|
||||
</span>
|
||||
) : (
|
||||
<span>last event: none</span>
|
||||
)}
|
||||
<button type="button" onClick={handleDumpDiagnostics}>
|
||||
Dump
|
||||
</button>
|
||||
<button type="button" onClick={handleClearDiagnostics}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,6 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import type { DemoRecording } from "../demo/types";
|
||||
import { useEngineSelector } from "../state";
|
||||
|
||||
interface DemoContextValue {
|
||||
recording: DemoRecording | null;
|
||||
|
|
@ -20,123 +13,107 @@ interface DemoContextValue {
|
|||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
/** Ref used by the scene component to sync playback time back to context. */
|
||||
playbackRef: React.RefObject<PlaybackState>;
|
||||
}
|
||||
|
||||
export interface PlaybackState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
speed: number;
|
||||
/** Set by the provider when seeking; cleared by the scene component. */
|
||||
pendingSeek: number | null;
|
||||
/** Set by the provider when play/pause changes; cleared by the scene. */
|
||||
pendingPlayState: boolean | null;
|
||||
}
|
||||
|
||||
const DemoContext = createContext<DemoContextValue | null>(null);
|
||||
|
||||
export function useDemo() {
|
||||
const context = useContext(DemoContext);
|
||||
if (!context) {
|
||||
throw new Error("useDemo must be used within DemoProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useDemoOptional() {
|
||||
return useContext(DemoContext);
|
||||
}
|
||||
|
||||
export function DemoProvider({ children }: { children: ReactNode }) {
|
||||
const [recording, setRecording] = useState<DemoRecording | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const playbackRef = useRef<PlaybackState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
speed: 1,
|
||||
pendingSeek: null,
|
||||
pendingPlayState: null,
|
||||
});
|
||||
export function useDemoRecording(): DemoRecording | null {
|
||||
return useEngineSelector((state) => state.playback.recording);
|
||||
}
|
||||
|
||||
const duration = recording?.duration ?? 0;
|
||||
export function useDemoIsPlaying(): boolean {
|
||||
return useEngineSelector((state) => state.playback.status === "playing");
|
||||
}
|
||||
|
||||
export function useDemoCurrentTime(): number {
|
||||
return useEngineSelector((state) => state.playback.timeMs / 1000);
|
||||
}
|
||||
|
||||
export function useDemoDuration(): number {
|
||||
return useEngineSelector((state) => state.playback.durationMs / 1000);
|
||||
}
|
||||
|
||||
export function useDemoSpeed(): number {
|
||||
return useEngineSelector((state) => state.playback.rate);
|
||||
}
|
||||
|
||||
export function useDemoActions() {
|
||||
const recording = useDemoRecording();
|
||||
const setDemoRecording = useEngineSelector((state) => state.setDemoRecording);
|
||||
const setPlaybackStatus = useEngineSelector(
|
||||
(state) => state.setPlaybackStatus,
|
||||
);
|
||||
const setPlaybackTime = useEngineSelector((state) => state.setPlaybackTime);
|
||||
const setPlaybackRate = useEngineSelector((state) => state.setPlaybackRate);
|
||||
|
||||
const setRecording = useCallback(
|
||||
(recording: DemoRecording | null) => {
|
||||
setDemoRecording(recording);
|
||||
},
|
||||
[setDemoRecording],
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
playbackRef.current.pendingPlayState = true;
|
||||
}, []);
|
||||
if (
|
||||
(recording?.isMetadataOnly || recording?.isPartial) &&
|
||||
!recording.streamingPlayback
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPlaybackStatus("playing");
|
||||
}, [recording, setPlaybackStatus]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
playbackRef.current.pendingPlayState = false;
|
||||
}, []);
|
||||
setPlaybackStatus("paused");
|
||||
}, [setPlaybackStatus]);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
setCurrentTime(time);
|
||||
playbackRef.current.pendingSeek = time;
|
||||
}, []);
|
||||
|
||||
const handleSetSpeed = useCallback((newSpeed: number) => {
|
||||
setSpeed(newSpeed);
|
||||
playbackRef.current.speed = newSpeed;
|
||||
}, []);
|
||||
|
||||
const handleSetRecording = useCallback((rec: DemoRecording | null) => {
|
||||
setRecording(rec);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setSpeed(1);
|
||||
playbackRef.current.isPlaying = false;
|
||||
playbackRef.current.currentTime = 0;
|
||||
playbackRef.current.speed = 1;
|
||||
playbackRef.current.pendingSeek = null;
|
||||
playbackRef.current.pendingPlayState = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Called by DemoPlayback on each frame to sync the current time back
|
||||
* to React state (throttled by the scene component).
|
||||
*/
|
||||
const updateCurrentTime = useCallback((time: number) => {
|
||||
setCurrentTime(time);
|
||||
}, []);
|
||||
|
||||
// Attach the updater to the ref so the scene component can call it
|
||||
// without needing it as a dependency.
|
||||
(playbackRef.current as any).updateCurrentTime = updateCurrentTime;
|
||||
|
||||
const context: DemoContextValue = useMemo(
|
||||
() => ({
|
||||
recording,
|
||||
setRecording: handleSetRecording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setSpeed: handleSetSpeed,
|
||||
playbackRef,
|
||||
}),
|
||||
[
|
||||
recording,
|
||||
handleSetRecording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
handleSetSpeed,
|
||||
],
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
setPlaybackTime(time * 1000);
|
||||
},
|
||||
[setPlaybackTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<DemoContext.Provider value={context}>{children}</DemoContext.Provider>
|
||||
const setSpeed = useCallback(
|
||||
(speed: number) => {
|
||||
setPlaybackRate(speed);
|
||||
},
|
||||
[setPlaybackRate],
|
||||
);
|
||||
|
||||
return {
|
||||
setRecording,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
setSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDemo(): DemoContextValue {
|
||||
const recording = useDemoRecording();
|
||||
const isPlaying = useDemoIsPlaying();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const duration = useDemoDuration();
|
||||
const speed = useDemoSpeed();
|
||||
const actions = useDemoActions();
|
||||
|
||||
return {
|
||||
recording,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
setRecording: actions.setRecording,
|
||||
play: actions.play,
|
||||
pause: actions.pause,
|
||||
seek: actions.seek,
|
||||
setSpeed: actions.setSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDemoOptional(): DemoContextValue {
|
||||
return useDemo();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,9 +211,17 @@ export const ForceFieldBare = memo(function ForceFieldBare({
|
|||
[datablock, numFrames],
|
||||
);
|
||||
|
||||
// Don't render if we have no textures
|
||||
// Render fallback mesh when textures are missing instead of disappearing.
|
||||
if (textureUrls.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<group position={position} quaternion={quaternion}>
|
||||
<ForceFieldFallback
|
||||
scale={scale}
|
||||
color={color}
|
||||
baseTranslucency={baseTranslucency}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, Suspense, useMemo } from "react";
|
||||
import { memo, Suspense, useMemo, useRef } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useGLTF, useTexture } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
|
||||
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
|
||||
import {
|
||||
|
|
@ -10,9 +11,10 @@ import {
|
|||
AdditiveBlending,
|
||||
Texture,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
} from "three";
|
||||
import { setupTexture } from "../textureUtils";
|
||||
import { useDebug } from "./SettingsProvider";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useIflTexture } from "./useIflTexture";
|
||||
|
|
@ -28,6 +30,10 @@ interface TextureProps {
|
|||
backGeometry?: BufferGeometry;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
/** DTS object visibility (0–1). Values < 1 enable alpha blending. */
|
||||
vis?: number;
|
||||
/** When true, material is created transparent for vis keyframe animation. */
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +55,7 @@ type MaterialResult =
|
|||
/**
|
||||
* Helper to apply volumetric fog and lighting multipliers to a material
|
||||
*/
|
||||
function applyShapeShaderModifications(
|
||||
export function applyShapeShaderModifications(
|
||||
mat: MeshBasicMaterial | MeshLambertMaterial,
|
||||
): void {
|
||||
mat.onBeforeCompile = (shader) => {
|
||||
|
|
@ -61,24 +67,33 @@ function applyShapeShaderModifications(
|
|||
};
|
||||
}
|
||||
|
||||
function createMaterialFromFlags(
|
||||
export function createMaterialFromFlags(
|
||||
baseMaterial: MeshStandardMaterial,
|
||||
texture: Texture,
|
||||
flagNames: Set<string>,
|
||||
isOrganic: boolean,
|
||||
vis: number = 1,
|
||||
animated: boolean = false,
|
||||
): MaterialResult {
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
const isAdditive = flagNames.has("Additive");
|
||||
const isSelfIlluminating = flagNames.has("SelfIlluminating");
|
||||
// DTS per-object visibility: when vis < 1, the engine sets fadeSet=true which
|
||||
// forces the Translucent flag and renders with GL_SRC_ALPHA/GL_ONE_MINUS_SRC_ALPHA.
|
||||
// Animated vis also needs transparent materials so opacity can be updated per frame.
|
||||
const isFaded = vis < 1 || animated;
|
||||
|
||||
// SelfIlluminating materials are unlit (use MeshBasicMaterial)
|
||||
if (isSelfIlluminating) {
|
||||
const isBlended = isAdditive || isTranslucent || isFaded;
|
||||
const mat = new MeshBasicMaterial({
|
||||
map: texture,
|
||||
side: 2, // DoubleSide
|
||||
transparent: isAdditive,
|
||||
alphaTest: isAdditive ? 0 : 0.5,
|
||||
transparent: isBlended,
|
||||
depthWrite: !isBlended,
|
||||
alphaTest: 0,
|
||||
fog: true,
|
||||
...(isFaded && { opacity: vis }),
|
||||
...(isAdditive && { blending: AdditiveBlending }),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
|
|
@ -92,8 +107,11 @@ function createMaterialFromFlags(
|
|||
if (isOrganic || isTranslucent) {
|
||||
const baseProps = {
|
||||
map: texture,
|
||||
transparent: false,
|
||||
alphaTest: 0.5,
|
||||
// When vis < 1, switch from alpha cutout to alpha blend (matching the engine's
|
||||
// fadeSet behavior which forces GL_BLEND with no alpha test)
|
||||
transparent: isFaded,
|
||||
alphaTest: isFaded ? 0 : 0.5,
|
||||
...(isFaded && { opacity: vis, depthWrite: false }),
|
||||
reflectivity: 0,
|
||||
};
|
||||
const backMat = new MeshLambertMaterial({
|
||||
|
|
@ -119,6 +137,11 @@ function createMaterialFromFlags(
|
|||
map: texture,
|
||||
side: 2, // DoubleSide
|
||||
reflectivity: 0,
|
||||
...(isFaded && {
|
||||
transparent: true,
|
||||
opacity: vis,
|
||||
depthWrite: false,
|
||||
}),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
return mat;
|
||||
|
|
@ -143,6 +166,8 @@ const IflTexture = memo(function IflTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
||||
|
|
@ -152,8 +177,16 @@ const IflTexture = memo(function IflTexture({
|
|||
const isOrganic = shapeName && isOrganicShape(shapeName);
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
|
||||
[material, texture, flagNames, isOrganic],
|
||||
() =>
|
||||
createMaterialFromFlags(
|
||||
material,
|
||||
texture,
|
||||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
animated,
|
||||
),
|
||||
[material, texture, flagNames, isOrganic, vis, animated],
|
||||
);
|
||||
|
||||
// Two-pass rendering for organic/translucent materials
|
||||
|
|
@ -197,6 +230,8 @@ const StaticTexture = memo(function StaticTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
const flagNames = new Set<string>(material.userData.flag_names ?? []);
|
||||
|
|
@ -223,8 +258,16 @@ const StaticTexture = memo(function StaticTexture({
|
|||
});
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => createMaterialFromFlags(material, texture, flagNames, isOrganic),
|
||||
[material, texture, flagNames, isOrganic],
|
||||
() =>
|
||||
createMaterialFromFlags(
|
||||
material,
|
||||
texture,
|
||||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
animated,
|
||||
),
|
||||
[material, texture, flagNames, isOrganic, vis, animated],
|
||||
);
|
||||
|
||||
// Two-pass rendering for organic/translucent materials
|
||||
|
|
@ -268,6 +311,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry,
|
||||
castShadow = false,
|
||||
receiveShadow = false,
|
||||
vis = 1,
|
||||
animated = false,
|
||||
}: TextureProps) {
|
||||
const flagNames = new Set(material.userData.flag_names ?? []);
|
||||
const isIflMaterial = flagNames.has("IflMaterial");
|
||||
|
|
@ -283,6 +328,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry={backGeometry}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
);
|
||||
} else if (material.name) {
|
||||
|
|
@ -294,6 +341,8 @@ export const ShapeTexture = memo(function ShapeTexture({
|
|||
backGeometry={backGeometry}
|
||||
castShadow={castShadow}
|
||||
receiveShadow={receiveShadow}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
@ -328,6 +377,22 @@ export function DebugPlaceholder({
|
|||
return debugMode ? <ShapePlaceholder color={color} label={label} /> : null;
|
||||
}
|
||||
|
||||
/** Shapes that don't have a .glb conversion and are rendered with built-in
|
||||
* Three.js geometry instead. These are editor-only markers in Tribes 2. */
|
||||
const HARDCODED_SHAPES = new Set(["octahedron.dts"]);
|
||||
|
||||
function HardcodedShape({ label }: { label?: string }) {
|
||||
const { debugMode } = useDebug();
|
||||
if (!debugMode) return null;
|
||||
return (
|
||||
<mesh>
|
||||
<icosahedronGeometry args={[1, 1]} />
|
||||
<meshBasicMaterial color="cyan" wireframe />
|
||||
{label ? <FloatingLabel color="cyan">{label}</FloatingLabel> : null}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that handles the common ErrorBoundary + Suspense + ShapeModel
|
||||
* pattern used across shape-rendering components.
|
||||
|
|
@ -347,6 +412,10 @@ export function ShapeRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
if (HARDCODED_SHAPES.has(shapeName.toLowerCase())) {
|
||||
return <HardcodedShape label={`${object._id}: ${shapeName}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
|
|
@ -361,6 +430,76 @@ export function ShapeRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
/** Check if a GLB node has an auto-playing "Ambient" vis animation. */
|
||||
function hasAmbientVisAnimation(userData: any): boolean {
|
||||
return (
|
||||
userData != null &&
|
||||
(userData.vis_sequence ?? "").toLowerCase() === "ambient" &&
|
||||
Array.isArray(userData.vis_keyframes) &&
|
||||
userData.vis_keyframes.length > 1 &&
|
||||
(userData.vis_duration ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps child meshes and animates their material opacity using DTS vis keyframes.
|
||||
* Used for auto-playing "Ambient" sequences (glow pulses, light effects).
|
||||
*/
|
||||
function AnimatedVisGroup({
|
||||
keyframes,
|
||||
duration,
|
||||
cyclic,
|
||||
children,
|
||||
}: {
|
||||
keyframes: number[];
|
||||
duration: number;
|
||||
cyclic: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const { animationEnabled } = useSettings();
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!animationEnabled) {
|
||||
group.traverse((child) => {
|
||||
if ((child as any).isMesh) {
|
||||
const mat = (child as any).material;
|
||||
if (mat && !Array.isArray(mat)) {
|
||||
mat.opacity = keyframes[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() / 1000;
|
||||
const t = cyclic
|
||||
? (elapsed % duration) / duration
|
||||
: Math.min(elapsed / duration, 1);
|
||||
|
||||
const n = keyframes.length;
|
||||
const pos = t * n;
|
||||
const lo = Math.floor(pos) % n;
|
||||
const hi = (lo + 1) % n;
|
||||
const frac = pos - Math.floor(pos);
|
||||
const vis = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
||||
|
||||
group.traverse((child) => {
|
||||
if ((child as any).isMesh) {
|
||||
const mat = (child as any).material;
|
||||
if (mat && !Array.isArray(mat)) {
|
||||
mat.opacity = vis;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return <group ref={groupRef}>{children}</group>;
|
||||
}
|
||||
|
||||
export const ShapeModel = memo(function ShapeModel() {
|
||||
const { object, shapeName, isOrganic } = useShapeInfo();
|
||||
const { debugMode } = useDebug();
|
||||
|
|
@ -384,7 +523,12 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
([name, node]: [string, any]) =>
|
||||
node.material &&
|
||||
node.material.name !== "Unassigned" &&
|
||||
!node.name.match(/^Hulk/i),
|
||||
!node.name.match(/^Hulk/i) &&
|
||||
// DTS per-object visibility: skip invisible objects (engine threshold
|
||||
// is 0.01) unless they have an Ambient vis animation that will bring
|
||||
// them to life (e.g. glow effects that pulse from 0 to 1).
|
||||
((node.userData?.vis ?? 1) > 0.01 ||
|
||||
hasAmbientVisAnimation(node.userData)),
|
||||
)
|
||||
.map(([name, node]: [string, any]) => {
|
||||
let geometry = filterGeometryByVertexGroups(
|
||||
|
|
@ -459,7 +603,15 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
}
|
||||
}
|
||||
|
||||
return { node, geometry, backGeometry };
|
||||
const vis: number = node.userData?.vis ?? 1;
|
||||
const visAnim = hasAmbientVisAnimation(node.userData)
|
||||
? {
|
||||
keyframes: node.userData.vis_keyframes as number[],
|
||||
duration: node.userData.vis_duration as number,
|
||||
cyclic: !!node.userData.vis_cyclic,
|
||||
}
|
||||
: undefined;
|
||||
return { node, geometry, backGeometry, vis, visAnim };
|
||||
});
|
||||
}, [nodes, hullBoneIndices, isOrganic]);
|
||||
|
||||
|
|
@ -469,41 +621,61 @@ export const ShapeModel = memo(function ShapeModel() {
|
|||
|
||||
return (
|
||||
<group rotation={[0, Math.PI / 2, 0]}>
|
||||
{processedNodes.map(({ node, geometry, backGeometry }) => (
|
||||
<Suspense
|
||||
key={node.id}
|
||||
fallback={
|
||||
<mesh geometry={geometry}>
|
||||
<meshStandardMaterial color="gray" wireframe />
|
||||
</mesh>
|
||||
}
|
||||
>
|
||||
{node.material ? (
|
||||
Array.isArray(node.material) ? (
|
||||
node.material.map((mat, index) => (
|
||||
<ShapeTexture
|
||||
key={index}
|
||||
material={mat as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
{processedNodes.map(({ node, geometry, backGeometry, vis, visAnim }) => {
|
||||
const animated = !!visAnim;
|
||||
const fallback = (
|
||||
<mesh geometry={geometry}>
|
||||
<meshStandardMaterial color="gray" wireframe />
|
||||
</mesh>
|
||||
);
|
||||
const textures = node.material ? (
|
||||
Array.isArray(node.material) ? (
|
||||
node.material.map((mat, index) => (
|
||||
<ShapeTexture
|
||||
material={node.material as MeshStandardMaterial}
|
||||
key={index}
|
||||
material={mat as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Suspense>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<ShapeTexture
|
||||
material={node.material as MeshStandardMaterial}
|
||||
shapeName={shapeName}
|
||||
geometry={geometry}
|
||||
backGeometry={backGeometry}
|
||||
castShadow={enableShadows}
|
||||
receiveShadow={enableShadows}
|
||||
vis={vis}
|
||||
animated={animated}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (visAnim) {
|
||||
return (
|
||||
<AnimatedVisGroup
|
||||
key={node.id}
|
||||
keyframes={visAnim.keyframes}
|
||||
duration={visAnim.duration}
|
||||
cyclic={visAnim.cyclic}
|
||||
>
|
||||
<Suspense fallback={fallback}>{textures}</Suspense>
|
||||
</AnimatedVisGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense key={node.id} fallback={fallback}>
|
||||
{textures}
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
{debugMode ? (
|
||||
<FloatingLabel>
|
||||
{object._id}: {shapeName}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { RefObject, useEffect, useState, useRef } from "react";
|
|||
import { Camera } from "three";
|
||||
import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
||||
import { LoadDemoButton } from "./LoadDemoButton";
|
||||
import { useDemoRecording } from "./DemoProvider";
|
||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
||||
|
||||
export function InspectorControls({
|
||||
|
|
@ -45,6 +46,8 @@ export function InspectorControls({
|
|||
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } =
|
||||
useControls();
|
||||
const { debugMode, setDebugMode } = useDebug();
|
||||
const demoRecording = useDemoRecording();
|
||||
const isDemoLoaded = demoRecording != null;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -84,6 +87,7 @@ export function InspectorControls({
|
|||
value={missionName}
|
||||
missionType={missionType}
|
||||
onChange={onChangeMission}
|
||||
disabled={isDemoLoaded}
|
||||
/>
|
||||
<div ref={focusAreaRef}>
|
||||
<button
|
||||
|
|
@ -182,6 +186,7 @@ export function InspectorControls({
|
|||
max={120}
|
||||
step={5}
|
||||
value={fov}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) => setFov(parseInt(event.target.value))}
|
||||
/>
|
||||
<output htmlFor="fovInput">{fov}</output>
|
||||
|
|
@ -195,6 +200,7 @@ export function InspectorControls({
|
|||
max={5}
|
||||
step={0.05}
|
||||
value={speedMultiplier}
|
||||
disabled={isDemoLoaded}
|
||||
onChange={(event) =>
|
||||
setSpeedMultiplier(parseFloat(event.target.value))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Group } from "three";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||
import { ShapeRenderer } from "./GenericShape";
|
||||
|
|
@ -6,6 +8,16 @@ import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
|||
import { useSimGroup } from "./SimGroup";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useDatablock } from "./useDatablock";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
|
||||
/** Handles TorqueScript's various truthy representations. */
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
const lower = value.toLowerCase();
|
||||
return lower !== "0" && lower !== "false" && lower !== "";
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
const TEAM_NAMES: Record<number, string> = {
|
||||
1: "Storm",
|
||||
|
|
@ -17,10 +29,23 @@ export function Item({ object }: { object: TorqueObject }) {
|
|||
const datablockName = getProperty(object, "dataBlock") ?? "";
|
||||
const datablock = useDatablock(datablockName);
|
||||
|
||||
const shouldRotate = isTruthy(
|
||||
getProperty(object, "rotate") ?? getProperty(datablock, "rotate")
|
||||
);
|
||||
|
||||
const position = useMemo(() => getPosition(object), [object]);
|
||||
const scale = useMemo(() => getScale(object), [object]);
|
||||
const q = useMemo(() => getRotation(object), [object]);
|
||||
|
||||
const { animationEnabled } = useSettings();
|
||||
const groupRef = useRef<Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (!groupRef.current || !shouldRotate || !animationEnabled) return;
|
||||
const t = performance.now() / 1000;
|
||||
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
|
||||
});
|
||||
|
||||
const shapeName = getProperty(datablock, "shapeFile");
|
||||
|
||||
if (!shapeName) {
|
||||
|
|
@ -34,7 +59,12 @@ export function Item({ object }: { object: TorqueObject }) {
|
|||
|
||||
return (
|
||||
<ShapeInfoProvider type="Item" object={object} shapeName={shapeName}>
|
||||
<group position={position} quaternion={q} scale={scale}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
{...(!shouldRotate && { quaternion: q })}
|
||||
scale={scale}
|
||||
>
|
||||
<ShapeRenderer loadingColor="pink">
|
||||
{label ? <FloatingLabel opacity={0.6}>{label}</FloatingLabel> : null}
|
||||
</ShapeRenderer>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useKeyboardControls } from "@react-three/drei";
|
||||
import { Controls } from "./ObserverControls";
|
||||
import { useDemoRecording } from "./DemoProvider";
|
||||
|
||||
export function KeyboardOverlay() {
|
||||
const recording = useDemoRecording();
|
||||
const forward = useKeyboardControls<Controls>((s) => s.forward);
|
||||
const backward = useKeyboardControls<Controls>((s) => s.backward);
|
||||
const left = useKeyboardControls<Controls>((s) => s.left);
|
||||
|
|
@ -13,6 +15,8 @@ export function KeyboardOverlay() {
|
|||
const lookLeft = useKeyboardControls<Controls>((s) => s.lookLeft);
|
||||
const lookRight = useKeyboardControls<Controls>((s) => s.lookRight);
|
||||
|
||||
if (recording) return null;
|
||||
|
||||
return (
|
||||
<div className="KeyboardOverlay">
|
||||
<div className="KeyboardOverlay-column">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { FiFilm } from "react-icons/fi";
|
||||
import { useDemo } from "./DemoProvider";
|
||||
import { parseDemoFile } from "../demo/parse";
|
||||
import { MdOndemandVideo } from "react-icons/md";
|
||||
import { useDemoActions, useDemoRecording } from "./DemoProvider";
|
||||
import { createDemoStreamingRecording } from "../demo/streaming";
|
||||
|
||||
export function LoadDemoButton() {
|
||||
const { setRecording, recording } = useDemo();
|
||||
const recording = useDemoRecording();
|
||||
const { setRecording } = useDemoActions();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parseTokenRef = useRef(0);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (recording) {
|
||||
// Unload the current recording.
|
||||
parseTokenRef.current += 1;
|
||||
setRecording(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -24,8 +27,14 @@ export function LoadDemoButton() {
|
|||
e.target.value = "";
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const demo = parseDemoFile(buffer);
|
||||
setRecording(demo);
|
||||
const parseToken = parseTokenRef.current + 1;
|
||||
parseTokenRef.current = parseToken;
|
||||
const recording = await createDemoStreamingRecording(buffer);
|
||||
if (parseTokenRef.current !== parseToken) {
|
||||
return;
|
||||
}
|
||||
// Metadata-first: mission/game-mode sync happens immediately.
|
||||
setRecording(recording);
|
||||
} catch (err) {
|
||||
console.error("Failed to load demo:", err);
|
||||
}
|
||||
|
|
@ -50,7 +59,7 @@ export function LoadDemoButton() {
|
|||
onClick={handleClick}
|
||||
data-active={recording ? "true" : undefined}
|
||||
>
|
||||
<FiFilm />
|
||||
<MdOndemandVideo className="DemoIcon" />
|
||||
<span className="ButtonLabel">
|
||||
{recording ? "Unload demo" : "Demo"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getSourceAndPath,
|
||||
} from "../manifest";
|
||||
import { MissionProvider } from "./MissionContext";
|
||||
import { engineStore } from "../state";
|
||||
|
||||
const loadScript = createScriptLoader();
|
||||
// Shared cache for parsed scripts - survives runtime restarts
|
||||
|
|
@ -72,6 +73,8 @@ function useExecutedMission(
|
|||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let isDisposed = false;
|
||||
let unsubscribeRuntimeEvents: (() => void) | null = null;
|
||||
|
||||
// Create progress tracker and update state on changes
|
||||
const progressTracker = createProgressTracker();
|
||||
|
|
@ -80,7 +83,7 @@ function useExecutedMission(
|
|||
};
|
||||
progressTracker.on("update", handleProgress);
|
||||
|
||||
const { runtime } = runServer({
|
||||
const { runtime, ready } = runServer({
|
||||
missionName,
|
||||
missionType,
|
||||
runtimeOptions: {
|
||||
|
|
@ -120,15 +123,45 @@ function useExecutedMission(
|
|||
"scripts/spdialog.cs",
|
||||
],
|
||||
},
|
||||
onMissionLoadDone: () => {
|
||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||
setState({ missionGroup, runtime, progress: 1 });
|
||||
},
|
||||
});
|
||||
|
||||
void ready
|
||||
.then(() => {
|
||||
if (isDisposed || controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Refresh the reactive runtime snapshot at mission-ready time.
|
||||
engineStore.getState().setRuntime(runtime);
|
||||
const missionGroup = runtime.getObjectByName("MissionGroup");
|
||||
setState({ missionGroup, runtime, progress: 1 });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("Mission runtime failed to become ready:", err);
|
||||
});
|
||||
|
||||
// Subscribe as soon as the runtime exists so no mutation batches are missed
|
||||
// between mission init and React component mount.
|
||||
unsubscribeRuntimeEvents = runtime.subscribeRuntimeEvents((event) => {
|
||||
if (event.type !== "batch.flushed") {
|
||||
return;
|
||||
}
|
||||
engineStore.getState().applyRuntimeBatch(event.events, {
|
||||
tick: event.tick,
|
||||
});
|
||||
});
|
||||
// Seed store immediately; indexes are refreshed again when `ready` resolves
|
||||
// after server mission load reaches its ready state.
|
||||
engineStore.getState().setRuntime(runtime);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
progressTracker.off("update", handleProgress);
|
||||
controller.abort();
|
||||
unsubscribeRuntimeEvents?.();
|
||||
engineStore.getState().clearRuntime();
|
||||
runtime.destroy();
|
||||
};
|
||||
}, [missionName, missionType, parsedMission]);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export function MissionSelect({
|
|||
value,
|
||||
missionType,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
missionType: string;
|
||||
|
|
@ -164,6 +165,7 @@ export function MissionSelect({
|
|||
missionName: string;
|
||||
missionType: string | undefined;
|
||||
}) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -267,6 +269,7 @@ export function MissionSelect({
|
|||
<Combobox
|
||||
ref={inputRef}
|
||||
autoSelect
|
||||
disabled={disabled}
|
||||
placeholder={displayValue}
|
||||
className="MissionSelect-input"
|
||||
onFocus={() => {
|
||||
|
|
|
|||
53
src/components/PlayerHUD.module.css
Normal file
53
src/components/PlayerHUD.module.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PlayerHUD {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.ChatWindow {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.Bar {
|
||||
width: 160px;
|
||||
height: 14px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.HealthBar {
|
||||
composes: Bar;
|
||||
top: 60px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.EnergyBar {
|
||||
composes: Bar;
|
||||
top: 80px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.BarFill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
.HealthBar .BarFill {
|
||||
background: #2ecc40;
|
||||
}
|
||||
|
||||
.EnergyBar .BarFill {
|
||||
background: #0af;
|
||||
}
|
||||
185
src/components/PlayerHUD.tsx
Normal file
185
src/components/PlayerHUD.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useMemo } from "react";
|
||||
import { useDemoCurrentTime, useDemoRecording } from "./DemoProvider";
|
||||
import type { DemoEntity, DemoKeyframe, CameraModeFrame } from "../demo/types";
|
||||
import { useEngineSelector } from "../state";
|
||||
import styles from "./PlayerHUD.module.css";
|
||||
|
||||
/**
|
||||
* Binary search for the most recent keyframe at or before `time`.
|
||||
* Returns the keyframe's health/energy values (carried forward from last
|
||||
* known ghost update).
|
||||
*/
|
||||
function getStatusAtTime(
|
||||
keyframes: DemoKeyframe[],
|
||||
time: number,
|
||||
): { health: number; energy: number } {
|
||||
if (keyframes.length === 0) return { health: 1, energy: 1 };
|
||||
|
||||
let lo = 0;
|
||||
let hi = keyframes.length - 1;
|
||||
|
||||
if (time <= keyframes[0].time) {
|
||||
return {
|
||||
health: keyframes[0].health ?? 1,
|
||||
energy: keyframes[0].energy ?? 1,
|
||||
};
|
||||
}
|
||||
if (time >= keyframes[hi].time) {
|
||||
return {
|
||||
health: keyframes[hi].health ?? 1,
|
||||
energy: keyframes[hi].energy ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keyframes[mid].time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
|
||||
return {
|
||||
health: keyframes[lo].health ?? 1,
|
||||
energy: keyframes[lo].energy ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Binary search for the active CameraModeFrame at a given time. */
|
||||
function getCameraModeAtTime(
|
||||
frames: CameraModeFrame[],
|
||||
time: number,
|
||||
): CameraModeFrame | null {
|
||||
if (frames.length === 0) return null;
|
||||
if (time < frames[0].time) return null;
|
||||
if (time >= frames[frames.length - 1].time) return frames[frames.length - 1];
|
||||
|
||||
let lo = 0;
|
||||
let hi = frames.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (frames[mid].time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return frames[lo];
|
||||
}
|
||||
|
||||
function HealthBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
<div className={styles.HealthBar}>
|
||||
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnergyBar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value * 100));
|
||||
return (
|
||||
<div className={styles.EnergyBar}>
|
||||
<div className={styles.BarFill} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatWindow() {
|
||||
return <div className={styles.ChatWindow} />;
|
||||
}
|
||||
|
||||
function WeaponSlots() {
|
||||
return <div className={styles.WeaponSlots} />;
|
||||
}
|
||||
|
||||
function ToolBelt() {
|
||||
return <div className={styles.ToolBelt} />;
|
||||
}
|
||||
|
||||
function Reticle() {
|
||||
return <div className={styles.Reticle} />;
|
||||
}
|
||||
|
||||
function TeamStats() {
|
||||
return <div className={styles.TeamStats} />;
|
||||
}
|
||||
|
||||
function Compass() {
|
||||
return <div className={styles.Compass} />;
|
||||
}
|
||||
|
||||
export function PlayerHUD() {
|
||||
const recording = useDemoRecording();
|
||||
const currentTime = useDemoCurrentTime();
|
||||
const streamSnapshot = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot,
|
||||
);
|
||||
|
||||
// Build an entity lookup by ID for quick access.
|
||||
const entityMap = useMemo(() => {
|
||||
const map = new Map<string | number, DemoEntity>();
|
||||
if (!recording) return map;
|
||||
for (const entity of recording.entities) {
|
||||
map.set(entity.id, entity);
|
||||
}
|
||||
return map;
|
||||
}, [recording]);
|
||||
|
||||
if (!recording) return null;
|
||||
if (recording.isMetadataOnly || recording.isPartial) {
|
||||
const status = streamSnapshot?.status;
|
||||
if (!status) return null;
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<Compass />
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
<TeamStats />
|
||||
<Reticle />
|
||||
<ToolBelt />
|
||||
<WeaponSlots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which entity to show status for based on camera mode.
|
||||
const frame = getCameraModeAtTime(recording.cameraModes, currentTime);
|
||||
|
||||
// Resolve health and energy for the active player:
|
||||
// - First-person: health from ghost entity (DamageMask), energy from the
|
||||
// recording_player entity (CO readPacketData, higher precision).
|
||||
// - Third-person (orbit): both from the orbit target entity.
|
||||
let status = { health: 1, energy: 1 };
|
||||
if (frame?.mode === "first-person") {
|
||||
const ghostEntity = recording.controlPlayerGhostId
|
||||
? entityMap.get(recording.controlPlayerGhostId)
|
||||
: undefined;
|
||||
const recEntity = entityMap.get("recording_player");
|
||||
const ghostStatus = ghostEntity
|
||||
? getStatusAtTime(ghostEntity.keyframes, currentTime)
|
||||
: undefined;
|
||||
const recStatus = recEntity
|
||||
? getStatusAtTime(recEntity.keyframes, currentTime)
|
||||
: undefined;
|
||||
status = {
|
||||
health: ghostStatus?.health ?? 1,
|
||||
// Prefer CO energy (available every tick) over ghost energy (sparse).
|
||||
energy: recStatus?.energy ?? ghostStatus?.energy ?? 1,
|
||||
};
|
||||
} else if (frame?.mode === "third-person" && frame.orbitTargetId) {
|
||||
const entity = entityMap.get(frame.orbitTargetId);
|
||||
if (entity) {
|
||||
status = getStatusAtTime(entity.keyframes, currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<Compass />
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
<TeamStats />
|
||||
<Reticle />
|
||||
<ToolBelt />
|
||||
<WeaponSlots />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { SimObject } from "./SimObject";
|
||||
import { useRuntimeChildIds, useRuntimeObjectById } from "../state";
|
||||
|
||||
export type SimGroupContextType = {
|
||||
object: TorqueObject;
|
||||
|
|
@ -16,7 +17,9 @@ export function useSimGroup() {
|
|||
}
|
||||
|
||||
export function SimGroup({ object }: { object: TorqueObject }) {
|
||||
const liveObject = useRuntimeObjectById(object._id) ?? object;
|
||||
const parent = useSimGroup();
|
||||
const childIds = useRuntimeChildIds(liveObject._id, liveObject._children ?? []);
|
||||
|
||||
const simGroup: SimGroupContextType = useMemo(() => {
|
||||
let team: number | null = null;
|
||||
|
|
@ -26,19 +29,19 @@ export function SimGroup({ object }: { object: TorqueObject }) {
|
|||
hasTeams = true;
|
||||
if (parent.team != null) {
|
||||
team = parent.team;
|
||||
} else if (object._name) {
|
||||
const match = object._name.match(/^team(\d+)$/i);
|
||||
} else if (liveObject._name) {
|
||||
const match = liveObject._name.match(/^team(\d+)$/i);
|
||||
if (match) {
|
||||
team = parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
} else if (object._name) {
|
||||
hasTeams = object._name.toLowerCase() === "teams";
|
||||
} else if (liveObject._name) {
|
||||
hasTeams = liveObject._name.toLowerCase() === "teams";
|
||||
}
|
||||
|
||||
return {
|
||||
// the current SimGroup's data
|
||||
object,
|
||||
object: liveObject,
|
||||
// the closest ancestor of this SimGroup
|
||||
parent,
|
||||
// whether this is, or is the descendant of, the "Teams" SimGroup
|
||||
|
|
@ -47,12 +50,12 @@ export function SimGroup({ object }: { object: TorqueObject }) {
|
|||
// or a descendant of one
|
||||
team,
|
||||
};
|
||||
}, [object, parent]);
|
||||
}, [liveObject, parent]);
|
||||
|
||||
return (
|
||||
<SimGroupContext.Provider value={simGroup}>
|
||||
{(object._children ?? []).map((child, i) => (
|
||||
<SimObject object={child} key={child._id} />
|
||||
{childIds.map((childId) => (
|
||||
<SimObject objectId={childId} key={childId} />
|
||||
))}
|
||||
</SimGroupContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Camera } from "./Camera";
|
|||
import { useSettings } from "./SettingsProvider";
|
||||
import { useMission } from "./MissionContext";
|
||||
import { getProperty } from "../mission";
|
||||
import { useEngineSelector, useRuntimeObjectById } from "../state";
|
||||
|
||||
const AudioEmitter = lazy(() =>
|
||||
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })),
|
||||
|
|
@ -51,27 +52,59 @@ const componentMap = {
|
|||
WayPoint,
|
||||
};
|
||||
|
||||
export function SimObject({ object }: { object: TorqueObject }) {
|
||||
/**
|
||||
* During demo playback, these mission-authored classes are rendered from demo
|
||||
* ghosts instead of the mission runtime scene tree.
|
||||
*/
|
||||
const demoGhostAuthoritativeClasses = new Set([
|
||||
"ForceFieldBare",
|
||||
"Item",
|
||||
"StaticShape",
|
||||
"Turret",
|
||||
]);
|
||||
|
||||
interface SimObjectProps {
|
||||
object?: TorqueObject;
|
||||
objectId?: number;
|
||||
}
|
||||
|
||||
export function SimObject({ object, objectId }: SimObjectProps) {
|
||||
const liveObject = useRuntimeObjectById(objectId ?? object?._id);
|
||||
const resolvedObject = liveObject ?? object;
|
||||
const { missionType } = useMission();
|
||||
const isDemoPlaybackActive = useEngineSelector(
|
||||
(state) => state.playback.recording != null,
|
||||
);
|
||||
|
||||
// FIXME: In theory we could make sure TorqueScript is calling `hide()`
|
||||
// based on the mission type already, which is built-in behavior, then just
|
||||
// make sure we respect the hidden/visible state here. For now do it this way.
|
||||
const shouldShowObject = useMemo(() => {
|
||||
if (!resolvedObject) {
|
||||
return false;
|
||||
}
|
||||
const missionTypesList = new Set(
|
||||
(getProperty(object, "missionTypesList") ?? "")
|
||||
(getProperty(resolvedObject, "missionTypesList") ?? "")
|
||||
.toLowerCase()
|
||||
.split(/s+/)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (
|
||||
!missionTypesList.size || missionTypesList.has(missionType.toLowerCase())
|
||||
);
|
||||
}, [object, missionType]);
|
||||
}, [resolvedObject, missionType]);
|
||||
|
||||
const Component = componentMap[object._className];
|
||||
if (!resolvedObject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = componentMap[resolvedObject._className];
|
||||
const isSuppressedByDemoAuthority =
|
||||
isDemoPlaybackActive &&
|
||||
demoGhostAuthoritativeClasses.has(resolvedObject._className);
|
||||
return shouldShowObject && Component ? (
|
||||
<Suspense>
|
||||
<Component object={object} />
|
||||
{!isSuppressedByDemoAuthority && <Component object={resolvedObject} />}
|
||||
</Suspense>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import type { TorqueObject } from "../torqueScript";
|
||||
import { useRuntime } from "./RuntimeProvider";
|
||||
import { useDatablockByName } from "../state";
|
||||
|
||||
/**
|
||||
* Look up a datablock by name from the runtime. Use with getProperty/getInt/getFloat.
|
||||
*
|
||||
* FIXME: This is not currently reactive! If new datablocks are defined, this
|
||||
* won't find them. We'd need to add an event/subscription system to the runtime
|
||||
* that fires when new datablocks are defined. Technically we should do the same
|
||||
* for the scene graph.
|
||||
*/
|
||||
/** Look up a datablock by name from runtime state (reactive). */
|
||||
export function useDatablock(
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
const runtime = useRuntime();
|
||||
if (!name) return undefined;
|
||||
return runtime.state.datablocks.get(name);
|
||||
return useDatablockByName(name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import type { TorqueObject } from "../torqueScript";
|
||||
import { useRuntime } from "./RuntimeProvider";
|
||||
import { useRuntimeObjectByName } from "../state";
|
||||
|
||||
/**
|
||||
* Look up a scene object by name from the runtime.
|
||||
*
|
||||
* FIXME: This is not currently reactive! If the object is created after
|
||||
* this hook runs, it won't be found. We'd need to add an event/subscription
|
||||
* system to the runtime that fires when objects are created.
|
||||
*/
|
||||
export function useSceneObject(name: string): TorqueObject | undefined {
|
||||
const runtime = useRuntime();
|
||||
return runtime.getObjectByName(name);
|
||||
return useRuntimeObjectByName(name);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue