simplify and unify entity management

This commit is contained in:
Brian Beck 2026-04-01 14:35:47 -07:00
parent dbff4e9fe3
commit b6975da8ed
135 changed files with 2535 additions and 1545 deletions

View file

@ -10,11 +10,17 @@ import {
} from "three";
import { createLogger } from "../logger";
import { audioToUrl } from "../loaders";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugMarker } from "./DebugBounds";
import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel";
import { engineStore } from "../state/engineStore";
import { AudioEmitterEntity } from "../state/gameEntityTypes";
import {
getAdjustAudioSpeed,
onAdjustAudioSpeedChange,
} from "./audioPlaybackRate";
const log = createLogger("AudioEmitter");
@ -26,21 +32,17 @@ export const audioBufferCache = new Map<string, AudioBuffer>();
// intrinsic pitch (1.0 for normal, or the voice pitch multiplier for chat).
const _activeSounds = new Map<Audio<GainNode | PannerNode>, number>();
/** Whether to adjust audio pitch to match playback speed. When false, sounds
* play at their original pitch regardless of fast-forward/slow-motion. */
let _adjustAudioSpeed = true;
export function setAdjustAudioSpeedFlag(value: boolean): void {
_adjustAudioSpeed = value;
// Re-apply rate to all active sounds immediately.
// Re-apply playback rate to all active sounds when the flag changes.
onAdjustAudioSpeedChange((value) => {
const rate = engineStore.getState().playback.rate;
for (const [sound, basePitch] of _activeSounds) {
try {
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
sound.setPlaybackRate(basePitch * (value ? rate : 1));
} catch {
/* disposed */
}
}
}
});
/** Register a sound for automatic playback rate tracking during streaming. */
export function trackSound(
@ -66,11 +68,8 @@ export function getSoundGeneration(): number {
return _soundGeneration;
}
/** Get the effective playback rate for a sound, respecting the adjustAudioSpeed setting. */
export function getEffectiveSoundRate(basePitch = 1): number {
const rate = engineStore.getState().playback.rate;
return basePitch * (_adjustAudioSpeed ? rate : 1);
}
// Re-export for convenience (canonical definition in audioPlaybackRate.ts).
export { getEffectiveSoundRate } from "./audioPlaybackRate";
/** Stop and unregister all tracked sounds. Called on recording change. */
export function stopAllTrackedSounds(): void {
@ -95,7 +94,7 @@ engineStore.subscribe(
(rate) => {
for (const [sound, basePitch] of _activeSounds) {
try {
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
sound.setPlaybackRate(basePitch * (getAdjustAudioSpeed() ? rate : 1));
} catch {
// Sound may have been disposed.
}
@ -173,14 +172,14 @@ export function playOneShotSound(
sound.setMaxDistance(resolved.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
sound.setPlaybackRate(getAdjustAudioSpeed() ? rate : 1);
if (position) {
sound.position.copy(position);
}
parent.add(sound);
_activeSounds.set(sound, 1);
sound.play();
sound.source!.onended = () => {
(sound.source as AudioBufferSourceNode).onended = () => {
_activeSounds.delete(sound);
try {
sound.disconnect();
@ -193,10 +192,10 @@ export function playOneShotSound(
const sound = new Audio(audioListener);
sound.setBuffer(buffer);
sound.setVolume(resolved.volume);
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
sound.setPlaybackRate(getAdjustAudioSpeed() ? rate : 1);
_activeSounds.set(sound, 1);
sound.play();
sound.source!.onended = () => {
(sound.source as AudioBufferSourceNode).onended = () => {
_activeSounds.delete(sound);
try {
sound.disconnect();
@ -338,7 +337,7 @@ export const AudioEmitter = memo(function AudioEmitter({
const gap =
gapMin === gapMax ? gapMin : randomValue * (gapMax - gapMin) + gapMin;
sound.loop = false;
(sound as any).loop = false;
const checkLoop = () => {
// Discard callbacks from a previous sound generation.
@ -446,20 +445,27 @@ export const AudioEmitter = memo(function AudioEmitter({
}
}, [audioEnabled]);
return debugMode ? (
// eslint-disable-next-line react-hooks/refs
<mesh position={emitterPosRef.current}>
<sphereGeometry args={[minDistance, 12, 12]} />
<meshBasicMaterial
color="#00ff00"
wireframe
opacity={0.05}
transparent
toneMapped={false}
/>
<FloatingLabel color="#00ff00" position={[0, minDistance + 1, 0]}>
{fileName}
</FloatingLabel>
</mesh>
) : null;
const isTarget = useIsDebugTourTarget(entity.id);
return (
<>
{debugMode && (
// eslint-disable-next-line react-hooks/refs
<mesh position={emitterPosRef.current}>
<sphereGeometry args={[minDistance, 12, 12]} />
<meshBasicMaterial
color="#00ff00"
wireframe
opacity={0.05}
transparent
toneMapped={false}
/>
<FloatingLabel color="#00ff00" position={[0, minDistance + 1, 0]}>
{fileName}
</FloatingLabel>
</mesh>
)}
{isTarget && <DebugMarker radius={1.5} />}
</>
);
});

View file

@ -2,6 +2,8 @@ import { useEffect, useId, useMemo } from "react";
import { Quaternion, Vector3 } from "three";
import { useCameras } from "./CamerasProvider";
import type { CameraEntity } from "../state/gameEntityTypes";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugMarker } from "./DebugBounds";
export function Camera({ entity }: { entity: CameraEntity }) {
const { registerCamera, unregisterCamera } = useCameras();
@ -37,5 +39,6 @@ export function Camera({ entity }: { entity: CameraEntity }) {
// but clone a new "flying" camera when the user moves. The other is to not
// have multiple cameras at all, but rather update the default camera with
// new position information when cycling. This uses the latter approach.
return null;
const isTarget = useIsDebugTourTarget(entity.id);
return isTarget ? <DebugMarker radius={1.5} /> : null;
}

View file

@ -14,6 +14,7 @@ import {
import { cameraTourStore } from "../state/cameraTourStore";
import type { TourAnimation } from "../state/cameraTourStore";
import type { TourTarget } from "./mapTourCategories";
import { globalFogUniforms } from "../globalFogUniforms";
import { createLogger } from "../logger";
const log = createLogger("CameraTourConsumer");
@ -23,8 +24,17 @@ function easeInOutCubic(t: number): number {
}
const FALLBACK_ORBIT_RADIUS = 3;
/** Orbit radius for entities without geometry (WayPoints, markers, etc.). */
const POINT_ORBIT_RADIUS = 10;
const DEFAULT_ORBIT_HEIGHT = 2;
const MIN_ORBIT_RADIUS = 1.8;
/** Orbit radius above which fog starts reducing during tours. */
const FOG_REDUCE_THRESHOLD = 50;
/** Divisor for fog scale: fogDistanceScale = orbitRadius / FOG_REDUCE_DIVISOR.
* Smaller values = more aggressive fog reduction. */
const FOG_REDUCE_DIVISOR = 200;
/** Speed at which fogDistanceScale animates (per second). */
const FOG_SCALE_SPEED = 2;
/** Extra orbit radius added beyond half the object's height. */
const ORBIT_PAD_VERTICAL = 1.8;
/** Extra orbit radius added beyond half the object's spread (width/length). */
@ -102,7 +112,21 @@ function resolveTargetBounds(
animation: TourAnimation,
): void {
const obj = scene.getObjectByName(target.entityId);
// Check if the scene object has any geometry to compute bounds from.
let hasGeometry = false;
if (obj) {
obj.traverse((child: any) => {
if (child.geometry) hasGeometry = true;
});
}
if (obj && !hasGeometry) {
// Entity exists but has no geometry (WayPoints, markers, etc.).
// Use target position directly with a wider orbit.
animation.orbitCenter = [...target.position];
animation.orbitRadius = POINT_ORBIT_RADIUS;
return;
}
if (obj && hasGeometry) {
// World-space AABB center for the orbit focus (where the camera looks).
_box.setFromObject(obj);
_box.getCenter(_center);
@ -130,7 +154,28 @@ function resolveTargetBounds(
const fromHeight = height / 2 + ORBIT_PAD_VERTICAL;
const fromSpread = spread / 2 + ORBIT_PAD_HORIZONTAL;
const computed = Math.max(fromHeight, fromSpread);
animation.orbitRadius = Math.max(MIN_ORBIT_RADIUS, computed);
// For very large objects (terrain, water), use target position as orbit
// center and compute a stable radius from the single-tile geometry bounds
// (setFromObject includes dynamic instanced tiles that vary with camera).
const isLarge = computed > 200;
if (isLarge) {
animation.orbitCenter = [...target.position];
// Use only the first geometry child's bounds for stable sizing.
let stableSpread = 0;
obj.traverse((child: any) => {
if (stableSpread > 0 || !child.geometry) return;
if (!child.geometry.boundingBox) child.geometry.computeBoundingBox();
const bb = child.geometry.boundingBox;
const sx = bb.max.x - bb.min.x;
const sy = bb.max.y - bb.min.y;
const sz = bb.max.z - bb.min.z;
stableSpread = Math.max(sx, sy, sz);
});
const stableRadius = (stableSpread / 2 + ORBIT_PAD_HORIZONTAL) * 0.75;
animation.orbitRadius = Math.max(MIN_ORBIT_RADIUS, stableRadius);
} else {
animation.orbitRadius = Math.max(MIN_ORBIT_RADIUS, computed);
}
const driver = fromHeight >= fromSpread ? "height" : "spread";
const clamp = computed < MIN_ORBIT_RADIUS ? " (clamped)" : "";
log.debug(
@ -189,8 +234,14 @@ function buildCurve(
// Midpoint: halfway between start and entry, elevated proportionally.
const mid = new Vector3().addVectors(startPos, entry).multiplyScalar(0.5);
// Pull midpoint toward the target and elevate.
mid.lerp(focus, 0.3);
// Pull midpoint toward the focus — but only if it won't create a dip
// closer than the orbit entry. For large orbits where the camera is
// already far out, pulling toward focus causes an unnecessary zoom-in.
const midDistToFocus = mid.distanceTo(focus);
const entryDistToFocus = entry.distanceTo(focus);
if (midDistToFocus > entryDistToFocus) {
mid.lerp(focus, 0.3);
}
mid.y += distance * 0.15;
return new CatmullRomCurve3(
@ -333,7 +384,6 @@ function advanceOrbit(
}
export function CameraTourConsumer() {
const invalidate = useThree((s) => s.invalidate);
const camera = useThree((s) => s.camera);
const scene = useThree((s) => s.scene);
const prevAnimationRef = useRef<TourAnimation | null>(null);
@ -357,6 +407,31 @@ export function CameraTourConsumer() {
useFrame((_state, delta) => {
const animation = cameraTourStore.getState().animation;
// Reduce fog when orbiting far targets. fogDistanceScale divides all
// distance-based fog calculations (both haze and volumetric volumes),
// making geometry appear closer to the camera for fog purposes.
const orbitR = animation ? getOrbitRadius(animation) : 0;
const targetScale =
animation && orbitR >= FOG_REDUCE_THRESHOLD
? orbitR / FOG_REDUCE_DIVISOR
: 1;
const currentScale = globalFogUniforms.fogDistanceScale.value;
if (currentScale !== targetScale) {
const step = FOG_SCALE_SPEED * delta;
if (targetScale > currentScale) {
globalFogUniforms.fogDistanceScale.value = Math.min(
currentScale + step,
targetScale,
);
} else {
globalFogUniforms.fogDistanceScale.value = Math.max(
currentScale - step,
targetScale,
);
}
}
if (!animation) {
if (prevAnimationRef.current) {
stripRoll(camera.quaternion);
@ -365,7 +440,6 @@ export function CameraTourConsumer() {
return;
}
invalidate();
prevAnimationRef.current = animation;
if (animation.phase === "traveling") {

View file

@ -10,7 +10,7 @@ import {
untrackSound,
} from "./AudioEmitter";
import { useSettings } from "./SettingsProvider";
import { engineStore, useEngineSelector } from "../state/engineStore";
import { useEngineSelector } from "../state/engineStore";
import type { ChatMessage } from "../stream/types";
/**
@ -52,7 +52,6 @@ export function ChatSoundPlayer() {
try {
const url = audioToUrl(msg.soundPath);
const pitch = msg.soundPitch ?? 1;
const rate = engineStore.getState().playback.rate;
const sender = msg.sender;
const gen = getSoundGeneration();
getCachedAudioBuffer(url, audioLoader, (buffer) => {
@ -84,7 +83,7 @@ export function ChatSoundPlayer() {
}
sound.play();
// Clean up the source node once playback finishes.
sound.source!.onended = () => {
(sound.source as AudioBufferSourceNode).onended = () => {
untrackSound(sound);
try {
sound.disconnect();

View file

@ -0,0 +1,38 @@
import { useMemo } from "react";
import { BoxGeometry, EdgesGeometry, SphereGeometry } from "three";
const debugMaterial = (
<lineBasicMaterial
color="#ff0000"
depthTest={false}
depthWrite={false}
fog={false}
transparent
/>
);
/** Red wireframe bounding box for debug tour visualization. */
export function DebugBounds({ size }: { size: [number, number, number] }) {
const edges = useMemo(
() => new EdgesGeometry(new BoxGeometry(...size)),
[size[0], size[1], size[2]], // eslint-disable-line react-hooks/exhaustive-deps
);
return (
<lineSegments geometry={edges} renderOrder={9999}>
{debugMaterial}
</lineSegments>
);
}
/** Red wireframe sphere for point entities without geometry. */
export function DebugMarker({ radius = 1 }: { radius?: number }) {
const edges = useMemo(
() => new EdgesGeometry(new SphereGeometry(radius, 8, 6)),
[radius],
);
return (
<lineSegments geometry={edges} renderOrder={9999}>
{debugMaterial}
</lineSegments>
);
}

View file

@ -1,53 +1,8 @@
import { Stats, Html } from "@react-three/drei";
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import {
AxesHelper,
Color,
MeshLambertMaterial,
SphereGeometry,
Vector3,
} from "three";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting, injectShapeEnvMap } from "../shapeMaterial";
import { useEffect, useRef } from "react";
import { AxesHelper } from "three";
import styles from "./DebugElements.module.css";
const debugSphereGeo = new SphereGeometry(10, 64, 64);
/**
* A chrome sphere that visualizes the env map. Placed 30 units in front of
* the camera so you can always see it.
*/
function DebugEnvMapSphere() {
const meshRef = useRef<THREE.Mesh>(null);
const material = useMemo(() => {
const mat = new MeshLambertMaterial({
color: new Color(0.5, 0.5, 0.5),
});
mat.customProgramCacheKey = () => "debug-envmap-sphere";
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
injectShapeEnvMap(shader, 1.0);
};
return mat;
}, []);
const _dir = useMemo(() => new Vector3(), []);
// Follow the camera: position 80 units in front.
useFrame(({ camera }) => {
const mesh = meshRef.current;
if (!mesh) return;
camera.getWorldDirection(_dir);
mesh.position.copy(camera.position).addScaledVector(_dir, 80);
});
return <mesh ref={meshRef} geometry={debugSphereGeo} material={material} />;
}
export function DebugElements() {
const axesRef = useRef<AxesHelper>(null);
@ -85,7 +40,6 @@ export function DebugElements() {
X
</span>
</Html>
<DebugEnvMapSphere />
</>
);
}

View file

@ -0,0 +1,135 @@
.Container {
}
.Title {
font-size: 13px;
font-weight: bold;
margin: 16px 10px 8px 10px;
}
.Header {
font-size: 12px;
opacity: 0.6;
padding: 2px 0;
}
.Group {
margin: 0;
}
.GroupHeader {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
padding: 6px 8px;
cursor: pointer;
user-select: none;
list-style: none;
}
.GroupHeader::-webkit-details-marker {
display: none;
}
.GroupHeader::before {
content: "[+] ";
font-family: monospace;
color: rgba(255, 255, 255, 0.4);
}
.Group[open] > .GroupHeader::before {
content: "[-] ";
}
.GroupCount {
font-weight: normal;
opacity: 0.5;
}
.List {
margin: 4px 0 8px 0;
padding: 0;
list-style: none;
background: rgba(0, 0, 0, 0.2);
overflow: hidden;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.ListItem {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.EntityRow {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
}
.EntityRow input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
.EntityInfo {
flex: 1 1 auto;
min-width: 0;
}
.LocateButton {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 0;
margin: 0;
padding: 8px 9px 8px 7px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 3px;
}
.LocateButton:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.1);
}
.LocateButton:disabled {
opacity: 0.25;
cursor: default;
}
.EntityRow[data-disabled] {
opacity: 0.4;
}
.ListItem:not(:last-child) {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.Type {
font-weight: bold;
font-size: 12px;
color: #fff;
}
.ID {
background: rgba(159, 166, 169, 0.2);
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
margin: 0 0 0 4px;
padding: 3px 5px;
border-radius: 3px;
}
.Detail {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin: 4px 0 0 0;
}

View file

@ -0,0 +1,169 @@
import { memo, useMemo, useCallback } from "react";
import { FaLocationArrow } from "react-icons/fa6";
import { useGameEntities } from "../state/gameEntityStore";
import { cameraTourStore } from "../state/cameraTourStore";
import type { GameEntity } from "../state/gameEntityTypes";
import { torqueToThree } from "../scene/coordinates";
import { gameEntityStore, useDebugHidden } from "../state/gameEntityStore";
import { useSettings } from "./SettingsProvider";
import styles from "./DebugEntityList.module.css";
function getEntityLabel(entity: GameEntity): string {
if (entity.renderType === "Player" && "playerName" in entity) {
return entity.playerName ?? entity.className;
}
if (entity.renderType === "WayPoint" && "label" in entity) {
return entity.label ?? entity.className;
}
return entity.className;
}
function getEntityDetail(entity: GameEntity): string | undefined {
if ("shapeName" in entity && entity.shapeName) return entity.shapeName;
if (entity.renderType === "InteriorInstance" && "interiorData" in entity) {
return entity.interiorData.interiorFile;
}
if ("dataBlock" in entity && entity.dataBlock) return entity.dataBlock;
if (entity.renderType === "TerrainBlock" && "terrainData" in entity)
return entity.terrainData.terrFileName;
if (entity.renderType === "WaterBlock" && "waterData" in entity)
return entity.waterData.surfaceName;
if ("audioFileName" in entity && entity.audioFileName)
return entity.audioFileName;
return undefined;
}
function getEntityPosition(
entity: GameEntity,
): [number, number, number] | undefined {
if ("position" in entity && entity.position) return entity.position;
// Scene entities store position inside their typed data.
if (entity.renderType === "InteriorInstance" && "interiorData" in entity) {
return torqueToThree(entity.interiorData.transform.position);
}
if (entity.renderType === "WaterBlock" && "waterData" in entity) {
const pos = torqueToThree(entity.waterData.transform.position);
const scale = entity.waterData.scale;
// Water transform position is the corner; offset to center.
return [pos[0] + scale.y / 2, pos[1] + scale.z / 2, pos[2] + scale.x / 2];
}
if (entity.renderType === "TerrainBlock") {
return [0, 0, 0];
}
return undefined;
}
function EntityRow({ entity }: { entity: GameEntity }) {
const pos = getEntityPosition(entity);
const detail = getEntityDetail(entity);
const label = getEntityLabel(entity);
const hidden = useDebugHidden(entity.id);
const { audioEnabled } = useSettings();
const isAudioEmitter = entity.renderType === "AudioEmitter";
const disabled = isAudioEmitter && !audioEnabled;
const canLocate = !!pos && !hidden && !disabled;
const handleClick = useCallback(() => {
if (!pos) return;
cameraTourStore
.getState()
.flyTo({ entityId: entity.id, label, position: pos }, "debug");
}, [entity.id, label, pos]);
const handleToggle = useCallback(() => {
const store = gameEntityStore.getState();
const entities = store.isStreaming
? store.streamEntities
: store.missionEntities;
const e = entities.get(entity.id);
if (e) {
// New object reference so useAllGameEntities' identity check detects the change.
entities.set(entity.id, { ...e, debugHidden: !e.debugHidden });
gameEntityStore.setState({ version: store.version + 1 });
}
}, [entity.id]);
return (
<div className={styles.EntityRow} data-disabled={disabled || undefined}>
<input
type="checkbox"
checked={!hidden}
onChange={handleToggle}
disabled={disabled}
title={hidden ? "Show entity" : "Hide entity"}
/>
<div className={styles.EntityInfo}>
<div>
<span className={styles.Type}>{label}</span>{" "}
<span className={styles.ID}>{entity.id}</span>
</div>
{detail && <span className={styles.Detail}>{detail}</span>}
</div>
{pos && (
<button
type="button"
className={styles.LocateButton}
onClick={handleClick}
disabled={!canLocate}
title={`Fly to ${label}`}
>
<FaLocationArrow />
</button>
)}
</div>
);
}
export const DebugEntityList = memo(function DebugEntityList() {
const entities = useGameEntities();
// useGameEntities re-renders on version bump, but the Map reference is
// stable (mutated in place). Use version as a useMemo dep to recompute.
const version = gameEntityStore.getState().version;
const grouped = useMemo(() => {
const groups = new Map<string, GameEntity[]>();
for (const entity of entities.values()) {
if (
entity.renderType === "Sky" ||
entity.renderType === "Sun" ||
entity.renderType === "MissionArea" ||
entity.renderType === "None"
)
continue;
const key = entity.className;
let list = groups.get(key);
if (!list) {
list = [];
groups.set(key, list);
}
list.push(entity);
}
// Sort groups by class name, entities within each group by ID.
const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
for (const [, list] of sorted) {
list.sort((a, b) => a.id.localeCompare(b.id));
}
return sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entities, version]);
return (
<div className={styles.Container}>
<h4 className={styles.Title}>Entity list</h4>
{grouped.map(([className, list]) => (
<details key={className} className={styles.Group}>
<summary className={styles.GroupHeader}>
{className}{" "}
<span className={styles.GroupCount}>({list.length})</span>
</summary>
<ul className={styles.List}>
{list.map((entity) => (
<li key={entity.id} className={styles.ListItem}>
<EntityRow entity={entity} />
</li>
))}
</ul>
</details>
))}
</div>
);
});

View file

@ -101,9 +101,9 @@ export const EntityRenderer = memo(function EntityRenderer({
case "WayPoint":
return <WayPoint entity={entity} />;
case "TerrainBlock":
return <TerrainBlock scene={entity.terrainData} />;
return <TerrainBlock entity={entity} />;
case "InteriorInstance":
return <InteriorInstance scene={entity.interiorData} />;
return <InteriorInstance entity={entity} />;
case "Sky":
return <Sky entity={entity} />;
case "Sun":
@ -164,6 +164,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
loadingColor={loadingColor}
streamEntity={torqueObject ? undefined : entity}
emap={entity.emap}
entityId={entity.id}
>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>

View file

@ -1,4 +1,4 @@
import { memo, useCallback, useRef, useState, useMemo, Suspense } from "react";
import { memo, useCallback, useRef, useState, useMemo } from "react";
import { Quaternion } from "three";
import type { Group } from "three";
import { useFrame } from "@react-three/fiber";
@ -72,6 +72,8 @@ const EntityWrapper = memo(function EntityWrapper({
}: {
entity: GameEntity;
}) {
if (entity.debugHidden) return null;
// Scene infrastructure handles its own positioning and Suspense — render
// directly. The named group allows the interpolation loop to skip them.
if (isSceneEntity(entity)) {

View file

@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { DebugSuspense } from "./DebugSuspense";
import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
@ -133,6 +135,7 @@ function ForceFieldMesh({
export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) {
const data = entity.forceFieldData!;
const scale = data.dimensions;
const isTarget = useIsDebugTourTarget(entity.id);
const textureUrls = useMemo(
() => data.textures.map((t) => textureToUrl(t)),
@ -150,17 +153,20 @@ export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) {
}
return (
<DebugSuspense
name={`ForceField`}
fallback={
<ForceFieldFallback
scale={scale}
color={data.color}
baseTranslucency={data.baseTranslucency}
/>
}
>
<ForceFieldMesh scale={scale} data={data} />
</DebugSuspense>
<>
<DebugSuspense
name={`ForceField`}
fallback={
<ForceFieldFallback
scale={scale}
color={data.color}
baseTranslucency={data.baseTranslucency}
/>
}
>
<ForceFieldMesh scale={scale} data={data} />
</DebugSuspense>
{isTarget && scale && <DebugBounds size={scale} />}
</>
);
}

View file

@ -1,24 +1,22 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import type { AnimationAction, Material } from "three";
import type { AnimationAction } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import {
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
AdditiveBlending,
AdditiveAnimationBlendMode,
AnimationMixer,
AnimationClip,
AnimationUtils,
LoopOnce,
LoopRepeat,
Texture,
BufferGeometry,
Group,
Box3,
Vector3,
} from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
@ -38,9 +36,11 @@ import {
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting, injectShapeEnvMap } from "../shapeMaterial";
import { createMaterialFromFlags } from "../shapeMaterial";
import type { MaterialResult } from "../shapeMaterial";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { useEntitySoundSlots } from "./useEntitySoundSlots";
import {
processShapeScene,
replaceWithShapeMaterial,
@ -51,6 +51,7 @@ import type { ThreadState as StreamThreadState } from "../stream/types";
/** Shape entity data readable in useFrame for streaming mode. */
interface StreamShapeEntity {
id: string;
threads?: StreamThreadState[];
damageState?: number;
emap?: boolean;
@ -62,6 +63,8 @@ interface StreamShapeEntity {
steeringYaw?: number;
frozen?: boolean;
maxSteeringAngle?: number;
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
dataBlockId?: number;
}
const log = createLogger("GenericShape");
@ -94,147 +97,6 @@ interface TextureProps {
animated?: boolean;
}
/**
* DTS Material Flags (from tsShape.h):
* - Translucent: Material has alpha transparency (smooth blending)
* - Additive: Additive blending mode
* - Subtractive: Subtractive blending mode
* - SelfIlluminating: Fullbright, no lighting applied
* - NeverEnvMap: Don't apply environment mapping
*/
type SingleMaterial =
| MeshStandardMaterial
| MeshBasicMaterial
| MeshLambertMaterial;
type MaterialResult =
| SingleMaterial
| [MeshLambertMaterial, MeshLambertMaterial];
// Stable onBeforeCompile callbacks — using shared function references lets
// Three.js's program cache match by identity rather than toString().
const lambertBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
};
const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
};
/**
* Helper to apply volumetric fog and lighting multipliers to a material.
* When envMapOptions is provided, also injects Tribes 2 env map reflectivity.
*/
export function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
envMapOptions?: { reflectionAmount: number },
): void {
if (!envMapOptions) {
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
return;
}
const matType = mat instanceof MeshLambertMaterial ? "lambert" : "basic";
mat.customProgramCacheKey = () => `shape-envmap-${matType}`;
const { reflectionAmount } = envMapOptions;
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
injectShapeEnvMap(shader, reflectionAmount);
};
}
export function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture | null,
flagNames: Set<string>,
isOrganic: boolean,
vis: number = 1,
animated: boolean = false,
reflectionAmount: number = 0,
): 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;
// Env map reflectivity: gated only by NeverEnvMap flag and reflectionAmount
// (which is 0 when the datablock doesn't have emap=true). Independent of
// SelfIlluminating/Additive — those affect lighting, not env mapping.
const enableEnvMap =
!flagNames.has("NeverEnvMap") && reflectionAmount > 0 && !isFaded;
const envMapOptions = enableEnvMap ? { reflectionAmount } : undefined;
// SelfIlluminating or Additive materials are unlit (use MeshBasicMaterial).
// Additive materials without SelfIlluminating (e.g. explosion shells) must
// also be unlit, otherwise they render black with no scene lighting.
if (isSelfIlluminating || isAdditive) {
const isBlended = isAdditive || isTranslucent || isFaded;
const mat = new MeshBasicMaterial({
map: texture,
side: 2, // DoubleSide
transparent: isBlended,
depthWrite: !isBlended,
alphaTest: 0,
fog: true,
...(isFaded && { opacity: vis }),
...(isAdditive && { blending: AdditiveBlending }),
});
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}
// For organic shapes or Translucent flag, use alpha cutout with Lambert shading
// Tribes 2 used fixed-function GL with specular disabled - purely diffuse lighting
// MeshLambertMaterial gives us the diffuse-only look that matches the original
// Return [BackSide, FrontSide] materials to render in two passes - avoids z-fighting
if (isOrganic || isTranslucent) {
const baseProps = {
map: texture,
// 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({
...baseProps,
side: 1, // BackSide
// Push back faces slightly behind in depth to avoid z-fighting with front
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
});
const frontMat = new MeshLambertMaterial({
...baseProps,
side: 0, // FrontSide
});
applyShapeShaderModifications(backMat, envMapOptions);
applyShapeShaderModifications(frontMat, envMapOptions);
return [backMat, frontMat];
}
// Default: use Lambert for diffuse-only lighting (matches Tribes 2)
const mat = new MeshLambertMaterial({
map: texture,
side: 2, // DoubleSide
reflectivity: 0,
...(isFaded && {
transparent: true,
opacity: vis,
depthWrite: false,
}),
});
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
@ -505,6 +367,7 @@ export const ShapeRenderer = memo(function ShapeRenderer({
loadingColor = "yellow",
streamEntity,
emap,
entityId,
children,
}: {
loadingColor?: string;
@ -512,6 +375,7 @@ export const ShapeRenderer = memo(function ShapeRenderer({
streamEntity?: StreamShapeEntity;
/** Datablock enables environment map reflections. */
emap?: boolean;
entityId?: string;
children?: React.ReactNode;
}) {
const { object, shapeName } = useShapeInfo();
@ -532,7 +396,11 @@ export const ShapeRenderer = memo(function ShapeRenderer({
}}
>
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
<ShapeModelLoader streamEntity={streamEntity} emap={emap} />
<ShapeModelLoader
streamEntity={streamEntity}
emap={emap}
entityId={entityId}
/>
{children}
</Suspense>
</ErrorBoundary>
@ -565,12 +433,14 @@ export const ShapeModel = memo(function ShapeModel({
gltf,
streamEntity,
emap,
entityId,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose fields are mutated in-place. */
streamEntity?: StreamShapeEntity;
/** Datablock enables environment map reflections. */
emap?: boolean;
entityId?: string;
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
@ -1270,6 +1140,23 @@ export const ShapeModel = memo(function ShapeModel({
}
});
// ShapeBase sound slots — managed as PositionalAudio, not entities.
useEntitySoundSlots(streamEntityRef, clonedScene);
const isTarget = useIsDebugTourTarget(entityId ?? "");
const shapeBounds = useMemo(() => {
if (!isTarget) return null;
const box = new Box3().setFromObject(gltf.scene);
const center = new Vector3();
const size = new Vector3();
box.getCenter(center);
box.getSize(size);
return {
center: [center.x, center.y, center.z] as [number, number, number],
size: [size.x, size.y, size.z] as [number, number, number],
};
}, [isTarget, gltf.scene]);
return (
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
@ -1278,6 +1165,11 @@ export const ShapeModel = memo(function ShapeModel({
{object?._id}: {shapeName}
</FloatingLabel>
) : null}
{shapeBounds && (
<group position={shapeBounds.center}>
<DebugBounds size={shapeBounds.size} />
</group>
)}
</group>
);
});
@ -1285,11 +1177,20 @@ export const ShapeModel = memo(function ShapeModel({
function ShapeModelLoader({
streamEntity,
emap,
entityId,
}: {
streamEntity?: StreamShapeEntity;
emap?: boolean;
entityId?: string;
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
return <ShapeModel gltf={gltf} streamEntity={streamEntity} emap={emap} />;
return (
<ShapeModel
gltf={gltf}
streamEntity={streamEntity}
emap={emap}
entityId={entityId}
/>
);
}

View file

@ -8,6 +8,7 @@ import {
} from "../state/liveConnectionStore";
import { useEngineStoreApi } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { gameEntityStore } from "../state/gameEntityStore";
import { useInputContext } from "./InputContext";
import { useTick, useGetTickFraction } from "./TickProvider";
import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers";
@ -748,7 +749,7 @@ function applyOrbitCamera(
// Height offset: approximate getWorldBox().getCenter() for players.
const isPlayer =
orbitTargetId != null &&
streamPlaybackStore.getState().entities.get(orbitTargetId)?.renderType ===
gameEntityStore.getState().streamEntities.get(orbitTargetId)?.renderType ===
"Player";
const centerZ = tz + (isPlayer ? 1.0 : 0);

View file

@ -21,6 +21,7 @@ import { hasMission } from "../manifest";
import { ChooseMapButton } from "./ChooseMapButton";
import { MapInfoButton } from "./MapInfoButton";
import { ShowScoresButton } from "./ShowScoresButton";
import { DebugEntityList } from "./DebugEntityList";
import buttonStyles from "./Button.module.css";
import styles from "./InspectorControls.module.css";
@ -419,6 +420,9 @@ export const InspectorControls = memo(function InspectorControls({
setFpsLimit(val === "" ? null : parseInt(val));
}}
>
{import.meta.env.DEV ? (
<option value="1">1</option>
) : null}
<option value="30">30</option>
<option value="60">60</option>
<option value="120">120</option>
@ -472,6 +476,7 @@ export const InspectorControls = memo(function InspectorControls({
app unrelated to rendering.
</p>
</div>
<DebugEntityList />
</Accordion>
</AccordionGroup>
</div>

View file

@ -10,10 +10,14 @@ import {
MeshLambertMaterial,
Texture,
SRGBColorSpace,
Box3,
Vector3,
} from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { textureToUrl, interiorToUrl } from "../loaders";
import type { SceneInteriorInstance } from "../scene/types";
import type { InteriorInstanceEntity } from "../state/gameEntityTypes";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import {
torqueToThree,
torqueScaleToThree,
@ -192,14 +196,30 @@ function InteriorMesh({ node }: { node: Mesh }) {
export const InteriorModel = memo(function InteriorModel({
interiorFile,
ghostIndex,
isTarget,
}: {
interiorFile: string;
ghostIndex?: number;
isTarget?: boolean;
}) {
const { nodes } = useInterior(interiorFile);
const gltf = useInterior(interiorFile);
const { nodes } = gltf;
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
const debugBounds = useMemo(() => {
if (!isTarget) return null;
const box = new Box3().setFromObject(gltf.scene);
const center = new Vector3();
const size = new Vector3();
box.getCenter(center);
box.getSize(size);
return {
center: [center.x, center.y, center.z] as [number, number, number],
size: [size.x, size.y, size.z] as [number, number, number],
};
}, [isTarget, gltf.scene]);
return (
<group rotation={[0, -Math.PI / 2, 0]}>
{Object.entries(nodes)
@ -212,6 +232,11 @@ export const InteriorModel = memo(function InteriorModel({
{ghostIndex}: {interiorFile}
</FloatingLabel>
) : null}
{debugBounds && (
<group position={debugBounds.center}>
<DebugBounds size={debugBounds.size} />
</group>
)}
</group>
);
});
@ -239,10 +264,12 @@ function DebugInteriorPlaceholder({ label }: { label?: string }) {
}
export const InteriorInstance = memo(function InteriorInstance({
scene,
entity,
}: {
scene: SceneInteriorInstance;
entity: InteriorInstanceEntity;
}) {
const scene = entity.interiorData;
const isTarget = useIsDebugTourTarget(entity.id);
const position = useMemo(
() => torqueToThree(scene.transform.position),
[scene.transform.position],
@ -276,6 +303,7 @@ export const InteriorInstance = memo(function InteriorInstance({
<InteriorModel
interiorFile={scene.interiorFile}
ghostIndex={scene.ghostIndex}
isTarget={isTarget}
/>
</DebugSuspense>
</ErrorBoundary>

View file

@ -44,6 +44,7 @@ import { useTouchDevice } from "./useTouchDevice";
import { GameDialogSpinner } from "./GameDialogSpinner";
import { ToggleSidebarButton } from "./ToggleSidebarButton";
import { ExitTourButton } from "./ExitTourButton";
import { startShapePreload } from "../shapePreloader";
import styles from "./MapInspector.module.css";
function ViewTransition({ children }: { children: ReactNode }) {
@ -124,6 +125,11 @@ export function MapInspector() {
const recording = useRecording();
const dataSource = useDataSource();
const hasStreamData = dataSource === "demo" || dataSource === "live";
// Start background preloading of shape GLBs so they're cached before needed.
useEffect(() => {
if (hasStreamData) startShapePreload();
}, [hasStreamData]);
// Sync the mission query param when streaming data provides a mission name.
const streamMissionName = useMissionName();
const streamMissionType = useMissionType();

View file

@ -12,6 +12,7 @@ import {
Object3D,
PositionalAudio,
Vector3,
Box3,
} from "three";
import type { AnimationAction } from "three";
import { AnimationClip } from "three";
@ -33,6 +34,9 @@ import { useStaticShape, ShapePlaceholder } from "./GenericShape";
import { useAnisotropy } from "./useAnisotropy";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { DebugSuspense } from "./DebugSuspense";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { useEntitySoundSlots } from "./useEntitySoundSlots";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
@ -496,21 +500,20 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const flagShapeRef = useRef(entity.flagShape);
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
// Jet thrust looping sound. Managed imperatively in useFrame based on
// entity.jetting — plays while jetting, stops when jetting ends.
// Sound parameters come from the player's datablock chain:
// PlayerData.sounds[30] (jetSound) → AudioProfile → AudioDescription
// ShapeBase sound slots (weapon switch sounds, etc.) — managed by shared hook.
const entityRef = useRef(entity);
entityRef.current = entity;
useEntitySoundSlots(entityRef, clonedScene);
// Jet thrust sound. Played client-side by Player::updateJetEffects via
// direct alxPlay3d — NOT networked through SoundMask. We derive it from
// entity.jetting (which comes from move trigger[3] or ghost MoveMask).
const { audioLoader, audioListener } = useAudio();
const audioSettings = useSettings();
const audioEnabled = audioSettings?.audioEnabled ?? false;
const { audioEnabled } = useSettings();
const jetSoundRef = useRef<PositionalAudio | null>(null);
const jetBufferRef = useRef<AudioBuffer | null>(null);
const jetProfileRef = useRef<ResolvedAudioProfile | null>(null);
/** PlayerData.Sounds enum index for jetSound. Tribes 2 reordered the
* open-source Torque Sounds enum to put jet sounds first (0-1). */
const JET_SOUND_INDEX = 0;
// Resolve and preload the jet sound from the player's datablock.
useEffect(() => {
if (!audioLoader) return;
@ -519,8 +522,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
if (!sp || !entity.dataBlockId) return;
const getDb = sp.getDataBlockData.bind(sp);
const playerDb = getDb(entity.dataBlockId);
// PlayerData.Sounds enum: Tribes 2 reordered the open-source Torque
// enum to put jet sounds first (index 0 = jetSound, 1 = wetJetSound).
const sounds = playerDb?.sounds as (number | null)[] | undefined;
const jetSoundId = sounds?.[JET_SOUND_INDEX];
const jetSoundId = sounds?.[0];
if (jetSoundId == null) return;
const resolved = resolveAudioProfile(jetSoundId, getDb);
if (!resolved) return;
@ -773,17 +778,13 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
headside.weight = blendWeight;
}
// Jet thrust sound: start/stop a looping positional audio based on
// entity.jetting. The engine plays ArmorJetSound (CloseLooping3d) while
// mJetting is true and stops it when false.
// Jet thrust sound: start/stop based on entity.jetting.
// Client-side only — Player::updateJetEffects uses alxPlay3d directly.
const isJetting = !!entity.jetting && !isDead;
const jetProfile = jetProfileRef.current;
// Check both our ref AND isPlaying — the sound can be externally stopped
// by stopAllTrackedSounds() (on seek/recording change) without our ref
// knowing about it.
const jetSound = jetSoundRef.current;
const soundActuallyPlaying = jetSound?.isPlaying ?? false;
if (isJetting && !soundActuallyPlaying) {
const jetPlaying = jetSound?.isPlaying ?? false;
if (isJetting && !jetPlaying) {
if (audioEnabled && audioListener && jetBufferRef.current && jetProfile) {
let sound = jetSound;
if (!sound) {
@ -806,7 +807,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
/* AudioContext suspended */
}
}
} else if (!isJetting && soundActuallyPlaying) {
} else if (!isJetting && jetPlaying) {
if (jetSound) {
untrackSound(jetSound);
try {
@ -832,6 +833,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
)}
<group rotation={[0, Math.PI / 2, 0]}>
<primitive object={clonedScene} />
<PlayerDebugBounds entityId={entity.id} scene={gltf.scene} />
</group>
{currentWeaponShape && mount0 && (
<ShapeErrorBoundary
@ -894,6 +896,34 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
);
}
function PlayerDebugBounds({
entityId,
scene,
}: {
entityId: string;
scene: Group;
}) {
const isTarget = useIsDebugTourTarget(entityId);
const bounds = useMemo(() => {
if (!isTarget) return null;
const box = new Box3().setFromObject(scene);
const center = new Vector3();
const size = new Vector3();
box.getCenter(center);
box.getSize(size);
return {
center: [center.x, center.y, center.z] as [number, number, number],
size: [size.x, size.y, size.z] as [number, number, number],
};
}, [isTarget, scene]);
if (!bounds) return null;
return (
<group position={bounds.center}>
<DebugBounds size={bounds.size} />
</group>
);
}
/**
* Build a DTS sequence-index -> name lookup from GLB metadata.
* Weapon GLBs include `dts_sequence_names` in scene extras, providing the

View file

@ -12,7 +12,7 @@ import {
} from "react";
import { useFogQueryState } from "./useQueryParams";
import { useTouchDevice } from "./useTouchDevice";
import { setAdjustAudioSpeedFlag } from "./AudioEmitter";
import { setAdjustAudioSpeedFlag } from "./audioPlaybackRate";
export const MIN_SPEED_MULTIPLIER = 0.01;
export const MAX_SPEED_MULTIPLIER = 1;

View file

@ -645,8 +645,11 @@ function DynamicFog({
if (enabled) {
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
// When fogDistanceScale > 1 (camera tour), stretch haze range so fog
// starts nearby but doesn't reach full until past the orbit distance.
const scale = globalFogUniforms.fogDistanceScale.value;
fog.near = scale > 1 ? Math.min(near, 100) : near;
fog.far = far * scale;
fog.color.copy(fogState.fogColor);
}
// When disabled, fog.near/far are already set to 1e10 by the useEffect

View file

@ -59,6 +59,7 @@ function mutateRenderFields(
e.headPitch = stream.headPitch;
e.headYaw = stream.headYaw;
e.targetRenderFlags = stream.targetRenderFlags;
e.soundSlots = stream.soundSlots;
break;
}
case "Shape": {
@ -67,6 +68,7 @@ function mutateRenderFields(
e.damageState = stream.damageState;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
e.soundSlots = stream.soundSlots;
break;
}
}
@ -83,16 +85,6 @@ function getEntityMap(snapshot: StreamSnapshot): EntityById {
return map;
}
/** Push the current entity map to the game entity store.
* Only triggers a version bump (and subscriber notifications) when the
* entity set changed (adds/removes). Render-field updates are mutated
* in-place on existing entity objects and read imperatively in useFrame. */
function pushEntitiesToStore(entityMap: Map<string, GameEntity>): void {
gameEntityStore
.getState()
.setAllStreamEntities(Array.from(entityMap.values()));
}
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
@ -115,7 +107,6 @@ export function StreamingController({
recording.streamingPlayback ?? null,
);
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
@ -123,19 +114,18 @@ export function StreamingController({
if (snapshot === lastSyncedSnapshotRef.current) return;
lastSyncedSnapshotRef.current = snapshot;
const prevMap = entityMapRef.current;
const nextMap = new Map<string, GameEntity>();
// Operate directly on the store's Map — one canonical source of truth.
const map = gameEntityStore.getState().streamEntities;
let structuralChange = false;
// Track which IDs are in the current snapshot for the removal pass.
const currentIds = new Set<string>();
for (const entity of snapshot.entities) {
let renderEntity = prevMap.get(entity.id);
currentIds.add(entity.id);
let renderEntity = map.get(entity.id);
// Identity change -> new component (unmount/remount).
// Compare fields that, when changed, require a full entity rebuild.
// Only compare shapeName for entity types that actually use it.
// Scene entities (Terrain, Interior, Sky, etc.), ForceFieldBare,
// AudioEmitter, WayPoint, and Camera don't have shapeName on their
// GameEntity, so comparing against entity.dataBlock would always
// mismatch and trigger a needless rebuild every frame.
const hasShapeName =
renderEntity &&
(renderEntity.renderType === "Shape" ||
@ -151,28 +141,21 @@ export function StreamingController({
(hasShapeName &&
entity.dataBlock != null &&
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
// weaponShape changes only force rebuild for non-Player shapes
// (turrets, vehicles). Players handle weapon changes internally
// via PlayerModel's Mount0 bone, and rebuilding on weapon change
// would lose animation state (death animations, etc.).
(renderEntity.renderType !== "Player" &&
hasShapeName &&
getField(renderEntity, "weaponShape") !== entity.weaponShape);
if (needsNewIdentity) {
const prevHidden = renderEntity?.debugHidden;
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
if (prevHidden) renderEntity.debugHidden = true;
map.set(entity.id, renderEntity);
structuralChange = true;
} else {
// Mutate render fields in-place on the existing entity object.
// Components read these imperatively in useFrame — no React
// re-render needed. This avoids store churn that starves Suspense.
mutateRenderFields(renderEntity!, entity);
}
nextMap.set(entity.id, renderEntity!);
// Keyframe update (mutable -- used for fallback position for
// retained explosion entities and per-frame reads in useFrame).
// Scene entities and None don't have keyframes.
// Keyframe update (mutable — position, rotation, velocity, etc.).
if (isSceneEntity(renderEntity!) || renderEntity!.renderType === "None")
continue;
const keyframes = renderEntity!.keyframes!;
@ -195,31 +178,24 @@ export function StreamingController({
kf.damageState = entity.damageState;
}
// Retain explosion entities with DTS shapes after they leave the snapshot.
// These entities are ephemeral (~1 tick) but the visual effect lasts seconds.
for (const [id, entity] of prevMap) {
if (nextMap.has(id)) continue;
// Removal pass: delete entities no longer in the snapshot.
// Retain explosion entities with DTS shapes for up to 5 seconds.
for (const [id, entity] of map) {
if (currentIds.has(id)) continue;
if (
entity.renderType === "Explosion" &&
entity.shapeName &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
if (age < 5) {
nextMap.set(id, entity);
continue;
}
if (age < 5) continue;
}
map.delete(id);
structuralChange = true;
}
// Only push to store when the entity set changed (adds/removes).
const shouldRebuild =
nextMap.size !== prevMap.size ||
[...nextMap.keys()].some((id) => !prevMap.has(id));
entityMapRef.current = nextMap;
if (shouldRebuild) {
pushEntitiesToStore(nextMap);
if (structuralChange) {
gameEntityStore.getState().bumpStreamVersion();
}
let nextFirstPersonShape: string | null = null;
@ -227,7 +203,7 @@ export function StreamingController({
snapshot.camera?.mode === "first-person" &&
snapshot.camera.controlEntityId
) {
const entity = nextMap.get(snapshot.camera.controlEntityId);
const entity = map.get(snapshot.camera.controlEntityId);
const sn = entity ? getField(entity, "shapeName") : undefined;
if (sn) {
nextFirstPersonShape = sn;
@ -245,7 +221,6 @@ export function StreamingController({
stopAllTrackedSounds();
streamRef.current = recording.streamingPlayback ?? null;
entityMapRef.current = new Map();
lastSyncedSnapshotRef.current = null;
publishedSnapshotRef.current = null;
resetStreamPlayback();
@ -402,10 +377,6 @@ export function StreamingController({
syncRenderableEntities(renderCurrent);
// Publish the entity map for imperative reads by components in useFrame.
// This is a plain object assignment — no React re-renders triggered.
streamPlaybackStore.getState().entities = entityMapRef.current;
// Publish snapshot when it changed.
if (renderCurrent !== publishedSnapshotRef.current) {
publishedSnapshotRef.current = renderCurrent;
@ -489,7 +460,7 @@ export function StreamingController({
// Imperative position interpolation via the shared entity root.
const currentEntities = getEntityMap(renderCurrent);
const previousEntities = getEntityMap(renderPrev);
const renderEntities = entityMapRef.current;
const renderEntities = gameEntityStore.getState().streamEntities;
const root = streamPlaybackStore.getState().root;
if (root) {
for (const child of root.children) {

View file

@ -18,7 +18,9 @@ import {
UnsignedByteType,
Vector3,
} from "three";
import type { SceneTerrainBlock } from "../scene/types";
import type { TerrainBlockEntity } from "../state/gameEntityTypes";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { createLogger } from "../logger";
import { torqueToThree } from "../scene/coordinates";
@ -482,10 +484,12 @@ function createVisibilityMask(emptySquares: number[]): DataTexture {
return texture;
}
export const TerrainBlock = memo(function TerrainBlock({
scene,
entity,
}: {
scene: SceneTerrainBlock;
entity: TerrainBlockEntity;
}) {
const scene = entity.terrainData;
const isTarget = useIsDebugTourTarget(entity.id);
const terrainFile = scene.terrFileName;
const squareSize = scene.squareSize || DEFAULT_SQUARE_SIZE;
const detailTexture = scene.detailTextureName || undefined;
@ -678,6 +682,45 @@ export const TerrainBlock = memo(function TerrainBlock({
lightmap={terrainLightmap ?? undefined}
/>
</instancedMesh>
{isTarget && terrain && (
<TerrainDebugBounds
heightMap={terrain.heightMap}
blockSize={blockSize}
basePosition={basePosition}
/>
)}
</>
);
});
function TerrainDebugBounds({
heightMap,
blockSize,
basePosition,
}: {
heightMap: Uint16Array;
blockSize: number;
basePosition: { x: number; z: number };
}) {
const bounds = useMemo(() => {
let maxH = 0;
for (let i = 0; i < heightMap.length; i++) {
const h = (heightMap[i] / 65535) * HEIGHT_SCALE;
if (h > maxH) maxH = h;
}
return {
center: [
basePosition.x + blockSize / 2,
maxH / 2,
basePosition.z + blockSize / 2,
] as [number, number, number],
size: [blockSize, maxH, blockSize] as [number, number, number],
};
}, [heightMap, blockSize, basePosition]);
return (
<group position={bounds.center}>
<DebugBounds size={bounds.size} />
</group>
);
}

View file

@ -1,4 +1,6 @@
import { memo, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { Box, useTexture } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import {
@ -107,6 +109,7 @@ export const WaterBlock = memo(function WaterBlock({
entity: WaterBlockEntity;
}) {
const scene = entity.waterData;
const isTarget = useIsDebugTourTarget(entity.id);
const { debugMode } = useDebug();
const q = useMemo(
() => matrixFToQuaternion(scene.transform),
@ -255,6 +258,17 @@ export const WaterBlock = memo(function WaterBlock({
<meshBasicMaterial color="#00fbff" wireframe />
</Box>
)}
{isTarget && (
<group
position={[
position[0] + scaleX / 2,
position[1] + scaleY / 2,
position[2] + scaleZ / 2,
]}
>
<DebugBounds size={[scaleX, scaleY, scaleZ]} />
</group>
)}
<Suspense
fallback={reps.map(([repX, repZ]) => {
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET

View file

@ -1,8 +1,16 @@
import type { WayPointEntity } from "../state/gameEntityTypes";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugMarker } from "./DebugBounds";
import { FloatingLabel } from "./FloatingLabel";
export function WayPoint({ entity }: { entity: WayPointEntity }) {
return entity.label ? (
<FloatingLabel opacity={0.6}>{entity.label}</FloatingLabel>
) : null;
const isTarget = useIsDebugTourTarget(entity.id);
return (
<>
{entity.label ? (
<FloatingLabel opacity={0.6}>{entity.label}</FloatingLabel>
) : null}
{isTarget && <DebugMarker radius={1.5} />}
</>
);
}

View file

@ -0,0 +1,30 @@
import { engineStore } from "../state/engineStore";
/** Whether to adjust audio pitch to match playback speed. When false, sounds
* play at their original pitch regardless of fast-forward/slow-motion. */
let _adjustAudioSpeed = true;
type FlagChangeListener = (value: boolean) => void;
const _listeners: FlagChangeListener[] = [];
export function setAdjustAudioSpeedFlag(value: boolean): void {
_adjustAudioSpeed = value;
for (const listener of _listeners) {
listener(value);
}
}
export function getAdjustAudioSpeed(): boolean {
return _adjustAudioSpeed;
}
/** Register a callback for when the adjustAudioSpeed flag changes. */
export function onAdjustAudioSpeedChange(listener: FlagChangeListener): void {
_listeners.push(listener);
}
/** Get the effective playback rate for a sound, respecting the adjustAudioSpeed setting. */
export function getEffectiveSoundRate(basePitch = 1): number {
const rate = engineStore.getState().playback.rate;
return basePitch * (_adjustAudioSpeed ? rate : 1);
}

View file

@ -0,0 +1,180 @@
/**
* Hook that manages ShapeBase sound slots as PositionalAudio objects,
* matching Tribes 2's architecture where sounds are OpenAL sources
* tracked internally by ShapeBase, not separate entities.
*/
import { useEffect, useRef } from "react";
import { PositionalAudio } from "three";
import type { Object3D } from "three";
import { useFrame } from "@react-three/fiber";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
getCachedAudioBuffer,
trackSound,
untrackSound,
type ResolvedAudioProfile,
} from "./AudioEmitter";
import { getEffectiveSoundRate } from "./audioPlaybackRate";
import { audioToUrl } from "../loaders";
import { engineStore } from "../state/engineStore";
import { useSettings } from "./SettingsProvider";
const MAX_SOUND_SLOTS = 4;
interface SlotState {
profileId: number;
sound: PositionalAudio;
profile: ResolvedAudioProfile;
}
/**
* Manage up to 4 sound slots for a ShapeBase entity.
* Reads soundSlots from the stream entity ref imperatively in useFrame.
*/
export function useEntitySoundSlots(
streamEntityRef: React.RefObject<
| {
soundSlots?: Array<{
index: number;
playing: boolean;
profileId?: number;
}>;
}
| null
| undefined
>,
parentObject: Object3D | null,
): void {
const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings();
const slotsRef = useRef<(SlotState | null)[]>(
Array.from({ length: MAX_SOUND_SLOTS }, () => null),
);
// Cache resolved profiles + buffers by profileId.
const profileCacheRef = useRef(
new Map<number, { profile: ResolvedAudioProfile; buffer: AudioBuffer }>(),
);
// Cleanup on unmount.
useEffect(() => {
return () => {
for (const slot of slotsRef.current) {
if (!slot) continue;
untrackSound(slot.sound);
try {
slot.sound.stop();
} catch {
/* already stopped */
}
try {
slot.sound.disconnect();
} catch {
/* already disconnected */
}
slot.sound.parent?.remove(slot.sound);
}
slotsRef.current = Array.from({ length: MAX_SOUND_SLOTS }, () => null);
};
}, []);
useFrame(() => {
if (!audioEnabled || !audioListener || !audioLoader || !parentObject)
return;
const entity = streamEntityRef.current;
const soundSlots = entity?.soundSlots;
const slots = slotsRef.current;
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
for (let i = 0; i < MAX_SOUND_SLOTS; i++) {
// Find this slot's data from the ghost update.
const slotData = soundSlots?.find((s) => s.index === i);
const shouldPlay = !!slotData?.playing && slotData.profileId != null;
const profileId = slotData?.profileId ?? -1;
const current = slots[i];
if (shouldPlay && isPlaying) {
// Check if we need to start or change the sound.
if (
current &&
current.profileId === profileId &&
current.sound.isPlaying
) {
// Already playing the right sound — nothing to do.
continue;
}
// Stop old sound if profile changed.
if (current && current.profileId !== profileId) {
untrackSound(current.sound);
try {
current.sound.stop();
} catch {
/* already stopped */
}
current.sound.parent?.remove(current.sound);
slots[i] = null;
}
// Resolve profile and buffer (may be cached).
const cached = profileCacheRef.current.get(profileId);
if (cached) {
// Have profile + buffer — start playing.
if (!slots[i]) {
const sound = new PositionalAudio(audioListener);
sound.setDistanceModel("inverse");
sound.setRefDistance(cached.profile.refDist);
sound.setMaxDistance(cached.profile.maxDist);
sound.setRolloffFactor(1);
sound.setVolume(cached.profile.volume);
sound.setBuffer(cached.buffer);
sound.setLoop(cached.profile.isLooping);
sound.setPlaybackRate(getEffectiveSoundRate());
parentObject.add(sound);
try {
sound.play();
trackSound(sound, 1);
} catch {
/* AudioContext suspended */
}
slots[i] = { profileId, sound, profile: cached.profile };
}
} else {
// Need to resolve — do it once, then it'll be picked up next frame.
const sp = engineStore.getState().playback;
const stream = sp.recording?.streamingPlayback;
if (!stream) continue;
const getDb = stream.getDataBlockData.bind(stream);
const resolved = resolveAudioProfile(profileId, getDb);
if (!resolved) continue;
try {
const url = audioToUrl(resolved.filename);
getCachedAudioBuffer(url, audioLoader, (buffer) => {
profileCacheRef.current.set(profileId, {
profile: resolved,
buffer,
});
});
} catch {
// File not in manifest.
}
}
} else {
// Should not be playing — stop if active.
if (current) {
untrackSound(current.sound);
try {
current.sound.stop();
} catch {
/* already stopped */
}
current.sound.parent?.remove(current.sound);
slots[i] = null;
}
}
}
});
}

View file

@ -65,7 +65,9 @@ export const fogFragmentShader = `
} else {
#endif
float dist = vFogDepth;
// Scale distance for fog calculations — makes everything appear closer/further
// for fog purposes without changing actual geometry positions.
float dist = vFogDepth / fogDistanceScale;
// Discard fragments at or beyond visible distance - matches Torque's behavior
// where objects beyond visibleDistance are not rendered at all.
@ -226,6 +228,15 @@ export function installCustomFogShader(): void {
#ifdef USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif
// Fog distance scale — multiplies all distance-based fog calculations.
// 1.0 = normal, >1 = less fog. Set by camera tour for distant orbits.
#ifdef HAS_FOG_DISTANCE_SCALE
uniform float fogDistanceScale;
#else
#define fogDistanceScale 1.0
#endif
#endif
`;
@ -265,6 +276,7 @@ export interface FogShaderUniformObjects {
fogVolumeData: { value: Float32Array };
cameraHeight: { value: number };
fogEnabled: { value: boolean };
fogDistanceScale: { value: number };
}
/**
@ -282,6 +294,7 @@ export function addFogUniformsToShader(
shader.uniforms.fogVolumeData = fogUniforms.fogVolumeData;
shader.uniforms.cameraHeight = fogUniforms.cameraHeight;
shader.uniforms.fogEnabled = fogUniforms.fogEnabled;
shader.uniforms.fogDistanceScale = fogUniforms.fogDistanceScale;
}
/**
@ -329,7 +342,8 @@ export function injectCustomFog(
// Add volumetric fog uniforms to fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
"#include <fog_pars_fragment>",
`#include <fog_pars_fragment>
`#define HAS_FOG_DISTANCE_SCALE
#include <fog_pars_fragment>
#ifdef USE_FOG
#define USE_VOLUMETRIC_FOG
uniform float fogVolumeData[12];

View file

@ -23,6 +23,9 @@ export const globalFogUniforms = {
},
cameraHeight: { value: 0 },
fogEnabled: { value: true },
/** Scales all fog distances (haze + volumes). 1.0 = normal, >1 = less fog.
* Used by camera tour to reduce fog when orbiting far from targets. */
fogDistanceScale: { value: 1 },
};
/**
@ -47,6 +50,7 @@ export function resetGlobalFogUniforms(): void {
globalFogUniforms.cameraHeight.value = 0;
globalFogUniforms.fogVolumeData.value.fill(0);
globalFogUniforms.fogEnabled.value = true;
globalFogUniforms.fogDistanceScale.value = 1;
}
/**

View file

@ -71,7 +71,10 @@ export function textureToUrl(name: string) {
}
export function audioToUrl(fileName: string) {
const url = getUrlForPath(`audio/${fileName}`);
// AudioProfile filenames from datablocks may omit the .wav extension
// (e.g. "fx/armor/thrust"). Torque tries extensions automatically.
const withExt = /\.\w+$/.test(fileName) ? fileName : `${fileName}.wav`;
const url = getUrlForPath(`audio/${withExt}`);
return url.replace(/\.wav$/i, ".m4a");
}

View file

@ -2,8 +2,17 @@
* Shape material utilities and shader modifications.
*/
import { Texture } from "three";
import {
MeshBasicMaterial,
MeshLambertMaterial,
MeshStandardMaterial,
AdditiveBlending,
Texture,
} from "three";
import type { Material } from "three";
import { SHAPE_LIGHTING } from "./lightingConfig";
import { injectCustomFog } from "./fogShader";
import { globalFogUniforms } from "./globalFogUniforms";
/**
* Inject lighting multipliers into a MeshLambertMaterial or MeshBasicMaterial shader.
@ -164,3 +173,138 @@ if (shapeEnvMapActive && shapeReflectionAmount > 0.0) {
#include <opaque_fragment>`,
);
}
// ── Shape material creation ──
export type SingleMaterial =
| MeshStandardMaterial
| MeshBasicMaterial
| MeshLambertMaterial;
export type MaterialResult =
| SingleMaterial
| [MeshLambertMaterial, MeshLambertMaterial];
// Stable onBeforeCompile callbacks — using shared function references lets
// Three.js's program cache match by identity rather than toString().
const lambertBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
};
const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
};
/**
* Helper to apply volumetric fog and lighting multipliers to a material.
* When envMapOptions is provided, also injects Tribes 2 env map reflectivity.
*/
export function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
envMapOptions?: { reflectionAmount: number },
): void {
if (!envMapOptions) {
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
return;
}
const matType = mat instanceof MeshLambertMaterial ? "lambert" : "basic";
mat.customProgramCacheKey = () => `shape-envmap-${matType}`;
const { reflectionAmount } = envMapOptions;
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
injectShapeEnvMap(shader, reflectionAmount);
};
}
export function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture | null,
flagNames: Set<string>,
isOrganic: boolean,
vis: number = 1,
animated: boolean = false,
reflectionAmount: number = 0,
): 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;
// Env map reflectivity: gated only by NeverEnvMap flag and reflectionAmount
// (which is 0 when the datablock doesn't have emap=true). Independent of
// SelfIlluminating/Additive — those affect lighting, not env mapping.
const enableEnvMap =
!flagNames.has("NeverEnvMap") && reflectionAmount > 0 && !isFaded;
const envMapOptions = enableEnvMap ? { reflectionAmount } : undefined;
// SelfIlluminating or Additive materials are unlit (use MeshBasicMaterial).
// Additive materials without SelfIlluminating (e.g. explosion shells) must
// also be unlit, otherwise they render black with no scene lighting.
if (isSelfIlluminating || isAdditive) {
const isBlended = isAdditive || isTranslucent || isFaded;
const mat = new MeshBasicMaterial({
map: texture,
side: 2, // DoubleSide
transparent: isBlended,
depthWrite: !isBlended,
alphaTest: 0,
fog: true,
...(isFaded && { opacity: vis }),
...(isAdditive && { blending: AdditiveBlending }),
});
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}
// For organic shapes or Translucent flag, use alpha cutout with Lambert shading
// Tribes 2 used fixed-function GL with specular disabled - purely diffuse lighting
// MeshLambertMaterial gives us the diffuse-only look that matches the original
// Return [BackSide, FrontSide] materials to render in two passes - avoids z-fighting
if (isOrganic || isTranslucent) {
const baseProps = {
map: texture,
// 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({
...baseProps,
side: 1, // BackSide
// Push back faces slightly behind in depth to avoid z-fighting with front
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
});
const frontMat = new MeshLambertMaterial({
...baseProps,
side: 0, // FrontSide
});
applyShapeShaderModifications(backMat, envMapOptions);
applyShapeShaderModifications(frontMat, envMapOptions);
return [backMat, frontMat];
}
// Default: use Lambert for diffuse-only lighting (matches Tribes 2)
const mat = new MeshLambertMaterial({
map: texture,
side: 2, // DoubleSide
reflectivity: 0,
...(isFaded && {
transparent: true,
opacity: vis,
depthWrite: false,
}),
});
applyShapeShaderModifications(mat, envMapOptions);
return mat;
}

79
src/shapePreloader.ts Normal file
View file

@ -0,0 +1,79 @@
/**
* Background preloader for shape GLBs. Drip-feeds useGLTF.preload() calls
* via setTimeout so higher-priority rendering and network requests aren't
* blocked. Covers all shapes from shapes.vl2 (~219 files).
*/
import { useGLTF } from "@react-three/drei";
import { getResourceList, getSourceAndPath } from "./manifest";
import { RESOURCE_ROOT_URL } from "./loaders";
import { createLogger } from "./logger";
const log = createLogger("shapePreloader");
/** Delay between preload batches (ms). */
const BATCH_DELAY = 200;
/** Number of shapes to preload per batch. */
const BATCH_SIZE = 2;
let started = false;
function shapePriority(name: string): number {
const lower = name.toLowerCase();
if (
lower.startsWith("bioderm_") ||
lower.endsWith("_male.dts") ||
lower.endsWith("_female.dts")
)
return 0;
if (lower.startsWith("weapon_")) return 1;
if (lower.startsWith("pack_")) return 2;
return 3;
}
/** All shape GLB URLs from shapes.vl2, sorted by priority. */
function getShapeUrls(): string[] {
return getResourceList()
.filter((key) => key.startsWith("shapes/") && key.endsWith(".dts"))
.filter((key) => {
const [sourcePath] = getSourceAndPath(key);
return sourcePath === "shapes.vl2";
})
.sort((a, b) => shapePriority(a) - shapePriority(b))
.map((key) => {
const [sourcePath, actualPath] = getSourceAndPath(key);
const glbPath = actualPath.replace(/\.dts$/i, ".glb");
return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${glbPath}`;
});
}
/**
* Start background preloading of all shapes.vl2 GLBs. Safe to call multiple
* times only the first call has any effect. Preloading is deferred and
* throttled so it doesn't compete with on-demand loads.
*/
export function startShapePreload(): void {
if (started) return;
started = true;
const urls = getShapeUrls();
log.info("Preloading %d shapes from shapes.vl2", urls.length);
let index = 0;
function preloadBatch() {
const end = Math.min(index + BATCH_SIZE, urls.length);
for (let i = index; i < end; i++) {
useGLTF.preload(urls[i]);
}
index = end;
if (index < urls.length) {
setTimeout(preloadBatch, BATCH_DELAY);
} else {
log.info("Shape preloading complete");
}
}
// Defer the first batch so we don't interfere with initial render.
setTimeout(preloadBatch, 1000);
}

View file

@ -7,6 +7,7 @@ export interface TourAnimation {
targets: TourTarget[];
/** Category name when this is a multi-target tour, for UI matching. */
categoryName: string | null;
tourType: TourType;
currentIndex: number;
phase: "traveling" | "orbiting";
elapsed: number;
@ -24,10 +25,16 @@ export interface TourAnimation {
orbitStartAngle: number;
}
export type TourType = "feature" | "debug";
export interface CameraTourState {
animation: TourAnimation | null;
flyTo(target: TourTarget): void;
startTour(targets: TourTarget[], categoryName: string): void;
flyTo(target: TourTarget, tourType?: TourType): void;
startTour(
targets: TourTarget[],
categoryName: string,
tourType?: TourType,
): void;
/** Advance to the next target in a tour (notifies subscribers). */
advanceTarget(): void;
cancel(): void;
@ -36,10 +43,12 @@ export interface CameraTourState {
function makeAnimation(
targets: TourTarget[],
categoryName: string | null = null,
tourType: TourType = "feature",
): TourAnimation {
return {
targets,
categoryName,
tourType,
currentIndex: 0,
phase: "traveling",
elapsed: 0,
@ -55,12 +64,12 @@ function makeAnimation(
export const cameraTourStore = createStore<CameraTourState>((set) => ({
animation: null,
flyTo(target) {
set({ animation: makeAnimation([target]) });
flyTo(target, tourType = "feature") {
set({ animation: makeAnimation([target], null, tourType) });
},
startTour(targets, categoryName) {
startTour(targets, categoryName, tourType = "feature") {
if (targets.length === 0) return;
set({ animation: makeAnimation(targets, categoryName) });
set({ animation: makeAnimation(targets, categoryName, tourType) });
},
advanceTarget() {
set((state) => {
@ -92,3 +101,13 @@ export function useCameraTour<T>(
): T {
return useStoreWithEqualityFn(cameraTourStore, selector, equality);
}
/** Returns true if this entity is the active debug tour target. */
export function useIsDebugTourTarget(entityId: string): boolean {
return useCameraTour((s) => {
const anim = s.animation;
if (!anim || anim.tourType !== "debug") return false;
const target = anim.targets[anim.currentIndex];
return target?.entityId === entityId;
});
}

View file

@ -69,9 +69,15 @@ export interface GameEntityState {
setStreamEntities(entities: GameEntity[]): void;
setAllStreamEntities(entities: GameEntity[]): void;
clearStreamEntities(): void;
/** Returns the store's streamEntities Map for direct mutation.
* Call bumpStreamVersion() after structural changes (adds/removes). */
getStreamEntitiesMap(): Map<string, GameEntity>;
/** Bump version to notify React subscribers. Call after adds/removes/
* identity rebuilds, NOT for in-place field mutations. */
bumpStreamVersion(): void;
}
export const gameEntityStore = createStore<GameEntityState>()((set) => ({
export const gameEntityStore = createStore<GameEntityState>()((set, get) => ({
missionEntities: new Map(),
streamEntities: new Map(),
isStreaming: false,
@ -278,6 +284,14 @@ export const gameEntityStore = createStore<GameEntityState>()((set) => ({
return { streamEntities: new Map(), version: state.version + 1 };
});
},
getStreamEntitiesMap() {
return get().streamEntities;
},
bumpStreamVersion() {
set((state) => ({ version: state.version + 1 }));
},
}));
// ── Selectors ──
@ -447,6 +461,16 @@ export function useMissionTypeDisplayName(): string | null {
);
}
/** Hook returning the debugHidden state for a specific entity. */
export function useDebugHidden(entityId: string): boolean {
return useStoreWithEqualityFn(gameEntityStore, (state) => {
const entities = state.isStreaming
? state.streamEntities
: state.missionEntities;
return entities.get(entityId)?.debugHidden ?? false;
});
}
/** Hook returning the mission display name (e.g. "Scarabrae"). */
export function useMissionDisplayName(): string | null {
return useStoreWithEqualityFn(

View file

@ -51,6 +51,8 @@ interface EntityBase {
spawnTime?: number;
runtimeObject?: unknown;
missionTypesList?: string;
/** Hidden via the debug entity list. */
debugHidden?: boolean;
}
// ── Scene infrastructure entities ──
@ -136,6 +138,8 @@ export interface ShapeEntity extends PositionedBase {
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
weaponShape?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** WheeledVehicle per-wheel state (speed, slip). */
wheels?: Array<{
speed: number;
@ -148,6 +152,8 @@ export interface ShapeEntity extends PositionedBase {
frozen?: boolean;
/** Vehicle max steering angle (radians), from datablock. */
maxSteeringAngle?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
}
export interface PlayerEntity extends PositionedBase {
@ -177,6 +183,8 @@ export interface PlayerEntity extends PositionedBase {
actionAtEnd?: boolean;
damageState?: number;
targetRenderFlags?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
}
export interface ForceFieldBareEntity extends PositionedBase {

View file

@ -1,8 +1,6 @@
import { createStore } from "zustand/vanilla";
import type { Group } from "three";
import type { StreamingPlayback } from "../stream/types";
import type { GameEntity } from "./gameEntityTypes";
export type DemoCameraMode = "original" | "freeFly" | "orbitOverride";
/**
@ -26,10 +24,6 @@ export interface StreamPlaybackState {
orbitOverrideYaw: number;
/** User-controlled orbit pitch (radians), used when cameraMode is "orbitOverride". */
orbitOverridePitch: number;
/** Live entity map, updated every frame. Components read from this in
* useFrame to get the latest render fields (threads, weapons, etc.)
* without triggering React re-renders. */
entities: Map<string, GameEntity>;
}
export const streamPlaybackStore = createStore<StreamPlaybackState>()(() => ({
@ -39,7 +33,6 @@ export const streamPlaybackStore = createStore<StreamPlaybackState>()(() => ({
cameraMode: "original",
orbitOverrideYaw: 0,
orbitOverridePitch: 0,
entities: new Map(),
}));
/** Reset all streaming playback state. Called when streaming ends. */

View file

@ -6,7 +6,9 @@ import {
ballisticProjectileClassNames,
seekerProjectileClassNames,
toEntityType,
toEntityId,
allocateEntityId,
resetEntityIdCounter,
GhostMessage,
IFF_GREEN,
IFF_RED,
TICK_DURATION_MS,
@ -111,6 +113,9 @@ export interface MutableEntity {
headYaw?: number;
targetRenderFlags?: number;
carryingFlag?: boolean;
/** ShapeBase sound slots (4 max). Raw ghost SoundMask data components
* manage PositionalAudio objects directly, matching Tribes 2's approach. */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** Item velocity interpolation state (dropped weapons/items).
* The real Tribes 2 client does NOT simulate physics (gravity/collision)
* for items it just interpolates position using server-sent velocity
@ -185,6 +190,8 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Entities ──
protected entities = new Map<string, MutableEntity>();
protected entityIdByGhostIndex = new Map<number, string>();
/** Incremented on structural entity changes (add/remove/create). */
protected entityGeneration = 0;
// ── Tick / time ──
protected tickCount = 0;
@ -325,8 +332,13 @@ export abstract class StreamEngine implements StreamingPlayback {
): string | undefined {
const byMap = this.entityIdByGhostIndex.get(ghostIndex);
if (byMap) return byMap;
// Ghost exists but has no ID yet — allocate one.
const trackerGhost = this.ghostTracker.getGhost(ghostIndex);
if (trackerGhost) return toEntityId(trackerGhost.className, ghostIndex);
if (trackerGhost) {
const id = allocateEntityId();
this.entityIdByGhostIndex.set(ghostIndex, id);
return id;
}
return undefined;
}
@ -341,9 +353,17 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Shared reset logic ──
protected resetSharedState(): void {
/** Clear all entity state (entities, ghostID map, ID counter, generation).
* Called on full reset and on GhostingMessageEvent (mission change). */
protected clearAllEntities(): void {
this.entities.clear();
this.entityIdByGhostIndex.clear();
this.entityGeneration++;
resetEntityIdCounter();
}
protected resetSharedState(): void {
this.clearAllEntities();
this.tickCount = 0;
this.camera = null;
this.chatMessages = [];
@ -653,6 +673,15 @@ export abstract class StreamEngine implements StreamingPlayback {
return;
}
// EndGhosting (message=2): the server called resetGhosting — all ghosts
// are invalidated. The parser clears the ghost tracker; we clear entities.
if (type === "GhostingMessageEvent") {
if ((data.message as number) === GhostMessage.EndGhosting) {
this.clearAllEntities();
}
return;
}
if (type === "RemoteCommandEvent" || eventName === "RemoteCommandEvent") {
const funcName = this.resolveNetString(data.funcName as string);
const args = data.args as string[];
@ -813,9 +842,9 @@ export abstract class StreamEngine implements StreamingPlayback {
if (ghost.type === "delete") {
if (prevEntityId) {
this.removeSoundSlotEntities(prevEntityId);
this.entities.delete(prevEntityId);
this.entityIdByGhostIndex.delete(ghostIndex);
this.entityGeneration++;
}
return;
}
@ -830,16 +859,14 @@ export abstract class StreamEngine implements StreamingPlayback {
return;
}
const entityId = toEntityId(className, ghostIndex);
const entityId = prevEntityId ?? allocateEntityId();
if (prevEntityId && prevEntityId !== entityId) {
this.removeSoundSlotEntities(prevEntityId);
this.entities.delete(prevEntityId);
}
let entity: MutableEntity;
const existingEntity = this.entities.get(entityId);
if (existingEntity && ghost.type === "create") {
this.removeSoundSlotEntities(entityId);
existingEntity.spawnTick = this.tickCount;
this.resetEntity(existingEntity);
entity = existingEntity;
@ -855,6 +882,7 @@ export abstract class StreamEngine implements StreamingPlayback {
rotation: [0, 0, 0, 1],
};
this.entities.set(entityId, entity);
this.entityGeneration++;
}
entity.ghostIndex = ghostIndex;
@ -916,6 +944,7 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.armAction = undefined;
entity.explosionDataBlockId = undefined;
entity.maintainEmitterId = undefined;
entity.soundSlots = undefined;
}
// ── Apply ghost data ──
@ -1157,8 +1186,6 @@ export abstract class StreamEngine implements StreamingPlayback {
: undefined;
if (position) {
entity.position = [position.x, position.y, position.z];
// Sync any sound-slot child entities with the new position.
this.updateSoundSlotPositions(entity);
}
// Direction
@ -1388,12 +1415,12 @@ export abstract class StreamEngine implements StreamingPlayback {
if (renderFlags != null) entity.targetRenderFlags = renderFlags;
}
// Ghost-level audio — spawn/remove synthetic AudioEmitter children
// ShapeBase sound slots — store on entity for component-level playback.
const sounds = data.sounds as
| Array<{ index: number; playing: boolean; profileId?: number }>
| undefined;
if (Array.isArray(sounds)) {
this.syncSoundSlotEntities(entity, sounds);
entity.soundSlots = sounds;
}
// WayPoint ghost fields
@ -1422,76 +1449,6 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Sound slot entities ──
/**
* Sync synthetic AudioEmitter entities for a parent ghost's sound slots.
* Each active sound slot spawns a child entity; inactive slots remove theirs.
*/
protected syncSoundSlotEntities(
parent: MutableEntity,
sounds: Array<{ index: number; playing: boolean; profileId?: number }>,
): void {
for (const s of sounds) {
const childId = `${parent.id}:sound:${s.index}`;
if (s.playing && typeof s.profileId === "number") {
// Resolve AudioProfile → AudioDescription
const profileBlock = this.getDataBlockData(s.profileId);
const rawFilename = profileBlock?.filename as string | undefined;
if (!rawFilename) continue;
const filename = rawFilename.endsWith(".wav")
? rawFilename
: `${rawFilename}.wav`;
const descId = profileBlock!.description as number | undefined;
const descBlock =
descId != null ? this.getDataBlockData(descId) : undefined;
const existing = this.entities.get(childId);
if (existing) {
// Update position to track parent
existing.position = parent.position;
} else {
// Create synthetic AudioEmitter entity
this.entities.set(childId, {
id: childId,
ghostIndex: parent.ghostIndex,
className: "AudioEmitter",
type: "AudioEmitter",
spawnTick: this.tickCount,
position: parent.position,
rotation: [0, 0, 0, 1],
audioFileName: filename,
audioVolume: (descBlock?.volume as number) ?? 1,
audioIs3D: (descBlock?.is3D as boolean) ?? true,
audioIsLooping: (descBlock?.isLooping as boolean) ?? false,
audioMinDistance: (descBlock?.referenceDistance as number) ?? 20,
audioMaxDistance: (descBlock?.maxDistance as number) ?? 100,
audioMinLoopGap: (descBlock?.minLoopGap as number) ?? 0,
audioMaxLoopGap: (descBlock?.maxLoopGap as number) ?? 0,
});
}
} else {
// Sound stopped — remove synthetic entity
this.entities.delete(childId);
}
}
}
/** Update positions of sound-slot children to track their parent. */
protected updateSoundSlotPositions(parent: MutableEntity): void {
for (let i = 0; i < 4; i++) {
const child = this.entities.get(`${parent.id}:sound:${i}`);
if (child) child.position = parent.position;
}
}
/** Remove any synthetic sound-slot entities belonging to a parent entity. */
protected removeSoundSlotEntities(parentId: string): void {
for (let i = 0; i < 4; i++) {
this.entities.delete(`${parentId}:sound:${i}`);
}
}
// ── Explosion spawning ──
protected resolveExplosionInfo(projDataBlockId: number):
@ -2316,6 +2273,7 @@ export abstract class StreamEngine implements StreamingPlayback {
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
sceneData: entity.sceneData,
forceFieldData: entity.forceFieldData,
});

View file

@ -7,7 +7,7 @@ import {
import { ghostToSceneObject } from "../scene";
import {
toEntityType,
toEntityId,
allocateEntityId,
TICK_DURATION_MS,
} from "./entityClassification";
import {
@ -374,6 +374,7 @@ class StreamingPlayback extends StreamEngine {
// Cached snapshot
private _cachedSnapshot: StreamSnapshot | null = null;
private _cachedSnapshotTick = -1;
private _cachedSnapshotGen = -1;
// Cached derived arrays
private _snap: {
@ -496,6 +497,7 @@ class StreamingPlayback extends StreamEngine {
this.ghostTracker = this.parser.getGhostTracker();
this._cachedSnapshot = null;
this._cachedSnapshotTick = -1;
this._cachedSnapshotGen = -1;
this._snap = null;
this.resetSharedState();
@ -622,12 +624,6 @@ class StreamingPlayback extends StreamEngine {
? (this.initialBlock.controlObjectData?.position as Vec3)
: undefined,
};
this.controlPlayerGhostId =
this.lastControlType === "player" &&
this.initialBlock.controlObjectGhostIndex >= 0
? toEntityId("Player", this.initialBlock.controlObjectGhostIndex)
: undefined;
for (const ghost of this.initialBlock.initialGhosts) {
if (ghost.type !== "create" || ghost.classId == null) continue;
const className = this.registry.getGhostParser(ghost.classId)?.name;
@ -636,7 +632,8 @@ class StreamingPlayback extends StreamEngine {
`No ghost parser for classId ${ghost.classId} (ghost index ${ghost.index})`,
);
}
const id = toEntityId(className, ghost.index);
const id = allocateEntityId();
this.entityIdByGhostIndex.set(ghost.index, id);
const entity: MutableEntity = {
id,
ghostIndex: ghost.index,
@ -658,6 +655,15 @@ class StreamingPlayback extends StreamEngine {
this.entityIdByGhostIndex.set(ghost.index, id);
}
// Resolve control player entity ID from the ghost index map.
this.controlPlayerGhostId =
this.lastControlType === "player" &&
this.initialBlock.controlObjectGhostIndex >= 0
? this.entityIdByGhostIndex.get(
this.initialBlock.controlObjectGhostIndex,
)
: undefined;
// Derive playerSensorGroup from the control player entity
if (
this.playerSensorGroup === 0 &&
@ -750,12 +756,17 @@ class StreamingPlayback extends StreamEngine {
}
getSnapshot(): StreamSnapshot {
if (this._cachedSnapshot && this._cachedSnapshotTick === this.moveTicks) {
if (
this._cachedSnapshot &&
this._cachedSnapshotTick === this.moveTicks &&
this._cachedSnapshotGen === this.entityGeneration
) {
return this._cachedSnapshot;
}
const snapshot = this.buildSnapshot();
this._cachedSnapshot = snapshot;
this._cachedSnapshotTick = this.moveTicks;
this._cachedSnapshotGen = this.entityGeneration;
return snapshot;
}
@ -819,7 +830,8 @@ class StreamingPlayback extends StreamEngine {
!didReset &&
wasExhausted === this.exhausted &&
this._cachedSnapshot &&
this._cachedSnapshotTick === this.moveTicks
this._cachedSnapshotTick === this.moveTicks &&
this._cachedSnapshotGen === this.entityGeneration
) {
return this._cachedSnapshot;
}
@ -910,6 +922,18 @@ class StreamingPlayback extends StreamEngine {
-MAX_PITCH,
MAX_PITCH,
);
// The control player's ghost skips MoveMask (the server knows the client
// predicts its own state). Derive jetting from the move's trigger[3],
// matching Tribes2.exe Player::updateMove: mJetting = trigger[3] &&
// energy >= minJetEnergy && state == MoveState && !disableMove.
const triggers = (block.parsed as { trigger?: boolean[] }).trigger;
if (triggers && this.controlPlayerGhostId) {
const entity = this.entities.get(this.controlPlayerGhostId);
if (entity) {
entity.jetting = !!triggers[3];
}
}
}
}

View file

@ -133,6 +133,7 @@ export function streamEntityToGameEntity(
headPitch: entity.headPitch,
headYaw: entity.headYaw,
targetRenderFlags: entity.targetRenderFlags,
soundSlots: entity.soundSlots,
} satisfies PlayerEntity;
}
@ -201,6 +202,26 @@ export function streamEntityToGameEntity(
} satisfies WayPointEntity;
}
// Non-rendered objects: editor-only markers, AI objectives, vehicle blockers.
// MissionMarker::onAdd only calls addToScene when gEditingMission is true.
// AIObjective and VehicleBlocker are server-side logic objects with no visuals.
if (
entity.className === "MissionMarker" ||
entity.className === "SpawnSphere" ||
entity.className === "AIObjective" ||
entity.className === "VehicleBlocker"
) {
return {
id: entity.id,
className: entity.className ?? entity.type,
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
spawnTime,
renderType: "None",
} satisfies NoneEntity;
}
// Camera
if (entity.className === "Camera") {
return {
@ -232,5 +253,6 @@ export function streamEntityToGameEntity(
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
} satisfies ShapeEntity;
}

View file

@ -1,3 +1,18 @@
/**
* GhostingMessageEvent message types (from NetConnection::GhostMSG enum).
* Sent by the server to control ghost scope lifecycle.
*/
export const GhostMessage = {
/** Server finished sending GhostAlways objects. Client must ack. */
GhostAlwaysDone: 0,
/** Client acknowledgment of GhostAlwaysDone. */
GhostAlwaysAck: 1,
/** EndGhosting — server called resetGhosting, clear all ghosts. */
EndGhosting: 2,
/** Server activated ghosting (begins sending scoped ghosts). */
GhostingActive: 3,
} as const;
/** Class names for vehicle ghosts. */
export const vehicleClassNames = new Set([
"FlyingVehicle",
@ -56,9 +71,20 @@ export function toEntityType(className: string): string {
return "Ghost";
}
/** Generate a stable entity ID from ghost class name and index. */
export function toEntityId(className: string, ghostIndex: number): string {
return `${className}_${ghostIndex}`;
/** First dynamic object ID, matching Torque's SimObjectId allocation.
* IDs 3-1026 are reserved for datablocks (DataBlockObjectIdFirst=3, 1024 slots). */
const FIRST_DYNAMIC_ID = 1027;
let _nextEntityId = FIRST_DYNAMIC_ID;
/** Reset the entity ID counter (e.g. on mission/recording change). */
export function resetEntityIdCounter(): void {
_nextEntityId = FIRST_DYNAMIC_ID;
}
/** Allocate the next sequential entity ID, mimicking Torque's registerObject. */
export function allocateEntityId(): string {
return String(_nextEntityId++);
}
/** Tribes 2 default IFF colors (sRGB 0-255). */

View file

@ -4,6 +4,7 @@ import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
import type { Vec3 } from "./streamHelpers";
import type { StreamSnapshot } from "./types";
import { StreamEngine } from "./StreamEngine";
import { GhostMessage } from "./entityClassification";
import type { RelayClient } from "./relayClient";
const log = createLogger("liveStreaming");
@ -337,9 +338,8 @@ export class LiveStreamAdapter extends StreamEngine {
sequence,
ghostCount,
);
if (message === 0) {
// GhostAlwaysDone → send type 1 acknowledgment
log.info("Sending ghost ack (type 1) for sequence %d", sequence);
if (message === GhostMessage.GhostAlwaysDone) {
log.info("Sending ghost ack for sequence %d", sequence);
this.relay.sendGhostAck(sequence, ghostCount);
}
}

View file

@ -68,7 +68,7 @@ export function buildGameEntityFromMission(
teamId?: number,
): GameEntity | null {
const className = object._className;
const id = `mission_${object._id}`;
const id = String(object._id);
const position = getPosition(object);
const scale = getScale(object);
const rotStr = object.rotation ?? "1 0 0 0";

View file

@ -22,7 +22,7 @@ import type {
import {
createMaterialFromFlags,
applyShapeShaderModifications,
} from "../components/GenericShape";
} from "../shapeMaterial";
import { isOrganicShape } from "../components/ShapeInfoProvider";
import {
loadIflAtlas,

View file

@ -144,6 +144,10 @@ export interface StreamEntity {
headPitch?: number;
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
headYaw?: number;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** WayPoint display label. */
label?: string;
// AudioEmitter ghost fields

View file

@ -1,10 +1,11 @@
import { parse as torqueScriptParse } from "@/generated/TorqueScript.js";
import { generate, type GeneratorOptions } from "./codegen";
import { parse, type ParseOptions } from "./parser";
import type { Program } from "./ast";
import { createRuntime } from "./runtime";
import { registerEngineStubs } from "./engineMethods";
import { TorqueObject, TorqueRuntime, TorqueRuntimeOptions } from "./types";
export { parse, type ParseOptions } from "./parser";
export { generate, type GeneratorOptions } from "./codegen";
export type { Program } from "./ast";
export { createBuiltins } from "./builtins";
@ -32,26 +33,8 @@ export type {
TorqueRuntimeOptions,
} from "./types";
export interface ParseOptions {
filename?: string;
}
export type TranspileOptions = ParseOptions & GeneratorOptions;
export function parse(source: string, options?: ParseOptions): Program {
try {
return torqueScriptParse(source);
} catch (error: any) {
if (options?.filename && error.location) {
throw new Error(
`${options.filename}:${error.location.start.line}:${error.location.start.column}: ${error.message}`,
{ cause: error },
);
}
throw error;
}
}
export function transpile(
source: string,
options?: TranspileOptions,

View file

@ -0,0 +1,20 @@
import { parse as torqueScriptParse } from "@/generated/TorqueScript.js";
import type { Program } from "./ast";
export interface ParseOptions {
filename?: string;
}
export function parse(source: string, options?: ParseOptions): Program {
try {
return torqueScriptParse(source);
} catch (error: any) {
if (options?.filename && error.location) {
throw new Error(
`${options.filename}:${error.location.start.line}:${error.location.start.column}: ${error.message}`,
{ cause: error },
);
}
throw error;
}
}

View file

@ -1,7 +1,8 @@
import picomatch from "picomatch";
import { createLogger } from "../logger";
import { generate } from "./codegen";
import { parse, type Program } from "./index";
import { parse } from "./parser";
import type { Program } from "./ast";
import { createBuiltins as defaultCreateBuiltins } from "./builtins";
const log = createLogger("runtime");

View file

@ -82,6 +82,7 @@ const vertexShader = /* glsl */ `
`;
const fragmentShader = /* glsl */ `
#define HAS_FOG_DISTANCE_SCALE
#include <fog_pars_fragment>
// Enable volumetric fog (must be defined before fog uniforms)
@ -219,6 +220,7 @@ export function createWaterMaterial(options?: {
fogVolumeData: globalFogUniforms.fogVolumeData,
cameraHeight: globalFogUniforms.cameraHeight,
fogEnabled: globalFogUniforms.fogEnabled,
fogDistanceScale: globalFogUniforms.fogDistanceScale,
},
vertexShader,
fragmentShader,