mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 14:34:51 +00:00
add env maps
This commit is contained in:
parent
9a95c04c68
commit
7c0e39d57c
491 changed files with 14447 additions and 1552 deletions
|
|
@ -1,38 +1,61 @@
|
|||
/**
|
||||
* Color parsing utilities for Tribes 2 mission files.
|
||||
* Color parsing utilities for Tribes 2 mission/scene files.
|
||||
*
|
||||
* Torque (2001) worked in gamma/sRGB space - colors in mission files are
|
||||
* Torque (2001) worked in gamma/sRGB space — colors in mission files are
|
||||
* specified as they should appear on screen. Three.js expects linear colors
|
||||
* for lighting calculations, so convert with .convertSRGBToLinear() when
|
||||
* passing to lit materials.
|
||||
* for lighting calculations, so convert with SRGBColorSpace or
|
||||
* .convertSRGBToLinear() when passing to lit materials.
|
||||
*/
|
||||
import { Color, SRGBColorSpace } from "three";
|
||||
import type { Color3, Color4 } from "./scene/types";
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
|
||||
* The values are interpreted as sRGB and stored as linear internally by Three.js.
|
||||
*
|
||||
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
|
||||
* @returns Color (linear internally), or undefined if no string
|
||||
*/
|
||||
/** Parse a Torque color string ("R G B" or "R G B A", values 0–1) as sRGB. */
|
||||
export function parseColor(colorString: string | undefined): Color | undefined {
|
||||
if (!colorString) return undefined;
|
||||
const parts = colorString.split(" ").map((s) => parseFloat(s));
|
||||
const [r = 0, g = 0, b = 0] = parts;
|
||||
// Interpret as sRGB, Three.js converts to linear internally
|
||||
return new Color().setRGB(r, g, b, SRGBColorSpace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Tribes 2 color string and convert to linear color space.
|
||||
* Use this when passing colors to Three.js lit materials.
|
||||
*
|
||||
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
|
||||
* @returns Color in linear space, or undefined if no string
|
||||
*/
|
||||
/** Parse a Torque color string and convert to linear color space. */
|
||||
export function parseColorLinear(
|
||||
colorString: string | undefined,
|
||||
): Color | undefined {
|
||||
const color = parseColor(colorString);
|
||||
return color?.convertSRGBToLinear();
|
||||
}
|
||||
|
||||
/** Parse a Torque color string to a plain {r, g, b} object (raw sRGB). */
|
||||
export function parseColor3(
|
||||
s: string | undefined,
|
||||
fallback: Color3 = { r: 0, g: 0, b: 0 },
|
||||
): Color3 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a Torque color string to a plain {r, g, b, a} object (raw sRGB). */
|
||||
export function parseColor4(
|
||||
s: string | undefined,
|
||||
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
||||
): Color4 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
a: parts[3] ?? fallback.a,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a Torque color string to a [r, g, b] tuple (raw sRGB). */
|
||||
export function parseColorTuple(s: string): [number, number, number] {
|
||||
const parts = s.split(" ").map((v) => parseFloat(v));
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
padding: 6px 8px;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRecording } from "./RecordingProvider";
|
||||
import { useRecording } from "./usePlayback";
|
||||
import { useInputMode } from "./InputContext";
|
||||
import { useCameraTour } from "../state/cameraTourStore";
|
||||
import { InputBindings } from "./InputBindings";
|
||||
|
|
|
|||
|
|
@ -26,6 +26,22 @@ 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.
|
||||
const rate = engineStore.getState().playback.rate;
|
||||
for (const [sound, basePitch] of _activeSounds) {
|
||||
try {
|
||||
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
|
||||
} catch {
|
||||
/* disposed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a sound for automatic playback rate tracking during streaming. */
|
||||
export function trackSound(
|
||||
sound: Audio<GainNode | PannerNode>,
|
||||
|
|
@ -50,6 +66,12 @@ 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);
|
||||
}
|
||||
|
||||
/** Stop and unregister all tracked sounds. Called on recording change. */
|
||||
export function stopAllTrackedSounds(): void {
|
||||
_soundGeneration++;
|
||||
|
|
@ -73,7 +95,7 @@ engineStore.subscribe(
|
|||
(rate) => {
|
||||
for (const [sound, basePitch] of _activeSounds) {
|
||||
try {
|
||||
sound.setPlaybackRate(basePitch * rate);
|
||||
sound.setPlaybackRate(basePitch * (_adjustAudioSpeed ? rate : 1));
|
||||
} catch {
|
||||
// Sound may have been disposed.
|
||||
}
|
||||
|
|
@ -151,7 +173,7 @@ export function playOneShotSound(
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(rate);
|
||||
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
|
||||
if (position) {
|
||||
sound.position.copy(position);
|
||||
}
|
||||
|
|
@ -171,7 +193,7 @@ export function playOneShotSound(
|
|||
const sound = new Audio(audioListener);
|
||||
sound.setBuffer(buffer);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(rate);
|
||||
sound.setPlaybackRate(_adjustAudioSpeed ? rate : 1);
|
||||
_activeSounds.set(sound, 1);
|
||||
sound.play();
|
||||
sound.source!.onended = () => {
|
||||
|
|
|
|||
|
|
@ -342,8 +342,7 @@ export function CameraTourConsumer() {
|
|||
useInputAction("nextStop", () => {
|
||||
const animation = cameraTourStore.getState().animation;
|
||||
if (!animation) return;
|
||||
const isLastTarget =
|
||||
animation.currentIndex >= animation.targets.length - 1;
|
||||
const isLastTarget = animation.currentIndex >= animation.targets.length - 1;
|
||||
if (isLastTarget) {
|
||||
cameraTourStore.getState().cancel();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { audioToUrl } from "../loaders";
|
|||
import { useAudio } from "./AudioContext";
|
||||
import {
|
||||
getCachedAudioBuffer,
|
||||
getEffectiveSoundRate,
|
||||
getSoundGeneration,
|
||||
trackSound,
|
||||
untrackSound,
|
||||
|
|
@ -76,7 +77,7 @@ export function ChatSoundPlayer() {
|
|||
}
|
||||
const sound = new Audio(audioListener);
|
||||
sound.setBuffer(buffer);
|
||||
sound.setPlaybackRate(pitch * rate);
|
||||
sound.setPlaybackRate(getEffectiveSoundRate(pitch));
|
||||
trackSound(sound, pitch);
|
||||
if (sender) {
|
||||
activeBySender.set(sender, sound);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,53 @@
|
|||
import { Stats, Html } from "@react-three/drei";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AxesHelper } from "three";
|
||||
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 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);
|
||||
|
||||
|
|
@ -40,6 +85,7 @@ export function DebugElements() {
|
|||
X
|
||||
</span>
|
||||
</Html>
|
||||
<DebugEnvMapSphere />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
useRecording,
|
||||
useSpeed,
|
||||
SPEED_OPTIONS,
|
||||
} from "./RecordingProvider";
|
||||
} from "./usePlayback";
|
||||
// import {
|
||||
// streamPlaybackStore,
|
||||
// type DemoCameraMode,
|
||||
|
|
@ -143,7 +143,7 @@ export function DemoPlaybackControls() {
|
|||
>
|
||||
{SPEED_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}x
|
||||
{s}×
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
TimelineEvent,
|
||||
TimelineEventType,
|
||||
} from "../state/demoTimelineStore";
|
||||
import { usePlaybackActions } from "./RecordingProvider";
|
||||
import { usePlaybackActions } from "./usePlayback";
|
||||
import { BsPlayFill } from "react-icons/bs";
|
||||
import { AiFillStop } from "react-icons/ai";
|
||||
import { LuCrosshair } from "react-icons/lu";
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
|||
<ShapeRenderer
|
||||
loadingColor={loadingColor}
|
||||
streamEntity={torqueObject ? undefined : entity}
|
||||
emap={entity.emap}
|
||||
>
|
||||
{flagLabel ? (
|
||||
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
import { memo, useCallback, useRef, useState, useMemo } from "react";
|
||||
import { memo, useCallback, useRef, useState, useMemo, Suspense } from "react";
|
||||
import { Quaternion } from "three";
|
||||
import type { Group } from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useAllGameEntities } from "../state/gameEntityStore";
|
||||
import type {
|
||||
GameEntity,
|
||||
PositionedEntity,
|
||||
PlayerEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import type { GameEntity, PositionedEntity } from "../state/gameEntityTypes";
|
||||
import { isSceneEntity } from "../state/gameEntityTypes";
|
||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||
import { EntityRenderer } from "./EntityRenderer";
|
||||
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
|
||||
import { PlayerNameplate } from "./PlayerNameplate";
|
||||
import { FlagMarker } from "./FlagMarker";
|
||||
import { entityTypeColor } from "../stream/playbackUtils";
|
||||
import { useEngineSelector } from "../state/engineStore";
|
||||
|
||||
/**
|
||||
* The ONE rendering component tree for all game entities.
|
||||
|
|
@ -94,18 +88,6 @@ const EntityWrapper = memo(function EntityWrapper({
|
|||
return <PositionedEntityWrapper entity={entity} />;
|
||||
});
|
||||
|
||||
/** Renders the player nameplate, subscribing to controlPlayerGhostId
|
||||
* internally so that PositionedEntityWrapper doesn't need to. Keeps
|
||||
* engine store mutations from triggering synchronous selector evaluations
|
||||
* on every positioned entity. */
|
||||
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
|
||||
const controlPlayerGhostId = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
|
||||
);
|
||||
if (entity.id === controlPlayerGhostId) return null;
|
||||
return <PlayerNameplate entity={entity} />;
|
||||
}
|
||||
|
||||
/** Imperatively tracks targetRenderFlags bit 0x2 on a game entity and
|
||||
* mounts/unmounts FlagMarker when the flag state changes. Entity field
|
||||
* mutations don't trigger React re-renders (ID-only equality), so this
|
||||
|
|
@ -145,8 +127,6 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
|
|||
return new Quaternion(...entity.rotation);
|
||||
}, [entity.rotation]);
|
||||
|
||||
const isPlayer = entity.renderType === "Player";
|
||||
|
||||
// Entities without a resolved shape get a wireframe placeholder.
|
||||
if (entity.renderType === "Shape" && !entity.shapeName) {
|
||||
return (
|
||||
|
|
@ -190,9 +170,6 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
|
|||
<ShapeErrorBoundary fallback={fallback}>
|
||||
<EntityRenderer entity={entity} />
|
||||
</ShapeErrorBoundary>
|
||||
{isPlayer && (
|
||||
<PlayerNameplateIfVisible entity={entity as PlayerEntity} />
|
||||
)}
|
||||
<FlagMarkerSlot entity={entity} />
|
||||
</group>
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { Color } from "three";
|
||||
import { parseColorLinear } from "../colorUtils";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { getFloat, getProperty } from "../mission";
|
||||
import type { SceneSky } from "../scene/types";
|
||||
|
|
@ -84,15 +85,7 @@ const FogUniformsContext =
|
|||
*
|
||||
* Torque (2001) worked in gamma space - colors were specified as they should
|
||||
* appear on screen. Three.js expects linear colors (it converts to sRGB on output).
|
||||
* We convert sRGB->linear so the final output matches the intended appearance.
|
||||
*/
|
||||
function parseColor(colorString: string | undefined): Color {
|
||||
if (!colorString) return new Color(0.5, 0.5, 0.5);
|
||||
const parts = colorString.split(" ").map((s) => parseFloat(s));
|
||||
const [r, g, b] = parts;
|
||||
// Convert from sRGB (how Torque specified colors) to linear (what Three.js expects)
|
||||
return new Color().setRGB(r, g, b).convertSRGBToLinear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fog volume property string.
|
||||
|
|
@ -148,7 +141,9 @@ export function parseFogState(
|
|||
? highVisibleDistance
|
||||
: visibleDistanceBase;
|
||||
|
||||
const fogColor = parseColor(getProperty(object, "fogColor"));
|
||||
const fogColor =
|
||||
parseColorLinear(getProperty(object, "fogColor")) ??
|
||||
new Color(0.5, 0.5, 0.5);
|
||||
|
||||
// Parse fog volumes (up to 3)
|
||||
// Note: fogVolumeColor is intentionally not parsed - see parseFogVolume comment
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { lazy, memo, Suspense } from "react";
|
||||
import { type RootState } from "@react-three/fiber";
|
||||
import { useDataSource } from "../state/gameEntityStore";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useRecording } from "./usePlayback";
|
||||
import { AudioProvider } from "./AudioContext";
|
||||
import { CamerasProvider } from "./CamerasProvider";
|
||||
import { InputProducer } from "./InputProducer";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { AnimationAction, Material } from "three";
|
||||
import { useGLTF, useTexture } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { createLogger } from "../logger";
|
||||
|
|
@ -12,13 +13,13 @@ import {
|
|||
AdditiveAnimationBlendMode,
|
||||
AnimationMixer,
|
||||
AnimationClip,
|
||||
AnimationUtils,
|
||||
LoopOnce,
|
||||
LoopRepeat,
|
||||
Texture,
|
||||
BufferGeometry,
|
||||
Group,
|
||||
} from "three";
|
||||
import type { AnimationAction, Material } from "three";
|
||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { setupTexture } from "../textureUtils";
|
||||
import { useAnisotropy } from "./useAnisotropy";
|
||||
|
|
@ -39,18 +40,25 @@ import {
|
|||
import type { IflAtlas } from "./useIflTexture";
|
||||
import { injectCustomFog } from "../fogShader";
|
||||
import { globalFogUniforms } from "../globalFogUniforms";
|
||||
import { injectShapeLighting } from "../shapeMaterial";
|
||||
import { injectShapeLighting, injectShapeEnvMap } from "../shapeMaterial";
|
||||
import {
|
||||
processShapeScene,
|
||||
replaceWithShapeMaterial,
|
||||
disposeClonedScene,
|
||||
buildRestPoseClip,
|
||||
} from "../stream/playbackUtils";
|
||||
import type { ThreadState as StreamThreadState } from "../stream/types";
|
||||
|
||||
/** Shape entity data readable in useFrame for streaming mode. */
|
||||
interface StreamShapeEntity {
|
||||
threads?: StreamThreadState[];
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
damageState?: number;
|
||||
emap?: boolean;
|
||||
wheels?: Array<{
|
||||
speed: number;
|
||||
lateralSlip: number;
|
||||
longitudinalSlip: number;
|
||||
}>;
|
||||
steeringYaw?: number;
|
||||
frozen?: boolean;
|
||||
maxSteeringAngle?: number;
|
||||
|
|
@ -115,14 +123,27 @@ const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
|
|||
|
||||
/**
|
||||
* 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 {
|
||||
mat.onBeforeCompile =
|
||||
mat instanceof MeshLambertMaterial
|
||||
? lambertBeforeCompile
|
||||
: basicBeforeCompile;
|
||||
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(
|
||||
|
|
@ -132,6 +153,7 @@ export function createMaterialFromFlags(
|
|||
isOrganic: boolean,
|
||||
vis: number = 1,
|
||||
animated: boolean = false,
|
||||
reflectionAmount: number = 0,
|
||||
): MaterialResult {
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
const isAdditive = flagNames.has("Additive");
|
||||
|
|
@ -141,6 +163,13 @@ export function createMaterialFromFlags(
|
|||
// 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.
|
||||
|
|
@ -156,7 +185,7 @@ export function createMaterialFromFlags(
|
|||
...(isFaded && { opacity: vis }),
|
||||
...(isAdditive && { blending: AdditiveBlending }),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
applyShapeShaderModifications(mat, envMapOptions);
|
||||
return mat;
|
||||
}
|
||||
|
||||
|
|
@ -186,13 +215,12 @@ export function createMaterialFromFlags(
|
|||
...baseProps,
|
||||
side: 0, // FrontSide
|
||||
});
|
||||
applyShapeShaderModifications(backMat);
|
||||
applyShapeShaderModifications(frontMat);
|
||||
applyShapeShaderModifications(backMat, envMapOptions);
|
||||
applyShapeShaderModifications(frontMat, envMapOptions);
|
||||
return [backMat, frontMat];
|
||||
}
|
||||
|
||||
// Default: use Lambert for diffuse-only lighting (matches Tribes 2)
|
||||
// Tribes 2 used fixed-function GL with specular disabled
|
||||
const mat = new MeshLambertMaterial({
|
||||
map: texture,
|
||||
side: 2, // DoubleSide
|
||||
|
|
@ -203,7 +231,7 @@ export function createMaterialFromFlags(
|
|||
depthWrite: false,
|
||||
}),
|
||||
});
|
||||
applyShapeShaderModifications(mat);
|
||||
applyShapeShaderModifications(mat, envMapOptions);
|
||||
return mat;
|
||||
}
|
||||
|
||||
|
|
@ -476,11 +504,14 @@ export function DebugPlaceholder({
|
|||
export const ShapeRenderer = memo(function ShapeRenderer({
|
||||
loadingColor = "yellow",
|
||||
streamEntity,
|
||||
emap,
|
||||
children,
|
||||
}: {
|
||||
loadingColor?: string;
|
||||
/** Stable entity reference whose fields are mutated in-place. */
|
||||
streamEntity?: StreamShapeEntity;
|
||||
/** Datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { object, shapeName } = useShapeInfo();
|
||||
|
|
@ -496,9 +527,12 @@ export const ShapeRenderer = memo(function ShapeRenderer({
|
|||
fallback={
|
||||
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
|
||||
}
|
||||
onError={(error) => {
|
||||
log.error("Shape error: %s: %o", shapeName, error);
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<ShapePlaceholder color={loadingColor} />}>
|
||||
<ShapeModelLoader streamEntity={streamEntity} />
|
||||
<ShapeModelLoader streamEntity={streamEntity} emap={emap} />
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
|
@ -530,10 +564,13 @@ interface ThreadState {
|
|||
export const ShapeModel = memo(function ShapeModel({
|
||||
gltf,
|
||||
streamEntity,
|
||||
emap,
|
||||
}: {
|
||||
gltf: ReturnType<typeof useStaticShape>;
|
||||
/** Stable entity reference whose fields are mutated in-place. */
|
||||
streamEntity?: StreamShapeEntity;
|
||||
/** Datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
}) {
|
||||
const { object, shapeName } = useShapeInfo();
|
||||
const { debugMode } = useDebug();
|
||||
|
|
@ -588,7 +625,10 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
});
|
||||
|
||||
processShapeScene(scene, shapeName ?? undefined, { anisotropy });
|
||||
processShapeScene(scene, shapeName ?? undefined, {
|
||||
anisotropy,
|
||||
emap: emap ?? streamEntity?.emap,
|
||||
});
|
||||
|
||||
// Un-hide IFL meshes that don't have a vis sequence — they should always
|
||||
// be visible. IFL meshes WITH vis sequences stay hidden until their
|
||||
|
|
@ -646,7 +686,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
visNodesBySequence: visBySeq,
|
||||
iflMeshes: iflInfos,
|
||||
};
|
||||
}, [gltf, anisotropy]);
|
||||
}, [gltf, anisotropy, emap]);
|
||||
|
||||
// Dispose cloned geometries and materials when the scene is replaced or
|
||||
// the component unmounts, to prevent GPU memory from accumulating.
|
||||
|
|
@ -658,6 +698,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}, [clonedScene, mixer]);
|
||||
|
||||
const threadsRef = useRef(new Map<number, ThreadState>());
|
||||
const additiveClipsRef = useRef(new Set<string>());
|
||||
const iflMeshAtlasRef = useRef(new Map<any, IflAtlas>());
|
||||
|
||||
interface IflAnimInfo {
|
||||
|
|
@ -728,8 +769,10 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
if (typeof rawNames === "string") {
|
||||
try {
|
||||
const names: string[] = JSON.parse(rawNames);
|
||||
const cyclic: boolean[] = typeof rawCyclic === "string" ? JSON.parse(rawCyclic) : [];
|
||||
const blend: boolean[] = typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
|
||||
const cyclic: boolean[] =
|
||||
typeof rawCyclic === "string" ? JSON.parse(rawCyclic) : [];
|
||||
const blend: boolean[] =
|
||||
typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const lower = names[i].toLowerCase();
|
||||
cycMap.set(lower, cyclic[i] ?? true);
|
||||
|
|
@ -752,6 +795,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
// handler reads ghost ThreadMask data and drives everything.
|
||||
useEffect(() => {
|
||||
const threads = threadsRef.current;
|
||||
const additiveClips = additiveClipsRef.current;
|
||||
const isMissionMode = streamEntityRef.current == null;
|
||||
|
||||
function prepareVisNode(v: VisNode) {
|
||||
|
|
@ -792,10 +836,16 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
}
|
||||
// Blend sequences (DTS flag 0x8) are delta transforms multiplied
|
||||
// onto the existing pose. Use Three.js additive blending so they
|
||||
// composite on top of non-blend threads (e.g. Deploy on Ambient).
|
||||
// Blend sequences (DTS flag 0x8) are delta transforms post-multiplied
|
||||
// onto the existing pose. The GLB stores rest*delta (Blender's glTF
|
||||
// exporter bakes in the rest pose). Subtracting the rest pose via
|
||||
// buildRestPoseClip recovers pure deltas for Three.js additive mode.
|
||||
if (seqBlendByName.has(seqLower)) {
|
||||
if (!additiveClips.has(seqLower)) {
|
||||
const restClip = buildRestPoseClip(gltf.scene, clip);
|
||||
AnimationUtils.makeClipAdditive(clip, 0, restClip, 30);
|
||||
additiveClips.add(seqLower);
|
||||
}
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
}
|
||||
action.reset().play();
|
||||
|
|
@ -834,6 +884,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
handlePlayThreadRef.current = null;
|
||||
handleStopThreadRef.current = null;
|
||||
prevDemoThreadsRef.current = undefined;
|
||||
additiveClips.clear();
|
||||
for (const slot of [...threads.keys()]) handleStopThread(slot);
|
||||
};
|
||||
}
|
||||
|
|
@ -1095,6 +1146,26 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
}
|
||||
|
||||
// Drive damage state Visibility — swap normal/HULK meshes.
|
||||
// In Torque, mHulkThread plays the "Visibility" sequence at pos 0.0
|
||||
// (Enabled) or 1.0 (Destroyed). The vis_keyframes on each mesh encode:
|
||||
// normal meshes [1,0] (visible→hidden), HULK meshes [0,1] (hidden→visible).
|
||||
const entity = streamEntityRef.current;
|
||||
const damageState = entity?.damageState ?? 0;
|
||||
const visibilityNodes = visNodesBySequence.get("visibility");
|
||||
if (visibilityNodes) {
|
||||
// Position 0.0 = Enabled (normal visible), 1.0 = Destroyed (HULK visible)
|
||||
const pos = damageState >= 2 ? 1.0 : 0.0;
|
||||
for (const { mesh, keyframes } of visibilityNodes) {
|
||||
const mat = mesh.material;
|
||||
if (!mat || Array.isArray(mat)) continue;
|
||||
const n = keyframes.length;
|
||||
const idx = Math.min(Math.floor(pos * n), n - 1);
|
||||
mat.opacity = keyframes[idx];
|
||||
mesh.visible = mat.opacity > 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
// Drive WheeledVehicle wheel/spring/turn animations from ghost state.
|
||||
const wheelAnims = wheelAnimsRef.current;
|
||||
if (wheelAnims && animationEnabled) {
|
||||
|
|
@ -1114,8 +1185,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
if (wa.wheelAction && wheel) {
|
||||
wa.rotation += wheel.speed * effectDelta * Math.PI * 2;
|
||||
wa.rotation -= Math.floor(wa.rotation); // wrap to [0,1)
|
||||
wa.wheelAction.time =
|
||||
wa.rotation * wa.wheelAction.getClip().duration;
|
||||
wa.wheelAction.time = wa.rotation * wa.wheelAction.getClip().duration;
|
||||
}
|
||||
|
||||
// Spring: ghost vehicles stay at rest (fully extended = pos 0).
|
||||
|
|
@ -1125,12 +1195,10 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
// Turn: steering angle → animation position.
|
||||
// Torque: pos = 0.5 - t * 0.5 where t = steerAngle² / maxSteeringAngle
|
||||
if (wa.turnAction) {
|
||||
const t =
|
||||
(steeringYaw * Math.abs(steeringYaw)) / maxSteeringAngle;
|
||||
const t = (steeringYaw * Math.abs(steeringYaw)) / maxSteeringAngle;
|
||||
const pos = 0.5 - t * 0.5;
|
||||
wa.turnAction.time =
|
||||
Math.max(0, Math.min(1, pos)) *
|
||||
wa.turnAction.getClip().duration;
|
||||
Math.max(0, Math.min(1, pos)) * wa.turnAction.getClip().duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1198,10 +1266,12 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
|
||||
function ShapeModelLoader({
|
||||
streamEntity,
|
||||
emap,
|
||||
}: {
|
||||
streamEntity?: { threads?: StreamThreadState[] };
|
||||
streamEntity?: StreamShapeEntity;
|
||||
emap?: boolean;
|
||||
}) {
|
||||
const { shapeName } = useShapeInfo();
|
||||
const gltf = useStaticShape(shapeName);
|
||||
return <ShapeModel gltf={gltf} streamEntity={streamEntity} />;
|
||||
return <ShapeModel gltf={gltf} streamEntity={streamEntity} emap={emap} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type { ClientMove } from "../../relay/types";
|
|||
|
||||
const log = createLogger("InputConsumer");
|
||||
|
||||
const MAX_SPEED = 270;
|
||||
const MAX_SPEED = 200;
|
||||
const LOCAL_MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -120,9 +120,7 @@ export function eventModifiersMatch(
|
|||
const wantShift = required?.includes("Shift") ?? false;
|
||||
const wantAlt = required?.includes("Alt") ?? false;
|
||||
return (
|
||||
e.ctrlKey === wantCtrl &&
|
||||
e.shiftKey === wantShift &&
|
||||
e.altKey === wantAlt
|
||||
e.ctrlKey === wantCtrl && e.shiftKey === wantShift && e.altKey === wantAlt
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Accordion, AccordionGroup } from "./Accordion";
|
|||
import { useTouchDevice } from "./useTouchDevice";
|
||||
import { DemoTimeline } from "./DemoTimeline";
|
||||
import { MapTourPanel } from "./MapTourPanel";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useRecording } from "./usePlayback";
|
||||
import { useDataSource, useMissionName } from "../state/gameEntityStore";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import { hasMission } from "../manifest";
|
||||
|
|
@ -74,6 +74,8 @@ export const InspectorControls = memo(function InspectorControls({
|
|||
setAudioEnabled,
|
||||
audioVolume,
|
||||
setAudioVolume,
|
||||
adjustAudioSpeed,
|
||||
setAdjustAudioSpeed,
|
||||
animationEnabled,
|
||||
setAnimationEnabled,
|
||||
fpsLimit,
|
||||
|
|
@ -362,6 +364,22 @@ export const InspectorControls = memo(function InspectorControls({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.CheckboxField}>
|
||||
<input
|
||||
id="adjustAudioSpeedInput"
|
||||
type="checkbox"
|
||||
checked={adjustAudioSpeed}
|
||||
onChange={(event) => {
|
||||
setAdjustAudioSpeed(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className={styles.Label}
|
||||
htmlFor="adjustAudioSpeedInput"
|
||||
>
|
||||
Adjust audio speed to match demo playback
|
||||
</label>
|
||||
</div>
|
||||
</Accordion>
|
||||
<Accordion value="graphics" label="Graphics">
|
||||
<div className={styles.CheckboxField}>
|
||||
|
|
|
|||
|
|
@ -262,7 +262,11 @@ export const InteriorInstance = memo(function InteriorInstance({
|
|||
/>
|
||||
}
|
||||
onError={(error) => {
|
||||
log.error("Failed to load %s: %s", scene.interiorFile, (error as Error).message);
|
||||
log.error(
|
||||
"Failed to load %s: %s",
|
||||
scene.interiorFile,
|
||||
(error as Error).message,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DebugSuspense
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
useIsPlaying,
|
||||
useRecording,
|
||||
useSpeed,
|
||||
} from "./RecordingProvider";
|
||||
} from "./usePlayback";
|
||||
import { useInputMode } from "./InputContext";
|
||||
import { useCameraTour } from "../state/cameraTourStore";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createLogger } from "../logger";
|
|||
import { cameraTourStore } from "../state/cameraTourStore";
|
||||
import { demoTimelineStore } from "../state/demoTimelineStore";
|
||||
import { liveConnectionStore } from "../state/liveConnectionStore";
|
||||
import { usePlaybackActions, useRecording } from "./RecordingProvider";
|
||||
import { usePlaybackActions, useRecording } from "./usePlayback";
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
const log = createLogger("LoadDemoButton");
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@ import { InspectorControls } from "@/src/components/InspectorControls";
|
|||
import { MissionSelect } from "@/src/components/MissionSelect";
|
||||
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
|
||||
import { useSettings } from "@/src/components/SettingsProvider";
|
||||
import {
|
||||
RecordingProvider,
|
||||
useRecording,
|
||||
} from "@/src/components/RecordingProvider";
|
||||
import { useRecording } from "@/src/components/usePlayback";
|
||||
import { useFeatures } from "@/src/components/FeaturesProvider";
|
||||
import {
|
||||
liveConnectionStore,
|
||||
|
|
@ -227,7 +224,7 @@ export function MapInspector() {
|
|||
|
||||
return (
|
||||
<main className={styles.Frame}>
|
||||
<RecordingProvider>
|
||||
<>
|
||||
<header className={styles.Toolbar}>
|
||||
<ToggleSidebarButton
|
||||
orientation="top"
|
||||
|
|
@ -358,7 +355,7 @@ export function MapInspector() {
|
|||
</Suspense>
|
||||
</ViewTransition>
|
||||
) : null}
|
||||
</RecordingProvider>
|
||||
</>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
untrackSound,
|
||||
} from "./AudioEmitter";
|
||||
import { effectNow, engineStore } from "../state/engineStore";
|
||||
import { getEffectiveSoundRate } from "./AudioEmitter";
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
|
|
@ -1278,7 +1279,7 @@ export function ParticleEffects({
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(playbackState.rate);
|
||||
sound.setPlaybackRate(getEffectiveSoundRate());
|
||||
sound.setLoop(true);
|
||||
sound.position.set(
|
||||
entity.position![1],
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@ function WeaponHUD() {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
function TeamScores() {
|
||||
const teamScores = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.teamScores,
|
||||
|
|
@ -416,8 +415,7 @@ export function PlayerHUD() {
|
|||
|
||||
// In free-fly mode the camera is disconnected from the player, so
|
||||
// player-specific HUD elements (health, energy, weapons, etc.) are hidden.
|
||||
const showPlayerElements =
|
||||
hasControlPlayer && cameraMode !== "freeFly";
|
||||
const showPlayerElements = hasControlPlayer && cameraMode !== "freeFly";
|
||||
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ import {
|
|||
PositionalAudio,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import type { AnimationAction, AnimationClip } from "three";
|
||||
import type { AnimationAction } from "three";
|
||||
import { AnimationClip } from "three";
|
||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import {
|
||||
ANIM_TRANSITION_TIME,
|
||||
DEFAULT_EYE_HEIGHT,
|
||||
buildRestPoseClip,
|
||||
disposeClonedScene,
|
||||
getKeyframeAtTime,
|
||||
getPosedNodeTransform,
|
||||
|
|
@ -40,26 +42,15 @@ import {
|
|||
trackSound,
|
||||
untrackSound,
|
||||
} from "./AudioEmitter";
|
||||
import { getEffectiveSoundRate } from "./AudioEmitter";
|
||||
import type { ResolvedAudioProfile } from "./AudioEmitter";
|
||||
import { audioToUrl } from "../loaders";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
|
||||
import { PlayerNameplate } from "./PlayerNameplate";
|
||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||
import type { PlayerEntity } from "../state/gameEntityTypes";
|
||||
|
||||
/**
|
||||
* Map weapon shape to the arm blend animation (armThread).
|
||||
* Only missile launcher and sniper rifle have custom arm poses; all others
|
||||
* use the default `lookde`.
|
||||
*/
|
||||
function getArmThread(weaponShape: string | undefined): string {
|
||||
if (!weaponShape) return "lookde";
|
||||
const lower = weaponShape.toLowerCase();
|
||||
if (lower.includes("missile")) return "lookms";
|
||||
if (lower.includes("sniper")) return "looksn";
|
||||
return "lookde";
|
||||
}
|
||||
|
||||
/** Number of table actions in the engine's ActionAnimationList (Tribes2.exe build 25034). */
|
||||
const NUM_TABLE_ACTION_ANIMS = 8;
|
||||
|
||||
|
|
@ -223,19 +214,25 @@ function stopLoopingSound(
|
|||
*/
|
||||
export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
||||
const engineStore = useEngineStoreApi();
|
||||
const shapeName = entity.shapeName ?? entity.dataBlock;
|
||||
const gltf = useStaticShape(shapeName!);
|
||||
const shapeName = entity.shapeName!;
|
||||
const gltf = useStaticShape(shapeName);
|
||||
const shapeAliases = useEngineSelector((state) => {
|
||||
const sn = shapeName?.toLowerCase();
|
||||
return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
|
||||
});
|
||||
const anisotropy = useAnisotropy();
|
||||
const controlPlayerGhostId = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
|
||||
);
|
||||
|
||||
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
|
||||
const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
|
||||
useMemo(() => {
|
||||
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
||||
const iflInits = processShapeScene(scene, undefined, { anisotropy });
|
||||
const iflInits = processShapeScene(scene, undefined, {
|
||||
anisotropy,
|
||||
emap: entity.emap,
|
||||
});
|
||||
|
||||
// Use front-face-only rendering so the camera can see out from inside the
|
||||
// model in first-person (backface culling hides interior faces).
|
||||
|
|
@ -265,7 +262,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
mount2: m2,
|
||||
iflInitializers: iflInits,
|
||||
};
|
||||
}, [gltf, anisotropy]);
|
||||
}, [gltf.scene, anisotropy, entity.emap]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -381,34 +378,79 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
}
|
||||
blendActionsRef.current = blendRefs;
|
||||
|
||||
// Arm pose blend actions: create one per available arm animation so we
|
||||
// can switch between them when the equipped weapon changes.
|
||||
// All arm clips use the lookde midpoint as the additive reference, so
|
||||
// switching from lookde to lookms captures the shoulder repositioning.
|
||||
const armActions = new Map<string, AnimationAction>();
|
||||
const lookdeClip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === "lookde",
|
||||
// In Torque, the "root" animation provides arm bone values (R Clavicle,
|
||||
// R UpperArm, etc.) that persist even when movement anims play — because
|
||||
// movement anims don't animate arm bones. In Three.js, when root fades
|
||||
// out, arm bones fall to the rest pose. Fix: extract root's arm-only
|
||||
// tracks into a permanent action that always plays at weight=1.
|
||||
const rootClip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === "root",
|
||||
);
|
||||
const fps = 30;
|
||||
const lookdeRefFrame = lookdeClip
|
||||
? Math.round((lookdeClip.duration * fps) / 2)
|
||||
: 0;
|
||||
for (const armName of ["lookde", "lookms", "looksn"]) {
|
||||
const clip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === armName,
|
||||
);
|
||||
if (!clip) continue;
|
||||
const cloned = clip.clone();
|
||||
// Use lookde's midpoint as reference for all arm clips so that
|
||||
// lookms/looksn capture the absolute shoulder offset.
|
||||
const refClip = lookdeClip ?? clip;
|
||||
AnimationUtils.makeClipAdditive(cloned, lookdeRefFrame, refClip, fps);
|
||||
const action = mixer.clipAction(cloned);
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
action.timeScale = 0;
|
||||
action.weight = 0;
|
||||
action.play();
|
||||
armActions.set(armName, action);
|
||||
if (rootClip) {
|
||||
// Find bones that movement anims DON'T animate — these need root's values.
|
||||
const movementBones = new Set<string>();
|
||||
for (const clip of gltf.animations) {
|
||||
const lower = clip.name.toLowerCase();
|
||||
if (["forward", "back", "side", "fall"].includes(lower)) {
|
||||
for (const t of clip.tracks) {
|
||||
movementBones.add(t.name.slice(0, t.name.lastIndexOf(".")));
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootArmTracks = rootClip.tracks.filter((t) => {
|
||||
const bone = t.name.slice(0, t.name.lastIndexOf("."));
|
||||
return !movementBones.has(bone);
|
||||
});
|
||||
if (rootArmTracks.length > 0) {
|
||||
const rootArmsClip = new AnimationClip(
|
||||
"root_arms",
|
||||
rootClip.duration,
|
||||
rootArmTracks,
|
||||
);
|
||||
const rootArmsAction = mixer.clipAction(rootArmsClip);
|
||||
rootArmsAction.play(); // weight=1, always on
|
||||
}
|
||||
}
|
||||
|
||||
// Arm pose blend actions (DTS blend sequences). These are applied
|
||||
// additively on top of root's arm base. Subtracting the rest pose via
|
||||
// buildRestPoseClip recovers pure deltas from the GLB's rest*delta
|
||||
// keyframes. Applied onto root's arm values, this matches Torque's
|
||||
// post-multiply behavior.
|
||||
//
|
||||
// Instead of hardcoding arm pose names, iterate ALL blend sequences
|
||||
// from the GLB's dts_sequence_blend metadata (skipping head/headside
|
||||
// which are handled separately with their own pitch/yaw scrubbing).
|
||||
const armActions = new Map<string, AnimationAction>();
|
||||
const rawSeqNames = gltf.scene.userData?.dts_sequence_names;
|
||||
const rawSeqBlend = gltf.scene.userData?.dts_sequence_blend;
|
||||
if (typeof rawSeqNames === "string" && typeof rawSeqBlend === "string") {
|
||||
try {
|
||||
const seqNames: string[] = JSON.parse(rawSeqNames);
|
||||
const seqBlend: boolean[] = JSON.parse(rawSeqBlend);
|
||||
for (let i = 0; i < seqNames.length; i++) {
|
||||
if (!seqBlend[i]) continue;
|
||||
const name = seqNames[i].toLowerCase();
|
||||
// head/headside are blend sequences but driven by headPitch/headYaw,
|
||||
// not the arm action index — handled separately above.
|
||||
if (name === "head" || name === "headside") continue;
|
||||
const clip = gltf.animations.find(
|
||||
(c) => c.name.toLowerCase() === name,
|
||||
);
|
||||
if (!clip) continue;
|
||||
const cloned = clip.clone();
|
||||
const restClip = buildRestPoseClip(gltf.scene, cloned);
|
||||
AnimationUtils.makeClipAdditive(cloned, 0, restClip, 30);
|
||||
const action = mixer.clipAction(cloned);
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
action.timeScale = 0;
|
||||
action.weight = 0;
|
||||
action.play();
|
||||
armActions.set(name, action);
|
||||
}
|
||||
} catch {
|
||||
/* malformed metadata */
|
||||
}
|
||||
}
|
||||
armActionsRef.current = armActions;
|
||||
|
||||
|
|
@ -685,8 +727,14 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
}
|
||||
}
|
||||
|
||||
// Switch arm blend animation based on equipped weapon.
|
||||
const desiredArm = getArmThread(entity.weaponShape);
|
||||
// Switch arm blend animation based on the networked arm action index.
|
||||
// The server resolves the weapon datablock's armThread field to an action
|
||||
// index and sends it via Player::packUpdate (ActionMask).
|
||||
const armEntry =
|
||||
entity.armAction != null
|
||||
? actionAnimMap.get(entity.armAction)
|
||||
: undefined;
|
||||
const desiredArm = armEntry?.clipName ?? "lookde";
|
||||
if (desiredArm !== activeArmRef.current) {
|
||||
const armActions = armActionsRef.current;
|
||||
const prev = activeArmRef.current
|
||||
|
|
@ -751,7 +799,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
try {
|
||||
sound.setBuffer(jetBufferRef.current);
|
||||
sound.setLoop(true);
|
||||
sound.setPlaybackRate(playback.rate);
|
||||
sound.setPlaybackRate(getEffectiveSoundRate());
|
||||
sound.play();
|
||||
trackSound(sound, 1);
|
||||
} catch {
|
||||
|
|
@ -779,6 +827,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{entity.id !== controlPlayerGhostId && (
|
||||
<PlayerNameplate entity={entity} />
|
||||
)}
|
||||
<group rotation={[0, Math.PI / 2, 0]}>
|
||||
<primitive object={clonedScene} />
|
||||
</group>
|
||||
|
|
@ -812,7 +863,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
<ShapePlaceholder color="cyan" label={currentPackShape} />
|
||||
}
|
||||
>
|
||||
<PackModel packShape={currentPackShape} mountBone={mount1} />
|
||||
<PackModel
|
||||
packShape={currentPackShape}
|
||||
mountBone={mount1}
|
||||
emap={entity.emap}
|
||||
/>
|
||||
</DebugSuspense>
|
||||
</ShapeErrorBoundary>
|
||||
)}
|
||||
|
|
@ -827,7 +882,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
<ShapePlaceholder color="cyan" label={currentFlagShape} />
|
||||
}
|
||||
>
|
||||
<PackModel packShape={currentFlagShape} mountBone={mount2} />
|
||||
<PackModel
|
||||
packShape={currentFlagShape}
|
||||
mountBone={mount2}
|
||||
emap={entity.emap}
|
||||
/>
|
||||
</DebugSuspense>
|
||||
</ShapeErrorBoundary>
|
||||
)}
|
||||
|
|
@ -888,7 +947,10 @@ function WeaponModel({
|
|||
weaponIflInitializers,
|
||||
} = useMemo(() => {
|
||||
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
|
||||
const iflInits = processShapeScene(clone, undefined, { anisotropy });
|
||||
const iflInits = processShapeScene(clone, undefined, {
|
||||
anisotropy,
|
||||
emap: entity.emap,
|
||||
});
|
||||
|
||||
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
|
||||
const mp = getPosedNodeTransform(
|
||||
|
|
@ -1097,7 +1159,7 @@ function WeaponModel({
|
|||
sound.setMaxDistance(resolved.maxDist);
|
||||
sound.setRolloffFactor(1);
|
||||
sound.setVolume(resolved.volume);
|
||||
sound.setPlaybackRate(playback.rate);
|
||||
sound.setPlaybackRate(getEffectiveSoundRate());
|
||||
sound.setLoop(true);
|
||||
weaponClone.add(sound);
|
||||
trackSound(sound);
|
||||
|
|
@ -1234,16 +1296,21 @@ function applyWeaponAnim(
|
|||
function PackModel({
|
||||
packShape,
|
||||
mountBone,
|
||||
emap,
|
||||
}: {
|
||||
packShape: string;
|
||||
mountBone: Object3D;
|
||||
emap?: boolean;
|
||||
}) {
|
||||
const packGltf = useStaticShape(packShape);
|
||||
const anisotropy = useAnisotropy();
|
||||
|
||||
const { packClone, packIflInitializers } = useMemo(() => {
|
||||
const clone = SkeletonUtils.clone(packGltf.scene) as Group;
|
||||
const iflInits = processShapeScene(clone, undefined, { anisotropy });
|
||||
const iflInits = processShapeScene(clone, undefined, {
|
||||
anisotropy,
|
||||
emap,
|
||||
});
|
||||
|
||||
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
|
||||
const mp = getPosedNodeTransform(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const EMPTY_KEYFRAMES: never[] = [];
|
|||
* bar. Fades out with distance.
|
||||
*/
|
||||
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
||||
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
|
||||
const gltf = useStaticShape(entity.shapeName!);
|
||||
const { groupRef, isVisible, opacityRef } = useFloatingLabelFade({
|
||||
fadeDistance: NAMEPLATE_FADE_DISTANCE,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "react";
|
||||
import { useFogQueryState } from "./useQueryParams";
|
||||
import { useTouchDevice } from "./useTouchDevice";
|
||||
import { setAdjustAudioSpeedFlag } from "./AudioEmitter";
|
||||
|
||||
export const MIN_SPEED_MULTIPLIER = 0.01;
|
||||
export const MAX_SPEED_MULTIPLIER = 1;
|
||||
|
|
@ -40,6 +41,8 @@ type SettingsContextType = {
|
|||
setWarriorName: StateSetter<string>;
|
||||
audioVolume: number;
|
||||
setAudioVolume: StateSetter<number>;
|
||||
adjustAudioSpeed: boolean;
|
||||
setAdjustAudioSpeed: StateSetter<boolean>;
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: StateSetter<boolean>;
|
||||
fpsLimit: number | null;
|
||||
|
|
@ -81,6 +84,7 @@ type PersistedSettings = {
|
|||
mouseSensitivity?: number;
|
||||
fov?: number;
|
||||
audioEnabled?: boolean;
|
||||
adjustAudioSpeed?: boolean;
|
||||
animationEnabled?: boolean;
|
||||
debugMode?: boolean;
|
||||
touchMode?: TouchMode;
|
||||
|
|
@ -134,6 +138,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
const [fov, setFov] = useState(90);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [audioVolume, setAudioVolume] = useState(0.75);
|
||||
const [adjustAudioSpeed, setAdjustAudioSpeed] = useState(true);
|
||||
const [animationEnabled, setAnimationEnabled] = useState(true);
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
|
||||
|
|
@ -176,6 +181,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
setWarriorName,
|
||||
audioVolume,
|
||||
setAudioVolume,
|
||||
adjustAudioSpeed,
|
||||
setAdjustAudioSpeed,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
fpsLimit,
|
||||
|
|
@ -194,6 +201,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
animationEnabled,
|
||||
warriorName,
|
||||
audioVolume,
|
||||
adjustAudioSpeed,
|
||||
sidebarOpen,
|
||||
fpsLimit,
|
||||
showInputOverlay,
|
||||
|
|
@ -294,6 +302,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
if (savedSettings.audioVolume != null) {
|
||||
setAudioVolume(savedSettings.audioVolume);
|
||||
}
|
||||
if (savedSettings.adjustAudioSpeed != null) {
|
||||
setAdjustAudioSpeed(savedSettings.adjustAudioSpeed);
|
||||
}
|
||||
if (savedSettings.invertScroll != null) {
|
||||
setInvertScroll(savedSettings.invertScroll);
|
||||
}
|
||||
|
|
@ -320,6 +331,11 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [isTouch]);
|
||||
|
||||
// Sync adjustAudioSpeed to the AudioEmitter module-level flag.
|
||||
useEffect(() => {
|
||||
setAdjustAudioSpeedFlag(adjustAudioSpeed);
|
||||
}, [adjustAudioSpeed]);
|
||||
|
||||
// Persist settings to localStorage with debouncing to avoid excessive writes
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -343,6 +359,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
touchMode,
|
||||
warriorName,
|
||||
audioVolume,
|
||||
adjustAudioSpeed,
|
||||
invertScroll,
|
||||
invertDrag,
|
||||
invertJoystick,
|
||||
|
|
@ -374,6 +391,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
touchMode,
|
||||
warriorName,
|
||||
audioVolume,
|
||||
adjustAudioSpeed,
|
||||
invertScroll,
|
||||
invertDrag,
|
||||
invertJoystick,
|
||||
|
|
|
|||
|
|
@ -24,19 +24,6 @@ import type { TorqueObject } from "../torqueScript";
|
|||
import type { ExplosionEntity, ShapeEntity } from "../state/gameEntityTypes";
|
||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||
|
||||
/**
|
||||
* Map weapon shape to the arm blend animation (armThread).
|
||||
* Only missile launcher and sniper rifle have custom arm poses; all others
|
||||
* use the default `lookde`.
|
||||
*/
|
||||
function getArmThread(weaponShape: string | undefined): string {
|
||||
if (!weaponShape) return "lookde";
|
||||
const lower = weaponShape.toLowerCase();
|
||||
if (lower.includes("missile")) return "lookms";
|
||||
if (lower.includes("sniper")) return "looksn";
|
||||
return "lookde";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mounted weapon using the Torque engine's mount system.
|
||||
*
|
||||
|
|
@ -54,7 +41,9 @@ export function WeaponModel({ entity }: { entity: ShapeEntity }) {
|
|||
|
||||
const mountTransform = useMemo(() => {
|
||||
// Get Mount0 from the player's posed skeleton with arm animation applied.
|
||||
const armThread = getArmThread(shapeName);
|
||||
// TODO: resolve arm animation from armAction index once actionAnimMap
|
||||
// is available here. For now use default arm pose.
|
||||
const armThread = "lookde";
|
||||
const m0 = getPosedNodeTransform(
|
||||
playerGltf.scene,
|
||||
playerGltf.animations,
|
||||
|
|
@ -275,7 +264,10 @@ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
|
|||
}
|
||||
});
|
||||
|
||||
processShapeScene(scene, entity.shapeName, { anisotropy });
|
||||
processShapeScene(scene, entity.shapeName, {
|
||||
anisotropy,
|
||||
emap: "emap" in entity ? (entity as any).emap : undefined,
|
||||
});
|
||||
|
||||
// Collect vis-animated nodes keyed by sequence name.
|
||||
const visNodes: VisNode[] = [];
|
||||
|
|
|
|||
163
src/components/ShapeViewer.module.css
Normal file
163
src/components/ShapeViewer.module.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
.Root {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.Sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #333;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.Title {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.Search {
|
||||
margin: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: #1a1a1a;
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.Search:focus {
|
||||
border-color: #5599cc;
|
||||
}
|
||||
|
||||
.ShapeList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ShapeItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 4px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ShapeItem:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ShapeItem[data-active="true"] {
|
||||
background: rgba(0, 100, 200, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.Main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.CanvasWrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.AnimPanel {
|
||||
height: 200px;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid #333;
|
||||
background: #222;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.AnimTitle {
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.AnimNote {
|
||||
margin: 0;
|
||||
padding: 0 14px 6px;
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.SpeedControl {
|
||||
padding: 0 14px 8px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.SpeedControl input[type="range"] {
|
||||
width: 120px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.AnimList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.AnimItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.AnimItem:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.AnimItem[data-active="true"] {
|
||||
background: rgba(0, 100, 200, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.AnimName {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.AnimIndex {
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
257
src/components/ShapeViewer.tsx
Normal file
257
src/components/ShapeViewer.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { OrbitControls, useGLTF } from "@react-three/drei";
|
||||
import {
|
||||
AnimationMixer,
|
||||
AnimationAction,
|
||||
LoopRepeat,
|
||||
NoToneMapping,
|
||||
SRGBColorSpace,
|
||||
} from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { shapeToUrl } from "../loaders";
|
||||
import { getResourceList, getSourceAndPath } from "../manifest";
|
||||
import styles from "./ShapeViewer.module.css";
|
||||
|
||||
// ── Shape list ──
|
||||
|
||||
interface ShapeItem {
|
||||
resourceKey: string;
|
||||
displayName: string;
|
||||
shapeName: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const allShapes: ShapeItem[] = getResourceList()
|
||||
.filter((key) => key.startsWith("shapes/") && key.endsWith(".dts"))
|
||||
.map((resourceKey) => {
|
||||
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
|
||||
const fileName = actualPath.split("/").pop() ?? actualPath;
|
||||
return {
|
||||
resourceKey,
|
||||
displayName: fileName,
|
||||
shapeName: fileName,
|
||||
source: sourcePath,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
// ── Shape model with animation controls ──
|
||||
|
||||
function ShapeScene({
|
||||
shapeName,
|
||||
activeAnimation,
|
||||
animationSpeed,
|
||||
onAnimationsLoaded,
|
||||
}: {
|
||||
shapeName: string;
|
||||
activeAnimation: string | null;
|
||||
animationSpeed: number;
|
||||
onAnimationsLoaded: (names: string[], seqNames: string[] | null) => void;
|
||||
}) {
|
||||
const url = shapeToUrl(shapeName);
|
||||
const gltf = useGLTF(url);
|
||||
const mixerRef = useRef<AnimationMixer | null>(null);
|
||||
const actionRef = useRef<AnimationAction | null>(null);
|
||||
|
||||
// Report animations to parent
|
||||
useEffect(() => {
|
||||
const glbNames = gltf.animations.map((a) => a.name);
|
||||
const rawSeqNames = gltf.scene.userData?.dts_sequence_names;
|
||||
let seqNames: string[] | null = null;
|
||||
if (typeof rawSeqNames === "string") {
|
||||
try {
|
||||
seqNames = JSON.parse(rawSeqNames);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
onAnimationsLoaded(glbNames, seqNames);
|
||||
}, [gltf, onAnimationsLoaded]);
|
||||
|
||||
// Create mixer
|
||||
useEffect(() => {
|
||||
const mixer = new AnimationMixer(gltf.scene);
|
||||
mixerRef.current = mixer;
|
||||
return () => {
|
||||
mixer.stopAllAction();
|
||||
mixerRef.current = null;
|
||||
};
|
||||
}, [gltf.scene]);
|
||||
|
||||
// Play selected animation
|
||||
useEffect(() => {
|
||||
const mixer = mixerRef.current;
|
||||
if (!mixer) return;
|
||||
|
||||
// Stop previous
|
||||
if (actionRef.current) {
|
||||
actionRef.current.stop();
|
||||
actionRef.current = null;
|
||||
}
|
||||
|
||||
if (!activeAnimation) return;
|
||||
|
||||
const clip = gltf.animations.find(
|
||||
(a) => a.name.toLowerCase() === activeAnimation.toLowerCase(),
|
||||
);
|
||||
if (!clip) return;
|
||||
|
||||
const action = mixer.clipAction(clip);
|
||||
action.setLoop(LoopRepeat, Infinity);
|
||||
action.reset().play();
|
||||
actionRef.current = action;
|
||||
|
||||
return () => {
|
||||
action.stop();
|
||||
actionRef.current = null;
|
||||
};
|
||||
}, [gltf.animations, activeAnimation]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
mixerRef.current?.update(delta * animationSpeed);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<primitive object={gltf.scene} rotation={[0, Math.PI / 2, 0]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main viewer ──
|
||||
|
||||
export function ShapeViewer() {
|
||||
const [selectedShape, setSelectedShape] = useState(
|
||||
allShapes[0]?.shapeName ?? "",
|
||||
);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [animations, setAnimations] = useState<string[]>([]);
|
||||
const [seqNames, setSeqNames] = useState<string[] | null>(null);
|
||||
const [activeAnimation, setActiveAnimation] = useState<string | null>(null);
|
||||
const [animationSpeed, setAnimationSpeed] = useState(1);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return allShapes;
|
||||
const lower = filter.toLowerCase();
|
||||
return allShapes.filter((s) => s.displayName.toLowerCase().includes(lower));
|
||||
}, [filter]);
|
||||
|
||||
const handleAnimationsLoaded = useMemo(
|
||||
() => (names: string[], seq: string[] | null) => {
|
||||
setAnimations(names);
|
||||
setSeqNames(seq);
|
||||
setActiveAnimation(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.Root}>
|
||||
<div className={styles.Sidebar}>
|
||||
<h2 className={styles.Title}>Shape Viewer</h2>
|
||||
<input
|
||||
className={styles.Search}
|
||||
type="text"
|
||||
placeholder="Filter shapes…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<div className={styles.ShapeList}>
|
||||
{filtered.map((shape) => (
|
||||
<button
|
||||
key={shape.resourceKey}
|
||||
type="button"
|
||||
className={styles.ShapeItem}
|
||||
data-active={shape.shapeName === selectedShape}
|
||||
onClick={() => setSelectedShape(shape.shapeName)}
|
||||
>
|
||||
{shape.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.Main}>
|
||||
<div className={styles.CanvasWrap}>
|
||||
<Canvas
|
||||
gl={{
|
||||
toneMapping: NoToneMapping,
|
||||
outputColorSpace: SRGBColorSpace,
|
||||
}}
|
||||
camera={{ position: [8, 6, 8], fov: 50 }}
|
||||
>
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[5, 10, 5]} intensity={0.8} />
|
||||
<gridHelper args={[50, 50, "#444", "#333"]} />
|
||||
<OrbitControls />
|
||||
<Suspense fallback={null}>
|
||||
{selectedShape && (
|
||||
<ShapeScene
|
||||
key={selectedShape}
|
||||
shapeName={selectedShape}
|
||||
activeAnimation={activeAnimation}
|
||||
animationSpeed={animationSpeed}
|
||||
onAnimationsLoaded={handleAnimationsLoaded}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
<div className={styles.AnimPanel}>
|
||||
<h3 className={styles.AnimTitle}>Animations ({animations.length})</h3>
|
||||
{seqNames && (
|
||||
<p className={styles.AnimNote}>
|
||||
DTS sequence order: {seqNames.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.SpeedControl}>
|
||||
<label>
|
||||
Speed: {animationSpeed.toFixed(1)}x
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.1}
|
||||
value={animationSpeed}
|
||||
onChange={(e) => setAnimationSpeed(parseFloat(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.AnimList}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.AnimItem}
|
||||
data-active={activeAnimation === null}
|
||||
onClick={() => setActiveAnimation(null)}
|
||||
>
|
||||
(none)
|
||||
</button>
|
||||
{animations.map((name, i) => {
|
||||
const dtsIndex = seqNames
|
||||
? seqNames.findIndex(
|
||||
(s) => s.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
: i;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={styles.AnimItem}
|
||||
data-active={
|
||||
activeAnimation?.toLowerCase() === name.toLowerCase()
|
||||
}
|
||||
onClick={() => setActiveAnimation(name)}
|
||||
>
|
||||
<span className={styles.AnimName}>{name}</span>
|
||||
{dtsIndex >= 0 && (
|
||||
<span className={styles.AnimIndex}>seq {dtsIndex}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,8 +4,18 @@ import { useThree, useFrame } from "@react-three/fiber";
|
|||
import { useCubeTexture } from "@react-three/drei";
|
||||
import { Color, Fog } from "three";
|
||||
import { createLogger } from "../logger";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import { loadDetailMapList, textureToUrl } from "../loaders";
|
||||
import { useDebug, useSettings } from "./SettingsProvider";
|
||||
import {
|
||||
FALLBACK_TEXTURE_URL,
|
||||
loadDetailMapList,
|
||||
textureToUrl,
|
||||
} from "../loaders";
|
||||
import {
|
||||
setShapeEnvMap,
|
||||
resetShapeEnvMap,
|
||||
shapeEnvMapUniforms,
|
||||
} from "../shapeMaterial";
|
||||
import { loadTexture, setupTexture } from "../textureUtils";
|
||||
import { CloudLayers } from "./CloudLayers";
|
||||
import { fogStateFromScene, type FogState } from "./FogProvider";
|
||||
import { installCustomFogShader } from "../fogShader";
|
||||
|
|
@ -310,6 +320,38 @@ export function SkyBox({
|
|||
[detailMapList],
|
||||
);
|
||||
|
||||
// Load the sphere-map environment texture (index 6 in the .dml) for shape
|
||||
// reflections. This is a dedicated 2D texture, NOT the skybox cubemap.
|
||||
// Some maps have broken emap paths (e.g. Katabatic references desert/skies/
|
||||
// but file is at ice/skies/) — skip if the texture doesn't resolve.
|
||||
useEffect(() => {
|
||||
const emapName = detailMapList?.[6];
|
||||
if (!emapName) return;
|
||||
const url = textureToUrl(emapName);
|
||||
// textureToUrl returns the fallback URL for missing textures (e.g.
|
||||
// Katabatic's DML has a broken emap path). Don't set a fallback as envmap.
|
||||
if (url === FALLBACK_TEXTURE_URL) return;
|
||||
// Load WITHOUT sRGB conversion (noColorSpace). The env map values stay as
|
||||
// raw sRGB bytes, which is what Torque's fixed-function pipeline operates
|
||||
// on. The 2x modulate: 2 * lit_base_linear * env_sRGB produces display
|
||||
// values that closely match Torque's 2 * lit_base_sRGB * env_sRGB.
|
||||
const tex = loadTexture(url, (loaded) => {
|
||||
setupTexture(loaded, { noColorSpace: true });
|
||||
setShapeEnvMap(loaded);
|
||||
});
|
||||
if (tex.image) {
|
||||
setupTexture(tex, { noColorSpace: true });
|
||||
setShapeEnvMap(tex);
|
||||
}
|
||||
return () => resetShapeEnvMap();
|
||||
}, [detailMapList]);
|
||||
|
||||
// In debug mode, show sphere map UVs as colors instead of the texture.
|
||||
const { debugMode } = useDebug();
|
||||
useEffect(() => {
|
||||
shapeEnvMapUniforms.shapeEnvMapDebugUV.value = debugMode;
|
||||
}, [debugMode]);
|
||||
|
||||
// Don't render until we have real texture URLs
|
||||
if (!skyBoxFiles) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function mutateRenderFields(
|
|||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.threads = stream.threads;
|
||||
e.weaponShape = stream.weaponShape;
|
||||
e.armAction = stream.armAction;
|
||||
e.packShape = stream.packShape;
|
||||
e.flagShape = stream.flagShape;
|
||||
e.falling = stream.falling;
|
||||
|
|
@ -63,6 +64,7 @@ function mutateRenderFields(
|
|||
case "Shape": {
|
||||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.threads = stream.threads;
|
||||
e.damageState = stream.damageState;
|
||||
e.targetRenderFlags = stream.targetRenderFlags;
|
||||
e.iffColor = stream.iffColor;
|
||||
break;
|
||||
|
|
@ -109,7 +111,9 @@ export function StreamingController({
|
|||
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const eyeOffsetRef = useRef(new Vector3(0, DEFAULT_EYE_HEIGHT, 0));
|
||||
const streamRef = useRef<StreamingPlayback | null>(recording.streamingPlayback ?? null);
|
||||
const streamRef = useRef<StreamingPlayback | null>(
|
||||
recording.streamingPlayback ?? null,
|
||||
);
|
||||
const publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const entityMapRef = useRef<Map<string, GameEntity>>(new Map());
|
||||
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
selectPing,
|
||||
useLiveSelector,
|
||||
} from "../state/liveConnectionStore";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useRecording } from "./usePlayback";
|
||||
import { LuCircleArrowOutUpLeft } from "react-icons/lu";
|
||||
import { BiSolidEject } from "react-icons/bi";
|
||||
import { formatPing } from "../stringUtils";
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
import { createContext, type Dispatch, ReactNode, type SetStateAction, useContext, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Handle for querying terrain data.
|
||||
*
|
||||
* TerrainBlock registers this via useEffect, allowing other components
|
||||
* to query terrain data without prop drilling.
|
||||
*/
|
||||
export interface TerrainHandle {
|
||||
/**
|
||||
* Query terrain height at world coordinates.
|
||||
* Coordinates wrap via `& 255` to support infinite terrain tiling,
|
||||
* matching Torque's FluidSupport.cc:
|
||||
* i = (((m_SquareY0+Y) & 255) << 8) + ((m_SquareX0+X) & 255);
|
||||
*/
|
||||
getHeightAt: (worldX: number, worldZ: number) => number;
|
||||
|
||||
/**
|
||||
* Check if a point is above terrain at given world coordinates.
|
||||
* Used for water masking (matching Torque's fluid reject mask).
|
||||
* Coordinates wrap to support infinite terrain tiling.
|
||||
*/
|
||||
isAboveTerrain: (worldX: number, worldZ: number, height: number) => boolean;
|
||||
|
||||
/**
|
||||
* Get primary terrain tile bounds in world coordinates.
|
||||
* Note: Terrain actually tiles infinitely via coordinate wrapping.
|
||||
*/
|
||||
getBounds: () => {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
};
|
||||
}
|
||||
|
||||
type StateSetter<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
interface TerrainContextValue {
|
||||
terrain: TerrainHandle | null;
|
||||
setTerrain: StateSetter<TerrainHandle | null>;
|
||||
}
|
||||
|
||||
const TerrainContext = createContext<TerrainContextValue | null>(null);
|
||||
|
||||
interface TerrainProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for terrain query handle.
|
||||
*
|
||||
* TerrainBlock registers its handle via useEffect on mount, and other
|
||||
* components (like WaterBlock) can access it to query terrain heights.
|
||||
*/
|
||||
export function TerrainProvider({ children }: TerrainProviderProps) {
|
||||
const [terrain, setTerrain] = useState<TerrainHandle | null>(null);
|
||||
const context = useMemo(() => ({ terrain, setTerrain }), [terrain]);
|
||||
|
||||
return (
|
||||
<TerrainContext.Provider value={context}>
|
||||
{children}
|
||||
</TerrainContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terrain handle from context.
|
||||
* Returns null if no TerrainBlock has registered yet.
|
||||
*/
|
||||
export function useTerrainHandle() {
|
||||
const context = useContext(TerrainContext);
|
||||
if (!context) {
|
||||
throw new Error("useTerrainHandle must be used within a TerrainProvider");
|
||||
}
|
||||
return context.terrain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the terrain setter for registration.
|
||||
* Used by TerrainBlock to register its handle.
|
||||
*/
|
||||
export function useRegisterTerrain() {
|
||||
const context = useContext(TerrainContext);
|
||||
if (!context) {
|
||||
throw new Error("useRegisterTerrain must be used within a TerrainProvider");
|
||||
}
|
||||
return context.setTerrain;
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { createLogger } from "../logger";
|
||||
import { getPosition, getProperty, getRotation, getScale } from "../mission";
|
||||
import { ShapeRenderer } from "./GenericShape";
|
||||
import { ShapeInfoProvider } from "./ShapeInfoProvider";
|
||||
import { useDatablock } from "./useDatablock";
|
||||
|
||||
const log = createLogger("Turret");
|
||||
export function Turret({ object }: { object: TorqueObject }) {
|
||||
const datablockName = getProperty(object, "dataBlock") ?? "";
|
||||
const barrelDatablockName = getProperty(object, "initialBarrel");
|
||||
const datablock = useDatablock(datablockName);
|
||||
const barrelDatablock = useDatablock(barrelDatablockName);
|
||||
const position = useMemo(() => getPosition(object), [object]);
|
||||
const q = useMemo(() => getRotation(object), [object]);
|
||||
const scale = useMemo(() => getScale(object), [object]);
|
||||
const shapeName = getProperty(datablock, "shapeFile");
|
||||
const barrelShapeName = getProperty(barrelDatablock, "shapeFile");
|
||||
if (!shapeName) {
|
||||
log.error("Turret missing shape for datablock: %s", datablockName);
|
||||
}
|
||||
// `initialBarrel` is optional - turrets can exist without a barrel mounted.
|
||||
// But if we do have one, it needs a shape name.
|
||||
if (barrelDatablockName && !barrelShapeName) {
|
||||
log.error(
|
||||
"Turret missing shape for barrel datablock: %s",
|
||||
barrelDatablockName,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ShapeInfoProvider type="Turret" object={object} shapeName={shapeName}>
|
||||
<group position={position} quaternion={q} scale={scale}>
|
||||
<ShapeRenderer />
|
||||
{barrelShapeName ? (
|
||||
<ShapeInfoProvider
|
||||
type="Turret"
|
||||
object={object}
|
||||
shapeName={barrelShapeName}
|
||||
>
|
||||
<group position={[0, 1.5, 0]}>
|
||||
<ShapeRenderer />
|
||||
</group>
|
||||
</ShapeInfoProvider>
|
||||
) : null}
|
||||
</group>
|
||||
</ShapeInfoProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -200,7 +200,9 @@ export const WaterBlock = memo(function WaterBlock({
|
|||
setReps((prevReps) => {
|
||||
if (
|
||||
prevReps.length === newReps.length &&
|
||||
prevReps.every((r, i) => r[0] === newReps[i][0] && r[1] === newReps[i][1])
|
||||
prevReps.every(
|
||||
(r, i) => r[0] === newReps[i][0] && r[1] === newReps[i][1],
|
||||
)
|
||||
) {
|
||||
return prevReps;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { FloatingLabel } from "./FloatingLabel";
|
|||
|
||||
export function WayPoint({ entity }: { entity: WayPointEntity }) {
|
||||
return entity.label ? (
|
||||
<FloatingLabel opacity={0.6}>
|
||||
{entity.label}
|
||||
</FloatingLabel>
|
||||
<FloatingLabel opacity={0.6}>{entity.label}</FloatingLabel>
|
||||
) : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { RefObject, useRef } from "react";
|
||||
import { Object3D } from "three";
|
||||
import { useWorldPosition } from "./useWorldPosition";
|
||||
|
||||
export function useDistanceFromCamera<T extends Object3D>(
|
||||
ref: RefObject<T>,
|
||||
): RefObject<number | null> {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const distanceRef = useRef<number>(null);
|
||||
const worldPosRef = useWorldPosition(ref);
|
||||
|
||||
useFrame(() => {
|
||||
if (!worldPosRef.current) {
|
||||
distanceRef.current = null;
|
||||
} else {
|
||||
distanceRef.current = camera.position.distanceTo(worldPosRef.current);
|
||||
}
|
||||
});
|
||||
|
||||
return distanceRef;
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import { useCallback, type ReactNode } from "react";
|
||||
import { useCallback } from "react";
|
||||
import type { StreamRecording } from "../stream/types";
|
||||
import { useEngineSelector } from "../state/engineStore";
|
||||
|
||||
export const SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4];
|
||||
|
||||
export function RecordingProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
export const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2, 3, 4];
|
||||
|
||||
export function useRecording(): StreamRecording | null {
|
||||
return useEngineSelector((state) => state.playback.recording);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useEffectEvent } from "react";
|
||||
import { getMissionInfo, getMissionList } from "../manifest";
|
||||
import { usePlaybackActions } from "./RecordingProvider";
|
||||
import { usePlaybackActions } from "./usePlayback";
|
||||
import type { CurrentMission } from "./useQueryParams";
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import type { TorqueObject } from "../torqueScript";
|
||||
import { useRuntimeObjectByName } from "../state/engineStore";
|
||||
|
||||
/**
|
||||
* Look up a scene object by name from the runtime.
|
||||
*/
|
||||
export function useSceneObject(name: string): TorqueObject | undefined {
|
||||
return useRuntimeObjectByName(name);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export type { TerrainFile };
|
|||
|
||||
export const BASE_URL = "/t2-mapper";
|
||||
export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
|
||||
export const FALLBACK_TEXTURE_URL = `${BASE_URL}/magenta.png`;
|
||||
export const FALLBACK_TEXTURE_URL = `${BASE_URL}/white.png`;
|
||||
|
||||
export function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
|
||||
let resourceKey;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import pino from "pino";
|
|||
/**
|
||||
* Module-scoped browser logging via pino.
|
||||
*
|
||||
* Control via VITE_LOG (comma-separated):
|
||||
* Control via LOG_LEVEL (comma-separated):
|
||||
* "debug" → all modules at debug
|
||||
* "liveStreaming:debug,DebugSuspense:trace" → those modules only, rest silent
|
||||
* "debug,liveStreaming:trace" → all at debug, liveStreaming at trace
|
||||
|
|
@ -27,12 +27,12 @@ const PINO_LEVELS = new Set([
|
|||
"silent",
|
||||
]);
|
||||
|
||||
/** Parse VITE_LOG into a global default and per-module overrides. */
|
||||
/** Parse LOG_LEVEL into a global default and per-module overrides. */
|
||||
function parseLogConfig(): {
|
||||
globalLevel: string;
|
||||
modules: Map<string, string>;
|
||||
} {
|
||||
const raw = import.meta.env.VITE_LOG?.trim();
|
||||
const raw = (process.env.LOG_LEVEL ?? "").trim();
|
||||
if (!raw) return { globalLevel: "info", modules: new Map() };
|
||||
|
||||
let globalLevel: string | null = null;
|
||||
|
|
|
|||
12302
src/manifest.json
12302
src/manifest.json
File diff suppressed because it is too large
Load diff
|
|
@ -98,6 +98,7 @@ export function getResourceList(): string[] {
|
|||
* 5. .bmp
|
||||
*/
|
||||
const standardTextureExt = ["", ".jpg", ".png", ".gif", ".bmp"];
|
||||
|
||||
export function getStandardTextureResourceKey(resourcePath: string) {
|
||||
const baseResourceKey = getResourceKey(resourcePath);
|
||||
for (const ext of standardTextureExt) {
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import { Skeleton, Bone, BufferGeometry } from "three";
|
||||
|
||||
/**
|
||||
* Extract hull bone indices from a skeleton
|
||||
* @param skeleton - The Three.js skeleton to scan
|
||||
* @returns Set of bone indices for bones matching the hull pattern (starts with "Hulk")
|
||||
*/
|
||||
export function getHullBoneIndices(skeleton: Skeleton): Set<number> {
|
||||
const hullBoneIndices = new Set<number>();
|
||||
|
||||
skeleton.bones.forEach((bone: Bone, index: number) => {
|
||||
if (bone.name.match(/^Hulk/i)) {
|
||||
hullBoneIndices.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
return hullBoneIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter geometry by removing faces influenced by hull bones
|
||||
* @param geometry - The Three.js geometry to filter
|
||||
* @param hullBoneIndices - Set of bone indices that represent hull (collision) geometry
|
||||
* @returns Filtered geometry with hull-influenced faces removed
|
||||
*/
|
||||
export function filterGeometryByVertexGroups(
|
||||
geometry: BufferGeometry,
|
||||
hullBoneIndices: Set<number>,
|
||||
): BufferGeometry {
|
||||
// If no hull bones or no skinning data, return original geometry
|
||||
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
const skinIndex = geometry.attributes.skinIndex;
|
||||
const skinWeight = geometry.attributes.skinWeight;
|
||||
const index = geometry.index;
|
||||
|
||||
// Track which vertices are influenced by hull bones
|
||||
const vertexHasHullInfluence = new Array(skinIndex.count).fill(false);
|
||||
|
||||
// Check each vertex's bone influences
|
||||
for (let i = 0; i < skinIndex.count; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const boneIndex = skinIndex.array[i * 4 + j];
|
||||
const weight = skinWeight.array[i * 4 + j];
|
||||
|
||||
// If this vertex has significant weight to a hull bone, mark it
|
||||
if (weight > 0.01 && hullBoneIndices.has(boneIndex)) {
|
||||
vertexHasHullInfluence[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build new index array excluding faces that use hull-influenced vertices
|
||||
if (index) {
|
||||
const newIndices: number[] = [];
|
||||
const indexArray = index.array;
|
||||
|
||||
for (let i = 0; i < indexArray.length; i += 3) {
|
||||
const i0 = indexArray[i];
|
||||
const i1 = indexArray[i + 1];
|
||||
const i2 = indexArray[i + 2];
|
||||
|
||||
// Only keep face if all vertices don't have hull influence
|
||||
if (
|
||||
!vertexHasHullInfluence[i0] &&
|
||||
!vertexHasHullInfluence[i1] &&
|
||||
!vertexHasHullInfluence[i2]
|
||||
) {
|
||||
newIndices.push(i0, i1, i2);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new geometry with filtered indices
|
||||
const filteredGeometry = geometry.clone();
|
||||
filteredGeometry.setIndex(newIndices);
|
||||
return filteredGeometry;
|
||||
}
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
|
@ -147,8 +147,12 @@ export function parseMissionScript(script: string): ParsedMission {
|
|||
pragma.missiontypes
|
||||
?.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((name) => (normalizedMissionTypes as Record<string, string>)[name.toLowerCase()] ?? name) ??
|
||||
[],
|
||||
.map(
|
||||
(name) =>
|
||||
(normalizedMissionTypes as Record<string, string>)[
|
||||
name.toLowerCase()
|
||||
] ?? name,
|
||||
) ?? [],
|
||||
missionBriefing: getSection("MISSION BRIEFING"),
|
||||
briefingWav: pragma.briefingwav ?? null,
|
||||
bitmap: pragma.bitmap ?? null,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import type { TorqueObject } from "../torqueScript";
|
||||
import { parseColor3, parseColor4 } from "../colorUtils";
|
||||
import type {
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
|
|
@ -15,8 +16,6 @@ import type {
|
|||
SceneObject,
|
||||
MatrixF,
|
||||
Vec3,
|
||||
Color3,
|
||||
Color4,
|
||||
SceneSkyFogVolume,
|
||||
SceneSkyCloudLayer,
|
||||
} from "./types";
|
||||
|
|
@ -54,33 +53,6 @@ function parseVec3(
|
|||
};
|
||||
}
|
||||
|
||||
function parseColor3(
|
||||
s: string | undefined,
|
||||
fallback: Color3 = { r: 0, g: 0, b: 0 },
|
||||
): Color3 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor4(
|
||||
s: string | undefined,
|
||||
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
||||
): Color4 {
|
||||
if (!s) return fallback;
|
||||
const parts = s.split(" ").map(Number);
|
||||
return {
|
||||
r: parts[0] ?? fallback.r,
|
||||
g: parts[1] ?? fallback.g,
|
||||
b: parts[2] ?? fallback.b,
|
||||
a: parts[3] ?? fallback.a,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MatrixF from .mis position ("x y z") and rotation ("ax ay az angleDeg").
|
||||
* Torque stores rotation as axis-angle in degrees.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,7 @@
|
|||
* Shape material utilities and shader modifications.
|
||||
*/
|
||||
|
||||
import {
|
||||
DoubleSide,
|
||||
MeshStandardMaterial,
|
||||
Texture,
|
||||
RepeatWrapping,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
SRGBColorSpace,
|
||||
} from "three";
|
||||
import { Texture } from "three";
|
||||
import { SHAPE_LIGHTING } from "./lightingConfig";
|
||||
|
||||
/**
|
||||
|
|
@ -44,54 +36,131 @@ uniform float shapeAmbientFactor;
|
|||
);
|
||||
}
|
||||
|
||||
// Shared shader modification function to avoid duplication
|
||||
const alphaAsRoughnessShaderModifier = (shader: any) => {
|
||||
// Modify fragment shader to extract alpha channel as roughness after map is sampled
|
||||
// We need to intercept after diffuseColor is set from the map
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <roughness_fragment>",
|
||||
`
|
||||
#include <roughness_fragment>
|
||||
// Override roughness with map alpha channel if map exists
|
||||
#ifdef USE_MAP
|
||||
roughnessFactor = texture2D(map, vMapUv).a;
|
||||
#endif
|
||||
`,
|
||||
);
|
||||
// ── Environment map reflectivity ──
|
||||
//
|
||||
// Tribes 2 uses a 2D sphere map texture (from the sky's .dml file, index 6)
|
||||
// for shape reflections. The MULTI_1 path (binary-verified via Ghidra) uses
|
||||
// GL_COMBINE with GL_INTERPOLATE (0x8575):
|
||||
// result = envmap * factor + base * (1 - factor)
|
||||
// where factor = base_texture_alpha * vertexAlpha
|
||||
// vertexAlpha = environmentMapAlpha * reflectionAmount (per material)
|
||||
// The base texture's alpha channel acts as a reflectance mask — bright alpha
|
||||
// areas show the env map, dark alpha areas keep the base appearance.
|
||||
// environmentMapAlpha = 1.0 for all game objects.
|
||||
|
||||
/**
|
||||
* Shared sphere map uniform. Sky component sets the real texture; materials
|
||||
* reference this object so updates propagate without recompilation.
|
||||
*/
|
||||
export const shapeEnvMapUniforms = {
|
||||
shapeEnvMap: { value: null as Texture | null },
|
||||
shapeEnvMapActive: { value: false },
|
||||
shapeEnvMapDebugUV: { value: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Configures a texture for use with alpha-as-roughness materials
|
||||
* @param texture - The texture to configure
|
||||
*/
|
||||
export function setupAlphaAsRoughnessTexture(
|
||||
texture: Texture,
|
||||
options: { anisotropy?: number } = {},
|
||||
) {
|
||||
texture.wrapS = texture.wrapT = RepeatWrapping;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.flipY = false;
|
||||
texture.anisotropy = options.anisotropy ?? 1;
|
||||
texture.generateMipmaps = true;
|
||||
texture.minFilter = LinearMipmapLinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
/** Set the shared shape environment sphere map (called by Sky component). */
|
||||
export function setShapeEnvMap(texture: Texture): void {
|
||||
shapeEnvMapUniforms.shapeEnvMap.value = texture;
|
||||
shapeEnvMapUniforms.shapeEnvMapActive.value = true;
|
||||
}
|
||||
|
||||
/** Reset env map (called when Sky unmounts). */
|
||||
export function resetShapeEnvMap(): void {
|
||||
shapeEnvMapUniforms.shapeEnvMap.value = null;
|
||||
shapeEnvMapUniforms.shapeEnvMapActive.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reusable shader-enhanced material that treats alpha as roughness
|
||||
* The same material instance can be used with different textures by setting the `map` property
|
||||
* @returns A pre-configured MeshStandardMaterial with the shader modifier attached
|
||||
* Inject Tribes 2 environment map reflectivity into a Lambert shader.
|
||||
* Uses a 2D sphere map texture with the classic GL_SPHERE_MAP UV formula.
|
||||
* Blends envmap color with diffuse based on the base texture's alpha channel.
|
||||
*/
|
||||
export function createAlphaAsRoughnessMaterial() {
|
||||
const material = new MeshStandardMaterial({
|
||||
side: DoubleSide,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
});
|
||||
export function injectShapeEnvMap(
|
||||
shader: {
|
||||
uniforms: Record<string, { value: unknown }>;
|
||||
fragmentShader: string;
|
||||
vertexShader: string;
|
||||
},
|
||||
reflectionAmount: number,
|
||||
): void {
|
||||
// Shared uniforms — values update when sky loads, no recompile needed.
|
||||
shader.uniforms.shapeEnvMap = shapeEnvMapUniforms.shapeEnvMap;
|
||||
shader.uniforms.shapeEnvMapActive = shapeEnvMapUniforms.shapeEnvMapActive;
|
||||
shader.uniforms.shapeEnvMapDebugUV = shapeEnvMapUniforms.shapeEnvMapDebugUV;
|
||||
shader.uniforms.shapeReflectionAmount = { value: reflectionAmount };
|
||||
|
||||
// Attach shader modifier (will be applied when shader is compiled)
|
||||
material.onBeforeCompile = alphaAsRoughnessShaderModifier;
|
||||
// Per-vertex sphere map UV computation, matching GL_SPHERE_MAP texgen.
|
||||
// GL_SPHERE_MAP computes UVs at vertices and interpolates linearly.
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
uniform sampler2D shapeEnvMap;
|
||||
uniform bool shapeEnvMapActive;
|
||||
uniform float shapeReflectionAmount;
|
||||
uniform bool shapeEnvMapDebugUV;
|
||||
varying vec2 vShapeSphereUV;
|
||||
`,
|
||||
);
|
||||
|
||||
return material;
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec2 vShapeSphereUV;
|
||||
`,
|
||||
);
|
||||
|
||||
// GL_SPHERE_MAP formula adapted for Torque's coordinate system.
|
||||
// Torque puts darkToOGLCoord (Z-up to Y-up) in the PROJECTION matrix, so
|
||||
// the MODELVIEW (and thus eye space) stays Z-up: X-right, Y-forward, Z-up.
|
||||
// Three.js eye space is Y-up: X-right, Y-up, Z-backward.
|
||||
// Mapping: torque.x = three.x, torque.y = -three.z, torque.z = three.y
|
||||
// The GL_SPHERE_MAP formula in Torque's eye space is:
|
||||
// m = 2*sqrt(rt.x² + rt.y² + (rt.z+1)²), s = rt.x/m+0.5, t = rt.y/m+0.5
|
||||
// Substituting Three.js coords:
|
||||
// m = 2*sqrt(r.x² + r.z² + (r.y+1)²), s = r.x/m+0.5, t = -r.z/m+0.5
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <fog_vertex>",
|
||||
`#include <fog_vertex>
|
||||
{
|
||||
vec3 _eyePos = (modelViewMatrix * vec4(transformed, 1.0)).xyz;
|
||||
#ifdef FLAT_SHADED
|
||||
vec3 _eyeN = vec3(0.0, 0.0, 1.0);
|
||||
#else
|
||||
vec3 _eyeN = normalize(normalMatrix * normal);
|
||||
#endif
|
||||
vec3 _eyeU = normalize(_eyePos);
|
||||
vec3 _r = reflect(_eyeU, _eyeN);
|
||||
float _m = 2.0 * sqrt(_r.x * _r.x + _r.z * _r.z + (_r.y + 1.0) * (_r.y + 1.0));
|
||||
vShapeSphereUV = vec2(_r.x, -_r.z) / _m + 0.5;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Binary-verified GL_INTERPOLATE (0x8575) with base texture alpha as
|
||||
// reflectance mask. The formula is:
|
||||
// result = envmap * factor + base * (1 - factor)
|
||||
// where factor = base_alpha * vertexAlpha
|
||||
// vertexAlpha = environmentMapAlpha(1.0) * reflectionAmount(per-material)
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <opaque_fragment>",
|
||||
`// Tribes 2 sphere-map environment reflections (GL_INTERPOLATE)
|
||||
if (shapeEnvMapActive && shapeReflectionAmount > 0.0) {
|
||||
if (shapeEnvMapDebugUV) {
|
||||
outgoingLight = vec3(vShapeSphereUV, 0.0);
|
||||
} else {
|
||||
vec3 _envColor = texture2D(shapeEnvMap, vShapeSphereUV).rgb;
|
||||
#ifdef USE_MAP
|
||||
float _baseAlpha = texture2D(map, vMapUv).a;
|
||||
#else
|
||||
float _baseAlpha = 1.0;
|
||||
#endif
|
||||
float _factor = _baseAlpha * shapeReflectionAmount;
|
||||
// Torque blends in sRGB space (fixed-function pipeline, no gamma).
|
||||
// Convert outgoingLight to sRGB, mix with sRGB env map, convert back.
|
||||
vec3 _baseSRGB = pow(max(outgoingLight, 0.0), vec3(1.0 / 2.2));
|
||||
outgoingLight = pow(mix(_baseSRGB, _envColor, _factor), vec3(2.2));
|
||||
}
|
||||
}
|
||||
#include <opaque_fragment>`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,9 @@ export const gameEntityStore = createStore<GameEntityState>()((set) => ({
|
|||
if (info.gameClassName) {
|
||||
const raw = info.gameClassName.replace(/Game$/i, "");
|
||||
updates.missionType =
|
||||
(normalizedMissionTypes as Record<string, string>)[raw.toLowerCase()] ?? raw;
|
||||
(normalizedMissionTypes as Record<string, string>)[
|
||||
raw.toLowerCase()
|
||||
] ?? raw;
|
||||
} else {
|
||||
updates.missionType = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,11 @@ export interface ShapeEntity extends PositionedBase {
|
|||
shapeName?: string;
|
||||
shapeType?: string;
|
||||
dataBlock?: string;
|
||||
/** Datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
threads?: ThreadState[];
|
||||
/** Torque DamageState: 0=Enabled, 1=Disabled, 2=Destroyed. */
|
||||
damageState?: number;
|
||||
rotate?: boolean;
|
||||
teamId?: number;
|
||||
barrelShapeName?: string;
|
||||
|
|
@ -133,7 +137,11 @@ export interface ShapeEntity extends PositionedBase {
|
|||
iffColor?: { r: number; g: number; b: number };
|
||||
weaponShape?: string;
|
||||
/** WheeledVehicle per-wheel state (speed, slip). */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
wheels?: Array<{
|
||||
speed: number;
|
||||
lateralSlip: number;
|
||||
longitudinalSlip: number;
|
||||
}>;
|
||||
/** Vehicle steering angle (radians). */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed). */
|
||||
|
|
@ -146,7 +154,11 @@ export interface PlayerEntity extends PositionedBase {
|
|||
renderType: "Player";
|
||||
shapeName?: string;
|
||||
dataBlock?: string;
|
||||
/** Datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
weaponShape?: string;
|
||||
/** Arm blend animation action index from Player ghost (networked). */
|
||||
armAction?: number;
|
||||
packShape?: string;
|
||||
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
|
||||
flagShape?: string;
|
||||
|
|
|
|||
|
|
@ -49,8 +49,7 @@ export interface LiveConnectionStore extends LiveConnectionState {
|
|||
sendCommand(command: string, ...args: string[]): void;
|
||||
}
|
||||
|
||||
const DEFAULT_RELAY_URL =
|
||||
import.meta.env.VITE_RELAY_URL || "ws://localhost:8765";
|
||||
const DEFAULT_RELAY_URL = process.env.RELAY_URL || "ws://localhost:8765";
|
||||
|
||||
export const liveConnectionStore = createStore<LiveConnectionStore>(
|
||||
(set, get) => ({
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export interface MutableEntity {
|
|||
dataBlockId?: number;
|
||||
shapeHint?: string;
|
||||
dataBlock?: string;
|
||||
/** Datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
visual?: StreamVisual;
|
||||
direction?: [number, number, number];
|
||||
weaponShape?: string;
|
||||
|
|
@ -82,6 +84,7 @@ export interface MutableEntity {
|
|||
maxEnergy?: number;
|
||||
actionAnim?: number;
|
||||
actionAtEnd?: boolean;
|
||||
armAction?: number;
|
||||
damageState?: number;
|
||||
targetId?: number;
|
||||
projectilePhysics?: "linear" | "ballistic" | "seeker";
|
||||
|
|
@ -127,7 +130,11 @@ export interface MutableEntity {
|
|||
audioMaxLoopGap?: number;
|
||||
sceneData?: SceneObject;
|
||||
/** WheeledVehicle per-wheel state from ghost data. */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
wheels?: Array<{
|
||||
speed: number;
|
||||
lateralSlip: number;
|
||||
longitudinalSlip: number;
|
||||
}>;
|
||||
/** Vehicle steering angle (radians), from ghost data. */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed MPB, etc.). */
|
||||
|
|
@ -906,6 +913,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.damageState = undefined;
|
||||
entity.actionAnim = undefined;
|
||||
entity.actionAtEnd = undefined;
|
||||
entity.armAction = undefined;
|
||||
entity.explosionDataBlockId = undefined;
|
||||
entity.maintainEmitterId = undefined;
|
||||
}
|
||||
|
|
@ -931,6 +939,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.shapeHint = shapeName;
|
||||
entity.dataBlock = shapeName;
|
||||
}
|
||||
if (blockData && "emap" in blockData) {
|
||||
entity.emap = !!blockData.emap;
|
||||
}
|
||||
if (
|
||||
entity.type === "Player" &&
|
||||
typeof blockData?.maxEnergy === "number"
|
||||
|
|
@ -993,14 +1004,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
| undefined;
|
||||
entity.forceFieldData = {
|
||||
textures,
|
||||
color: color1
|
||||
? [color1.r, color1.g, color1.b]
|
||||
: [1, 1, 1],
|
||||
baseTranslucency:
|
||||
(blockData.baseTranslucency as number) ?? 1,
|
||||
dimensions: scale
|
||||
? [scale.y, scale.z, scale.x]
|
||||
: [1, 1, 1],
|
||||
color: color1 ? [color1.r, color1.g, color1.b] : [1, 1, 1],
|
||||
baseTranslucency: (blockData.baseTranslucency as number) ?? 1,
|
||||
dimensions: scale ? [scale.y, scale.z, scale.x] : [1, 1, 1],
|
||||
framesPerSec: (blockData.framesPerSec as number) ?? 1,
|
||||
scrollSpeed: (blockData.scrollSpeed as number) ?? 0,
|
||||
umapping: (blockData.umapping as number) ?? 1,
|
||||
|
|
@ -1237,7 +1243,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
"Item %s (%s): atRest=false pos=%s vel=%s",
|
||||
entity.id,
|
||||
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
|
||||
data.position ? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}` : "none",
|
||||
data.position
|
||||
? `${(data.position as Vec3).x.toFixed(1)},${(data.position as Vec3).y.toFixed(1)},${(data.position as Vec3).z.toFixed(1)}`
|
||||
: "none",
|
||||
`${vel.x.toFixed(1)},${vel.y.toFixed(1)},${vel.z.toFixed(1)}`,
|
||||
);
|
||||
} else if (atRest === true) {
|
||||
|
|
@ -1245,7 +1253,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
"Item %s (%s): atRest=true pos=%s",
|
||||
entity.id,
|
||||
entity.shapeHint ?? entity.dataBlock ?? `db#${entity.dataBlockId}`,
|
||||
entity.position ? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}` : "none",
|
||||
entity.position
|
||||
? `${entity.position[0].toFixed(1)},${entity.position[1].toFixed(1)},${entity.position[2].toFixed(1)}`
|
||||
: "none",
|
||||
);
|
||||
entity.itemPhysics = undefined;
|
||||
}
|
||||
|
|
@ -1335,6 +1345,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.actionAnim = data.action;
|
||||
entity.actionAtEnd = !!data.actionAtEnd;
|
||||
}
|
||||
if (typeof data.armAction === "number") {
|
||||
entity.armAction = data.armAction;
|
||||
}
|
||||
|
||||
// Threads
|
||||
if (Array.isArray(data.threads)) {
|
||||
|
|
@ -2280,6 +2293,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
energy: entity.energy,
|
||||
actionAnim: entity.actionAnim,
|
||||
actionAtEnd: entity.actionAtEnd,
|
||||
armAction: entity.armAction,
|
||||
damageState: entity.damageState,
|
||||
faceViewer: entity.faceViewer,
|
||||
threads: entity.threads,
|
||||
|
|
|
|||
|
|
@ -204,7 +204,11 @@ export async function scanDemoTimeline(
|
|||
} catch (err) {
|
||||
// Parser cursor state is unknown after a throw — stop scanning and
|
||||
// return whatever events we've found rather than risking an infinite loop.
|
||||
log.warn("Stopping scan at block %d due to read error: %o", blockCount, err);
|
||||
log.warn(
|
||||
"Stopping scan at block %d due to read error: %o",
|
||||
blockCount,
|
||||
err,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!block) break;
|
||||
|
|
@ -268,15 +272,9 @@ export async function scanDemoTimeline(
|
|||
recorderClientId != null &&
|
||||
args.length >= 6
|
||||
) {
|
||||
const clientId = parseInt(
|
||||
resolveNetString(args[4], netStrings),
|
||||
10,
|
||||
);
|
||||
const clientId = parseInt(resolveNetString(args[4], netStrings), 10);
|
||||
if (clientId === recorderClientId) {
|
||||
const teamId = parseInt(
|
||||
resolveNetString(args[5], netStrings),
|
||||
10,
|
||||
);
|
||||
const teamId = parseInt(resolveNetString(args[5], netStrings), 10);
|
||||
if (!isNaN(teamId)) recorderTeamId = teamId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,9 @@ export function streamEntityToGameEntity(
|
|||
renderType: "Player",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
emap: entity.emap,
|
||||
weaponShape: entity.weaponShape,
|
||||
armAction: entity.armAction,
|
||||
packShape: entity.packShape,
|
||||
flagShape: entity.flagShape,
|
||||
falling: entity.falling,
|
||||
|
|
@ -219,7 +221,10 @@ export function streamEntityToGameEntity(
|
|||
? "Item"
|
||||
: "StaticShape",
|
||||
dataBlock: entity.dataBlock,
|
||||
emap: entity.emap,
|
||||
damageState: entity.damageState,
|
||||
weaponShape: entity.weaponShape,
|
||||
armAction: entity.armAction,
|
||||
threads: entity.threads,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
iffColor: entity.iffColor,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
WayPointEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import { getPosition, getProperty, getScale } from "../mission";
|
||||
import { parseColorTuple } from "../colorUtils";
|
||||
import {
|
||||
terrainFromMis,
|
||||
interiorFromMis,
|
||||
|
|
@ -35,11 +36,6 @@ function isTruthy(value: unknown): boolean {
|
|||
return !!value;
|
||||
}
|
||||
|
||||
function parseColor3(colorStr: string): [number, number, number] {
|
||||
const parts = colorStr.split(" ").map((s) => parseFloat(s));
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
||||
function parseRotationToQuat(
|
||||
rotationStr: string,
|
||||
): [number, number, number, number] {
|
||||
|
|
@ -215,6 +211,10 @@ function buildShapeEntity(
|
|||
shapeName,
|
||||
shapeType,
|
||||
dataBlock: datablockName || undefined,
|
||||
emap:
|
||||
getProperty(datablock, "emap") != null
|
||||
? isTruthy(getProperty(datablock, "emap"))
|
||||
: undefined,
|
||||
teamId,
|
||||
};
|
||||
|
||||
|
|
@ -251,7 +251,7 @@ function buildForceFieldEntity(
|
|||
): ForceFieldBareEntity {
|
||||
const colorStr = getProperty(datablock, "color");
|
||||
const color = colorStr
|
||||
? parseColor3(colorStr)
|
||||
? parseColorTuple(colorStr)
|
||||
: ([1, 1, 1] as [number, number, number]);
|
||||
const baseTranslucency =
|
||||
parseFloat(getProperty(datablock, "baseTranslucency")) || 1;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
NoColorSpace,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
QuaternionKeyframeTrack,
|
||||
Vector3,
|
||||
VectorKeyframeTrack,
|
||||
} from "three";
|
||||
import type {
|
||||
BufferGeometry,
|
||||
|
|
@ -27,7 +29,6 @@ import {
|
|||
getFrameIndexForTime,
|
||||
updateAtlasFrame,
|
||||
} from "../components/useIflTexture";
|
||||
import { getHullBoneIndices } from "../meshUtils";
|
||||
import { loadTexture, setupTexture } from "../textureUtils";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type { Keyframe } from "./types";
|
||||
|
|
@ -141,6 +142,51 @@ export function getKeyframeAtTime(
|
|||
return keyframes[lo];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 1-frame AnimationClip that captures the skeleton's rest/bind pose
|
||||
* for every bone track in `targetClip`. Used as a reference for
|
||||
* `makeClipAdditive` on DTS blend sequences — the Blender glTF exporter
|
||||
* bakes `rest * delta` into blend keyframes, so subtracting the rest pose
|
||||
* recovers the pure deltas needed for Three.js additive blending.
|
||||
*/
|
||||
export function buildRestPoseClip(
|
||||
scene: Object3D,
|
||||
targetClip: AnimationClip,
|
||||
): AnimationClip {
|
||||
// Build a name → node lookup from the scene graph.
|
||||
const nodesByName = new Map<string, Object3D>();
|
||||
scene.traverse((n) => {
|
||||
if (n.name) nodesByName.set(n.name, n);
|
||||
});
|
||||
|
||||
const tracks: (QuaternionKeyframeTrack | VectorKeyframeTrack)[] = [];
|
||||
|
||||
for (const track of targetClip.tracks) {
|
||||
// Track names are like "BoneName.quaternion" or "BoneName.position".
|
||||
const dotIdx = track.name.lastIndexOf(".");
|
||||
if (dotIdx === -1) continue;
|
||||
const nodeName = track.name.slice(0, dotIdx);
|
||||
const prop = track.name.slice(dotIdx + 1);
|
||||
const node = nodesByName.get(nodeName);
|
||||
if (!node) continue;
|
||||
|
||||
if (prop === "quaternion") {
|
||||
const q = node.quaternion;
|
||||
tracks.push(
|
||||
new QuaternionKeyframeTrack(track.name, [0], [q.x, q.y, q.z, q.w]),
|
||||
);
|
||||
} else if (prop === "position") {
|
||||
const p = node.position;
|
||||
tracks.push(new VectorKeyframeTrack(track.name, [0], [p.x, p.y, p.z]));
|
||||
} else if (prop === "scale") {
|
||||
const s = node.scale;
|
||||
tracks.push(new VectorKeyframeTrack(track.name, [0], [s.x, s.y, s.z]));
|
||||
}
|
||||
}
|
||||
|
||||
return new AnimationClip("_restPose", 0, tracks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a shape scene, apply the "Root" idle animation at t=0, and return the
|
||||
* world-space transform of the named node. This evaluates the skeleton at its
|
||||
|
|
@ -193,51 +239,6 @@ export function getPosedNodeTransform(
|
|||
return { position, quaternion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove faces influenced by hull bones, mutating the geometry in place.
|
||||
* Unlike filterGeometryByVertexGroups (which clones), this is safe when the
|
||||
* geometry is already our own copy (e.g. from SkeletonUtils.clone).
|
||||
*/
|
||||
function filterHullFaces(
|
||||
geometry: BufferGeometry,
|
||||
hullBoneIndices: Set<number>,
|
||||
): void {
|
||||
if (hullBoneIndices.size === 0 || !geometry.attributes.skinIndex) return;
|
||||
|
||||
const skinIndex = geometry.attributes.skinIndex;
|
||||
const skinWeight = geometry.attributes.skinWeight;
|
||||
const index = geometry.index;
|
||||
if (!index) return;
|
||||
|
||||
const vertexHasHullInfluence = new Array(skinIndex.count).fill(false);
|
||||
for (let i = 0; i < skinIndex.count; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const boneIndex = skinIndex.array[i * 4 + j];
|
||||
const weight = skinWeight.array[i * 4 + j];
|
||||
if (weight > 0.01 && hullBoneIndices.has(boneIndex)) {
|
||||
vertexHasHullInfluence[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIndices: number[] = [];
|
||||
const indexArray = index.array;
|
||||
for (let i = 0; i < indexArray.length; i += 3) {
|
||||
const i0 = indexArray[i];
|
||||
const i1 = indexArray[i + 1];
|
||||
const i2 = indexArray[i + 2];
|
||||
if (
|
||||
!vertexHasHullInfluence[i0] &&
|
||||
!vertexHasHullInfluence[i1] &&
|
||||
!vertexHasHullInfluence[i2]
|
||||
) {
|
||||
newIndices.push(i0, i1, i2);
|
||||
}
|
||||
}
|
||||
geometry.setIndex(newIndices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth vertex normals across co-located split vertices (same position, different
|
||||
* UVs). Matches the technique used by ShapeModel for consistent lighting.
|
||||
|
|
@ -307,10 +308,11 @@ export function replaceWithShapeMaterial(
|
|||
mat: MeshStandardMaterial,
|
||||
vis: number,
|
||||
isOrganic = false,
|
||||
options: { anisotropy?: number } = {},
|
||||
options: { anisotropy?: number; emap?: boolean } = {},
|
||||
): ShapeMaterialResult {
|
||||
const resourcePath: string | undefined = mat.userData?.resource_path;
|
||||
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
|
||||
const reflectionAmount: number = mat.userData?.reflection_amount ?? 1.0;
|
||||
|
||||
if (!resourcePath) {
|
||||
// No texture path — plain Lambert fallback with fog/lighting shaders.
|
||||
|
|
@ -326,6 +328,9 @@ export function replaceWithShapeMaterial(
|
|||
// IFL materials need async atlas loading — create with null map to avoid
|
||||
// "Resource not found" warnings from textureToUrl, and return an initializer
|
||||
// that loads the atlas and sets up per-frame animation.
|
||||
const emapEnabled = !!options.emap;
|
||||
const effectiveReflectionAmount = emapEnabled ? reflectionAmount : 0;
|
||||
|
||||
if (flagNames.has("IflMaterial")) {
|
||||
const result = createMaterialFromFlags(
|
||||
mat,
|
||||
|
|
@ -333,6 +338,8 @@ export function replaceWithShapeMaterial(
|
|||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
false,
|
||||
effectiveReflectionAmount,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
const material = result[1];
|
||||
|
|
@ -371,6 +378,8 @@ export function replaceWithShapeMaterial(
|
|||
flagNames,
|
||||
isOrganic,
|
||||
vis,
|
||||
false,
|
||||
effectiveReflectionAmount,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
return { material: result[1], backMaterial: result[0] };
|
||||
|
|
@ -397,7 +406,10 @@ async function initializeIflMaterial(
|
|||
|
||||
let disposed = false;
|
||||
const prevOnBeforeRender = mesh.onBeforeRender;
|
||||
mesh.onBeforeRender = function (this: any, ...args: Parameters<typeof mesh.onBeforeRender>) {
|
||||
mesh.onBeforeRender = function (
|
||||
this: any,
|
||||
...args: Parameters<typeof mesh.onBeforeRender>
|
||||
) {
|
||||
prevOnBeforeRender?.apply(this, args);
|
||||
if (disposed) return;
|
||||
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
|
||||
|
|
@ -417,28 +429,27 @@ async function initializeIflMaterial(
|
|||
export function processShapeScene(
|
||||
scene: Object3D,
|
||||
shapeName?: string,
|
||||
options: { anisotropy?: number } = {},
|
||||
options: { anisotropy?: number; emap?: boolean } = {},
|
||||
): IflInitializer[] {
|
||||
const iflInitializers: IflInitializer[] = [];
|
||||
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
|
||||
|
||||
// Find skeleton for hull bone filtering.
|
||||
let skeleton: any = null;
|
||||
scene.traverse((n: any) => {
|
||||
if (!skeleton && n.skeleton) skeleton = n.skeleton;
|
||||
});
|
||||
const hullBoneIndices = skeleton
|
||||
? getHullBoneIndices(skeleton)
|
||||
: new Set<number>();
|
||||
|
||||
// Collect back-face meshes to add after traversal (can't modify during traverse).
|
||||
const backFaceMeshes: Array<{ parent: Object3D; mesh: any }> = [];
|
||||
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh) return;
|
||||
|
||||
// Hide unwanted nodes: hull geometry, unassigned materials.
|
||||
if (node.name.match(/^Hulk/i) || node.material?.name === "Unassigned") {
|
||||
// Hide collision-only meshes. In Torque, these live in detail levels with
|
||||
// size < 0 (utility details). The Blender addon exports dts_detail_size.
|
||||
if (node.material?.name === "Unassigned") {
|
||||
node.visible = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof node.userData?.dts_detail_size === "number" &&
|
||||
node.userData.dts_detail_size < 0
|
||||
) {
|
||||
node.visible = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -446,23 +457,20 @@ export function processShapeScene(
|
|||
// Hide vis-animated meshes (default vis < 0.01) but DON'T skip material
|
||||
// replacement — they need correct textures for when they become visible
|
||||
// (e.g. disc launcher's Disc mesh toggles visibility via state machine).
|
||||
const hasVisSequence = !!node.userData?.vis_sequence;
|
||||
if ((node.userData?.vis ?? 1) < 0.01) {
|
||||
node.visible = false;
|
||||
}
|
||||
|
||||
// Filter hull-influenced triangles and smooth normals.
|
||||
// SkeletonUtils.clone already deep-clones geometry, so no extra clone
|
||||
// is needed — we can mutate in place.
|
||||
if (node.geometry) {
|
||||
filterHullFaces(node.geometry, hullBoneIndices);
|
||||
smoothVertexNormals(node.geometry);
|
||||
}
|
||||
|
||||
// Replace PBR materials with diffuse-only Lambert materials.
|
||||
// For vis-animated meshes, use vis=1 so the material is fully opaque —
|
||||
// their visibility is toggled via node.visible, not material opacity.
|
||||
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
|
||||
const vis: number = node.userData?.vis_sequence
|
||||
? 1
|
||||
: (node.userData?.vis ?? 1);
|
||||
if (Array.isArray(node.material)) {
|
||||
node.material = node.material.map((m: MeshStandardMaterial) => {
|
||||
const result = replaceWithShapeMaterial(m, vis, isOrganic, options);
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@ export function pickMoveAnimation(
|
|||
// be skiing/airborne since the engine limits grounded speed.
|
||||
const horizontalSpeedSq = vx * vx + vy * vy;
|
||||
const airborne =
|
||||
Math.abs(vz) > 2 ||
|
||||
horizontalSpeedSq > MAX_GROUND_SPEED * MAX_GROUND_SPEED;
|
||||
Math.abs(vz) > 2 || horizontalSpeedSq > MAX_GROUND_SPEED * MAX_GROUND_SPEED;
|
||||
|
||||
if (airborne) {
|
||||
// Tribes2.exe checks mJetting here: jetting → JetAnim, else → RootAnim.
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ export interface StreamEntity {
|
|||
className?: string;
|
||||
dataBlockId?: number;
|
||||
shapeHint?: string;
|
||||
/** Whether the datablock enables environment map reflections. */
|
||||
emap?: boolean;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position?: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
|
|
@ -154,7 +156,11 @@ export interface StreamEntity {
|
|||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
/** WheeledVehicle per-wheel state. */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
wheels?: Array<{
|
||||
speed: number;
|
||||
lateralSlip: number;
|
||||
longitudinalSlip: number;
|
||||
}>;
|
||||
/** Vehicle steering angle (radians). */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed). */
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
} from "three";
|
||||
|
||||
const _bitmapLoader = new ImageBitmapLoader();
|
||||
// Prevent the browser from premultiplying alpha, which destroys RGB data in
|
||||
// transparent pixels. Tribes 2 uses the alpha channel for purposes other than
|
||||
// transparency (e.g. environment map masking) on many non-Translucent textures.
|
||||
_bitmapLoader.setOptions({ premultiplyAlpha: "none" });
|
||||
const _textureCache = new Map<string, Texture>();
|
||||
|
||||
/**
|
||||
|
|
@ -86,6 +90,8 @@ export interface TextureSetupOptions {
|
|||
disableMipmaps?: boolean;
|
||||
/** Override anisotropy level. Default: max supported by the GPU. */
|
||||
anisotropy?: number;
|
||||
/** Skip sRGB colorspace assignment (for textures sampled in sRGB space). */
|
||||
noColorSpace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,10 +105,17 @@ export function setupTexture<T extends Texture>(
|
|||
tex: T,
|
||||
options: TextureSetupOptions = {},
|
||||
): T {
|
||||
const { repeat = [1, 1], disableMipmaps = false, anisotropy } = options;
|
||||
const {
|
||||
repeat = [1, 1],
|
||||
disableMipmaps = false,
|
||||
anisotropy,
|
||||
noColorSpace = false,
|
||||
} = options;
|
||||
|
||||
tex.wrapS = tex.wrapT = RepeatWrapping;
|
||||
tex.colorSpace = SRGBColorSpace;
|
||||
if (!noColorSpace) {
|
||||
tex.colorSpace = SRGBColorSpace;
|
||||
}
|
||||
tex.repeat.set(...repeat);
|
||||
tex.flipY = false; // DDS/DIF textures are already flipped
|
||||
tex.anisotropy = anisotropy ?? 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue