mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 06:24:47 +00:00
simplify and unify entity management
This commit is contained in:
parent
dbff4e9fe3
commit
b6975da8ed
135 changed files with 2535 additions and 1545 deletions
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
38
src/components/DebugBounds.tsx
Normal file
38
src/components/DebugBounds.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
135
src/components/DebugEntityList.module.css
Normal file
135
src/components/DebugEntityList.module.css
Normal 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;
|
||||
}
|
||||
169
src/components/DebugEntityList.tsx
Normal file
169
src/components/DebugEntityList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
30
src/components/audioPlaybackRate.ts
Normal file
30
src/components/audioPlaybackRate.ts
Normal 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);
|
||||
}
|
||||
180
src/components/useEntitySoundSlots.ts
Normal file
180
src/components/useEntitySoundSlots.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
79
src/shapePreloader.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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, ghost→ID 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import type {
|
|||
import {
|
||||
createMaterialFromFlags,
|
||||
applyShapeShaderModifications,
|
||||
} from "../components/GenericShape";
|
||||
} from "../shapeMaterial";
|
||||
import { isOrganicShape } from "../components/ShapeInfoProvider";
|
||||
import {
|
||||
loadIflAtlas,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
src/torqueScript/parser.ts
Normal file
20
src/torqueScript/parser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue