mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-12 15:04:47 +00:00
skin support, shape mounting unification
This commit is contained in:
parent
14e25beaf8
commit
bc7d30c5c6
98 changed files with 1952 additions and 1744 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 (0–1). 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
40
src/components/resolveEmap.ts
Normal file
40
src/components/resolveEmap.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
22
src/components/useRotation.ts
Normal file
22
src/components/useRotation.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue