skin support, shape mounting unification

This commit is contained in:
Brian Beck 2026-04-03 09:41:19 -07:00
parent 14e25beaf8
commit bc7d30c5c6
98 changed files with 1952 additions and 1744 deletions

View file

@ -7,7 +7,13 @@ import { MapInspector } from "@/src/components/MapInspector";
import { SettingsProvider } from "@/src/components/SettingsProvider";
// Three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export default function HomePage() {
return (

View file

@ -14,7 +14,7 @@ const debugMaterial = (
/** Red wireframe bounding box for debug tour visualization. */
export function DebugBounds({ size }: { size: [number, number, number] }) {
const edges = useMemo(
() => new EdgesGeometry(new BoxGeometry(...size)),
() => new EdgesGeometry(new BoxGeometry(size[0], size[1], size[2])),
[size[0], size[1], size[2]], // eslint-disable-line react-hooks/exhaustive-deps
);
return (

View file

@ -128,8 +128,28 @@
}
.Detail {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
display: flex;
flex-direction: column;
gap: 1px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin: 4px 0 0 0;
padding: 0;
}
.Detail > div {
display: flex;
gap: 4px;
}
.Detail dt {
color: rgba(255, 255, 255, 0.35);
}
.Detail dt::after {
content: ":";
}
.Detail dd {
margin: 0;
}

View file

@ -18,19 +18,35 @@ function getEntityLabel(entity: GameEntity): string {
return entity.className;
}
function getEntityDetail(entity: GameEntity): string | undefined {
if ("shapeName" in entity && entity.shapeName) return entity.shapeName;
if (entity.renderType === "InteriorInstance" && "interiorData" in entity) {
return entity.interiorData.interiorFile;
function getEntityDetail(
entity: GameEntity,
): Record<string, string> | undefined {
const fields: Record<string, string> = {};
if ("shapeName" in entity && entity.shapeName) {
fields.shape = entity.shapeName;
}
if ("dataBlock" in entity && entity.dataBlock) return entity.dataBlock;
if (entity.renderType === "TerrainBlock" && "terrainData" in entity)
return entity.terrainData.terrFileName;
if (entity.renderType === "WaterBlock" && "waterData" in entity)
return entity.waterData.surfaceName;
if ("audioFileName" in entity && entity.audioFileName)
return entity.audioFileName;
return undefined;
if (entity.renderType === "Player" && "skinPrefName" in entity) {
if (entity.skinPrefName) fields.skin = entity.skinPrefName;
else if (entity.skinName) fields.skin = entity.skinName;
}
if (entity.renderType === "InteriorInstance" && "interiorData" in entity) {
fields.interior = entity.interiorData.interiorFile;
}
if (!fields.shape && "dataBlock" in entity && entity.dataBlock) {
fields.dataBlock = entity.dataBlock;
}
if (entity.renderType === "TerrainBlock" && "terrainData" in entity) {
fields.terrain = entity.terrainData.terrFileName;
}
if (entity.renderType === "WaterBlock" && "waterData" in entity) {
fields.surface = entity.waterData.surfaceName;
}
if ("audioFileName" in entity && entity.audioFileName) {
fields.audio = entity.audioFileName;
}
return Object.keys(fields).length > 0 ? fields : undefined;
}
function getEntityPosition(
@ -96,7 +112,16 @@ function EntityRow({ entity }: { entity: GameEntity }) {
<span className={styles.Type}>{label}</span>{" "}
<span className={styles.ID}>{entity.id}</span>
</div>
{detail && <span className={styles.Detail}>{detail}</span>}
{detail && (
<dl className={styles.Detail}>
{Object.entries(detail).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
)}
</div>
{pos && (
<button

View file

@ -1,18 +1,17 @@
import { lazy, memo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { lazy, memo, useMemo, useRef } from "react";
import type { Group } from "three";
import type {
GameEntity,
ShapeEntity as ShapeEntityType,
} from "../state/gameEntityTypes";
import { ShapeRenderer, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer, MountedShapeContent } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider";
import { DebugSuspense } from "./DebugSuspense";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { FloatingLabel } from "./FloatingLabel";
import { useSettings } from "./SettingsProvider";
import { DEFAULT_TEAM_NAMES } from "../stringUtils";
import { useDataSource } from "../state/gameEntityStore";
import { resolveEmapFromDatablock } from "./resolveEmap";
import { Camera } from "./Camera";
import { WayPoint } from "./WayPoint";
import { TerrainBlock } from "./TerrainBlock";
@ -20,6 +19,7 @@ import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
import { AudioEnabled } from "./AudioEnabled";
import type { TorqueObject } from "../torqueScript";
import { useRotation } from "./useRotation";
function createLazy(
name: string,
@ -48,7 +48,7 @@ function createLazy(
const PlayerModel = createLazy("PlayerModel", () => import("./PlayerModel"));
const ExplosionShape = createLazy(
"ExplosionShape",
() => import("./ShapeModel"),
() => import("./ExplosionShape"),
);
const TracerProjectile = createLazy(
"TracerProjectile",
@ -64,7 +64,6 @@ const ForceFieldBare = createLazy(
);
const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
/**
* Renders a GameEntity by dispatching to the appropriate renderer based
@ -121,23 +120,24 @@ export const EntityRenderer = memo(function EntityRenderer({
});
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
const { animationEnabled } = useSettings();
const dataSource = useDataSource();
const isStreaming = dataSource === "demo" || dataSource === "live";
const groupRef = useRef<Group>(null);
// Y-axis spinning for Items with rotate=true
useFrame(() => {
if (!groupRef.current || !entity.rotate || !animationEnabled) return;
const t = performance.now() / 1000;
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
});
useRotation(entity, groupRef);
if (!entity.shapeName) {
throw new Error(`Shape entity missing shapeName: ${entity.id}`);
}
const torqueObject = entity.runtimeObject as TorqueObject | undefined;
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
const emap = useMemo(
() => resolveEmapFromDatablock(entity.dataBlockId, entity.dataBlock),
[entity.dataBlockId, entity.dataBlock],
);
// Flag label for flag Items
const isFlag = entity.dataBlock?.toLowerCase() === "flag";
const teamName =
@ -155,48 +155,35 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
return (
<ShapeInfoProvider
object={torqueObject}
object={entity.runtimeObject as TorqueObject | undefined}
shapeName={entity.shapeName}
type={shapeType}
>
<group ref={entity.rotate ? groupRef : undefined}>
<ShapeRenderer
loadingColor={loadingColor}
streamEntity={torqueObject ? undefined : entity}
emap={entity.emap}
streamEntity={isStreaming ? entity : undefined}
emap={emap}
entityId={entity.id}
skinName={entity.skinName}
mounted={
entity.weaponShape
? {
0: (
<MountedShapeContent
shapeName={entity.weaponShape}
imageDataBlockId={entity.imageDataBlockIds?.[0]}
entityId={entity.id}
/>
),
}
: undefined
}
>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
) : null}
</ShapeRenderer>
{entity.barrelShapeName && (
<ShapeInfoProvider
object={torqueObject}
shapeName={entity.barrelShapeName}
type="Turret"
>
<group position={[0, 1.5, 0]}>
<ShapeRenderer />
</group>
</ShapeInfoProvider>
)}
{entity.weaponShape && (
<ShapeErrorBoundary
fallback={
<ShapePlaceholder color="red" label={entity.weaponShape} />
}
>
<DebugSuspense
name={`Weapon:${entity.id}/${entity.weaponShape}`}
fallback={
<ShapePlaceholder color="cyan" label={entity.weaponShape} />
}
>
<WeaponModel entity={entity} />
</DebugSuspense>
</ShapeErrorBoundary>
)}
</group>
</ShapeInfoProvider>
);

View file

@ -1,120 +1,21 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { AnimationMixer, LoopOnce, Quaternion, Vector3 } from "three";
import { AnimationMixer, LoopOnce } from "three";
import type { Group, Material } from "three";
import { effectNow, engineStore } from "../state/engineStore";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
_r90,
_r90inv,
disposeClonedScene,
getPosedNodeTransform,
processShapeScene,
} from "../stream/playbackUtils";
import { disposeClonedScene, processShapeScene } from "../stream/playbackUtils";
import {
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { ShapeRenderer, useStaticShape } from "./GenericShape";
} from "./iflAtlas";
import type { IflAtlas } from "./iflAtlas";
import { useStaticShape } from "./GenericShape";
import { useAnisotropy } from "./useAnisotropy";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript";
import type { ExplosionEntity, ShapeEntity } from "../state/gameEntityTypes";
import type { ExplosionEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
/**
* Renders a mounted weapon using the Torque engine's mount system.
*
* The weapon's `Mountpoint` node is aligned to the player's `Mount0` node
* (right hand). Both nodes come from the GLB skeleton in its idle ("Root"
* animation) pose, with the weapon-specific arm animation applied additively.
* The mount transform is conjugated by ShapeRenderer's 90° Y rotation:
* T_mount = R90 * M0 * MP^(-1) * R90^(-1).
*/
export function WeaponModel({ entity }: { entity: ShapeEntity }) {
const shapeName = entity.weaponShape;
const playerShapeName = entity.shapeName;
const playerGltf = useStaticShape(playerShapeName!);
const weaponGltf = useStaticShape(shapeName!);
const mountTransform = useMemo(() => {
// Get Mount0 from the player's posed skeleton with arm animation applied.
// 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,
"Mount0",
[armThread],
);
if (!m0) return { position: undefined, quaternion: undefined };
// Get Mountpoint from weapon (may not be animated).
const mp = getPosedNodeTransform(
weaponGltf.scene,
weaponGltf.animations,
"Mountpoint",
);
// Compute T_mount = R90 * M0 * MP^(-1) * R90^(-1)
// This conjugates the GLB-space mount transform by ShapeRenderer's 90° Y
// rotation so the weapon is correctly oriented in entity space.
let combinedPos: Vector3;
let combinedQuat: Quaternion;
if (mp) {
// MP^(-1)
const mpInvQuat = mp.quaternion.clone().invert();
const mpInvPos = mp.position.clone().negate().applyQuaternion(mpInvQuat);
// M0 * MP^(-1)
combinedQuat = m0.quaternion.clone().multiply(mpInvQuat);
combinedPos = mpInvPos
.clone()
.applyQuaternion(m0.quaternion)
.add(m0.position);
} else {
combinedPos = m0.position.clone();
combinedQuat = m0.quaternion.clone();
}
// R90 * combined * R90^(-1)
const mountPos = combinedPos.applyQuaternion(_r90);
const mountQuat = _r90.clone().multiply(combinedQuat).multiply(_r90inv);
return { position: mountPos, quaternion: mountQuat };
}, [
playerGltf.animations,
playerGltf.scene,
shapeName,
weaponGltf.animations,
weaponGltf.scene,
]);
const torqueObject = useMemo<TorqueObject>(
() => ({
_class: "weapon",
_className: "Weapon",
_id: 0,
}),
[],
);
return (
<ShapeInfoProvider object={torqueObject} shapeName={shapeName!} type="Item">
<group
position={mountTransform.position}
quaternion={mountTransform.quaternion}
>
<ShapeRenderer loadingColor="#4488ff" />
</group>
</ShapeInfoProvider>
);
}
// ── Explosion shape rendering ──
//
// Explosion DTS shapes are flat billboard planes with IFL-animated textures,
@ -266,7 +167,6 @@ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
processShapeScene(scene, entity.shapeName, {
anisotropy,
emap: "emap" in entity ? (entity as any).emap : undefined,
});
// Collect vis-animated nodes keyed by sequence name.

View file

@ -1,10 +1,11 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { Fragment, memo, Suspense, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import type { AnimationAction } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { createPortal, useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import { shapeToUrl } from "../loaders";
import {
MeshStandardMaterial,
AdditiveAnimationBlendMode,
@ -13,16 +14,15 @@ import {
AnimationUtils,
LoopOnce,
LoopRepeat,
BufferGeometry,
Group,
Box3,
Vector3,
} from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useAnisotropy } from "./useAnisotropy";
import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useShapeInfo, ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider";
import {
useEngineSelector,
effectNow,
@ -30,14 +30,11 @@ import {
} from "../state/engineStore";
import { FloatingLabel } from "./FloatingLabel";
import {
useIflTexture,
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture";
import { createMaterialFromFlags } from "../shapeMaterial";
import type { MaterialResult } from "../shapeMaterial";
} from "./iflAtlas";
import type { IflAtlas } from "./iflAtlas";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { useEntitySoundSlots } from "./useEntitySoundSlots";
@ -46,15 +43,22 @@ import {
replaceWithShapeMaterial,
disposeClonedScene,
buildRestPoseClip,
getPosedNodeTransform,
} from "../stream/playbackUtils";
import { resolveEmapFromImageSlot } from "./resolveEmap";
import type { ThreadState as StreamThreadState } from "../stream/types";
const STANDARD_90_ROTATION: [x: number, y: number, z: number] = [
0,
Math.PI / 2,
0,
];
/** Shape entity data readable in useFrame for streaming mode. */
interface StreamShapeEntity {
id: string;
threads?: StreamThreadState[];
damageState?: number;
emap?: boolean;
wheels?: Array<{
speed: number;
lateralSlip: number;
@ -69,6 +73,57 @@ interface StreamShapeEntity {
const log = createLogger("GenericShape");
/**
* Content for a mounted shape. Computes the Mountpoint inverse offset from the
* child shape's GLB so the child's grip point aligns to the parent's mount bone.
* Rendered via createPortal into the parent's mount bone.
*/
export function MountedShapeContent({
shapeName,
imageDataBlockId,
entityId,
shapeType = "StaticShape",
skinName,
}: {
shapeName: string;
imageDataBlockId?: number;
entityId?: string;
shapeType?: StaticShapeType;
skinName?: string;
}) {
const childGltf = useStaticShape(shapeName);
const emap = useMemo(
() => resolveEmapFromImageSlot(imageDataBlockId),
[imageDataBlockId],
);
// Compute Mountpoint inverse so the child's grip aligns to the bone origin.
const offset = useMemo(() => {
const mp = getPosedNodeTransform(
childGltf.scene as Group,
childGltf.animations,
"Mountpoint",
);
if (!mp) return null;
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
return { position: invPos, quaternion: invQuat };
}, [childGltf.scene, childGltf.animations]);
return (
<ShapeInfoProvider shapeName={shapeName} type={shapeType}>
<group position={offset?.position} quaternion={offset?.quaternion}>
<ShapeRenderer
emap={emap}
entityId={entityId}
skinName={skinName}
noRotation
/>
</group>
</ShapeInfoProvider>
);
}
/** WheeledVehicle per-wheel animation state (position-controlled, not threaded). */
interface WheelAnimState {
wheelAction?: AnimationAction;
@ -83,20 +138,6 @@ function shapeNowSec(): number {
return recording != null ? effectNow() / 1000 : performance.now() / 1000;
}
/** Shared props for texture rendering components */
interface TextureProps {
material: MeshStandardMaterial;
shapeName?: string;
geometry?: BufferGeometry;
backGeometry?: BufferGeometry;
castShadow?: boolean;
receiveShadow?: boolean;
/** DTS object visibility (01). Values < 1 enable alpha blending. */
vis?: number;
/** When true, material is created transparent for vis keyframe animation. */
animated?: boolean;
}
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
@ -105,232 +146,9 @@ export function useStaticShape(shapeName: string) {
return useGLTF(url);
}
/**
* Animated IFL (Image File List) material component. Creates a sprite sheet
* from all frames and animates via texture offset.
*/
const IflTexture = memo(function IflTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
const iflPath = `textures/${resourcePath}.ifl`;
const texture = useIflTexture(iflPath);
const isOrganic = !!(shapeName && isOrganicShape(shapeName));
const customMaterial = useMemo(
() =>
createMaterialFromFlags(
material,
texture,
flagNames,
isOrganic,
vis,
animated,
),
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
return (
<>
<mesh
geometry={backGeometry || geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[0]} attach="material" />
</mesh>
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[1]} attach="material" />
</mesh>
</>
);
}
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
function useDisposeMaterial(material: MaterialResult) {
useEffect(() => {
return () => {
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else {
material.dispose();
}
};
}, [material]);
}
const EMPTY_FLAG_NAMES = new Set<string>();
const StaticTexture = memo(function StaticTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = false,
}: TextureProps) {
const resourcePath = material.userData.resource_path;
const flagNames = useMemo(
() =>
material.userData.flag_names
? new Set<string>(material.userData.flag_names)
: EMPTY_FLAG_NAMES,
[material.userData.flag_names],
);
const url = useMemo(() => {
if (!resourcePath) {
log.warn(
'No resource_path found on "%s" — rendering fallback',
shapeName,
);
}
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
}, [resourcePath, shapeName]);
const isOrganic = !!(shapeName && isOrganicShape(shapeName));
const isTranslucent = flagNames.has("Translucent");
const anisotropy = useAnisotropy();
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupTexture(texture, { disableMipmaps: true, anisotropy });
}
// Standard color texture setup for diffuse-only materials
return setupTexture(texture, { anisotropy });
});
const customMaterial = useMemo(
() =>
createMaterialFromFlags(
material,
texture,
flagNames,
isOrganic,
vis,
animated,
),
[material, texture, flagNames, isOrganic, vis, animated],
);
useDisposeMaterial(customMaterial);
// Two-pass rendering for organic/translucent materials
// Render BackSide first (with flipped normals), then FrontSide
if (Array.isArray(customMaterial)) {
return (
<>
<mesh
geometry={backGeometry || geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[0]} attach="material" />
</mesh>
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial[1]} attach="material" />
</mesh>
</>
);
}
return (
<mesh
geometry={geometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
<primitive object={customMaterial} attach="material" />
</mesh>
);
});
export const ShapeTexture = memo(function ShapeTexture({
material,
shapeName,
geometry,
backGeometry,
castShadow = false,
receiveShadow = false,
vis = 1,
animated = false,
}: TextureProps) {
const flagNames = new Set(material.userData.flag_names ?? []);
const isIflMaterial = flagNames.has("IflMaterial");
const resourcePath = material.userData.resource_path;
// Use IflTexture for animated materials
if (isIflMaterial && resourcePath) {
return (
<IflTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
vis={vis}
animated={animated}
/>
);
} else if (material.name) {
return (
<StaticTexture
material={material}
shapeName={shapeName}
geometry={geometry}
backGeometry={backGeometry}
castShadow={castShadow}
receiveShadow={receiveShadow}
vis={vis}
animated={animated}
/>
);
} else {
return null;
}
});
// Dead code removed: IflTexture, StaticTexture, ShapeTexture, useDisposeMaterial
// were part of an unused React-based IFL rendering path. All IFL materials
// are now handled imperatively via loadIflAtlas + processShapeScene.
export function ShapePlaceholder({
color,
@ -369,6 +187,9 @@ export const ShapeRenderer = memo(function ShapeRenderer({
emap,
entityId,
children,
mounted,
noRotation,
skinName,
}: {
loadingColor?: string;
/** Stable entity reference whose fields are mutated in-place. */
@ -377,19 +198,23 @@ export const ShapeRenderer = memo(function ShapeRenderer({
emap?: boolean;
entityId?: string;
children?: React.ReactNode;
/** Content to render at each mount point bone (Mount0, Mount1, etc.). */
mounted?: Record<number, ReactNode>;
/** Skip the 90° Y rotation (for shapes mounted inside a parent that already rotates). */
noRotation?: boolean;
/** Skin texture URL (Torque reSkin: replaces "base." textures with this URL). */
skinName?: string;
}) {
const { object, shapeName } = useShapeInfo();
const { shapeName } = useShapeInfo();
if (!shapeName) {
return (
<DebugPlaceholder color="orange" label={`${object?._id}: <missing>`} />
);
return <DebugPlaceholder color="orange" label={`${entityId}: <missing>`} />;
}
return (
<ErrorBoundary
fallback={
<DebugPlaceholder color="red" label={`${object?._id}: ${shapeName}`} />
<DebugPlaceholder color="red" label={`${entityId}: ${shapeName}`} />
}
onError={(error) => {
log.error("Shape error: %s: %o", shapeName, error);
@ -400,8 +225,12 @@ export const ShapeRenderer = memo(function ShapeRenderer({
streamEntity={streamEntity}
emap={emap}
entityId={entityId}
/>
{children}
mounted={mounted}
noRotation={noRotation}
skinName={skinName}
>
{children}
</ShapeModelLoader>
</Suspense>
</ErrorBoundary>
);
@ -434,6 +263,10 @@ export const ShapeModel = memo(function ShapeModel({
streamEntity,
emap,
entityId,
children,
mounted,
noRotation,
skinName,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose fields are mutated in-place. */
@ -441,6 +274,13 @@ export const ShapeModel = memo(function ShapeModel({
/** Datablock enables environment map reflections. */
emap?: boolean;
entityId?: string;
children?: ReactNode;
/** Content to render at each mount point bone (Mount0, Mount1, etc.). */
mounted?: Record<number, ReactNode>;
/** Skip the 90° Y rotation (for mounted shapes). */
noRotation?: boolean;
/** Skin texture URL (Torque reSkin: replaces "base." textures). */
skinName?: string;
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
@ -458,6 +298,7 @@ export const ShapeModel = memo(function ShapeModel({
mesh: any;
iflPath: string;
hasVisSequence: boolean;
repeat: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
@ -487,6 +328,7 @@ export const ShapeModel = memo(function ShapeModel({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!ud?.vis_sequence,
repeat: flags.has("SWrap") || flags.has("TWrap"),
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
@ -497,7 +339,8 @@ export const ShapeModel = memo(function ShapeModel({
processShapeScene(scene, shapeName ?? undefined, {
anisotropy,
emap: emap ?? streamEntity?.emap,
emap,
skinName,
});
// Un-hide IFL meshes that don't have a vis sequence — they should always
@ -582,7 +425,7 @@ export const ShapeModel = memo(function ShapeModel({
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf, anisotropy, emap]);
}, [gltf.scene, gltf.animations, shapeName, anisotropy, emap, skinName]);
// Dispose cloned geometries and materials when the scene is replaced or
// the component unmounts, to prevent GPU memory from accumulating.
@ -598,6 +441,8 @@ export const ShapeModel = memo(function ShapeModel({
interface IflAnimInfo {
atlas: IflAtlas;
/** Material reference for swap-mode texture updates. */
mat: any;
sequenceName?: string;
/** Controlling sequence duration in seconds. */
sequenceDuration?: number;
@ -629,7 +474,7 @@ export const ShapeModel = memo(function ShapeModel({
iflAnimInfosRef.current = [];
iflMeshAtlasRef.current.clear();
for (const info of iflMeshes) {
loadIflAtlas(info.iflPath)
loadIflAtlas(info.iflPath, { repeat: info.repeat })
.then((atlas) => {
const mat = Array.isArray(info.mesh.material)
? info.mesh.material[0]
@ -640,6 +485,7 @@ export const ShapeModel = memo(function ShapeModel({
}
const iflInfo = {
atlas,
mat,
sequenceName: info.iflSequence,
sequenceDuration: info.iflDuration,
cyclic: info.iflCyclic,
@ -1102,40 +948,43 @@ export const ShapeModel = memo(function ShapeModel({
if (iflAnimInfos.length > 0) {
iflTimeRef.current += effectDelta;
for (const info of iflAnimInfos) {
if (!animationEnabled) {
updateAtlasFrame(info.atlas, 0);
continue;
}
if (info.sequenceName && info.sequenceDuration) {
// Sequence-driven IFL: find the thread playing this sequence and
// compute time = pos * duration + toolBegin (matching the engine).
let iflTime = 0;
for (const [, thread] of threads) {
if (thread.sequence === info.sequenceName) {
const elapsed = shapeNowSec() - thread.startTime;
const dur = info.sequenceDuration;
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
const pos = info.cyclic
? (elapsed / dur) % 1
: Math.min(elapsed / dur, 1);
iflTime = pos * dur + (info.toolBegin ?? 0);
break;
}
const updateAtlasAndMaterial = (
info: IflAnimInfo,
frameIndex: number,
) => {
updateAtlasFrame(info.atlas, frameIndex);
if (info.atlas.swapMode && info.mat.map !== info.atlas.texture) {
info.mat.map = info.atlas.texture;
info.mat.needsUpdate = true;
}
updateAtlasFrame(
info.atlas,
getFrameIndexForTime(info.atlas, iflTime),
);
} else {
// No controlling sequence: use accumulated real time.
// (In the engine, these would stay at frame 0, but cycling is more
// useful for display purposes.)
updateAtlasFrame(
info.atlas,
getFrameIndexForTime(info.atlas, iflTimeRef.current),
);
};
let frameIndex = 0;
if (animationEnabled) {
let iflTime = 0;
if (info.sequenceName && info.sequenceDuration) {
// Sequence-driven IFL: find the thread playing this sequence and
// compute time = pos * duration + toolBegin (matching the engine).
for (const [, thread] of threads) {
if (thread.sequence === info.sequenceName) {
const elapsed = shapeNowSec() - thread.startTime;
const dur = info.sequenceDuration;
// Reproduce th->pos: cyclic wraps [0,1), non-cyclic clamps [0,1]
const pos = info.cyclic
? (elapsed / dur) % 1
: Math.min(elapsed / dur, 1);
iflTime = pos * dur + (info.toolBegin ?? 0);
break;
}
}
} else {
// No controlling sequence: use accumulated real time.
// (In the engine, these would stay at frame 0, but cycling is more
// useful for display purposes.)
iflTime = iflTimeRef.current;
}
frameIndex = getFrameIndexForTime(info.atlas, iflTime);
}
updateAtlasAndMaterial(info, frameIndex);
}
}
});
@ -1157,12 +1006,26 @@ export const ShapeModel = memo(function ShapeModel({
};
}, [isTarget, gltf.scene]);
// Find mount point bones in the cloned scene for portal rendering.
const mountBones = useMemo(() => {
if (!mounted) return null;
const bones: Record<number, Group> = {};
for (const slot of Object.keys(mounted)) {
const index = Number(slot);
const nodeName = `Mount${index}`;
clonedScene.traverse((node: any) => {
if (node.name === nodeName) bones[index] = node;
});
}
return Object.keys(bones).length > 0 ? bones : null;
}, [clonedScene, mounted]);
return (
<group rotation={[0, Math.PI / 2, 0]}>
<group rotation={noRotation ? undefined : STANDARD_90_ROTATION}>
<primitive object={clonedScene} />
{debugMode ? (
<FloatingLabel>
{object?._id}: {shapeName}
{entityId}: {shapeName}
</FloatingLabel>
) : null}
{shapeBounds && (
@ -1170,6 +1033,17 @@ export const ShapeModel = memo(function ShapeModel({
<DebugBounds size={shapeBounds.size} />
</group>
)}
{children}
{mountBones &&
mounted &&
Object.entries(mounted).map(([slot, content]) => {
const bone = mountBones[Number(slot)];
return bone ? (
<Fragment key={slot}>
{createPortal(<group>{content}</group>, bone)}
</Fragment>
) : null;
})}
</group>
);
});
@ -1178,10 +1052,18 @@ function ShapeModelLoader({
streamEntity,
emap,
entityId,
children,
mounted,
noRotation,
skinName,
}: {
streamEntity?: StreamShapeEntity;
emap?: boolean;
entityId?: string;
children?: ReactNode;
mounted?: Record<number, ReactNode>;
noRotation?: boolean;
skinName?: string;
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
@ -1191,6 +1073,11 @@ function ShapeModelLoader({
streamEntity={streamEntity}
emap={emap}
entityId={entityId}
/>
mounted={mounted}
noRotation={noRotation}
skinName={skinName}
>
{children}
</ShapeModel>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { createPortal, useFrame } from "@react-three/fiber";
import {
AdditiveAnimationBlendMode,
AnimationMixer,
@ -19,7 +19,6 @@ import { AnimationClip } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import {
ANIM_TRANSITION_TIME,
DEFAULT_EYE_HEIGHT,
buildRestPoseClip,
disposeClonedScene,
getKeyframeAtTime,
@ -30,13 +29,23 @@ import { pickMoveAnimation } from "../stream/playerAnimation";
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
import type { WeaponAnimState } from "../stream/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape, ShapePlaceholder } from "./GenericShape";
import { useQuery } from "@tanstack/react-query";
import {
useStaticShape,
ShapePlaceholder,
MountedShapeContent,
} from "./GenericShape";
import { textureToUrl } from "../loaders";
import { useAnisotropy } from "./useAnisotropy";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { DebugSuspense } from "./DebugSuspense";
import { useIsDebugTourTarget } from "../state/cameraTourStore";
import { DebugBounds } from "./DebugBounds";
import { useEntitySoundSlots } from "./useEntitySoundSlots";
import {
resolveEmapFromDatablock,
resolveEmapFromImageSlot,
} from "./resolveEmap";
import { useAudio } from "./AudioContext";
import {
resolveAudioProfile,
@ -55,6 +64,53 @@ import { PlayerNameplate } from "./PlayerNameplate";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes";
/**
* Per-entity animated eye bone position in model space (GLB coordinates).
* Written by PlayerModel after mixer.update(), read by StreamingController
* for first-person camera positioning.
*/
export const playerEyePositions = new Map<string, Vector3>();
const SKIN_BASE_URL = "https://exogen.github.io/t2-skins/skins/";
const SKIN_MANIFEST_URL = "https://exogen.github.io/t2-skins/skins.json";
/** Map shape DTS name to skin texture suffix. */
const SKIN_SUFFIXES: Record<string, string> = {
"light_male.dts": "lmale",
"light_female.dts": "lfemale",
"medium_male.dts": "mmale",
"medium_female.dts": "mfemale",
"heavy_male.dts": "hmale",
"bioderm_light.dts": "lbioderm",
"bioderm_medium.dts": "mbioderm",
"bioderm_heavy.dts": "hbioderm",
};
/** Processed custom skin manifest: suffix → Set of available skin names. */
type SkinLookup = Record<string, Set<string>>;
/** Fetch the remote custom skins manifest (once, cached forever).
* Returns a lookup of suffix Set<skinName> for O(1) has() checks. */
function useCustomSkinManifest() {
return useQuery<SkinLookup>({
queryKey: ["customSkinManifest"],
queryFn: async () => {
const res = await fetch(SKIN_MANIFEST_URL);
if (!res.ok) throw new Error(`${res.status}`);
const raw: { customSkins?: Record<string, string[]> } = await res.json();
const lookup: SkinLookup = {};
if (raw.customSkins) {
for (const [suffix, names] of Object.entries(raw.customSkins)) {
lookup[suffix] = new Set(names);
}
}
return lookup;
},
staleTime: Infinity,
retry: 1,
});
}
/** Number of table actions in the engine's ActionAnimationList (Tribes2.exe build 25034). */
const NUM_TABLE_ACTION_ANIMS = 8;
@ -229,47 +285,90 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
(state) => state.playback.streamSnapshot?.controlPlayerGhostId,
);
// Resolve skin texture URL: local manifest first, then remote manifest.
const { data: skinManifest } = useCustomSkinManifest();
const skinUrl = useMemo(() => {
const skin = entity.skinPrefName ?? entity.skinName;
if (!skin || skin === "base") return undefined;
const suffix = SKIN_SUFFIXES[shapeName.toLowerCase()];
if (!suffix) return undefined;
// 1. Check local manifest (built-in skins like beagle, swolf, baseb).
try {
return textureToUrl(`skins/${skin}.${suffix}`, null);
} catch {
// Not in local manifest.
}
// 2. Check remote manifest (custom skins).
if (skinManifest?.[suffix]?.has(skin)) {
return `${SKIN_BASE_URL}${skin}.${suffix}.png`;
}
// 3. Not found — no skin override.
return undefined;
}, [entity.skinPrefName, entity.skinName, shapeName, skinManifest]);
// Resolve emap per-datablock (emap is a datablock property, not entity).
const emap = useMemo(
() => resolveEmapFromDatablock(entity.dataBlockId, entity.dataBlock),
[entity.dataBlockId, entity.dataBlock],
);
// 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,
emap: entity.emap,
});
const {
clonedScene,
mixer,
mount0,
mount1,
mount2,
eyeBone,
iflInitializers,
} = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
const iflInits = processShapeScene(scene, undefined, {
anisotropy,
emap: emap,
skinUrl,
});
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
scene.traverse((n: any) => {
if (n.isMesh && n.material) {
const mats = Array.isArray(n.material) ? n.material : [n.material];
for (const m of mats) m.side = FrontSide;
}
});
// Use front-face-only rendering so the camera can see out from inside the
// model in first-person (backface culling hides interior faces).
scene.traverse((n: any) => {
if (n.isMesh && n.material) {
const mats = Array.isArray(n.material) ? n.material : [n.material];
for (const m of mats) m.side = FrontSide;
}
});
const mix = new AnimationMixer(scene);
const mix = new AnimationMixer(scene);
let m0: Object3D | null = null;
let m1: Object3D | null = null;
let m2: Object3D | null = null;
scene.traverse((n) => {
if (!m0 && n.name === "Mount0") m0 = n;
if (!m1 && n.name === "Mount1") m1 = n;
if (!m2 && n.name === "Mount2") m2 = n;
});
let m0: Object3D | null = null;
let m1: Object3D | null = null;
let m2: Object3D | null = null;
let eye: Object3D | null = null;
scene.traverse((n) => {
if (!m0 && n.name === "Mount0") m0 = n;
if (!m1 && n.name === "Mount1") m1 = n;
if (!m2 && n.name === "Mount2") m2 = n;
if (!eye && n.name === "Eye") eye = n;
});
return {
clonedScene: scene,
mixer: mix,
mount0: m0,
mount1: m1,
mount2: m2,
iflInitializers: iflInits,
};
}, [gltf.scene, anisotropy, entity.emap]);
return {
clonedScene: scene,
mixer: mix,
mount0: m0,
mount1: m1,
mount2: m2,
eyeBone: eye as Object3D | null,
iflInitializers: iflInits,
};
}, [gltf.scene, anisotropy, emap, skinUrl]);
useEffect(() => {
return () => {
playerEyePositions.delete(entity.id);
disposeClonedScene(clonedScene);
mixer.uncacheRoot(clonedScene);
};
@ -502,7 +601,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// ShapeBase sound slots (weapon switch sounds, etc.) — managed by shared hook.
const entityRef = useRef(entity);
entityRef.current = entity;
entityRef.current = entity; // eslint-disable-line react-hooks/refs
useEntitySoundSlots(entityRef, clonedScene);
// Jet thrust sound. Played client-side by Player::updateJetEffects via
@ -824,6 +923,28 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
} else {
mixer.update(0);
}
// Write animated Eye bone position for first-person camera.
// Torque's Player::getEyeTransform reads the eye node's POSITION
// from the animated skeleton (rotation is discarded — head rotation
// is constructed from mHead pitch/yaw instead).
if (eyeBone) {
clonedScene.updateWorldMatrix(false, true);
let eyePos = playerEyePositions.get(entity.id);
if (!eyePos) {
eyePos = new Vector3();
playerEyePositions.set(entity.id, eyePos);
}
// Get Eye bone position in GLB model-local space.
eyeBone.getWorldPosition(eyePos);
clonedScene.worldToLocal(eyePos);
// Convert GLB (x,y,z) → entity-local Three.js space via R90:
// same swizzle as PlayerEyeOffset's static extraction.
const gx = eyePos.x;
const gy = eyePos.y;
const gz = eyePos.z;
eyePos.set(gz, gy, -gx);
}
});
return (
@ -854,44 +975,31 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
</DebugSuspense>
</ShapeErrorBoundary>
)}
{currentPackShape && mount1 && (
<ShapeErrorBoundary
key={currentPackShape}
fallback={<ShapePlaceholder color="red" label={currentPackShape} />}
>
<DebugSuspense
name={`Pack:${entity.id}/${currentPackShape}`}
fallback={
<ShapePlaceholder color="cyan" label={currentPackShape} />
}
>
<PackModel
packShape={currentPackShape}
mountBone={mount1}
emap={entity.emap}
{currentPackShape &&
mount1 &&
createPortal(
<Suspense>
<MountedShapeContent
shapeName={currentPackShape}
imageDataBlockId={entity.imageDataBlockIds?.[2]}
entityId={entity.id}
/>
</DebugSuspense>
</ShapeErrorBoundary>
)}
{currentFlagShape && mount2 && (
<ShapeErrorBoundary
key={currentFlagShape}
fallback={<ShapePlaceholder color="red" label={currentFlagShape} />}
>
<DebugSuspense
name={`Flag:${entity.id}/${currentFlagShape}`}
fallback={
<ShapePlaceholder color="cyan" label={currentFlagShape} />
}
>
<PackModel
packShape={currentFlagShape}
mountBone={mount2}
emap={entity.emap}
</Suspense>,
mount1,
)}
{currentFlagShape &&
mount2 &&
createPortal(
<Suspense>
<MountedShapeContent
shapeName={currentFlagShape}
imageDataBlockId={entity.imageDataBlockIds?.[3]}
entityId={entity.id}
skinName={entity.imageSkinNames?.[3]}
/>
</DebugSuspense>
</ShapeErrorBoundary>
)}
</Suspense>,
mount2,
)}
</>
);
}
@ -966,6 +1074,10 @@ function WeaponModel({
}) {
const engineStore = useEngineStoreApi();
const weaponGltf = useStaticShape(weaponShape);
const emap = useMemo(
() => resolveEmapFromImageSlot(entity.imageDataBlockIds?.[0]),
[entity.imageDataBlockIds],
);
const anisotropy = useAnisotropy();
// Clone weapon with skeleton bindings, create dedicated mixer.
@ -979,7 +1091,7 @@ function WeaponModel({
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
const iflInits = processShapeScene(clone, undefined, {
anisotropy,
emap: entity.emap,
emap,
});
// Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0.
@ -1025,7 +1137,7 @@ function WeaponModel({
visNodesBySequence: visBySeq,
weaponIflInitializers: iflInits,
};
}, [weaponGltf, anisotropy]);
}, [weaponGltf, anisotropy, emap]);
useEffect(() => {
return () => {
@ -1317,96 +1429,3 @@ function applyWeaponAnim(
currentAnimRef.current = targetName;
}
}
/**
* Attaches a pack shape to the player's Mount1 bone. Packs are static
* mounted images (no state machine or animation) just positioned via
* the pack shape's Mountpoint node inverse offset, same as weapons.
*/
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,
emap,
});
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
const mp = getPosedNodeTransform(
packGltf.scene,
packGltf.animations,
"Mountpoint",
);
if (mp) {
const invQuat = mp.quaternion.clone().invert();
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
clone.position.copy(invPos);
clone.quaternion.copy(invQuat);
}
return { packClone: clone, packIflInitializers: iflInits };
}, [packGltf, anisotropy]);
useEffect(() => {
mountBone.add(packClone);
return () => {
mountBone.remove(packClone);
disposeClonedScene(packClone);
};
}, [packClone, mountBone]);
// Initialize IFL materials (animated texture sequences).
useEffect(() => {
const cleanups: (() => void)[] = [];
for (const { mesh, initialize } of packIflInitializers) {
initialize(mesh, () => streamPlaybackStore.getState().time)
.then((dispose) => cleanups.push(dispose))
.catch(() => {});
}
return () => cleanups.forEach((fn) => fn());
}, [packIflInitializers]);
return null;
}
/**
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton
* hierarchy. Its world Y in GLB Y-up space gives the height above the player's
* feet, which we use as the first-person camera offset.
*/
export function PlayerEyeOffset({
shapeName,
eyeOffsetRef,
}: {
shapeName: string;
eyeOffsetRef: MutableRefObject<Vector3>;
}) {
const gltf = useStaticShape(shapeName);
useEffect(() => {
// Get Eye node position from the posed (Root animation) skeleton.
const eye = getPosedNodeTransform(gltf.scene, gltf.animations, "Eye");
if (eye) {
// Convert from GLB space to entity space via ShapeRenderer's R90:
// R90 maps GLB (x,y,z) -> entity (z, y, -x).
// This gives ~(0.169, 2.122, 0.0) -- 17cm forward and 2.12m up.
eyeOffsetRef.current.set(eye.position.z, eye.position.y, -eye.position.x);
} else {
eyeOffsetRef.current.set(0, DEFAULT_EYE_HEIGHT, 0);
}
}, [gltf, eyeOffsetRef]);
return null;
}

View file

@ -1,4 +1,4 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { Quaternion, Vector3 } from "three";
import {
@ -8,7 +8,7 @@ import {
} from "../stream/playbackUtils";
import { useSettings } from "./SettingsProvider";
import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel";
import { playerEyePositions } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter";
import { useEngineStoreApi, advanceEffectClock } from "../state/engineStore";
import { gameEntityStore } from "../state/gameEntityStore";
@ -66,6 +66,7 @@ function mutateRenderFields(
const e = renderEntity as unknown as Record<string, unknown>;
e.threads = stream.threads;
e.damageState = stream.damageState;
e.weaponShape = stream.weaponShape;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
e.soundSlots = stream.soundSlots;
@ -88,10 +89,55 @@ function getEntityMap(snapshot: StreamSnapshot): EntityById {
const _tmpVec = new Vector3();
const _interpQuatA = new Quaternion();
const _interpQuatB = new Quaternion();
const _headQuat = new Quaternion();
const _bodyQuat = new Quaternion();
const _billboardFlip = new Quaternion(0, 1, 0, 0); // 180° around Y
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3();
/** PlayerData::maxLookAngle — all Tribes 2 armor datablocks use 1.5 rad (~85.9°). */
const DEFAULT_MAX_LOOK_ANGLE = 1.5;
// Axes for head rotation quaternion construction (Three.js Y-up space).
const _axisX = new Vector3(1, 0, 0);
const _axisY = new Vector3(0, 1, 0);
/**
* Compute first-person camera transform from entity state, matching
* Torque's Player::getEyeTransform (binary-verified at FUN_005eead0).
*
* Position = worldTransform * animatedEyeNodePosition
* Rotation = bodyQuat * headQuat(yaw, pitch)
*
* The eye node's animated ROTATION is discarded only its position is used.
* Head rotation is constructed from mHead.x (pitch) and mHead.z (yaw).
*/
function computeFirstPersonCamera(
camera: { position: Vector3; quaternion: Quaternion },
playerGroup: { position: Vector3; quaternion: Quaternion },
eyePos: Vector3,
headPitch: number,
headYaw: number,
maxLookAngle = DEFAULT_MAX_LOOK_ANGLE,
): void {
// Position: body position + body rotation * eye offset.
_tmpVec.copy(eyePos).applyQuaternion(playerGroup.quaternion);
camera.position.copy(playerGroup.position).add(_tmpVec);
// Rotation: body * head, where head = yaw(Y) * pitch(X).
// In Torque (Z-up): zmat(mHead.z) * xmat(mHead.x).
// In Three.js body-local frame: Y rotation for yaw, X for pitch.
// Pitch is negated: Torque positive pitch = look up, but positive
// Three.js X rotation = look down.
const pitchRad = -headPitch * maxLookAngle;
const yawRad = headYaw * maxLookAngle;
_headQuat.setFromAxisAngle(_axisY, yawRad);
_bodyQuat.setFromAxisAngle(_axisX, pitchRad);
_headQuat.multiply(_bodyQuat); // yaw * pitch
camera.quaternion.copy(playerGroup.quaternion).multiply(_headQuat);
}
export function StreamingController({
recording,
}: {
@ -102,13 +148,11 @@ export function StreamingController({
const playbackClockRef = useRef(0);
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 publishedSnapshotRef = useRef<StreamSnapshot | null>(null);
const lastSyncedSnapshotRef = useRef<StreamSnapshot | null>(null);
const [firstPersonShape, setFirstPersonShape] = useState<string | null>(null);
const syncRenderableEntities = useCallback((snapshot: StreamSnapshot) => {
if (snapshot === lastSyncedSnapshotRef.current) return;
@ -179,39 +223,30 @@ export function StreamingController({
}
// Removal pass: delete entities no longer in the snapshot.
// Retain explosion entities with DTS shapes for up to 5 seconds.
for (const [id, entity] of map) {
if (currentIds.has(id)) continue;
if (
entity.renderType === "Explosion" &&
entity.shapeName &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
if (age < 5) continue;
// Skip removal when the snapshot is empty — this happens during mission
// transitions (EndGhosting clears the engine's entities, but no new ones
// have arrived yet). Keeping the old render entities visible avoids a
// blank screen flash; they'll be replaced when the new mission loads.
if (currentIds.size > 0) {
for (const [id, entity] of map) {
if (currentIds.has(id)) continue;
// Retain explosion entities with DTS shapes for up to 5 seconds.
if (
entity.renderType === "Explosion" &&
entity.shapeName &&
entity.spawnTime != null
) {
const age = snapshot.timeSec - entity.spawnTime;
if (age < 5) continue;
}
map.delete(id);
structuralChange = true;
}
map.delete(id);
structuralChange = true;
}
if (structuralChange) {
gameEntityStore.getState().bumpStreamVersion();
}
let nextFirstPersonShape: string | null = null;
if (
snapshot.camera?.mode === "first-person" &&
snapshot.camera.controlEntityId
) {
const entity = map.get(snapshot.camera.controlEntityId);
const sn = entity ? getField(entity, "shapeName") : undefined;
if (sn) {
nextFirstPersonShape = sn;
}
}
setFirstPersonShape((prev) =>
prev === nextFirstPersonShape ? prev : nextFirstPersonShape,
);
}, []);
useEffect(() => {
@ -487,7 +522,7 @@ export function StreamingController({
continue;
}
}
if (!entity?.position) {
if (!entity?.position || entity.hidden) {
child.visible = false;
continue;
}
@ -610,22 +645,38 @@ export function StreamingController({
}
}
if (
cameraMode === "original" &&
mode === "first-person" &&
root &&
currentCamera?.controlEntityId
) {
// First-person camera: either add the eye offset on top of the
// stream camera position (original mode), or fully compute the
// camera transform from entity state (non-authoritative, e.g.
// observing a different player than the demo recorder).
if (mode === "first-person" && root && currentCamera?.controlEntityId) {
const eyePos = playerEyePositions.get(currentCamera.controlEntityId);
const playerGroup = root.children.find(
(child) => child.name === currentCamera.controlEntityId,
);
if (playerGroup) {
_tmpVec
.copy(eyeOffsetRef.current)
.applyQuaternion(playerGroup.quaternion);
state.camera.position.add(_tmpVec);
} else {
state.camera.position.y += eyeOffsetRef.current.y;
if (cameraMode === "original") {
// Stream camera position = entity base position. Add animated
// eye bone offset (rotated by body orientation).
if (eyePos && playerGroup) {
_tmpVec.copy(eyePos).applyQuaternion(playerGroup.quaternion);
state.camera.position.add(_tmpVec);
} else {
state.camera.position.y += DEFAULT_EYE_HEIGHT;
}
} else if (playerGroup) {
// Non-authoritative: compute full camera transform from entity
// state, matching Torque's Player::getEyeTransform.
const controlEntity = currentEntities.get(
currentCamera.controlEntityId,
);
computeFirstPersonCamera(
state.camera,
playerGroup,
eyePos ?? _tmpVec.set(0, DEFAULT_EYE_HEIGHT, 0),
controlEntity?.headPitch ?? 0,
controlEntity?.headYaw ?? 0,
);
}
}
@ -645,14 +696,6 @@ export function StreamingController({
playback={recording.streamingPlayback}
snapshotRef={currentTickSnapshotRef}
/>
{firstPersonShape && (
<Suspense>
<PlayerEyeOffset
shapeName={firstPersonShape}
eyeOffsetRef={eyeOffsetRef}
/>
</Suspense>
)}
</>
);
}

View file

@ -1,23 +1,19 @@
import { useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
CanvasTexture,
ClampToEdgeWrapping,
NearestFilter,
RepeatWrapping,
SRGBColorSpace,
Texture,
} from "three";
import { iflTextureToUrl, loadImageFrameList } from "../loaders";
import { loadTextureAsync } from "../textureUtils";
import { useTick, TICK_RATE } from "./TickProvider";
import { useSettings } from "./SettingsProvider";
/** One IFL tick in seconds (Torque converts at 1/30s per tick). */
export const IFL_TICK_SECONDS = 1 / 30;
export interface IflAtlas {
texture: CanvasTexture;
texture: CanvasTexture | Texture;
columns: number;
rows: number;
/** Number of unique image slots in the atlas grid. */
@ -33,6 +29,10 @@ export interface IflAtlas {
totalDurationSeconds: number;
/** Last rendered atlas slot, to avoid redundant offset updates. */
lastSlot: number;
/** When true, swap individual textures instead of atlas offsets (for RepeatWrapping). */
swapMode?: boolean;
/** Individual textures per unique frame (only set when swapMode=true). */
frameTextures?: Texture[];
}
// Module-level cache for atlas textures, shared across all components.
@ -117,6 +117,39 @@ function createAtlas(
};
}
/**
* Create a swap-mode "atlas" for textures that need RepeatWrapping.
* Instead of packing into a grid, stores individual textures and swaps
* the material's map on each frame change.
*/
function createSwapAtlas(
uniqueTextures: Texture[],
frameToSlot: number[],
): IflAtlas {
if (uniqueTextures.length === 0) {
throw new Error("Cannot create IFL swap atlas with no textures");
}
// Set up each texture for repeat wrapping.
for (const tex of uniqueTextures) {
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.flipY = false;
tex.needsUpdate = true;
}
return {
texture: uniqueTextures[0],
columns: 1,
rows: 1,
slotCount: uniqueTextures.length,
frameToSlot,
frameOffsetSeconds: [],
totalDurationSeconds: 0,
lastSlot: -1,
swapMode: true,
frameTextures: uniqueTextures,
};
}
function computeTiming(
atlas: IflAtlas,
frames: { name: string; frameCount: number }[],
@ -133,15 +166,28 @@ function computeTiming(
* Set the atlas texture offset to show the image for the given IFL entry.
* Uses `frameToSlot` to map the entry index to the atlas grid position.
*/
export function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
/**
* Update the atlas to show the given frame. In atlas mode, adjusts texture
* offset. In swap mode, updates `atlas.texture` to the frame's texture
* (caller must apply it to the material's .map).
* Returns true if the texture changed (swap mode only caller needs to
* update material.map).
*/
export function updateAtlasFrame(atlas: IflAtlas, frameIndex: number): boolean {
const slot = atlas.frameToSlot[frameIndex] ?? 0;
if (slot === atlas.lastSlot) return;
if (slot === atlas.lastSlot) return false;
atlas.lastSlot = slot;
if (atlas.swapMode && atlas.frameTextures) {
atlas.texture = atlas.frameTextures[slot] ?? atlas.frameTextures[0];
return true;
}
const col = slot % atlas.columns;
// Flip row: canvas Y=0 is top, but texture V=0 is bottom.
const row = atlas.rows - 1 - Math.floor(slot / atlas.columns);
atlas.texture.offset.set(col / atlas.columns, row / atlas.rows);
return false;
}
/**
@ -164,8 +210,12 @@ export function getFrameIndexForTime(atlas: IflAtlas, seconds: number): number {
* if the same IFL has been loaded before. The returned atlas can be animated
* per-frame with `updateAtlasFrame` + `getFrameIndexForTime`.
*/
export async function loadIflAtlas(iflPath: string): Promise<IflAtlas> {
const cached = atlasCache.get(iflPath);
export async function loadIflAtlas(
iflPath: string,
options?: { repeat?: boolean },
): Promise<IflAtlas> {
const cacheKey = options?.repeat ? `${iflPath}:repeat` : iflPath;
const cached = atlasCache.get(cacheKey);
if (cached) return cached;
const frames = await loadImageFrameList(iflPath);
@ -173,52 +223,12 @@ export async function loadIflAtlas(iflPath: string): Promise<IflAtlas> {
const urls = uniqueNames.map((name) => iflTextureToUrl(name, iflPath));
const textures = await Promise.all(urls.map(loadTextureAsync));
const atlas = createAtlas(textures, frameToSlot);
// Use swap mode for repeating textures (atlas ClampToEdge breaks tiling).
const atlas = options?.repeat
? createSwapAtlas(textures, frameToSlot)
: createAtlas(textures, frameToSlot);
computeTiming(atlas, frames);
atlasCache.set(iflPath, atlas);
atlasCache.set(cacheKey, atlas);
return atlas;
}
/**
* Loads an IFL (Image File List) and returns an animated texture.
* The texture atlas is shared across all components using the same IFL path.
*/
export function useIflTexture(iflPath: string): Texture {
const { animationEnabled } = useSettings();
const { data: frames } = useSuspenseQuery({
queryKey: ["ifl", iflPath],
queryFn: () => loadImageFrameList(iflPath),
});
const { uniqueNames, frameToSlot } = useMemo(
() => deduplicateFrames(frames),
[frames],
);
const textureUrls = useMemo(
() => uniqueNames.map((name) => iflTextureToUrl(name, iflPath)),
[uniqueNames, iflPath],
);
const textures = useTexture(textureUrls);
const atlas = useMemo(() => {
let cached = atlasCache.get(iflPath);
if (!cached) {
cached = createAtlas(textures, frameToSlot);
atlasCache.set(iflPath, cached);
}
computeTiming(cached, frames);
return cached;
}, [iflPath, textures, frames, frameToSlot]);
useTick((tick) => {
const time = tick / TICK_RATE;
const frameIndex = animationEnabled ? getFrameIndexForTime(atlas, time) : 0;
updateAtlasFrame(atlas, frameIndex);
});
return atlas.texture;
}

View file

@ -0,0 +1,40 @@
/**
* Resolve the `emap` flag from a datablock. Works for both streaming mode
* (numeric datablock ID) and mission mode (name-based lookup).
*/
import { engineStore } from "../state/engineStore";
export function resolveEmapFromDatablock(
dataBlockId?: number,
dataBlockName?: string,
): boolean {
// Streaming mode: look up by numeric ID.
if (dataBlockId != null) {
const sp = engineStore.getState().playback.recording?.streamingPlayback;
if (sp) {
const db = sp.getDataBlockData(dataBlockId);
return !!db?.emap;
}
}
// Mission mode: look up by name via TorqueScript runtime.
if (dataBlockName) {
const runtime = engineStore.getState().runtime.runtime;
if (runtime) {
const db = runtime.state.datablocks.get(dataBlockName);
return !!db?.emap;
}
}
return false;
}
/** Resolve emap for a mounted image by its datablock ID. */
export function resolveEmapFromImageSlot(imageDataBlockId?: number): boolean {
if (imageDataBlockId == null) return false;
const sp = engineStore.getState().playback.recording?.streamingPlayback;
if (sp) {
const db = sp.getDataBlockData(imageDataBlockId);
return !!db?.emap;
}
return false;
}

View file

@ -89,9 +89,16 @@ export function useEntitySoundSlots(
const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing";
// Build index for O(1) slot lookup (avoids find() per slot per frame).
const slotByIndex: Array<
{ index: number; playing: boolean; profileId?: number } | undefined
> = [];
if (soundSlots) {
for (const s of soundSlots) slotByIndex[s.index] = s;
}
for (let i = 0; i < MAX_SOUND_SLOTS; i++) {
// Find this slot's data from the ghost update.
const slotData = soundSlots?.find((s) => s.index === i);
const slotData = slotByIndex[i];
const shouldPlay = !!slotData?.playing && slotData.profileId != null;
const profileId = slotData?.profileId ?? -1;
const current = slots[i];

View file

@ -0,0 +1,22 @@
import { RefObject } from "react";
import { Group } from "three";
import { ShapeEntity } from "../state/gameEntityTypes";
import { useSettings } from "./SettingsProvider";
import { useFrame } from "@react-three/fiber";
const noop = () => {};
export function useRotation(entity: ShapeEntity, ref: RefObject<Group | null>) {
const { animationEnabled } = useSettings();
useFrame(
entity.rotate && animationEnabled
? () => {
if (ref.current) {
const t = performance.now() / 1000;
ref.current.rotation.y = (t / 3.0) * Math.PI * 2;
}
}
: noop,
);
}

View file

@ -65,9 +65,12 @@ export function iflTextureToUrl(name: string, iflPath: string) {
return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL);
}
export function textureToUrl(name: string) {
export function textureToUrl(
name: string,
fallbackUrl: string | null = FALLBACK_TEXTURE_URL,
): string {
const resourceKey = getStandardTextureResourceKey(`textures/${name}`);
return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL);
return getUrlForPath(resourceKey, fallbackUrl ?? undefined);
}
export function audioToUrl(fileName: string) {

View file

@ -127,14 +127,15 @@ export interface ShapeEntity extends PositionedBase {
shapeName?: string;
shapeType?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin name from TargetManager (e.g. "beagle" for flags, team skins). */
skinName?: string;
threads?: ThreadState[];
/** Torque DamageState: 0=Enabled, 1=Disabled, 2=Destroyed. */
damageState?: number;
rotate?: boolean;
teamId?: number;
barrelShapeName?: string;
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
weaponShape?: string;
@ -160,8 +161,10 @@ export interface PlayerEntity extends PositionedBase {
renderType: "Player";
shapeName?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin names for each mounted image slot (e.g. flag team skin on slot 3). */
imageSkinNames?: (string | undefined)[];
weaponShape?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
@ -171,6 +174,10 @@ export interface PlayerEntity extends PositionedBase {
falling?: boolean;
jetting?: boolean;
playerName?: string;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
/** Player preferred skin (chosen skin like "RandySavage"). */
skinPrefName?: string;
iffColor?: { r: number; g: number; b: number };
threads?: ThreadState[];
weaponImageState?: WeaponImageState;

View file

@ -19,6 +19,7 @@ import {
yawPitchToQuaternion,
playerYawToQuaternion,
torqueQuatToThreeJS,
matrixFToThreeJSQuat,
torqueQuatHeading,
torqueQuatPitch,
isValidPosition,
@ -71,12 +72,16 @@ export interface MutableEntity {
dataBlockId?: number;
shapeHint?: string;
dataBlock?: string;
/** Datablock enables environment map reflections. */
emap?: boolean;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
playerName?: string;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
/** Player preferred skin override (chosen skin like "RandySavage"). */
skinPrefName?: string;
position?: [number, number, number];
rotation: [number, number, number, number];
velocity?: [number, number, number];
@ -106,18 +111,24 @@ export interface MutableEntity {
weaponImageStatesDbId?: number;
packShape?: string;
flagShape?: string;
/** Skin names for each mounted image slot (from ghost ImageMask skinTag). */
imageSkinNames?: (string | undefined)[];
falling?: boolean;
jetting?: boolean;
headPitch?: number;
headYaw?: number;
targetRenderFlags?: number;
carryingFlag?: boolean;
/** ShapeBase sound slots (4 max). Raw ghost SoundMask data components
* manage PositionalAudio objects directly, matching Tribes 2's approach. */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** ShapeBase hidden state (binaryCloak from CloakMask). True = invisible.
* Flags are hidden when carried by a player; the flag Item ghost stays
* in scope but shouldn't render. */
hidden?: boolean;
/** Item mStatic flag (from InitialUpdateMask). Static items (flags at
* flagstand) skip all physics in Item::processTick. */
isStaticItem?: boolean;
/** Item velocity interpolation state. The client simulates full physics
* (gravity, collision, bounce) for non-static, non-at-rest items. */
itemPhysics?: {
@ -212,6 +223,8 @@ export abstract class StreamEngine implements StreamingPlayback {
// ── Target system ──
protected targetNames = new Map<number, string>();
protected targetSkins = new Map<number, string>();
protected targetSkinPrefs = new Map<number, string>();
protected targetTeams = new Map<number, number>();
protected targetRenderFlags = new Map<number, number>();
/** Deferred nameTag→targetId for TargetInfoEvents that arrived before their NetStringEvent. */
@ -375,6 +388,8 @@ export abstract class StreamEngine implements StreamingPlayback {
this.audioEvents = [];
this.netStrings.clear();
this.targetNames.clear();
this.targetSkins.clear();
this.targetSkinPrefs.clear();
this.targetTeams.clear();
this.targetRenderFlags.clear();
this.sensorGroupColors.clear();
@ -619,18 +634,31 @@ export abstract class StreamEngine implements StreamingPlayback {
if (targetId != null && renderFlags != null) {
this.targetRenderFlags.set(targetId, renderFlags);
}
// Skin tags — resolve via net string table.
const skinTag = data.skinTag as number | undefined;
if (targetId != null && skinTag != null && skinTag !== 0x400) {
const resolved = this.netStrings.get(skinTag);
if (resolved) this.targetSkins.set(targetId, resolved);
}
const skinPrefTag = data.skinPrefTag as number | undefined;
if (targetId != null && skinPrefTag != null && skinPrefTag !== 0x400) {
const resolved = this.netStrings.get(skinPrefTag);
if (resolved) this.targetSkinPrefs.set(targetId, resolved);
}
// Apply all known target info to existing entities.
if (targetId != null) {
const name = this.targetNames.get(targetId);
const team = this.targetTeams.get(targetId);
const rf = this.targetRenderFlags.get(targetId);
const skin = this.targetSkins.get(targetId);
const skinPref = this.targetSkinPrefs.get(targetId);
for (const entity of this.entities.values()) {
if (entity.targetId === targetId) {
if (name) {
entity.playerName = name;
}
if (name) entity.playerName = name;
if (team != null) entity.sensorGroup = team;
if (rf != null) entity.targetRenderFlags = rf;
if (skin) entity.skinName = skin;
if (skinPref) entity.skinPrefName = skinPref;
}
}
}
@ -922,11 +950,15 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.visual = undefined;
entity.targetId = undefined;
entity.targetRenderFlags = undefined;
entity.carryingFlag = undefined;
entity.sensorGroup = undefined;
entity.playerName = undefined;
entity.weaponShape = undefined;
entity.imageDataBlockIds = undefined;
entity.packShape = undefined;
entity.flagShape = undefined;
entity.imageSkinNames = undefined;
entity.skinName = undefined;
entity.skinPrefName = undefined;
entity.falling = undefined;
entity.jetting = undefined;
entity.weaponImageState = undefined;
@ -940,6 +972,7 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.energy = undefined;
entity.maxEnergy = undefined;
entity.damageState = undefined;
entity.hidden = undefined;
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
entity.armAction = undefined;
@ -970,9 +1003,6 @@ 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"
@ -1075,12 +1105,15 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.frozen = data.frozen;
}
// Weapon images (Player)
if (entity.type === "Player") {
// Mounted images (ShapeBase slot 0-3). All ShapeBase subclasses have
// image slots: Player weapons, Turret barrels, Vehicle turrets, etc.
{
const images = data.images as
| Array<{
index?: number;
dataBlockId?: number;
skinTagIndex?: number;
skinName?: string;
triggerDown?: boolean;
ammo?: boolean;
loaded?: boolean;
@ -1089,20 +1122,16 @@ export abstract class StreamEngine implements StreamingPlayback {
fireCount?: number;
}>
| undefined;
if (Array.isArray(images) && images.length > 0) {
if (images && images.length > 0) {
const weaponImage = images.find((img) => img.index === 0);
if (weaponImage?.dataBlockId && weaponImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(weaponImage.dataBlockId);
const weaponShape = resolveShapeName("ShapeBaseImageData", blockData);
if (weaponShape) {
const mountPoint = blockData?.mountPoint as number | undefined;
if (
(mountPoint == null || mountPoint <= 0) &&
!/pack_/i.test(weaponShape)
) {
entity.weaponShape = weaponShape;
}
entity.weaponShape = weaponShape;
}
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[0] = weaponImage.dataBlockId;
const prev = entity.weaponImageState;
entity.weaponImageState = {
@ -1126,47 +1155,74 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.weaponShape = undefined;
entity.weaponImageState = undefined;
entity.weaponImageStates = undefined;
if (entity.imageDataBlockIds) entity.imageDataBlockIds[0] = undefined;
}
// Pack image (slot 2 = $BackpackSlot, mountPoint 1 = Mount1)
const packImage = images.find((img) => img.index === 2);
if (packImage?.dataBlockId && packImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(packImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.packShape = shape;
// Pack and flag image slots are Player-only. Other ShapeBase types
// (vehicles, turrets) can have weapons in slots 2-3 but those are NOT
// packs/flags — only slot 0 is universal.
if (entity.type === "Player") {
// Pack image (slot 2 = $BackpackSlot, mountPoint 1 = Mount1)
const packImage = images.find((img) => img.index === 2);
if (packImage?.dataBlockId && packImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(packImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.packShape = shape;
}
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[2] = packImage.dataBlockId;
} else if (packImage && !packImage.dataBlockId) {
entity.packShape = undefined;
if (entity.imageDataBlockIds)
entity.imageDataBlockIds[2] = undefined;
}
// Flag image (slot 3 = $FlagSlot, mountPoint 2 = Mount2)
const flagImage = images.find((img) => img.index === 3);
if (flagImage?.dataBlockId && flagImage.dataBlockId > 0) {
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
entity.imageDataBlockIds[3] = flagImage.dataBlockId;
const blockData = this.getDataBlockData(flagImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.flagShape = shape;
}
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev | 0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
} else if (flagImage && !flagImage.dataBlockId) {
entity.flagShape = undefined;
if (entity.imageDataBlockIds)
entity.imageDataBlockIds[3] = undefined;
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev & ~0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
} else if (packImage && !packImage.dataBlockId) {
entity.packShape = undefined;
}
// Flag image (slot 3 = $FlagSlot, mountPoint 2 = Mount2)
const flagImage = images.find((img) => img.index === 3);
if (flagImage?.dataBlockId && flagImage.dataBlockId > 0) {
entity.carryingFlag = true;
const blockData = this.getDataBlockData(flagImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.flagShape = shape;
// Per-slot image skin tags (e.g. flag team skin on slot 3).
for (const img of images) {
if (img.index == null) continue;
let skinName: string | undefined;
if (img.skinTagIndex != null) {
skinName = this.netStrings.get(img.skinTagIndex);
} else if (img.skinName) {
skinName = img.skinName;
}
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev | 0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
} else if (flagImage && !flagImage.dataBlockId) {
entity.carryingFlag = false;
entity.flagShape = undefined;
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev & ~0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
if (skinName) {
if (!entity.imageSkinNames) entity.imageSkinNames = [];
entity.imageSkinNames[img.index] = skinName;
}
}
}
@ -1223,6 +1279,16 @@ export abstract class StreamEngine implements StreamingPlayback {
).rotation,
);
if (converted) entity.rotation = converted;
} else if (
Array.isArray(
(data.transform as { elements?: unknown } | undefined)?.elements,
)
) {
// MatrixF (16 floats) — decompose rotation from the 3x3 submatrix.
const converted = matrixFToThreeJSQuat(
(data.transform as { elements: number[] }).elements,
);
if (converted) entity.rotation = converted;
} else if (
entity.type === "Item" &&
typeof (data.rotation as { angle?: unknown } | undefined)?.angle ===
@ -1271,7 +1337,11 @@ export abstract class StreamEngine implements StreamingPlayback {
}
}
const atRest = data.atRest as boolean | undefined;
if (atRest === false && !entity.isStaticItem && isVec3Like(data.velocity)) {
if (
atRest === false &&
!entity.isStaticItem &&
isVec3Like(data.velocity)
) {
const vel = data.velocity as Vec3;
entity.itemPhysics = {
velocity: [vel.x, vel.y, vel.z],
@ -1379,6 +1449,11 @@ export abstract class StreamEngine implements StreamingPlayback {
if (typeof data.damageState === "number") {
entity.damageState = data.damageState;
}
// CloakMask: binaryCloak is mFadeVal == 1.0 (binary-verified at
// FUN_005eead0). true = fully visible, false = invisible (faded out).
if (typeof data.binaryCloak === "boolean") {
entity.hidden = !data.binaryCloak;
}
if (typeof data.action === "number") {
entity.actionAnim = data.action;
entity.actionAtEnd = !!data.actionAtEnd;
@ -1387,6 +1462,18 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.armAction = data.armAction;
}
// MountedMask: when a player unmounts from a vehicle, the server resets
// to RootAnim (table action 0) via Player::onUnmount→setActionThread.
// Table actions (0-6) are never sent over the wire, so actionAnim would
// remain at the stale Sitting index. Clear it on unmount.
if (typeof data.mountObject === "number") {
if (data.mountObject === -1) {
// Unmounting — reset action to table anim (not sent by server).
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
}
}
// Threads
if (Array.isArray(data.threads)) {
const incoming = data.threads as ThreadState[];
@ -1424,6 +1511,10 @@ export abstract class StreamEngine implements StreamingPlayback {
}
const renderFlags = this.targetRenderFlags.get(data.targetId);
if (renderFlags != null) entity.targetRenderFlags = renderFlags;
const skin = this.targetSkins.get(data.targetId);
if (skin) entity.skinName = skin;
const skinPref = this.targetSkinPrefs.get(data.targetId);
if (skinPref) entity.skinPrefName = skinPref;
}
// ShapeBase sound slots — store on entity for component-level playback.
@ -1599,8 +1690,14 @@ export abstract class StreamEngine implements StreamingPlayback {
for (const entity of this.entities.values()) {
const phys = entity.itemPhysics;
// Binary-verified: Item::processTick skips physics when
// mStatic || mAtRest || isHidden. We check static and atRest.
if (!phys || phys.atRest || entity.isStaticItem || !entity.position)
// mStatic || mAtRest || isHidden.
if (
!phys ||
phys.atRest ||
entity.isStaticItem ||
entity.hidden ||
!entity.position
)
continue;
const v = phys.velocity;
const p = entity.position;
@ -2225,7 +2322,7 @@ export abstract class StreamEngine implements StreamingPlayback {
? (this.targetRenderFlags.get(entity.targetId) ??
entity.targetRenderFlags)
: entity.targetRenderFlags;
if (entity.type === "Player" && !entity.carryingFlag) {
if (entity.type === "Player" && !entity.flagShape) {
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
}
@ -2240,11 +2337,15 @@ export abstract class StreamEngine implements StreamingPlayback {
shapeHint: entity.shapeHint,
dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape,
imageDataBlockIds: entity.imageDataBlockIds,
imageSkinNames: entity.imageSkinNames,
packShape: entity.packShape,
flagShape: entity.flagShape,
falling: entity.falling,
jetting: entity.jetting,
playerName: entity.playerName,
skinName: entity.skinName,
skinPrefName: entity.skinPrefName,
targetRenderFlags: renderFlags,
iffColor:
(entity.type === "Player" || ((renderFlags ?? 0) & 0x2) !== 0) &&
@ -2265,6 +2366,7 @@ export abstract class StreamEngine implements StreamingPlayback {
actionAtEnd: entity.actionAtEnd,
armAction: entity.armAction,
damageState: entity.damageState,
hidden: entity.hidden,
faceViewer: entity.faceViewer,
threads: entity.threads,
explosionDataBlockId: entity.explosionDataBlockId,

View file

@ -339,6 +339,8 @@ class StreamingPlayback extends StreamEngine {
targetEntries: Array<{
targetId: number;
name?: string;
skin?: string;
skinPref?: string;
sensorGroup: number;
targetData: number;
}>;
@ -513,6 +515,9 @@ class StreamingPlayback extends StreamEngine {
stripTaggedStringMarkup(entry.name).trim(),
);
}
if (entry.skin) this.targetSkins.set(entry.targetId, entry.skin);
if (entry.skinPref)
this.targetSkinPrefs.set(entry.targetId, entry.skinPref);
this.targetTeams.set(entry.targetId, entry.sensorGroup);
this.targetRenderFlags.set(entry.targetId, entry.targetData);
}

View file

@ -94,165 +94,159 @@ export function streamEntityToGameEntity(
}
// Projectile visuals
if (entity.visual?.kind === "tracer") {
return {
...positionedBase(entity, spawnTime),
renderType: "Tracer",
visual: entity.visual,
dataBlock: entity.dataBlock,
direction: entity.direction,
} satisfies TracerEntity;
}
if (entity.visual?.kind === "sprite") {
return {
...positionedBase(entity, spawnTime),
renderType: "Sprite",
visual: entity.visual,
} satisfies SpriteEntity;
}
// Player
if (entity.type === "Player") {
return {
...positionedBase(entity, spawnTime),
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,
jetting: entity.jetting,
playerName: entity.playerName,
iffColor: entity.iffColor,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
targetRenderFlags: entity.targetRenderFlags,
soundSlots: entity.soundSlots,
} satisfies PlayerEntity;
}
// Explosion — only render a shape if the datablock specifies one; particle-only
// explosions (e.g. BlasterExplosion) still exist as entities for ParticleEffects.
if (entity.type === "Explosion") {
if (entity.dataBlock) {
switch (entity.visual?.kind) {
case "tracer":
return {
...positionedBase(entity, spawnTime),
renderType: "Explosion",
renderType: "Tracer",
visual: entity.visual,
dataBlock: entity.dataBlock,
direction: entity.direction,
} satisfies TracerEntity;
case "sprite":
return {
...positionedBase(entity, spawnTime),
renderType: "Sprite",
visual: entity.visual,
} satisfies SpriteEntity;
}
switch (entity.className) {
case "Player":
return {
...positionedBase(entity, spawnTime),
renderType: "Player",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
explosionDataBlockId: entity.explosionDataBlockId,
faceViewer: entity.faceViewer,
} satisfies ExplosionEntity;
}
return {
...positionedBase(entity, spawnTime),
renderType: "None",
} satisfies NoneEntity;
}
imageDataBlockIds: entity.imageDataBlockIds,
imageSkinNames: entity.imageSkinNames,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
packShape: entity.packShape,
flagShape: entity.flagShape,
falling: entity.falling,
jetting: entity.jetting,
playerName: entity.playerName,
skinName: entity.skinName,
skinPrefName: entity.skinPrefName,
iffColor: entity.iffColor,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
targetRenderFlags: entity.targetRenderFlags,
soundSlots: entity.soundSlots,
} satisfies PlayerEntity;
// Force field
if (entity.className === "ForceFieldBare") {
return {
...positionedBase(entity, spawnTime),
renderType: "ForceFieldBare",
forceFieldData: entity.forceFieldData
? {
textures: entity.forceFieldData.textures,
color: entity.forceFieldData.color,
baseTranslucency: entity.forceFieldData.baseTranslucency,
numFrames: entity.forceFieldData.textures.length,
framesPerSec: entity.forceFieldData.framesPerSec,
scrollSpeed: entity.forceFieldData.scrollSpeed,
umapping: entity.forceFieldData.umapping,
vmapping: entity.forceFieldData.vmapping,
dimensions: entity.forceFieldData.dimensions,
}
: undefined,
} satisfies ForceFieldBareEntity;
}
case "Explosion":
// Only render a shape if the datablock specifies one; particle-only explosions
// (e.g. BlasterExplosion) still exist as entities for ParticleEffects.
if (entity.dataBlock) {
return {
...positionedBase(entity, spawnTime),
renderType: "Explosion",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
explosionDataBlockId: entity.explosionDataBlockId,
faceViewer: entity.faceViewer,
} satisfies ExplosionEntity;
}
return {
...positionedBase(entity, spawnTime),
renderType: "None",
} satisfies NoneEntity;
// Audio emitter
if (entity.className === "AudioEmitter") {
return {
...positionedBase(entity, spawnTime),
renderType: "AudioEmitter",
audioFileName: entity.audioFileName,
audioVolume: entity.audioVolume,
audioIs3D: entity.audioIs3D,
audioIsLooping: entity.audioIsLooping ?? true,
audioMinDistance: entity.audioMinDistance,
audioMaxDistance: entity.audioMaxDistance,
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
} satisfies AudioEmitterEntity;
}
case "ForceFieldBare":
return {
...positionedBase(entity, spawnTime),
renderType: "ForceFieldBare",
forceFieldData: entity.forceFieldData
? {
textures: entity.forceFieldData.textures,
color: entity.forceFieldData.color,
baseTranslucency: entity.forceFieldData.baseTranslucency,
numFrames: entity.forceFieldData.textures.length,
framesPerSec: entity.forceFieldData.framesPerSec,
scrollSpeed: entity.forceFieldData.scrollSpeed,
umapping: entity.forceFieldData.umapping,
vmapping: entity.forceFieldData.vmapping,
dimensions: entity.forceFieldData.dimensions,
}
: undefined,
} satisfies ForceFieldBareEntity;
// WayPoint
if (entity.className === "WayPoint") {
return {
...positionedBase(entity, spawnTime),
renderType: "WayPoint",
label: entity.label,
} satisfies WayPointEntity;
}
case "AudioEmitter":
return {
...positionedBase(entity, spawnTime),
renderType: "AudioEmitter",
audioFileName: entity.audioFileName,
audioVolume: entity.audioVolume,
audioIs3D: entity.audioIs3D,
audioIsLooping: entity.audioIsLooping ?? true,
audioMinDistance: entity.audioMinDistance,
audioMaxDistance: entity.audioMaxDistance,
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
} satisfies AudioEmitterEntity;
// Non-rendered objects: editor-only markers, AI objectives, vehicle blockers.
// MissionMarker::onAdd only calls addToScene when gEditingMission is true.
// AIObjective and VehicleBlocker are server-side logic objects with no visuals.
if (
entity.className === "MissionMarker" ||
entity.className === "SpawnSphere" ||
entity.className === "AIObjective" ||
entity.className === "VehicleBlocker"
) {
return {
id: entity.id,
className: entity.className ?? entity.type,
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
spawnTime,
renderType: "None",
} satisfies NoneEntity;
}
case "WayPoint":
return {
...positionedBase(entity, spawnTime),
renderType: "WayPoint",
label: entity.label,
} satisfies WayPointEntity;
// Camera
if (entity.className === "Camera") {
return {
...positionedBase(entity, spawnTime),
renderType: "Camera",
} satisfies CameraEntity;
}
// Non-rendered objects: editor-only markers, AI objectives, vehicle blockers.
// MissionMarker::onAdd only calls addToScene when gEditingMission is true.
// AIObjective and VehicleBlocker are server-side logic objects with no visuals.
case "AIObjective":
case "MissionMarker":
case "PhysicalZone":
case "SpawnSphere":
case "VehicleBlocker":
return {
id: entity.id,
className: entity.className ?? entity.type,
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
spawnTime,
renderType: "None",
} satisfies NoneEntity;
// Default: generic DTS shape
return {
...positionedBase(entity, spawnTime),
renderType: "Shape",
shapeName: entity.dataBlock,
shapeType:
entity.className === "Turret"
? "Turret"
: entity.className === "Item"
? "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,
wheels: entity.wheels,
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
} satisfies ShapeEntity;
case "Camera":
return {
...positionedBase(entity, spawnTime),
renderType: "Camera",
} satisfies CameraEntity;
default:
// Default: generic DTS shape
return {
...positionedBase(entity, spawnTime),
renderType: "Shape",
shapeName: entity.dataBlock,
shapeType:
entity.className === "Turret"
? "Turret"
: entity.className === "Item"
? "Item"
: "StaticShape",
dataBlock: entity.dataBlock,
imageDataBlockIds: entity.imageDataBlockIds,
skinName: entity.skinName,
damageState: entity.damageState,
weaponShape: entity.weaponShape,
armAction: entity.armAction,
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,
wheels: entity.wheels,
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
} satisfies ShapeEntity;
}
}

View file

@ -8,6 +8,7 @@ import type {
WayPointEntity,
} from "../state/gameEntityTypes";
import { getPosition, getProperty, getScale } from "../mission";
import { DEFAULT_FLAG_SKINS } from "../stringUtils";
import { parseColorTuple } from "../colorUtils";
import {
terrainFromMis,
@ -211,10 +212,6 @@ function buildShapeEntity(
shapeName,
shapeType,
dataBlock: datablockName || undefined,
emap:
getProperty(datablock, "emap") != null
? isTruthy(getProperty(datablock, "emap"))
: undefined,
teamId,
};
@ -222,13 +219,17 @@ function buildShapeEntity(
entity.rotate = isTruthy(
getProperty(object, "rotate") ?? getProperty(datablock, "rotate"),
);
// Flag Items get a team skin (e.g. "beagle" for Blood Eagle).
if (datablockName.toLowerCase() === "flag" && teamId != null) {
entity.skinName = DEFAULT_FLAG_SKINS[teamId];
}
}
if (className === "Turret") {
const barrelName = getProperty(object, "initialBarrel");
if (barrelName) {
const barrelDb = resolveDatablock(runtime, barrelName);
entity.barrelShapeName = getProperty(barrelDb, "shapeFile");
entity.weaponShape = getProperty(barrelDb, "shapeFile");
}
}

View file

@ -28,7 +28,7 @@ import {
loadIflAtlas,
getFrameIndexForTime,
updateAtlasFrame,
} from "../components/useIflTexture";
} from "../components/iflAtlas";
import { loadTexture, setupTexture } from "../textureUtils";
import { textureToUrl } from "../loaders";
import type { Keyframe } from "./types";
@ -50,11 +50,6 @@ const _tracerOrientMat = new Matrix4();
const _upY = new Vector3(0, 1, 0);
/** ShapeRenderer's 90° Y rotation and its inverse, used for mount transforms. */
export const _r90 = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
Math.PI / 2,
);
export const _r90inv = _r90.clone().invert();
// ── Pure functions ──
@ -308,7 +303,12 @@ export function replaceWithShapeMaterial(
mat: MeshStandardMaterial,
vis: number,
isOrganic = false,
options: { anisotropy?: number; emap?: boolean } = {},
options: {
anisotropy?: number;
emap?: boolean;
skinUrl?: string;
skinName?: string;
} = {},
): ShapeMaterialResult {
const resourcePath: string | undefined = mat.userData?.resource_path;
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
@ -332,6 +332,7 @@ export function replaceWithShapeMaterial(
const effectiveReflectionAmount = emapEnabled ? reflectionAmount : 0;
if (flagNames.has("IflMaterial")) {
const repeat = flagNames.has("SWrap") || flagNames.has("TWrap");
const result = createMaterialFromFlags(
mat,
null,
@ -347,21 +348,51 @@ export function replaceWithShapeMaterial(
material,
backMaterial: result[0],
initialize: (mesh, getTime) =>
initializeIflMaterial(material, resourcePath, mesh, getTime),
initializeIflMaterial(material, resourcePath, mesh, getTime, repeat),
};
}
return {
material: result,
initialize: (mesh, getTime) =>
initializeIflMaterial(result, resourcePath, mesh, getTime),
initializeIflMaterial(result, resourcePath, mesh, getTime, repeat),
};
}
// Load texture via ImageBitmapLoader (decodes off main thread). The returned
// Texture is empty initially and gets populated when the image arrives;
// Three.js re-renders automatically once loaded.
const url = textureToUrl(resourcePath);
const texture = loadTexture(url);
// Torque reSkin: replace "base." prefix with "{skinName}." in the resource
// path, then resolve to a URL. skinUrl takes precedence (pre-computed URL
// for player custom skins from external sources). skinName does the
// replacement locally (for flag team skins and other built-in skins).
const isBaseTexture = /[/\\]base\./i.test(resourcePath);
let skinTextureUrl: string | undefined;
if (isBaseTexture && options.skinUrl) {
skinTextureUrl = options.skinUrl;
} else if (isBaseTexture && options.skinName && options.skinName !== "base") {
const skinnedPath = resourcePath.replace(
/\bbase\./i,
`${options.skinName}.`,
);
try {
skinTextureUrl = textureToUrl(skinnedPath, null);
} catch {
// Skin texture not found — fall through to default.
}
}
const usingSkin = !!skinTextureUrl;
const url = skinTextureUrl ?? textureToUrl(resourcePath);
const texture = loadTexture(
url,
undefined,
usingSkin
? () => {
// Skin failed (404) — load the default texture into the same object.
const fallbackUrl = textureToUrl(resourcePath);
loadTexture(fallbackUrl, (loaded) => {
texture.image = loaded.image;
texture.needsUpdate = true;
});
}
: undefined,
);
const isTranslucent = flagNames.has("Translucent");
if (isOrganic || isTranslucent) {
setupTexture(texture, {
@ -397,9 +428,10 @@ async function initializeIflMaterial(
resourcePath: string,
mesh: Object3D,
getTime: () => number,
repeat = false,
): Promise<() => void> {
const iflPath = `textures/${resourcePath}.ifl`;
const atlas = await loadIflAtlas(iflPath);
const atlas = await loadIflAtlas(iflPath, { repeat });
(material as any).map = atlas.texture;
material.needsUpdate = true;
@ -413,6 +445,12 @@ async function initializeIflMaterial(
prevOnBeforeRender?.apply(this, args);
if (disposed) return;
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
// In swap mode, always sync material.map — multiple meshes may share
// the same atlas but have different material instances.
if (atlas.swapMode && (material as any).map !== atlas.texture) {
(material as any).map = atlas.texture;
material.needsUpdate = true;
}
};
return () => {
@ -429,7 +467,12 @@ async function initializeIflMaterial(
export function processShapeScene(
scene: Object3D,
shapeName?: string,
options: { anisotropy?: number; emap?: boolean } = {},
options: {
anisotropy?: number;
emap?: boolean;
skinUrl?: string;
skinName?: string;
} = {},
): IflInitializer[] {
const iflInitializers: IflInitializer[] = [];
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;

View file

@ -95,6 +95,61 @@ export function torqueQuatToThreeJS(q: {
return [x * invLen, y * invLen, z * invLen, w * invLen];
}
/**
* Extract a quaternion from a Torque row-major MatrixF (16 floats) and
* convert to Three.js coordinate system. The 3x3 upper-left submatrix
* contains the rotation. MatrixF is row-major: m[row*4+col].
*/
export function matrixFToThreeJSQuat(
elements: number[],
): [number, number, number, number] | null {
if (elements.length < 12) return null;
// Extract 3x3 rotation from row-major MatrixF.
// Row i, col j = elements[i*4 + j]
const m00 = elements[0],
m01 = elements[1],
m02 = elements[2];
const m10 = elements[4],
m11 = elements[5],
m12 = elements[6];
const m20 = elements[8],
m21 = elements[9],
m22 = elements[10];
// Shepperd's method for matrix → quaternion.
const trace = m00 + m11 + m22;
let qx: number, qy: number, qz: number, qw: number;
if (trace > 0) {
const s = 0.5 / Math.sqrt(trace + 1);
qw = 0.25 / s;
qx = (m21 - m12) * s;
qy = (m02 - m20) * s;
qz = (m10 - m01) * s;
} else if (m00 > m11 && m00 > m22) {
const s = 2 * Math.sqrt(1 + m00 - m11 - m22);
qw = (m21 - m12) / s;
qx = 0.25 * s;
qy = (m01 + m10) / s;
qz = (m02 + m20) / s;
} else if (m11 > m22) {
const s = 2 * Math.sqrt(1 + m11 - m00 - m22);
qw = (m02 - m20) / s;
qx = (m01 + m10) / s;
qy = 0.25 * s;
qz = (m20 + m12) / s;
} else {
const s = 2 * Math.sqrt(1 + m22 - m00 - m11);
qw = (m10 - m01) / s;
qx = (m02 + m20) / s;
qy = (m12 + m21) / s;
qz = 0.25 * s;
}
// Convert Torque quat to Three.js.
return torqueQuatToThreeJS({ x: qx, y: qy, z: qz, w: qw });
}
/** Extract heading (yaw around Torque Z axis) from a Torque quaternion. */
export function torqueQuatHeading(q: {
x: number;

View file

@ -99,6 +99,10 @@ export interface StreamEntity {
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
/** Datablock IDs for each mounted image slot (0-3). */
imageDataBlockIds?: (number | undefined)[];
/** Skin names for each mounted image slot (0-3), from ghost ImageMask skinTag. */
imageSkinNames?: (string | undefined)[];
playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number };
@ -108,8 +112,6 @@ 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]. */
@ -121,6 +123,8 @@ export interface StreamEntity {
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
/** ShapeBase hidden state (binaryCloak). True = invisible. */
hidden?: boolean;
faceViewer?: boolean;
/** DTS animation thread states from ghost ThreadMask data. */
threads?: ThreadState[];
@ -136,6 +140,10 @@ export interface StreamEntity {
packShape?: string;
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
flagShape?: string;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
/** Player preferred skin (chosen skin like "RandySavage"). */
skinPrefName?: string;
/** True when the player has no ground contact and is falling. */
falling?: boolean;
/** True when the player is using jetpack thrust. */

View file

@ -24,6 +24,19 @@ export const DEFAULT_TEAM_NAMES: Record<number, string> = {
6: "Phoenix",
};
/**
* Default flag skin names per team from serverDefaults.cs ($Host::teamSkin).
* Used by CTFGame::getTeamSkin() as fallback when custom skins are disabled.
*/
export const DEFAULT_FLAG_SKINS: Record<number, string> = {
1: "base",
2: "baseb",
3: "swolf",
4: "dsword",
5: "beagle",
6: "cotp",
};
/**
* Replicates `GameBase::getGameName()` from gameBase.cs.
* Combines targetNameTag and targetTypeTag from the datablock into a

View file

@ -30,6 +30,7 @@ const _textureCache = new Map<string, Texture>();
export function loadTexture(
url: string,
onLoad?: (texture: Texture) => void,
onError?: (url: string) => void,
): Texture {
const cached = _textureCache.get(url);
if (cached) {
@ -42,11 +43,20 @@ export function loadTexture(
// This matches our codebase where all textures use flipY = false.
texture.flipY = false;
_textureCache.set(url, texture);
_bitmapLoader.load(url, (bitmap) => {
texture.image = bitmap;
texture.needsUpdate = true;
onLoad?.(texture);
});
_bitmapLoader.load(
url,
(bitmap) => {
texture.image = bitmap;
texture.needsUpdate = true;
onLoad?.(texture);
},
undefined,
() => {
// Remove failed URL from cache so fallback can be tried.
_textureCache.delete(url);
onError?.(url);
},
);
return texture;
}