mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 14:34:51 +00:00
vehicle spawning and mounting improvements
This commit is contained in:
parent
bc7d30c5c6
commit
76b2d11e14
114 changed files with 2118 additions and 1538 deletions
|
|
@ -2,6 +2,10 @@
|
|||
import { Suspense } from "react";
|
||||
import { NuqsAdapter } from "nuqs/adapters/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
skinManifestQueryKey,
|
||||
fetchSkinManifest,
|
||||
} from "@/src/components/PlayerModel";
|
||||
import { FeaturesProvider } from "@/src/components/FeaturesProvider";
|
||||
import { MapInspector } from "@/src/components/MapInspector";
|
||||
import { SettingsProvider } from "@/src/components/SettingsProvider";
|
||||
|
|
@ -15,6 +19,16 @@ const queryClient = new QueryClient({
|
|||
},
|
||||
});
|
||||
|
||||
// Prefetch the custom skins manifest at startup so it's in the cache before
|
||||
// any PlayerModel renders. This avoids a race where useQuery inside PlayerModel
|
||||
// can't trigger a re-render during streaming (store mutations starve React's
|
||||
// concurrent rendering).
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: skinManifestQueryKey,
|
||||
queryFn: fetchSkinManifest,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
.Container {
|
||||
.Container[data-filtered] {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
margin: 16px 10px 8px 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.Header {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
padding: 2px 0;
|
||||
padding: 18px 4px 4px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.Group {
|
||||
|
|
@ -18,10 +22,13 @@
|
|||
}
|
||||
|
||||
.GroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 6px 8px;
|
||||
padding: 6px 8px 6px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
|
|
@ -31,14 +38,24 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.GroupHeader::before {
|
||||
content: "[+] ";
|
||||
font-family: monospace;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
.OpenedIcon {
|
||||
display: none;
|
||||
font-size: 15px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.Group[open] > .GroupHeader::before {
|
||||
content: "[-] ";
|
||||
.ClosedIcon {
|
||||
display: inline-block;
|
||||
font-size: 15px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.Group[open] .OpenedIcon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Group[open] .ClosedIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.GroupCount {
|
||||
|
|
@ -146,10 +163,20 @@
|
|||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.Detail dt::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.Detail dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.FilterInput {
|
||||
font-family: inherit;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.FilterInput:focus {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useMemo, useCallback } from "react";
|
||||
import { memo, useMemo, useCallback, useState, useDeferredValue } from "react";
|
||||
import { FaLocationArrow } from "react-icons/fa6";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { useGameEntities } from "../state/gameEntityStore";
|
||||
import { cameraTourStore } from "../state/cameraTourStore";
|
||||
import type { GameEntity } from "../state/gameEntityTypes";
|
||||
|
|
@ -7,6 +8,7 @@ import { torqueToThree } from "../scene/coordinates";
|
|||
import { gameEntityStore, useDebugHidden } from "../state/gameEntityStore";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
import styles from "./DebugEntityList.module.css";
|
||||
import { FaRegMinusSquare, FaRegPlusSquare } from "react-icons/fa";
|
||||
|
||||
function getEntityLabel(entity: GameEntity): string {
|
||||
if (entity.renderType === "Player" && "playerName" in entity) {
|
||||
|
|
@ -116,7 +118,7 @@ function EntityRow({ entity }: { entity: GameEntity }) {
|
|||
<dl className={styles.Detail}>
|
||||
{Object.entries(detail).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dt>{key}:</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -138,15 +140,45 @@ function EntityRow({ entity }: { entity: GameEntity }) {
|
|||
);
|
||||
}
|
||||
|
||||
const ENTITY_MATCH_KEYS = [
|
||||
"id",
|
||||
"className",
|
||||
"label",
|
||||
"playerName",
|
||||
"skinPrefName",
|
||||
"shapeName",
|
||||
"dataBlock",
|
||||
"audioFileName",
|
||||
"interiorData.interiorFile",
|
||||
"terrainData.terrFileName",
|
||||
"waterData.surfaceName",
|
||||
];
|
||||
|
||||
export const DebugEntityList = memo(function DebugEntityList() {
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const activeFilterText = useDeferredValue(filterText.trim());
|
||||
|
||||
const entities = useGameEntities();
|
||||
// useGameEntities re-renders on version bump, but the Map reference is
|
||||
// stable (mutated in place). Use version as a useMemo dep to recompute.
|
||||
const version = gameEntityStore.getState().version;
|
||||
|
||||
const filteredEntities = useMemo(() => {
|
||||
const allEntities = Array.from(entities.values());
|
||||
|
||||
return activeFilterText
|
||||
? matchSorter(allEntities, activeFilterText, {
|
||||
keys: ENTITY_MATCH_KEYS,
|
||||
})
|
||||
: allEntities;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeFilterText, entities, version]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, GameEntity[]>();
|
||||
for (const entity of entities.values()) {
|
||||
|
||||
for (const entity of filteredEntities) {
|
||||
if (
|
||||
entity.renderType === "Sky" ||
|
||||
entity.renderType === "Sun" ||
|
||||
|
|
@ -168,16 +200,33 @@ export const DebugEntityList = memo(function DebugEntityList() {
|
|||
list.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
return sorted;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entities, version]);
|
||||
}, [filteredEntities]);
|
||||
|
||||
return (
|
||||
<div className={styles.Container}>
|
||||
<h4 className={styles.Title}>Entity list</h4>
|
||||
<section
|
||||
className={styles.Container}
|
||||
data-filtered={activeFilterText ? true : undefined}
|
||||
>
|
||||
<header className={styles.Header}>
|
||||
<h4 className={styles.Title}>Entity list</h4>
|
||||
<input
|
||||
type="search"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
size={16}
|
||||
placeholder="Filter"
|
||||
className={styles.FilterInput}
|
||||
/>
|
||||
</header>
|
||||
{grouped.map(([className, list]) => (
|
||||
<details key={className} className={styles.Group}>
|
||||
<details
|
||||
key={className}
|
||||
className={styles.Group}
|
||||
open={activeFilterText ? true : undefined}
|
||||
>
|
||||
<summary className={styles.GroupHeader}>
|
||||
{className}{" "}
|
||||
<FaRegPlusSquare className={styles.ClosedIcon} />
|
||||
<FaRegMinusSquare className={styles.OpenedIcon} /> {className}{" "}
|
||||
<span className={styles.GroupCount}>({list.length})</span>
|
||||
</summary>
|
||||
<ul className={styles.List}>
|
||||
|
|
@ -189,6 +238,6 @@ export const DebugEntityList = memo(function DebugEntityList() {
|
|||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,11 +93,8 @@ function renderEventDescription(event: TimelineEvent): React.ReactNode {
|
|||
</>
|
||||
);
|
||||
}
|
||||
if (event.type === "match-start") {
|
||||
return "Match started";
|
||||
}
|
||||
if (event.type === "match-end") {
|
||||
return "Match ended";
|
||||
if (event.type === "match-start" || event.type === "match-end") {
|
||||
return event.description;
|
||||
}
|
||||
return event.description;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,12 +73,15 @@ const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
|
|||
*/
|
||||
export const EntityRenderer = memo(function EntityRenderer({
|
||||
entity,
|
||||
objectMounts,
|
||||
}: {
|
||||
entity: GameEntity;
|
||||
/** Object-mounted entities (players in vehicles, turrets on vehicles). */
|
||||
objectMounts?: Record<number, React.ReactNode>;
|
||||
}) {
|
||||
switch (entity.renderType) {
|
||||
case "Shape":
|
||||
return <ShapeEntity entity={entity} />;
|
||||
return <ShapeEntity entity={entity} objectMounts={objectMounts} />;
|
||||
case "ForceFieldBare":
|
||||
return <ForceFieldBare entity={entity} />;
|
||||
case "Player":
|
||||
|
|
@ -119,7 +122,13 @@ export const EntityRenderer = memo(function EntityRenderer({
|
|||
}
|
||||
});
|
||||
|
||||
function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
||||
function ShapeEntity({
|
||||
entity,
|
||||
objectMounts,
|
||||
}: {
|
||||
entity: ShapeEntityType;
|
||||
objectMounts?: Record<number, React.ReactNode>;
|
||||
}) {
|
||||
const dataSource = useDataSource();
|
||||
const isStreaming = dataSource === "demo" || dataSource === "live";
|
||||
const groupRef = useRef<Group>(null);
|
||||
|
|
@ -153,6 +162,30 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
|||
? "#00ff88"
|
||||
: "yellow";
|
||||
|
||||
// Merge image mounts (all 8 slots) with object mounts (players in vehicles).
|
||||
// Both use the same Mount bones. Each image slot's mount bone comes from
|
||||
// dataBlock->mountPoint (binary-verified), not from the slot index.
|
||||
// Object mounts take priority over image mounts at the same bone.
|
||||
const allMounts = useMemo(() => {
|
||||
const m: Record<number, React.ReactNode> = { ...objectMounts };
|
||||
const slots = entity.imageSlots;
|
||||
if (slots) {
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
const slot = slots[i];
|
||||
if (!slot?.shapeName || slot.mountPoint in m) continue;
|
||||
m[slot.mountPoint] = (
|
||||
<MountedShapeContent
|
||||
shapeName={slot.shapeName}
|
||||
imageDataBlockId={slot.dataBlockId}
|
||||
entityId={entity.id}
|
||||
skinName={slot.skinName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.keys(m).length > 0 ? m : undefined;
|
||||
}, [objectMounts, entity.imageSlots, entity.id]);
|
||||
|
||||
return (
|
||||
<ShapeInfoProvider
|
||||
object={entity.runtimeObject as TorqueObject | undefined}
|
||||
|
|
@ -166,19 +199,7 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
|
|||
emap={emap}
|
||||
entityId={entity.id}
|
||||
skinName={entity.skinName}
|
||||
mounted={
|
||||
entity.weaponShape
|
||||
? {
|
||||
0: (
|
||||
<MountedShapeContent
|
||||
shapeName={entity.weaponShape}
|
||||
imageDataBlockId={entity.imageDataBlockIds?.[0]}
|
||||
entityId={entity.id}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
mounted={allMounts}
|
||||
>
|
||||
{flagLabel ? (
|
||||
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { memo, useCallback, useRef, useState, useMemo } from "react";
|
||||
import React, {
|
||||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Quaternion } from "three";
|
||||
import type { Group } from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
|
|
@ -55,13 +62,37 @@ const EntityLayer = memo(function EntityLayer() {
|
|||
}
|
||||
}
|
||||
|
||||
// Build object mount relationships: which entities are mounted on which.
|
||||
// Mounted entities render inside their target's mount bone (via createPortal
|
||||
// in ShapeRenderer), NOT as top-level positioned entities.
|
||||
const mountedIds = new Set<string>();
|
||||
const mountChildren = new Map<string, Map<number, GameEntity>>();
|
||||
for (const entity of cache.values()) {
|
||||
const mountId = entity.mountObjectId;
|
||||
if (mountId && cache.has(mountId)) {
|
||||
mountedIds.add(entity.id);
|
||||
let children = mountChildren.get(mountId);
|
||||
if (!children) {
|
||||
children = new Map();
|
||||
mountChildren.set(mountId, children);
|
||||
}
|
||||
children.set(entity.mountNode ?? 0, entity);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
[...cache.values()].map((entity) => (
|
||||
<EntityWrapper key={entity.id} entity={entity} />
|
||||
))
|
||||
[...cache.values()]
|
||||
.filter((entity) => !mountedIds.has(entity.id))
|
||||
.map((entity) => (
|
||||
<EntityWrapper
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
mountChildren={mountChildren.get(entity.id)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
|
@ -69,8 +100,10 @@ const EntityLayer = memo(function EntityLayer() {
|
|||
|
||||
const EntityWrapper = memo(function EntityWrapper({
|
||||
entity,
|
||||
mountChildren,
|
||||
}: {
|
||||
entity: GameEntity;
|
||||
mountChildren?: Map<number, GameEntity>;
|
||||
}) {
|
||||
if (entity.debugHidden) return null;
|
||||
|
||||
|
|
@ -87,7 +120,9 @@ const EntityWrapper = memo(function EntityWrapper({
|
|||
if (entity.renderType === "None") return null;
|
||||
|
||||
// From here, entity is a PositionedEntity
|
||||
return <PositionedEntityWrapper entity={entity} />;
|
||||
return (
|
||||
<PositionedEntityWrapper entity={entity} mountChildren={mountChildren} />
|
||||
);
|
||||
});
|
||||
|
||||
/** Imperatively tracks targetRenderFlags bit 0x2 on a game entity and
|
||||
|
|
@ -121,7 +156,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
|
|||
return <FlagMarker entity={entity} />;
|
||||
}
|
||||
|
||||
function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
|
||||
function PositionedEntityWrapper({
|
||||
entity,
|
||||
mountChildren,
|
||||
}: {
|
||||
entity: PositionedEntity;
|
||||
mountChildren?: Map<number, GameEntity>;
|
||||
}) {
|
||||
const position = entity.position;
|
||||
const scale = entity.scale;
|
||||
const quaternion = useMemo(() => {
|
||||
|
|
@ -161,6 +202,22 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
|
|||
</mesh>
|
||||
);
|
||||
|
||||
// Build object mount content for entities mounted on this one (e.g. players
|
||||
// sitting in a vehicle). Each mounted entity renders via EntityRenderer
|
||||
// inside the target's mount bone (portaled by ShapeRenderer).
|
||||
const objectMounts = useMemo(() => {
|
||||
if (!mountChildren || mountChildren.size === 0) return undefined;
|
||||
const mounts: Record<number, React.ReactNode> = {};
|
||||
for (const [node, child] of mountChildren) {
|
||||
mounts[node] = (
|
||||
<Suspense key={child.id}>
|
||||
<EntityRenderer entity={child} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return mounts;
|
||||
}, [mountChildren]);
|
||||
|
||||
return (
|
||||
<group
|
||||
name={entity.id}
|
||||
|
|
@ -170,7 +227,7 @@ function PositionedEntityWrapper({ entity }: { entity: PositionedEntity }) {
|
|||
>
|
||||
<group name="model">
|
||||
<ShapeErrorBoundary fallback={fallback}>
|
||||
<EntityRenderer entity={entity} />
|
||||
<EntityRenderer entity={entity} objectMounts={objectMounts} />
|
||||
</ShapeErrorBoundary>
|
||||
<FlagMarkerSlot entity={entity} />
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
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 type { AnimationAction, Object3D } from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { createPortal, useFrame } from "@react-three/fiber";
|
||||
import { createLogger } from "../logger";
|
||||
import { shapeToUrl } from "../loaders";
|
||||
import { shapeToUrl, textureToUrl } from "../loaders";
|
||||
import {
|
||||
MeshStandardMaterial,
|
||||
AdditiveAnimationBlendMode,
|
||||
|
|
@ -14,9 +14,12 @@ import {
|
|||
AnimationUtils,
|
||||
LoopOnce,
|
||||
LoopRepeat,
|
||||
NormalBlending,
|
||||
Group,
|
||||
Box3,
|
||||
Vector3,
|
||||
RepeatWrapping,
|
||||
NoColorSpace,
|
||||
} from "three";
|
||||
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { useAnisotropy } from "./useAnisotropy";
|
||||
|
|
@ -46,7 +49,49 @@ import {
|
|||
getPosedNodeTransform,
|
||||
} from "../stream/playbackUtils";
|
||||
import { resolveEmapFromImageSlot } from "./resolveEmap";
|
||||
import { playerEyePositions } from "./PlayerModel";
|
||||
import type { ThreadState as StreamThreadState } from "../stream/types";
|
||||
import { loadTexture } from "../textureUtils";
|
||||
|
||||
// ── Cloak texture (binary-verified: special/cloakTexture with UV scrolling) ──
|
||||
// Lazy-loaded on first use since cloaking is rare.
|
||||
|
||||
import type { Texture } from "three";
|
||||
|
||||
let _cloakTexture: Texture | null = null;
|
||||
function getCloakTexture(): Texture {
|
||||
if (!_cloakTexture) {
|
||||
_cloakTexture = loadTexture(textureToUrl("special/cloakTexture"));
|
||||
_cloakTexture.wrapS = RepeatWrapping;
|
||||
_cloakTexture.wrapT = RepeatWrapping;
|
||||
_cloakTexture.colorSpace = NoColorSpace;
|
||||
}
|
||||
return _cloakTexture;
|
||||
}
|
||||
|
||||
// Global UV offset matching engine's static shiftX/shiftY with different moduli
|
||||
// to create a non-repeating shimmer pattern.
|
||||
let _cloakShiftX = 0;
|
||||
let _cloakShiftY = 0;
|
||||
let _cloakLastFrame = -1;
|
||||
function advanceCloakUV(frameId: number): void {
|
||||
if (frameId === _cloakLastFrame) return;
|
||||
_cloakLastFrame = frameId;
|
||||
_cloakShiftX = (_cloakShiftX + 1) % 128;
|
||||
_cloakShiftY = (_cloakShiftY + 1) % 127;
|
||||
getCloakTexture().offset.set(_cloakShiftX / 127, _cloakShiftY / 126);
|
||||
}
|
||||
|
||||
// Counter-rotate for object-mounted content (players in vehicles).
|
||||
// The mount bone is inside the vehicle's R90 Y group (DTS Z-up → Three.js
|
||||
// Y-up). The mounted PlayerModel applies its own R90 Y. We need to undo the
|
||||
// player's R90 and rotate from GLB Y-up back to DTS Z-up so the player
|
||||
// stands upright relative to the vehicle's DTS bone coordinate system.
|
||||
const MOUNT_COUNTER_ROTATION: [x: number, y: number, z: number] = [
|
||||
Math.PI / 2,
|
||||
-Math.PI / 2,
|
||||
0,
|
||||
];
|
||||
|
||||
const STANDARD_90_ROTATION: [x: number, y: number, z: number] = [
|
||||
0,
|
||||
|
|
@ -68,6 +113,8 @@ interface StreamShapeEntity {
|
|||
frozen?: boolean;
|
||||
maxSteeringAngle?: number;
|
||||
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
|
||||
fadeVal?: number;
|
||||
cloakLevel?: number;
|
||||
dataBlockId?: number;
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +297,7 @@ interface ThreadState {
|
|||
action?: AnimationAction;
|
||||
visNodes?: VisNode[];
|
||||
startTime: number;
|
||||
forward: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -556,7 +604,13 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
}
|
||||
|
||||
function handlePlayThread(slot: number, sequenceName: string) {
|
||||
// Match binary's updateThread (FUN_005ebf00): direction is implemented
|
||||
// via timeScale (+1 forward, -1 backward). State 0=Play, 1=Stop, 2=Pause.
|
||||
function handlePlayThread(
|
||||
slot: number,
|
||||
sequenceName: string,
|
||||
forward = true,
|
||||
) {
|
||||
const seqLower = sequenceName.toLowerCase();
|
||||
handleStopThread(slot);
|
||||
|
||||
|
|
@ -565,6 +619,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
const thread: ThreadState = {
|
||||
sequence: seqLower,
|
||||
startTime: shapeNowSec(),
|
||||
forward,
|
||||
};
|
||||
|
||||
if (clip && mixer) {
|
||||
|
|
@ -582,7 +637,13 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
if (seqBlendByName.has(seqLower)) {
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
}
|
||||
action.reset().play();
|
||||
action.timeScale = forward ? 1 : -1;
|
||||
action.reset();
|
||||
// For backward playback, start at the end of the clip.
|
||||
if (!forward) {
|
||||
action.time = clip.duration;
|
||||
}
|
||||
action.play();
|
||||
thread.action = action;
|
||||
}
|
||||
|
||||
|
|
@ -598,11 +659,12 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
const thread = threads.get(slot);
|
||||
if (!thread) return;
|
||||
if (thread.action) thread.action.stop();
|
||||
// Binary Stop: reset position to 0.0, freeze. Vis nodes go to frame 0.
|
||||
if (thread.visNodes) {
|
||||
for (const v of thread.visNodes) {
|
||||
v.mesh.visible = false;
|
||||
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
|
||||
v.mesh.material.opacity = v.keyframes[0];
|
||||
v.mesh.visible = v.keyframes[0] > 0.01;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -795,51 +857,70 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
!prev ||
|
||||
prev.sequence !== t.sequence ||
|
||||
prev.state !== t.state ||
|
||||
prev.forward !== t.forward ||
|
||||
prev.atEnd !== t.atEnd;
|
||||
if (!changed) continue;
|
||||
|
||||
// When only atEnd changed (false→true) on a playing thread with
|
||||
// the same sequence, the animation has finished on the server.
|
||||
// Don't restart it — snap to the end pose so one-shot animations
|
||||
// like "deploy" stay clamped instead of collapsing back.
|
||||
const onlyAtEndChanged =
|
||||
prev &&
|
||||
prev.sequence === t.sequence &&
|
||||
prev.state === t.state &&
|
||||
t.state === 0 &&
|
||||
!prev.atEnd &&
|
||||
t.atEnd;
|
||||
if (onlyAtEndChanged) {
|
||||
const thread = threads.get(slot);
|
||||
if (thread?.action) {
|
||||
const clip = thread.action.getClip();
|
||||
thread.action.time = t.forward ? clip.duration : 0;
|
||||
thread.action.setLoop(LoopOnce, 1);
|
||||
thread.action.clampWhenFinished = true;
|
||||
thread.action.paused = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const seqName = seqIndexToName[t.sequence];
|
||||
if (!seqName) continue;
|
||||
if (t.state === 0) {
|
||||
playThread(slot, seqName);
|
||||
// If the animation is already finished (atEnd=true on first
|
||||
// appearance — e.g. a deployed MPB entering scope), snap to
|
||||
// the end pose immediately instead of replaying it.
|
||||
|
||||
// Match binary updateThread (FUN_005ebf00):
|
||||
// State 0=Play, 1=Stop, 2=Pause
|
||||
if (t.state === 1) {
|
||||
// Stop: reset to start, freeze.
|
||||
stopThread(slot);
|
||||
} else if (t.state === 2) {
|
||||
// Pause: freeze at current position.
|
||||
const thread = threads.get(slot);
|
||||
if (thread?.action) {
|
||||
thread.action.paused = true;
|
||||
}
|
||||
} else {
|
||||
// Play (state === 0)
|
||||
if (t.atEnd) {
|
||||
const thread = threads.get(slot);
|
||||
// Already finished: snap to end pose, freeze.
|
||||
// Check if we need to start the thread first.
|
||||
let thread = threads.get(slot);
|
||||
if (!thread || thread.sequence !== seqName) {
|
||||
playThread(slot, seqName, t.forward);
|
||||
thread = threads.get(slot);
|
||||
}
|
||||
if (thread?.action) {
|
||||
const clip = thread.action.getClip();
|
||||
thread.action.time = t.forward ? clip.duration : 0;
|
||||
thread.action.timeScale = 1;
|
||||
thread.action.setLoop(LoopOnce, 1);
|
||||
thread.action.clampWhenFinished = true;
|
||||
thread.action.paused = true;
|
||||
}
|
||||
// Snap vis nodes to end pose.
|
||||
if (thread?.visNodes) {
|
||||
for (const v of thread.visNodes) {
|
||||
const mat = v.mesh.material;
|
||||
if (!mat || Array.isArray(mat)) continue;
|
||||
const endIdx = t.forward
|
||||
? v.keyframes.length - 1
|
||||
: 0;
|
||||
mat.opacity = v.keyframes[endIdx];
|
||||
v.mesh.visible = mat.opacity > 0.01;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Actively playing: (re)start with correct direction.
|
||||
// Only restart if sequence or direction changed.
|
||||
const thread = threads.get(slot);
|
||||
const needRestart =
|
||||
!thread ||
|
||||
thread.sequence !== seqName ||
|
||||
thread.forward !== t.forward;
|
||||
if (needRestart) {
|
||||
playThread(slot, seqName, t.forward);
|
||||
} else if (thread?.action?.paused) {
|
||||
// Resume from pause with correct direction.
|
||||
thread.action.paused = false;
|
||||
thread.action.timeScale = t.forward ? 1 : -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopThread(slot);
|
||||
}
|
||||
} else if (prev) {
|
||||
// Thread disappeared — stop it.
|
||||
|
|
@ -854,7 +935,8 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
|
||||
// Drive vis opacity animations for active threads.
|
||||
for (const [, thread] of threads) {
|
||||
// Direction is handled by computing position forward or backward.
|
||||
for (const [slot, thread] of threads) {
|
||||
if (!thread.visNodes) continue;
|
||||
|
||||
for (const { mesh, keyframes, duration, cyclic } of thread.visNodes) {
|
||||
|
|
@ -863,20 +945,29 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
|
||||
if (!animationEnabled) {
|
||||
mat.opacity = keyframes[0];
|
||||
mesh.visible = mat.opacity > 0.01;
|
||||
continue;
|
||||
}
|
||||
|
||||
const elapsed = shapeNowSec() - thread.startTime;
|
||||
const t = cyclic
|
||||
? (elapsed % duration) / duration
|
||||
: Math.min(elapsed / duration, 1);
|
||||
let t: number;
|
||||
if (cyclic) {
|
||||
// Cyclic: wrap position, ignoring direction (cyclic always advances).
|
||||
t = ((elapsed % duration) + duration) % duration / duration;
|
||||
} else if (thread.forward) {
|
||||
t = Math.min(elapsed / duration, 1);
|
||||
} else {
|
||||
// Backward: start at 1.0 and move toward 0.0.
|
||||
t = Math.max(1 - elapsed / duration, 0);
|
||||
}
|
||||
|
||||
const n = keyframes.length;
|
||||
const pos = t * n;
|
||||
const lo = Math.floor(pos) % n;
|
||||
const hi = (lo + 1) % n;
|
||||
const frac = pos - Math.floor(pos);
|
||||
const pos = t * (n - 1);
|
||||
const lo = Math.min(Math.floor(pos), n - 1);
|
||||
const hi = Math.min(lo + 1, n - 1);
|
||||
const frac = pos - lo;
|
||||
mat.opacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
|
||||
mesh.visible = mat.opacity > 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -989,6 +1080,82 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
});
|
||||
|
||||
// ShapeBase fade opacity and cloak texture effect.
|
||||
// Fade (mFadeVal): opacity-only, used by startFade().
|
||||
// Cloak (mCloakLevel): replaces textures with cloakTexture + UV scrolling,
|
||||
// alpha = 0.125 + (1 - cloakLevel) * 0.875. Binary-verified rendering path.
|
||||
const lastFadeValRef = useRef(1);
|
||||
const lastCloakLevelRef = useRef(0);
|
||||
useFrame((state) => {
|
||||
const entity = streamEntityRef.current;
|
||||
const fadeVal = entity?.fadeVal ?? 1;
|
||||
const cloakLevel = entity?.cloakLevel ?? 0;
|
||||
const isCloak = cloakLevel > 0;
|
||||
|
||||
// Advance global cloak UV offset once per frame (all cloaked shapes share it).
|
||||
if (isCloak)
|
||||
advanceCloakUV(
|
||||
state.frameloop === "never" ? 0 : (state.clock.elapsedTime * 60) | 0,
|
||||
);
|
||||
|
||||
if (
|
||||
fadeVal === lastFadeValRef.current &&
|
||||
cloakLevel === lastCloakLevelRef.current
|
||||
)
|
||||
return;
|
||||
lastFadeValRef.current = fadeVal;
|
||||
lastCloakLevelRef.current = cloakLevel;
|
||||
|
||||
// Cloak alpha OVERRIDES fade (binary-verified: TSMesh uses else-if —
|
||||
// alwaysAlpha takes precedence over overrideFade when both are set).
|
||||
const combinedAlpha = isCloak ? 0.125 + (1 - cloakLevel) * 0.875 : fadeVal;
|
||||
const cloakTex = isCloak ? getCloakTexture() : _cloakTexture;
|
||||
|
||||
clonedScene.traverse((node: any) => {
|
||||
if (!node.isMesh || !node.material || Array.isArray(node.material))
|
||||
return;
|
||||
const mat = node.material;
|
||||
const ud = (mat.userData ??= {});
|
||||
|
||||
// Save originals on first encounter.
|
||||
if (ud._baseFadeOpacity == null) {
|
||||
ud._baseFadeOpacity = mat.opacity ?? 1;
|
||||
ud._baseFadeTransparent = mat.transparent ?? false;
|
||||
ud._originalMap = mat.map;
|
||||
// Originally-translucent materials keep their own texture during cloak.
|
||||
// Detect via alphaTest (organic/Translucent cutout) or non-normal blending
|
||||
// (Additive). These match how createMaterialFromFlags sets up materials.
|
||||
ud._isOriginallyTranslucent =
|
||||
(ud._baseFadeTransparent as boolean) ||
|
||||
mat.alphaTest > 0 ||
|
||||
mat.blending !== NormalBlending;
|
||||
}
|
||||
|
||||
const baseOpacity = ud._baseFadeOpacity as number;
|
||||
|
||||
// Cloak texture replacement (non-translucent materials only).
|
||||
if (isCloak && !ud._isOriginallyTranslucent) {
|
||||
if (mat.map !== cloakTex) {
|
||||
mat.map = cloakTex;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
} else if (
|
||||
!isCloak &&
|
||||
ud._originalMap !== undefined &&
|
||||
mat.map === cloakTex
|
||||
) {
|
||||
mat.map = ud._originalMap;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
|
||||
mat.opacity = combinedAlpha * baseOpacity;
|
||||
mat.transparent =
|
||||
combinedAlpha < 1 || (ud._baseFadeTransparent as boolean);
|
||||
mat.depthWrite =
|
||||
combinedAlpha >= 1 && !(ud._baseFadeTransparent as boolean);
|
||||
});
|
||||
});
|
||||
|
||||
// ShapeBase sound slots — managed as PositionalAudio, not entities.
|
||||
useEntitySoundSlots(streamEntityRef, clonedScene);
|
||||
|
||||
|
|
@ -1006,19 +1173,53 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
};
|
||||
}, [isTarget, gltf.scene]);
|
||||
|
||||
// Find mount point bones in the cloned scene for portal rendering.
|
||||
// Find mount point bones in the cloned scene for portal rendering
|
||||
// and for vehicle mount position tracking.
|
||||
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;
|
||||
});
|
||||
}
|
||||
// Always look for Mount0 (pilot seat for vehicles).
|
||||
clonedScene.traverse((node: any) => {
|
||||
const match = node.name.match(/^Mount(\d+)$/);
|
||||
if (match) bones[Number(match[1])] = node;
|
||||
});
|
||||
return Object.keys(bones).length > 0 ? bones : null;
|
||||
}, [clonedScene, mounted]);
|
||||
}, [clonedScene]);
|
||||
|
||||
// Find Eye node for vehicle camera positioning. Vehicles have an Eye node
|
||||
// in their DTS shape that defines the cockpit viewpoint.
|
||||
const eyeBone = useMemo((): Object3D | null => {
|
||||
let found: Object3D | null = null;
|
||||
clonedScene.traverse((node: any) => {
|
||||
if (node.name === "Eye") found = node;
|
||||
});
|
||||
return found;
|
||||
}, [clonedScene]);
|
||||
|
||||
// Write animated Eye node position to the shared eye position map so the
|
||||
// camera system can use it (same map as PlayerModel's eye bone).
|
||||
useEffect(() => {
|
||||
if (!eyeBone || !entityId) return;
|
||||
return () => {
|
||||
playerEyePositions.delete(entityId);
|
||||
};
|
||||
}, [eyeBone, entityId]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!eyeBone || !entityId) return;
|
||||
let eyePos = playerEyePositions.get(entityId);
|
||||
if (!eyePos) {
|
||||
eyePos = new Vector3();
|
||||
playerEyePositions.set(entityId, eyePos);
|
||||
}
|
||||
eyeBone.getWorldPosition(eyePos);
|
||||
clonedScene.worldToLocal(eyePos);
|
||||
// Convert GLB (x,y,z) → entity-local Three.js space via R90
|
||||
// (same swizzle as PlayerModel's eye extraction).
|
||||
const gx = eyePos.x;
|
||||
const gy = eyePos.y;
|
||||
const gz = eyePos.z;
|
||||
eyePos.set(gz, gy, -gx);
|
||||
});
|
||||
|
||||
return (
|
||||
<group rotation={noRotation ? undefined : STANDARD_90_ROTATION}>
|
||||
|
|
@ -1040,7 +1241,10 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
const bone = mountBones[Number(slot)];
|
||||
return bone ? (
|
||||
<Fragment key={slot}>
|
||||
{createPortal(<group>{content}</group>, bone)}
|
||||
{createPortal(
|
||||
<group rotation={MOUNT_COUNTER_ROTATION}>{content}</group>,
|
||||
bone,
|
||||
)}
|
||||
</Fragment>
|
||||
) : null;
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
Activity,
|
||||
Fragment,
|
||||
startTransition,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
|
|
@ -173,7 +173,8 @@ export function MissionSelect({
|
|||
autoFocus?: boolean;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [latestSearchValue, setSearchValue] = useState("");
|
||||
const searchValue = useDeferredValue(latestSearchValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const missionTypeRef = useRef<string | null>(missionType);
|
||||
|
||||
|
|
@ -203,7 +204,7 @@ export function MissionSelect({
|
|||
}
|
||||
},
|
||||
setValue: (value) => {
|
||||
startTransition(() => setSearchValue(value));
|
||||
setSearchValue(value);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ function Reticle() {
|
|||
if (!snap || snap.camera?.mode !== "first-person") return undefined;
|
||||
const ctrl = snap.controlPlayerGhostId;
|
||||
if (!ctrl) return undefined;
|
||||
return snap.entities.find((e: StreamEntity) => e.id === ctrl)?.weaponShape;
|
||||
return snap.entities.find((e: StreamEntity) => e.id === ctrl)
|
||||
?.imageSlots?.[0]?.shapeName;
|
||||
});
|
||||
if (weaponShape === undefined) return null;
|
||||
const weapon = normalizeWeaponName(weaponShape);
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import {
|
|||
} from "../stream/playbackUtils";
|
||||
import { pickMoveAnimation } from "../stream/playerAnimation";
|
||||
import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { WeaponAnimState } from "../stream/weaponStateMachine";
|
||||
import { getAliasedActions } from "../torqueScript/shapeConstructor";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useStaticShape,
|
||||
ShapePlaceholder,
|
||||
|
|
@ -89,23 +89,25 @@ const SKIN_SUFFIXES: Record<string, string> = {
|
|||
/** 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. */
|
||||
/** Skin manifest query key and fetcher, shared with App.tsx prefetch. */
|
||||
export const skinManifestQueryKey = ["customSkinManifest"] as const;
|
||||
export async function fetchSkinManifest(): Promise<SkinLookup> {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
queryKey: skinManifestQueryKey,
|
||||
queryFn: fetchSkinManifest,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
|
@ -286,6 +288,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
);
|
||||
|
||||
// Resolve skin texture URL: local manifest first, then remote manifest.
|
||||
// The manifest is prefetched at app startup (see App.tsx) so it's
|
||||
// available synchronously here — no async wait that could be starved
|
||||
// by store mutations during streaming playback.
|
||||
const { data: skinManifest } = useCustomSkinManifest();
|
||||
|
||||
const skinUrl = useMemo(() => {
|
||||
|
|
@ -335,10 +340,16 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
|
||||
// Use front-face-only rendering so the camera can see out from inside the
|
||||
// model in first-person (backface culling hides interior faces).
|
||||
// Disable frustum culling — when portaled into a vehicle mount bone, the
|
||||
// bounding sphere is in local space but the world transform comes from the
|
||||
// bone chain, causing incorrect culling.
|
||||
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;
|
||||
if (n.isMesh) {
|
||||
n.frustumCulled = false;
|
||||
if (n.material) {
|
||||
const mats = Array.isArray(n.material) ? n.material : [n.material];
|
||||
for (const m of mats) m.side = FrontSide;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -590,14 +601,14 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
|
||||
// Track weaponShape changes. The entity is mutated in-place by the
|
||||
// streaming layer (no React re-render), so we sync it in useFrame.
|
||||
const weaponShapeRef = useRef(entity.weaponShape);
|
||||
const [currentWeaponShape, setCurrentWeaponShape] = useState(
|
||||
entity.weaponShape,
|
||||
);
|
||||
const packShapeRef = useRef(entity.packShape);
|
||||
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
|
||||
const flagShapeRef = useRef(entity.flagShape);
|
||||
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
|
||||
// Derive weapon/pack/flag from imageSlots.
|
||||
const getSlotShape = (slot: number) => entity.imageSlots?.[slot]?.shapeName;
|
||||
const weaponShapeRef = useRef(getSlotShape(0));
|
||||
const [currentWeaponShape, setCurrentWeaponShape] = useState(getSlotShape(0));
|
||||
const packShapeRef = useRef(getSlotShape(2));
|
||||
const [currentPackShape, setCurrentPackShape] = useState(getSlotShape(2));
|
||||
const flagShapeRef = useRef(getSlotShape(3));
|
||||
const [currentFlagShape, setCurrentFlagShape] = useState(getSlotShape(3));
|
||||
|
||||
// ShapeBase sound slots (weapon switch sounds, etc.) — managed by shared hook.
|
||||
const entityRef = useRef(entity);
|
||||
|
|
@ -663,17 +674,20 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
|
||||
// Per-frame animation selection and mixer update.
|
||||
useFrame((_, delta) => {
|
||||
if (entity.weaponShape !== weaponShapeRef.current) {
|
||||
weaponShapeRef.current = entity.weaponShape;
|
||||
setCurrentWeaponShape(entity.weaponShape);
|
||||
const curWeapon = getSlotShape(0);
|
||||
if (curWeapon !== weaponShapeRef.current) {
|
||||
weaponShapeRef.current = curWeapon;
|
||||
setCurrentWeaponShape(curWeapon);
|
||||
}
|
||||
if (entity.packShape !== packShapeRef.current) {
|
||||
packShapeRef.current = entity.packShape;
|
||||
setCurrentPackShape(entity.packShape);
|
||||
const curPack = getSlotShape(2);
|
||||
if (curPack !== packShapeRef.current) {
|
||||
packShapeRef.current = curPack;
|
||||
setCurrentPackShape(curPack);
|
||||
}
|
||||
if (entity.flagShape !== flagShapeRef.current) {
|
||||
flagShapeRef.current = entity.flagShape;
|
||||
setCurrentFlagShape(entity.flagShape);
|
||||
const curFlag = getSlotShape(3);
|
||||
if (curFlag !== flagShapeRef.current) {
|
||||
flagShapeRef.current = curFlag;
|
||||
setCurrentFlagShape(curFlag);
|
||||
}
|
||||
const playback = engineStore.getState().playback;
|
||||
const isPlaying = playback.status === "playing";
|
||||
|
|
@ -929,7 +943,6 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
// 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();
|
||||
|
|
@ -981,7 +994,7 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
<Suspense>
|
||||
<MountedShapeContent
|
||||
shapeName={currentPackShape}
|
||||
imageDataBlockId={entity.imageDataBlockIds?.[2]}
|
||||
imageDataBlockId={entity.imageSlots?.[2]?.dataBlockId}
|
||||
entityId={entity.id}
|
||||
/>
|
||||
</Suspense>,
|
||||
|
|
@ -993,9 +1006,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
<Suspense>
|
||||
<MountedShapeContent
|
||||
shapeName={currentFlagShape}
|
||||
imageDataBlockId={entity.imageDataBlockIds?.[3]}
|
||||
imageDataBlockId={entity.imageSlots?.[3]?.dataBlockId}
|
||||
entityId={entity.id}
|
||||
skinName={entity.imageSkinNames?.[3]}
|
||||
skinName={entity.imageSlots?.[3]?.skinName}
|
||||
/>
|
||||
</Suspense>,
|
||||
mount2,
|
||||
|
|
@ -1075,8 +1088,8 @@ function WeaponModel({
|
|||
const engineStore = useEngineStoreApi();
|
||||
const weaponGltf = useStaticShape(weaponShape);
|
||||
const emap = useMemo(
|
||||
() => resolveEmapFromImageSlot(entity.imageDataBlockIds?.[0]),
|
||||
[entity.imageDataBlockIds],
|
||||
() => resolveEmapFromImageSlot(entity.imageSlots?.[0]?.dataBlockId),
|
||||
[entity.imageSlots],
|
||||
);
|
||||
const anisotropy = useAnisotropy();
|
||||
|
||||
|
|
|
|||
|
|
@ -42,14 +42,16 @@ function mutateRenderFields(
|
|||
renderEntity: GameEntity,
|
||||
stream: StreamEntity,
|
||||
): void {
|
||||
// Fields common to all positioned entities.
|
||||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.mountObjectId = stream.mountObjectId;
|
||||
e.mountNode = stream.mountNode;
|
||||
e.imageSlots = stream.imageSlots;
|
||||
|
||||
switch (renderEntity.renderType) {
|
||||
case "Player": {
|
||||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.threads = stream.threads;
|
||||
e.weaponShape = stream.weaponShape;
|
||||
e.armAction = stream.armAction;
|
||||
e.packShape = stream.packShape;
|
||||
e.flagShape = stream.flagShape;
|
||||
e.falling = stream.falling;
|
||||
e.jetting = stream.jetting;
|
||||
e.weaponImageState = stream.weaponImageState;
|
||||
|
|
@ -63,10 +65,11 @@ function mutateRenderFields(
|
|||
break;
|
||||
}
|
||||
case "Shape": {
|
||||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.threads = stream.threads;
|
||||
e.damageState = stream.damageState;
|
||||
e.weaponShape = stream.weaponShape;
|
||||
e.fadeVal = stream.fadeVal;
|
||||
e.cloakLevel = stream.cloakLevel;
|
||||
e.armAction = stream.armAction;
|
||||
e.targetRenderFlags = stream.targetRenderFlags;
|
||||
e.iffColor = stream.iffColor;
|
||||
e.soundSlots = stream.soundSlots;
|
||||
|
|
@ -146,6 +149,7 @@ export function StreamingController({
|
|||
const engineStore = useEngineStoreApi();
|
||||
const { fov: userFov } = useSettings();
|
||||
const playbackClockRef = useRef(0);
|
||||
const lastSeekTimeRef = useRef(0);
|
||||
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const currentTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
const streamRef = useRef<StreamingPlayback | null>(
|
||||
|
|
@ -187,7 +191,7 @@ export function StreamingController({
|
|||
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
|
||||
(renderEntity.renderType !== "Player" &&
|
||||
hasShapeName &&
|
||||
getField(renderEntity, "weaponShape") !== entity.weaponShape);
|
||||
getField(renderEntity, "imageSlots") !== entity.imageSlots);
|
||||
|
||||
if (needsNewIdentity) {
|
||||
const prevHidden = renderEntity?.debugHidden;
|
||||
|
|
@ -196,7 +200,13 @@ export function StreamingController({
|
|||
map.set(entity.id, renderEntity);
|
||||
structuralChange = true;
|
||||
} else {
|
||||
// Detect mount state changes — EntityScene needs a store re-render
|
||||
// to re-evaluate mount relationships (portal rendering).
|
||||
const prevMount = renderEntity!.mountObjectId;
|
||||
mutateRenderFields(renderEntity!, entity);
|
||||
if (renderEntity!.mountObjectId !== prevMount) {
|
||||
structuralChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyframe update (mutable — position, rotation, velocity, etc.).
|
||||
|
|
@ -260,6 +270,7 @@ export function StreamingController({
|
|||
publishedSnapshotRef.current = null;
|
||||
resetStreamPlayback();
|
||||
playbackClockRef.current = 0;
|
||||
lastSeekTimeRef.current = 0;
|
||||
prevTickSnapshotRef.current = null;
|
||||
currentTickSnapshotRef.current = null;
|
||||
|
||||
|
|
@ -345,17 +356,10 @@ export function StreamingController({
|
|||
const storeState = engineStore.getState();
|
||||
const playback = storeState.playback;
|
||||
const isPlaying = playback.status === "playing";
|
||||
const requestedTimeSec = playback.timeMs / 1000;
|
||||
const externalSeekWhilePaused =
|
||||
!isPlaying &&
|
||||
Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
|
||||
const externalSeekWhilePlaying =
|
||||
isPlaying &&
|
||||
Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
|
||||
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
|
||||
const isSeeking = playback.seekTime !== lastSeekTimeRef.current;
|
||||
if (isSeeking) {
|
||||
// Sync stream cursor to UI/programmatic seek.
|
||||
playbackClockRef.current = requestedTimeSec;
|
||||
lastSeekTimeRef.current = playback.seekTime;
|
||||
playbackClockRef.current = playback.seekTime;
|
||||
}
|
||||
|
||||
// Advance the shared effect clock so all effect timers (particles,
|
||||
|
|
@ -522,7 +526,7 @@ export function StreamingController({
|
|||
continue;
|
||||
}
|
||||
}
|
||||
if (!entity?.position || entity.hidden) {
|
||||
if (!entity?.position || (entity.fadeVal === 0 && !entity.cloakLevel)) {
|
||||
child.visible = false;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -591,7 +595,11 @@ export function StreamingController({
|
|||
_orbitTarget.copy(targetGroup.position);
|
||||
// Torque orbits the target's render world-box center; player positions
|
||||
// in our stream are feet-level, so lift to an approximate center.
|
||||
if (orbitEntity?.type === "Player") {
|
||||
// For vehicles, use the datablock's cameraOffset (vertical Z offset
|
||||
// in Torque space = Y in Three.js).
|
||||
if (currentCamera.orbitOffset) {
|
||||
_orbitTarget.y += currentCamera.orbitOffset;
|
||||
} else if (orbitEntity?.type === "Player") {
|
||||
_orbitTarget.y += 1.0;
|
||||
}
|
||||
|
||||
|
|
@ -683,11 +691,6 @@ export function StreamingController({
|
|||
if (isPlaying && snapshot.exhausted) {
|
||||
storeState.setPlaybackStatus("paused");
|
||||
}
|
||||
|
||||
const timeMs = playbackClockRef.current * 1000;
|
||||
if (Math.abs(timeMs - playback.timeMs) > 0.5) {
|
||||
storeState.setPlaybackTime(timeMs);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const log = createLogger("TerrainBlock");
|
|||
import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
|
||||
import { loadTerrain } from "../loaders";
|
||||
import { uint16ToFloat32 } from "../arrayUtils";
|
||||
import { setupMask } from "../textureUtils";
|
||||
import { packMasksRGB } from "../textureUtils";
|
||||
import { TerrainTile, TerrainMaterial } from "./TerrainTile";
|
||||
import {
|
||||
createTerrainHeightSampler,
|
||||
|
|
@ -565,10 +565,11 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
// Visibility mask for pooled tiles - all visible (no empty squares)
|
||||
// This is a stable reference shared by all pooled tiles
|
||||
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
|
||||
// Shared alpha textures from terrain alphaMaps - created once for all tiles
|
||||
const sharedAlphaTextures = useMemo(() => {
|
||||
// Pack alpha masks into RGB textures (3 masks per texture) to reduce
|
||||
// sampler count. 6 layers → 2 RGB textures instead of 6 R textures.
|
||||
const packedAlphaTextures = useMemo(() => {
|
||||
if (!terrain) return null;
|
||||
return terrain.alphaMaps.map((data) => setupMask(data));
|
||||
return packMasksRGB(terrain.alphaMaps);
|
||||
}, [terrain]);
|
||||
// Calculate the maximum number of tiles that can be visible at once.
|
||||
const poolSize = useMemo(() => {
|
||||
|
|
@ -636,14 +637,14 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
!terrain ||
|
||||
!sharedGeometry ||
|
||||
!sharedDisplacementMap ||
|
||||
!sharedAlphaTextures
|
||||
!packedAlphaTextures
|
||||
) {
|
||||
log.debug(
|
||||
"Not ready: terrain=%s geometry=%s displacement=%s alpha=%s",
|
||||
!!terrain,
|
||||
!!sharedGeometry,
|
||||
!!sharedDisplacementMap,
|
||||
!!sharedAlphaTextures,
|
||||
!!packedAlphaTextures,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -659,7 +660,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
geometry={sharedGeometry}
|
||||
displacementMap={sharedDisplacementMap}
|
||||
visibilityMask={primaryVisibilityMask}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
alphaTextures={packedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap ?? undefined}
|
||||
/>
|
||||
|
|
@ -677,7 +678,7 @@ export const TerrainBlock = memo(function TerrainBlock({
|
|||
displacementMap={sharedDisplacementMap}
|
||||
visibilityMask={pooledVisibilityMask}
|
||||
textureNames={terrain.textureNames}
|
||||
alphaTextures={sharedAlphaTextures}
|
||||
alphaTextures={packedAlphaTextures}
|
||||
detailTextureName={detailTexture}
|
||||
lightmap={terrainLightmap ?? undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -147,14 +147,13 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
|
|||
// Key for shader structure changes (detail texture, lightmap)
|
||||
const materialKey = `${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
|
||||
|
||||
// Displacement is done on CPU, so no displacementMap needed
|
||||
// We keep 'map' to provide UV coordinates for shader (vMapUv)
|
||||
// Use MeshLambertMaterial for compatibility with shadow maps
|
||||
// Displacement is done on CPU — no displacementMap or map needed.
|
||||
// UVs are provided by vTerrainUv (injected in the vertex shader),
|
||||
// avoiding an unused `map` sampler that would waste a texture unit.
|
||||
return (
|
||||
<meshLambertMaterial
|
||||
ref={materialRef}
|
||||
key={materialKey}
|
||||
map={displacementMap}
|
||||
depthWrite
|
||||
side={FrontSide}
|
||||
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,9 @@ function createSwapAtlas(
|
|||
tex.wrapS = tex.wrapT = RepeatWrapping;
|
||||
tex.colorSpace = SRGBColorSpace;
|
||||
tex.flipY = false;
|
||||
tex.needsUpdate = true;
|
||||
if (tex.image) {
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
texture: uniqueTextures[0],
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ export function useIsPlaying(): boolean {
|
|||
return useEngineSelector((state) => state.playback.status === "playing");
|
||||
}
|
||||
|
||||
/** Playback time for UI display, floored to whole seconds. The selector
|
||||
* evaluates on every store mutation but only triggers a re-render when
|
||||
* the displayed second changes (~1/s). */
|
||||
export function useCurrentTime(): number {
|
||||
return useEngineSelector((state) => state.playback.timeMs / 1000);
|
||||
return useEngineSelector((state) =>
|
||||
Math.floor(
|
||||
state.playback.streamSnapshot?.timeSec ?? state.playback.seekTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useDuration(): number {
|
||||
|
|
@ -30,7 +37,7 @@ export function usePlaybackActions() {
|
|||
const setPlaybackStatus = useEngineSelector(
|
||||
(state) => state.setPlaybackStatus,
|
||||
);
|
||||
const setPlaybackTime = useEngineSelector((state) => state.setPlaybackTime);
|
||||
const seekPlayback = useEngineSelector((state) => state.seekPlayback);
|
||||
const setPlaybackRate = useEngineSelector((state) => state.setPlaybackRate);
|
||||
|
||||
const setRec = useCallback(
|
||||
|
|
@ -50,10 +57,10 @@ export function usePlaybackActions() {
|
|||
}, [setPlaybackStatus]);
|
||||
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
setPlaybackTime(time * 1000);
|
||||
(timeSec: number) => {
|
||||
seekPlayback(timeSec);
|
||||
},
|
||||
[setPlaybackTime],
|
||||
[seekPlayback],
|
||||
);
|
||||
|
||||
const setSpeed = useCallback(
|
||||
|
|
|
|||
|
|
@ -29164,10 +29164,6 @@
|
|||
"missions/TrueGrit.mis",
|
||||
["z_mappacks/TWL_T2arenaOfficialMaps.vl2"]
|
||||
],
|
||||
"missions/tst_spheremap.mis": [
|
||||
"missions/TST_SphereMap.mis",
|
||||
["z_mappacks/zDMP-4.7.3DX.vl2"]
|
||||
],
|
||||
"missions/tusklt.mis": [
|
||||
"missions/TuskLT.mis",
|
||||
["z_mappacks/z_DMP2-V0.6.vl2"]
|
||||
|
|
@ -30843,7 +30839,6 @@
|
|||
["z_mappacks/z_DMP2-V0.6.vl2"]
|
||||
],
|
||||
"shapes/chaingun_shot.dts": ["shapes/chaingun_shot.dts", ["shapes.vl2"]],
|
||||
"shapes/chrometest.dts": ["shapes/chromeTest.dts", ["shapes.vl2"]],
|
||||
"shapes/debris_generic.dts": ["shapes/debris_generic.dts", ["shapes.vl2"]],
|
||||
"shapes/debris_generic_small.dts": [
|
||||
"shapes/debris_generic_small.dts",
|
||||
|
|
@ -61282,11 +61277,6 @@
|
|||
"displayName": "True Grit",
|
||||
"missionTypes": ["Arena"]
|
||||
},
|
||||
"TST_SphereMap": {
|
||||
"resourcePath": "missions/tst_spheremap.mis",
|
||||
"displayName": "TST-SphereMap",
|
||||
"missionTypes": ["CTF"]
|
||||
},
|
||||
"TuskLT": {
|
||||
"resourcePath": "missions/tusklt.mis",
|
||||
"displayName": "DMP2-Tusk LT",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
import type {
|
||||
TerrainBlockGhostData,
|
||||
InteriorInstanceGhostData,
|
||||
TSStaticGhostData,
|
||||
SkyGhostData,
|
||||
SunGhostData,
|
||||
MissionAreaGhostData,
|
||||
WaterBlockGhostData,
|
||||
ParsedData,
|
||||
AffineTransform,
|
||||
MatrixF as ParserMatrixF,
|
||||
} from "t2-demo-parser";
|
||||
import type {
|
||||
SceneTerrainBlock,
|
||||
SceneInteriorInstance,
|
||||
|
|
@ -16,147 +28,121 @@ import { createLogger } from "../logger";
|
|||
|
||||
const log = createLogger("ghostToScene");
|
||||
|
||||
type GhostData = Record<string, unknown>;
|
||||
const DEFAULT_VEC3: Vec3 = { x: 0, y: 0, z: 0 };
|
||||
const UNIT_SCALE: Vec3 = { x: 1, y: 1, z: 1 };
|
||||
|
||||
function vec3(v: unknown, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 {
|
||||
if (v && typeof v === "object" && "x" in v) return v as Vec3;
|
||||
return fallback;
|
||||
function color3Or(v: Color3 | undefined, fallback: Color3): Color3 {
|
||||
return v ?? fallback;
|
||||
}
|
||||
|
||||
function color3(v: unknown, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 {
|
||||
if (v && typeof v === "object" && "r" in v) return v as Color3;
|
||||
return fallback;
|
||||
function color4Or(v: Color4 | undefined, fallback: Color4): Color4 {
|
||||
return v ?? fallback;
|
||||
}
|
||||
|
||||
function color4(
|
||||
v: unknown,
|
||||
fallback: Color4 = { r: 0.5, g: 0.5, b: 0.5, a: 1 },
|
||||
): Color4 {
|
||||
if (v && typeof v === "object" && "r" in v) return v as Color4;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function matrixF(v: unknown): MatrixF {
|
||||
if (
|
||||
v &&
|
||||
typeof v === "object" &&
|
||||
"elements" in v &&
|
||||
Array.isArray((v as any).elements)
|
||||
) {
|
||||
return v as MatrixF;
|
||||
}
|
||||
// readAffineTransform() returns {position, rotation} — convert to MatrixF.
|
||||
if (v && typeof v === "object" && "position" in v && "rotation" in v) {
|
||||
const { position: pos, rotation: q } = v as {
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { x: number; y: number; z: number; w: number };
|
||||
};
|
||||
// Quaternion to column-major 4×4 matrix (idx = row + col*4).
|
||||
const xx = q.x * q.x,
|
||||
yy = q.y * q.y,
|
||||
zz = q.z * q.z;
|
||||
const xy = q.x * q.y,
|
||||
xz = q.x * q.z,
|
||||
yz = q.y * q.z;
|
||||
const wx = q.w * q.x,
|
||||
wy = q.w * q.y,
|
||||
wz = q.w * q.z;
|
||||
/**
|
||||
* Convert a parser transform (MatrixF or AffineTransform) to the scene's
|
||||
* MatrixF format. The parser may emit either depending on the ghost class.
|
||||
*/
|
||||
function toMatrixF(v: ParserMatrixF | AffineTransform | undefined): MatrixF {
|
||||
if (!v) {
|
||||
return {
|
||||
elements: [
|
||||
1 - 2 * (yy + zz),
|
||||
2 * (xy + wz),
|
||||
2 * (xz - wy),
|
||||
0,
|
||||
2 * (xy - wz),
|
||||
1 - 2 * (xx + zz),
|
||||
2 * (yz + wx),
|
||||
0,
|
||||
2 * (xz + wy),
|
||||
2 * (yz - wx),
|
||||
1 - 2 * (xx + yy),
|
||||
0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
1,
|
||||
],
|
||||
position: { x: pos.x, y: pos.y, z: pos.z },
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
position: DEFAULT_VEC3,
|
||||
};
|
||||
}
|
||||
// MatrixF: has elements array
|
||||
if ("elements" in v) {
|
||||
return v;
|
||||
}
|
||||
// AffineTransform: has position + rotation quaternion
|
||||
const { position: pos, rotation: q } = v;
|
||||
const xx = q.x * q.x,
|
||||
yy = q.y * q.y,
|
||||
zz = q.z * q.z;
|
||||
const xy = q.x * q.y,
|
||||
xz = q.x * q.z,
|
||||
yz = q.y * q.z;
|
||||
const wx = q.w * q.x,
|
||||
wy = q.w * q.y,
|
||||
wz = q.w * q.z;
|
||||
return {
|
||||
elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
elements: [
|
||||
1 - 2 * (yy + zz),
|
||||
2 * (xy + wz),
|
||||
2 * (xz - wy),
|
||||
0,
|
||||
2 * (xy - wz),
|
||||
1 - 2 * (xx + zz),
|
||||
2 * (yz + wx),
|
||||
0,
|
||||
2 * (xz + wy),
|
||||
2 * (yz - wx),
|
||||
1 - 2 * (xx + yy),
|
||||
0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
1,
|
||||
],
|
||||
position: { x: pos.x, y: pos.y, z: pos.z },
|
||||
};
|
||||
}
|
||||
|
||||
export function terrainFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: TerrainBlockGhostData,
|
||||
): SceneTerrainBlock {
|
||||
return {
|
||||
className: "TerrainBlock",
|
||||
ghostIndex,
|
||||
terrFileName: (data.terrFileName as string) ?? "",
|
||||
detailTextureName: (data.detailTextureName as string) ?? "",
|
||||
squareSize: (data.squareSize as number) ?? 8,
|
||||
emptySquareRuns: data.emptySquareRuns as number[] | undefined,
|
||||
terrFileName: data.terrFileName ?? "",
|
||||
detailTextureName: data.detailTextureName ?? "",
|
||||
squareSize: data.squareSize ?? 8,
|
||||
emptySquareRuns: data.emptySquareRuns,
|
||||
};
|
||||
}
|
||||
|
||||
export function interiorFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: InteriorInstanceGhostData,
|
||||
): SceneInteriorInstance {
|
||||
return {
|
||||
className: "InteriorInstance",
|
||||
ghostIndex,
|
||||
interiorFile: (data.interiorFile as string) ?? "",
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
showTerrainInside: (data.showTerrainInside as boolean) ?? false,
|
||||
skinBase: (data.skinBase as string) ?? "",
|
||||
alarmState: (data.alarmState as boolean) ?? false,
|
||||
interiorFile: data.interiorFile ?? "",
|
||||
transform: toMatrixF(data.transform),
|
||||
scale: data.scale ?? UNIT_SCALE,
|
||||
showTerrainInside: data.showTerrainInside ?? false,
|
||||
skinBase: data.skinBase ?? "",
|
||||
alarmState: data.alarmState ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function tsStaticFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: TSStaticGhostData,
|
||||
): SceneTSStatic {
|
||||
return {
|
||||
className: "TSStatic",
|
||||
ghostIndex,
|
||||
shapeName: (data.shapeName as string) ?? "",
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
shapeName: data.shapeName ?? "",
|
||||
transform: toMatrixF(data.transform),
|
||||
scale: data.scale ?? UNIT_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
|
||||
const fogVolumes = Array.isArray(data.fogVolumes)
|
||||
? (
|
||||
data.fogVolumes as Array<{
|
||||
visibleDistance?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
color?: Color3;
|
||||
}>
|
||||
).map((v) => ({
|
||||
export function skyFromGhost(ghostIndex: number, data: SkyGhostData): SceneSky {
|
||||
const fogVolumes = data.fogVolumes
|
||||
? data.fogVolumes.map((v) => ({
|
||||
visibleDistance: v.visibleDistance ?? 0,
|
||||
minHeight: v.minHeight ?? 0,
|
||||
maxHeight: v.maxHeight ?? 0,
|
||||
color: color3(v.color),
|
||||
color: color3Or(v.color, { r: 0, g: 0, b: 0 }),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const cloudLayers = Array.isArray(data.cloudLayers)
|
||||
? (
|
||||
data.cloudLayers as Array<{
|
||||
texture?: string;
|
||||
heightPercent?: number;
|
||||
speed?: number;
|
||||
}>
|
||||
).map((c) => ({
|
||||
const cloudLayers = data.cloudLayers
|
||||
? data.cloudLayers.map((c) => ({
|
||||
texture: c.texture ?? "",
|
||||
heightPercent: c.heightPercent ?? 0,
|
||||
speed: c.speed ?? 0,
|
||||
|
|
@ -166,61 +152,56 @@ export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky {
|
|||
return {
|
||||
className: "Sky",
|
||||
ghostIndex,
|
||||
materialList: (data.materialList as string) ?? "",
|
||||
fogColor: color3(data.fogColor),
|
||||
visibleDistance: (data.visibleDistance as number) ?? 1000,
|
||||
fogDistance: (data.fogDistance as number) ?? 0,
|
||||
skySolidColor: color3(data.skySolidColor),
|
||||
useSkyTextures: (data.useSkyTextures as boolean) ?? true,
|
||||
materialList: data.materialList ?? "",
|
||||
fogColor: color3Or(data.fogColor, { r: 0, g: 0, b: 0 }),
|
||||
visibleDistance: data.visibleDistance ?? 1000,
|
||||
fogDistance: data.fogDistance ?? 0,
|
||||
skySolidColor: color3Or(data.skySolidColor, { r: 0, g: 0, b: 0 }),
|
||||
useSkyTextures: data.useSkyTextures ?? true,
|
||||
fogVolumes,
|
||||
cloudLayers,
|
||||
windVelocity: vec3(data.windVelocity),
|
||||
windVelocity: data.windVelocity ?? DEFAULT_VEC3,
|
||||
};
|
||||
}
|
||||
|
||||
export function sunFromGhost(ghostIndex: number, data: GhostData): SceneSun {
|
||||
export function sunFromGhost(ghostIndex: number, data: SunGhostData): SceneSun {
|
||||
return {
|
||||
className: "Sun",
|
||||
ghostIndex,
|
||||
direction: vec3(data.direction, { x: 0.57735, y: 0.57735, z: -0.57735 }),
|
||||
color: color4(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
|
||||
ambient: color4(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
|
||||
textures: Array.isArray(data.textures)
|
||||
? (data.textures as string[])
|
||||
: undefined,
|
||||
direction: data.direction ?? { x: 0.57735, y: 0.57735, z: -0.57735 },
|
||||
color: color4Or(data.color, { r: 0.7, g: 0.7, b: 0.7, a: 1 }),
|
||||
ambient: color4Or(data.ambient, { r: 0.5, g: 0.5, b: 0.5, a: 1 }),
|
||||
textures: data.textures,
|
||||
};
|
||||
}
|
||||
|
||||
export function missionAreaFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: MissionAreaGhostData,
|
||||
): SceneMissionArea {
|
||||
const area = data.area as
|
||||
| { x: number; y: number; w: number; h: number }
|
||||
| undefined;
|
||||
return {
|
||||
className: "MissionArea",
|
||||
ghostIndex,
|
||||
area: area ?? { x: -512, y: -512, w: 1024, h: 1024 },
|
||||
flightCeiling: (data.flightCeiling as number) ?? 2000,
|
||||
flightCeilingRange: (data.flightCeilingRange as number) ?? 50,
|
||||
area: data.area ?? { x: -512, y: -512, w: 1024, h: 1024 },
|
||||
flightCeiling: data.flightCeiling ?? 2000,
|
||||
flightCeilingRange: data.flightCeilingRange ?? 50,
|
||||
};
|
||||
}
|
||||
|
||||
export function waterBlockFromGhost(
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: WaterBlockGhostData,
|
||||
): SceneWaterBlock {
|
||||
return {
|
||||
className: "WaterBlock",
|
||||
ghostIndex,
|
||||
transform: matrixF(data.transform),
|
||||
scale: vec3(data.scale, { x: 1, y: 1, z: 1 }),
|
||||
surfaceName: (data.surfaceName as string) ?? "",
|
||||
envMapName: (data.envMapName as string) ?? "",
|
||||
surfaceOpacity: (data.surfaceOpacity as number) ?? 0.75,
|
||||
waveMagnitude: (data.waveMagnitude as number) ?? 1.0,
|
||||
envMapIntensity: (data.envMapIntensity as number) ?? 1.0,
|
||||
transform: toMatrixF(data.transform),
|
||||
scale: data.scale ?? UNIT_SCALE,
|
||||
surfaceName: data.surfaceName ?? "",
|
||||
envMapName: data.envMapName ?? "",
|
||||
surfaceOpacity: data.surfaceOpacity ?? 0.75,
|
||||
waveMagnitude: data.waveMagnitude ?? 1.0,
|
||||
envMapIntensity: data.envMapIntensity ?? 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -228,66 +209,71 @@ export function waterBlockFromGhost(
|
|||
export function ghostToSceneObject(
|
||||
className: string,
|
||||
ghostIndex: number,
|
||||
data: GhostData,
|
||||
data: ParsedData,
|
||||
): SceneObject | null {
|
||||
let result: SceneObject | null;
|
||||
switch (className) {
|
||||
case "TerrainBlock":
|
||||
result = terrainFromGhost(ghostIndex, data);
|
||||
case "TerrainBlock": {
|
||||
const result = terrainFromGhost(
|
||||
ghostIndex,
|
||||
data as TerrainBlockGhostData,
|
||||
);
|
||||
log.debug(
|
||||
"TerrainBlock #%d: terrFileName=%s",
|
||||
ghostIndex,
|
||||
(result as SceneTerrainBlock).terrFileName,
|
||||
result.terrFileName,
|
||||
);
|
||||
return result;
|
||||
case "InteriorInstance":
|
||||
result = interiorFromGhost(ghostIndex, data);
|
||||
}
|
||||
case "InteriorInstance": {
|
||||
const result = interiorFromGhost(
|
||||
ghostIndex,
|
||||
data as InteriorInstanceGhostData,
|
||||
);
|
||||
log.debug(
|
||||
"InteriorInstance #%d: interiorFile=%s",
|
||||
ghostIndex,
|
||||
(result as SceneInteriorInstance).interiorFile,
|
||||
result.interiorFile,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
case "TSStatic":
|
||||
return tsStaticFromGhost(ghostIndex, data);
|
||||
return tsStaticFromGhost(ghostIndex, data as TSStaticGhostData);
|
||||
case "Sky": {
|
||||
result = skyFromGhost(ghostIndex, data);
|
||||
const sky = result as SceneSky;
|
||||
const result = skyFromGhost(ghostIndex, data as SkyGhostData);
|
||||
log.debug(
|
||||
"Sky #%d: materialList=%s fogColor=(%s, %s, %s) visibleDist=%d fogDist=%d useSkyTextures=%s",
|
||||
ghostIndex,
|
||||
sky.materialList,
|
||||
sky.fogColor.r.toFixed(3),
|
||||
sky.fogColor.g.toFixed(3),
|
||||
sky.fogColor.b.toFixed(3),
|
||||
sky.visibleDistance,
|
||||
sky.fogDistance,
|
||||
sky.useSkyTextures,
|
||||
result.materialList,
|
||||
result.fogColor.r.toFixed(3),
|
||||
result.fogColor.g.toFixed(3),
|
||||
result.fogColor.b.toFixed(3),
|
||||
result.visibleDistance,
|
||||
result.fogDistance,
|
||||
result.useSkyTextures,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
case "Sun": {
|
||||
result = sunFromGhost(ghostIndex, data);
|
||||
const sun = result as SceneSun;
|
||||
const result = sunFromGhost(ghostIndex, data as SunGhostData);
|
||||
log.debug(
|
||||
"Sun #%d: dir=(%s, %s, %s) color=(%s, %s, %s) ambient=(%s, %s, %s)",
|
||||
ghostIndex,
|
||||
sun.direction.x.toFixed(3),
|
||||
sun.direction.y.toFixed(3),
|
||||
sun.direction.z.toFixed(3),
|
||||
sun.color.r.toFixed(3),
|
||||
sun.color.g.toFixed(3),
|
||||
sun.color.b.toFixed(3),
|
||||
sun.ambient.r.toFixed(3),
|
||||
sun.ambient.g.toFixed(3),
|
||||
sun.ambient.b.toFixed(3),
|
||||
result.direction.x.toFixed(3),
|
||||
result.direction.y.toFixed(3),
|
||||
result.direction.z.toFixed(3),
|
||||
result.color.r.toFixed(3),
|
||||
result.color.g.toFixed(3),
|
||||
result.color.b.toFixed(3),
|
||||
result.ambient.r.toFixed(3),
|
||||
result.ambient.g.toFixed(3),
|
||||
result.ambient.b.toFixed(3),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
case "MissionArea":
|
||||
return missionAreaFromGhost(ghostIndex, data);
|
||||
return missionAreaFromGhost(ghostIndex, data as MissionAreaGhostData);
|
||||
case "WaterBlock":
|
||||
return waterBlockFromGhost(ghostIndex, data);
|
||||
return waterBlockFromGhost(ghostIndex, data as WaterBlockGhostData);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ export interface RuntimeSliceState {
|
|||
export interface PlaybackSliceState {
|
||||
recording: StreamRecording | null;
|
||||
status: PlaybackStatus;
|
||||
timeMs: number;
|
||||
/** Seek target in seconds. Written by UI seek actions, read by
|
||||
* StreamingController to detect and execute seeks. */
|
||||
seekTime: number;
|
||||
rate: number;
|
||||
durationMs: number;
|
||||
streamSnapshot: StreamSnapshot | null;
|
||||
|
|
@ -48,7 +50,7 @@ export interface EngineStoreState {
|
|||
tickInfo?: RuntimeTickInfo,
|
||||
): void;
|
||||
setRecording(recording: StreamRecording | null): void;
|
||||
setPlaybackTime(ms: number): void;
|
||||
seekPlayback(timeSec: number): void;
|
||||
setPlaybackStatus(status: PlaybackStatus): void;
|
||||
setPlaybackRate(rate: number): void;
|
||||
setPlaybackStreamSnapshot(snapshot: StreamSnapshot | null): void;
|
||||
|
|
@ -111,7 +113,7 @@ const initialState: Omit<
|
|||
| "clearRuntime"
|
||||
| "applyRuntimeBatch"
|
||||
| "setRecording"
|
||||
| "setPlaybackTime"
|
||||
| "seekPlayback"
|
||||
| "setPlaybackStatus"
|
||||
| "setPlaybackRate"
|
||||
| "setPlaybackStreamSnapshot"
|
||||
|
|
@ -128,7 +130,7 @@ const initialState: Omit<
|
|||
playback: {
|
||||
recording: null,
|
||||
status: "stopped",
|
||||
timeMs: 0,
|
||||
seekTime: 0,
|
||||
rate: 1,
|
||||
durationMs: 0,
|
||||
streamSnapshot: null,
|
||||
|
|
@ -259,7 +261,7 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
playback: {
|
||||
recording,
|
||||
status: recording ? "stopped" : state.playback.status,
|
||||
timeMs: recording ? 0 : state.playback.timeMs,
|
||||
seekTime: recording ? 0 : state.playback.seekTime,
|
||||
rate: recording ? 1 : state.playback.rate,
|
||||
durationMs,
|
||||
// Preserve the last snapshot so HUD/chat persist after unload.
|
||||
|
|
@ -268,14 +270,14 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
}));
|
||||
},
|
||||
|
||||
setPlaybackTime(ms: number) {
|
||||
seekPlayback(timeSec: number) {
|
||||
set((state) => {
|
||||
const clamped = clamp(ms, 0, state.playback.durationMs);
|
||||
const clamped = clamp(timeSec, 0, state.playback.durationMs / 1000);
|
||||
return {
|
||||
...state,
|
||||
playback: {
|
||||
...state.playback,
|
||||
timeMs: clamped,
|
||||
seekTime: clamped,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
ImageSlot,
|
||||
Keyframe,
|
||||
ThreadState,
|
||||
TracerVisual,
|
||||
|
|
@ -53,6 +54,10 @@ interface EntityBase {
|
|||
missionTypesList?: string;
|
||||
/** Hidden via the debug entity list. */
|
||||
debugHidden?: boolean;
|
||||
/** Entity ID of the object this entity is mounted on (vehicle, etc.). */
|
||||
mountObjectId?: string;
|
||||
/** Mount point node index on the mount target (0 = pilot). */
|
||||
mountNode?: number;
|
||||
}
|
||||
|
||||
// ── Scene infrastructure entities ──
|
||||
|
|
@ -117,6 +122,8 @@ interface PositionedBase extends EntityBase {
|
|||
scale?: [number, number, number];
|
||||
velocity?: [number, number, number];
|
||||
keyframes?: Keyframe[];
|
||||
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
|
||||
imageSlots?: (ImageSlot | undefined)[];
|
||||
}
|
||||
|
||||
// ── Gameplay entities ──
|
||||
|
|
@ -127,8 +134,6 @@ export interface ShapeEntity extends PositionedBase {
|
|||
shapeName?: string;
|
||||
shapeType?: string;
|
||||
dataBlock?: string;
|
||||
/** 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[];
|
||||
|
|
@ -138,7 +143,6 @@ export interface ShapeEntity extends PositionedBase {
|
|||
teamId?: number;
|
||||
targetRenderFlags?: number;
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
weaponShape?: string;
|
||||
/** Arm blend animation action index from Player ghost (networked). */
|
||||
armAction?: number;
|
||||
/** WheeledVehicle per-wheel state (speed, slip). */
|
||||
|
|
@ -155,22 +159,18 @@ export interface ShapeEntity extends PositionedBase {
|
|||
maxSteeringAngle?: number;
|
||||
/** ShapeBase sound slots (from ghost SoundMask). */
|
||||
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
|
||||
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
|
||||
fadeVal?: number;
|
||||
/** Cloak level (0=visible, 1=fully cloaked). Used for cloak texture effect. */
|
||||
cloakLevel?: number;
|
||||
}
|
||||
|
||||
export interface PlayerEntity extends PositionedBase {
|
||||
renderType: "Player";
|
||||
shapeName?: string;
|
||||
dataBlock?: string;
|
||||
/** 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;
|
||||
packShape?: string;
|
||||
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
|
||||
flagShape?: string;
|
||||
falling?: boolean;
|
||||
jetting?: boolean;
|
||||
playerName?: string;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type {
|
|||
BackpackHudState,
|
||||
ChatSegment,
|
||||
ChatMessage,
|
||||
ImageSlot,
|
||||
ThreadState,
|
||||
StreamVisual,
|
||||
StreamCamera,
|
||||
|
|
@ -54,6 +55,18 @@ import type {
|
|||
WeaponImageState,
|
||||
WeaponImageDataBlockState,
|
||||
} from "./types";
|
||||
import type {
|
||||
ParsedData,
|
||||
GhostAlwaysObjectEventData,
|
||||
NetStringEventData,
|
||||
TargetInfoEventData,
|
||||
SetSensorGroupEventData,
|
||||
SensorGroupColorEventData,
|
||||
RemoteCommandEventData,
|
||||
Sim3DAudioEventData,
|
||||
Sim2DAudioEventData,
|
||||
GhostingMessageEventData,
|
||||
} from "t2-demo-parser";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const log = createLogger("StreamEngine");
|
||||
|
|
@ -74,9 +87,8 @@ export interface MutableEntity {
|
|||
dataBlock?: string;
|
||||
visual?: StreamVisual;
|
||||
direction?: [number, number, number];
|
||||
weaponShape?: string;
|
||||
/** Datablock IDs for each mounted image slot (0-3). */
|
||||
imageDataBlockIds?: (number | undefined)[];
|
||||
/** Mounted image slots (0-7). Each has shape, mount bone from datablock. */
|
||||
imageSlots?: (ImageSlot | undefined)[];
|
||||
playerName?: string;
|
||||
/** Player skin (team skin like "base", "baseb"). */
|
||||
skinName?: string;
|
||||
|
|
@ -109,22 +121,29 @@ export interface MutableEntity {
|
|||
weaponImageState?: WeaponImageState;
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
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;
|
||||
/** Ghost index of the object this entity is mounted on (vehicle, etc.).
|
||||
* When set, the entity's rendered position is derived from the mount
|
||||
* target's transform, not from its own ghost position. */
|
||||
mountObjectGhostIndex?: number;
|
||||
/** Mount point node on the mount target (0 = pilot, 1+ = passenger/turret). */
|
||||
mountNode?: number;
|
||||
/** 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;
|
||||
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
|
||||
fadeVal?: number;
|
||||
/** Active fade animation state. Set when CloakMask fading=true. */
|
||||
fadeState?: { fadeOut: boolean; fadeTime: number; elapsed: number };
|
||||
/** Whether the cloak is active (mCloaked). */
|
||||
cloaked?: boolean;
|
||||
/** Cloak level (0=visible, 1=fully cloaked). Interpolated client-side
|
||||
* at rate dt*2 per tick (0.5s transition). Binary-verified. */
|
||||
cloakLevel?: number;
|
||||
/** Item mStatic flag (from InitialUpdateMask). Static items (flags at
|
||||
* flagstand) skip all physics in Item::processTick. */
|
||||
isStaticItem?: boolean;
|
||||
|
|
@ -172,7 +191,7 @@ export interface MutableEntity {
|
|||
|
||||
export type RuntimeControlObject = {
|
||||
ghostIndex: number;
|
||||
data?: Record<string, unknown>;
|
||||
data?: ParsedData;
|
||||
position?: Vec3;
|
||||
};
|
||||
|
||||
|
|
@ -299,7 +318,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// ── Abstract methods ──
|
||||
|
||||
/** Resolve datablock data by numeric ID. */
|
||||
abstract getDataBlockData(id: number): Record<string, unknown> | undefined;
|
||||
abstract getDataBlockData(id: number): ParsedData | undefined;
|
||||
|
||||
/** Get TSShapeConstructor sequence entries for a shape name. */
|
||||
abstract getShapeConstructorSequences(
|
||||
|
|
@ -313,9 +332,10 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
* Get camera yaw/pitch for this tick. Demo accumulates from move deltas;
|
||||
* live reads from server-provided rotation.
|
||||
*/
|
||||
protected abstract getCameraYawPitch(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number };
|
||||
protected abstract getCameraYawPitch(data: ParsedData | undefined): {
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
};
|
||||
|
||||
/** DTS shape names for weapon effects that should be preloaded. */
|
||||
abstract getEffectShapes(): string[];
|
||||
|
|
@ -457,7 +477,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
|
||||
protected processControlObject(gameState: {
|
||||
controlObjectGhostIndex?: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
controlObjectData?: ParsedData;
|
||||
compressionPoint?: Vec3;
|
||||
cameraFov?: number;
|
||||
}): void {
|
||||
|
|
@ -468,11 +488,16 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
? gameState.controlObjectGhostIndex
|
||||
: prevControl.ghostIndex;
|
||||
const compressionPoint = gameState.compressionPoint;
|
||||
const controlPosition = isValidPosition(controlData?.position as Vec3)
|
||||
? (controlData?.position as Vec3)
|
||||
: isValidPosition(compressionPoint)
|
||||
? compressionPoint
|
||||
: prevControl.position;
|
||||
// When piloting a vehicle, the player's writePacketData skips position
|
||||
// (binary-verified: mounted check at Player::writePacketData). The
|
||||
// controlData.position may be (0,0,0) or absent. Use compressionPoint
|
||||
// which is the vehicle position, updated every packet.
|
||||
const controlPosition =
|
||||
!this.isPiloting && isValidPosition(controlData?.position as Vec3)
|
||||
? (controlData?.position as Vec3)
|
||||
: isValidPosition(compressionPoint)
|
||||
? compressionPoint
|
||||
: prevControl.position;
|
||||
|
||||
this.latestControl = {
|
||||
ghostIndex: nextGhostIndex,
|
||||
|
|
@ -550,7 +575,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// ── Event processing ──
|
||||
|
||||
protected processEvent(
|
||||
event: { classId: number; parsedData?: Record<string, unknown> },
|
||||
event: { classId: number; parsedData?: ParsedData },
|
||||
eventName: string | undefined,
|
||||
): void {
|
||||
const data = event.parsedData;
|
||||
|
|
@ -561,10 +586,11 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// These arrive as events, not ghost updates, but contain full ghost data.
|
||||
// Create entities from them so scene infrastructure renders.
|
||||
if (type === "GhostAlwaysObjectEvent") {
|
||||
const ghostIndex = data.ghostIndex as number | undefined;
|
||||
const classId = data.classId as number | undefined;
|
||||
const objectData = data.objectData as Record<string, unknown> | undefined;
|
||||
const hasData = data._hasObjectData as boolean | undefined;
|
||||
const evt = data as GhostAlwaysObjectEventData;
|
||||
const ghostIndex = evt.ghostIndex;
|
||||
const classId = evt.classId;
|
||||
const objectData = evt.objectData;
|
||||
const hasData = evt._hasObjectData;
|
||||
const className =
|
||||
typeof classId === "number"
|
||||
? (this.registry.getGhostParser(classId)?.name ??
|
||||
|
|
@ -591,8 +617,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
|
||||
if (type === "NetStringEvent" || eventName === "NetStringEvent") {
|
||||
const id = data.id as number;
|
||||
const value = data.value as string;
|
||||
const evt = data as NetStringEventData;
|
||||
const id = evt.id;
|
||||
const value = evt.value;
|
||||
if (id != null && typeof value === "string") {
|
||||
this.netStrings.set(id, value);
|
||||
// Resolve any TargetInfoEvents that were waiting for this string.
|
||||
|
|
@ -612,8 +639,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
|
||||
if (type === "TargetInfoEvent" || eventName === "TargetInfoEvent") {
|
||||
const targetId = data.targetId as number | undefined;
|
||||
const nameTag = data.nameTag as number | undefined;
|
||||
const evt = data as TargetInfoEventData;
|
||||
const targetId = evt.targetId;
|
||||
const nameTag = evt.nameTag;
|
||||
if (targetId != null && nameTag != null) {
|
||||
const resolved = this.netStrings.get(nameTag);
|
||||
if (resolved) {
|
||||
|
|
@ -626,21 +654,21 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.pendingNameTags.set(nameTag, targetId);
|
||||
}
|
||||
}
|
||||
const sensorGroup = data.sensorGroup as number | undefined;
|
||||
const sensorGroup = evt.sensorGroup;
|
||||
if (targetId != null && sensorGroup != null) {
|
||||
this.targetTeams.set(targetId, sensorGroup);
|
||||
}
|
||||
const renderFlags = data.renderFlags as number | undefined;
|
||||
const renderFlags = evt.renderFlags;
|
||||
if (targetId != null && renderFlags != null) {
|
||||
this.targetRenderFlags.set(targetId, renderFlags);
|
||||
}
|
||||
// Skin tags — resolve via net string table.
|
||||
const skinTag = data.skinTag as number | undefined;
|
||||
const skinTag = evt.skinTag;
|
||||
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;
|
||||
const skinPrefTag = evt.skinPrefTag;
|
||||
if (targetId != null && skinPrefTag != null && skinPrefTag !== 0x400) {
|
||||
const resolved = this.netStrings.get(skinPrefTag);
|
||||
if (resolved) this.targetSkinPrefs.set(targetId, resolved);
|
||||
|
|
@ -666,7 +694,8 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
|
||||
if (type === "SetSensorGroupEvent" || eventName === "SetSensorGroupEvent") {
|
||||
const sg = data.sensorGroup as number | undefined;
|
||||
const evt = data as SetSensorGroupEventData;
|
||||
const sg = evt.sensorGroup;
|
||||
if (sg != null) this.playerSensorGroup = sg;
|
||||
return;
|
||||
}
|
||||
|
|
@ -675,16 +704,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
type === "SensorGroupColorEvent" ||
|
||||
eventName === "SensorGroupColorEvent"
|
||||
) {
|
||||
const sg = data.sensorGroup as number;
|
||||
const colors = data.colors as
|
||||
| Array<{
|
||||
index: number;
|
||||
r?: number;
|
||||
g?: number;
|
||||
b?: number;
|
||||
default?: boolean;
|
||||
}>
|
||||
| undefined;
|
||||
const evt = data as SensorGroupColorEventData;
|
||||
const sg = evt.sensorGroup;
|
||||
const colors = evt.colors;
|
||||
if (colors) {
|
||||
let map = this.sensorGroupColors.get(sg);
|
||||
if (!map) {
|
||||
|
|
@ -705,15 +727,17 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// EndGhosting (message=2): the server called resetGhosting — all ghosts
|
||||
// are invalidated. The parser clears the ghost tracker; we clear entities.
|
||||
if (type === "GhostingMessageEvent") {
|
||||
if ((data.message as number) === GhostMessage.EndGhosting) {
|
||||
const evt = data as GhostingMessageEventData;
|
||||
if (evt.message === GhostMessage.EndGhosting) {
|
||||
this.clearAllEntities();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "RemoteCommandEvent" || eventName === "RemoteCommandEvent") {
|
||||
const funcName = this.resolveNetString(data.funcName as string);
|
||||
const args = data.args as string[];
|
||||
const evt = data as RemoteCommandEventData;
|
||||
const funcName = this.resolveNetString(evt.funcName);
|
||||
const args = evt.args;
|
||||
const timeSec = this.getTimeSec();
|
||||
|
||||
if (funcName === "ChatMessage" && args.length >= 4) {
|
||||
|
|
@ -825,12 +849,17 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
eventName === "Sim3DAudioEvent" ||
|
||||
eventName === "Sim2DAudioEvent"
|
||||
) {
|
||||
const profileId = data.profileId as number;
|
||||
const is3D =
|
||||
type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent";
|
||||
const evt = is3D
|
||||
? (data as Sim3DAudioEventData)
|
||||
: (data as Sim2DAudioEventData);
|
||||
const profileId = evt.profileId;
|
||||
if (typeof profileId === "number") {
|
||||
const timeSec = this.getTimeSec();
|
||||
const is3D =
|
||||
type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent";
|
||||
const position = is3D ? (data.position as Vec3 | undefined) : undefined;
|
||||
const position = is3D
|
||||
? (data as Sim3DAudioEventData).position
|
||||
: undefined;
|
||||
this.audioEvents.push({ profileId, position, timeSec });
|
||||
if (this.audioEvents.length > 100) {
|
||||
this.audioEvents.splice(0, this.audioEvents.length - 100);
|
||||
|
|
@ -845,7 +874,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}): void {
|
||||
const ghostIndex = ghost.index;
|
||||
const prevEntityId = this.entityIdByGhostIndex.get(ghostIndex);
|
||||
|
|
@ -926,7 +955,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
const sceneObj = ghostToSceneObject(
|
||||
className,
|
||||
ghostIndex,
|
||||
ghost.parsedData as Record<string, unknown>,
|
||||
ghost.parsedData as ParsedData,
|
||||
);
|
||||
if (sceneObj) entity.sceneData = sceneObj;
|
||||
}
|
||||
|
|
@ -952,11 +981,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.targetRenderFlags = undefined;
|
||||
entity.sensorGroup = undefined;
|
||||
entity.playerName = undefined;
|
||||
entity.weaponShape = undefined;
|
||||
entity.imageDataBlockIds = undefined;
|
||||
entity.packShape = undefined;
|
||||
entity.flagShape = undefined;
|
||||
entity.imageSkinNames = undefined;
|
||||
entity.imageSlots = undefined;
|
||||
entity.mountObjectGhostIndex = undefined;
|
||||
entity.mountNode = undefined;
|
||||
entity.skinName = undefined;
|
||||
entity.skinPrefName = undefined;
|
||||
entity.falling = undefined;
|
||||
|
|
@ -972,7 +999,10 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.energy = undefined;
|
||||
entity.maxEnergy = undefined;
|
||||
entity.damageState = undefined;
|
||||
entity.hidden = undefined;
|
||||
entity.fadeVal = undefined;
|
||||
entity.fadeState = undefined;
|
||||
entity.cloaked = undefined;
|
||||
entity.cloakLevel = undefined;
|
||||
entity.actionAnim = undefined;
|
||||
entity.actionAtEnd = undefined;
|
||||
entity.armAction = undefined;
|
||||
|
|
@ -986,7 +1016,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
|
||||
protected applyGhostData(
|
||||
entity: MutableEntity,
|
||||
rawData: Record<string, unknown> | undefined,
|
||||
rawData: ParsedData | undefined,
|
||||
): void {
|
||||
if (!rawData) return;
|
||||
const data = rawData;
|
||||
|
|
@ -1123,106 +1153,93 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}>
|
||||
| undefined;
|
||||
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) {
|
||||
entity.weaponShape = weaponShape;
|
||||
}
|
||||
if (!entity.imageDataBlockIds) entity.imageDataBlockIds = [];
|
||||
entity.imageDataBlockIds[0] = weaponImage.dataBlockId;
|
||||
|
||||
const prev = entity.weaponImageState;
|
||||
entity.weaponImageState = {
|
||||
dataBlockId: weaponImage.dataBlockId,
|
||||
triggerDown: weaponImage.triggerDown ?? prev?.triggerDown ?? false,
|
||||
ammo: weaponImage.ammo ?? prev?.ammo ?? true,
|
||||
loaded: weaponImage.loaded ?? prev?.loaded ?? true,
|
||||
target: weaponImage.target ?? prev?.target ?? false,
|
||||
wet: weaponImage.wet ?? prev?.wet ?? false,
|
||||
fireCount: weaponImage.fireCount ?? prev?.fireCount ?? 0,
|
||||
};
|
||||
|
||||
if (
|
||||
blockData &&
|
||||
entity.weaponImageStatesDbId !== weaponImage.dataBlockId
|
||||
) {
|
||||
entity.weaponImageStates = parseWeaponImageStates(blockData);
|
||||
entity.weaponImageStatesDbId = weaponImage.dataBlockId;
|
||||
}
|
||||
} else if (weaponImage && !weaponImage.dataBlockId) {
|
||||
entity.weaponShape = undefined;
|
||||
entity.weaponImageState = undefined;
|
||||
entity.weaponImageStates = undefined;
|
||||
if (entity.imageDataBlockIds) entity.imageDataBlockIds[0] = undefined;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-slot image skin tags (e.g. flag team skin on slot 3).
|
||||
// Process all 8 image slots uniformly. The mount bone for each
|
||||
// image comes from dataBlock->mountPoint (binary-verified), NOT
|
||||
// from the slot index.
|
||||
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 (skinName) {
|
||||
if (!entity.imageSkinNames) entity.imageSkinNames = [];
|
||||
entity.imageSkinNames[img.index] = skinName;
|
||||
if (img.index == null || img.index < 0 || img.index >= 8) continue;
|
||||
|
||||
if (img.dataBlockId && img.dataBlockId > 0) {
|
||||
const blockData = this.getDataBlockData(img.dataBlockId);
|
||||
const shapeName = resolveShapeName("ShapeBaseImageData", blockData);
|
||||
const mountPoint =
|
||||
typeof blockData?.mountPoint === "number"
|
||||
? blockData.mountPoint
|
||||
: 0;
|
||||
|
||||
// Resolve skin from net string tag or inline name.
|
||||
let skinName: string | undefined;
|
||||
if (img.skinTagIndex != null) {
|
||||
skinName = this.netStrings.get(img.skinTagIndex);
|
||||
} else if (img.skinName) {
|
||||
skinName = img.skinName;
|
||||
}
|
||||
|
||||
if (shapeName) {
|
||||
if (!entity.imageSlots) entity.imageSlots = [];
|
||||
entity.imageSlots[img.index] = {
|
||||
shapeName,
|
||||
mountPoint,
|
||||
dataBlockId: img.dataBlockId,
|
||||
skinName,
|
||||
};
|
||||
}
|
||||
|
||||
// Slot 0: weapon state machine (triggerDown, ammo, etc.)
|
||||
if (img.index === 0) {
|
||||
const prev = entity.weaponImageState;
|
||||
entity.weaponImageState = {
|
||||
dataBlockId: img.dataBlockId,
|
||||
triggerDown: img.triggerDown ?? prev?.triggerDown ?? false,
|
||||
ammo: img.ammo ?? prev?.ammo ?? true,
|
||||
loaded: img.loaded ?? prev?.loaded ?? true,
|
||||
target: img.target ?? prev?.target ?? false,
|
||||
wet: img.wet ?? prev?.wet ?? false,
|
||||
fireCount: img.fireCount ?? prev?.fireCount ?? 0,
|
||||
};
|
||||
if (
|
||||
blockData &&
|
||||
entity.weaponImageStatesDbId !== img.dataBlockId
|
||||
) {
|
||||
entity.weaponImageStates = parseWeaponImageStates(blockData);
|
||||
entity.weaponImageStatesDbId = img.dataBlockId;
|
||||
}
|
||||
}
|
||||
|
||||
// Slot 3 on Players: flag — update targetRenderFlags bit 0x2.
|
||||
if (img.index === 3 && entity.type === "Player") {
|
||||
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 (!img.dataBlockId) {
|
||||
// Clear slot.
|
||||
if (entity.imageSlots) {
|
||||
entity.imageSlots[img.index] = undefined;
|
||||
}
|
||||
|
||||
// Slot 0: clear weapon state.
|
||||
if (img.index === 0) {
|
||||
entity.weaponImageState = undefined;
|
||||
entity.weaponImageStates = undefined;
|
||||
}
|
||||
|
||||
// Slot 3 on Players: clear flag render flag.
|
||||
if (img.index === 3 && entity.type === "Player") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1449,10 +1466,43 @@ 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;
|
||||
// CloakMask (binary-verified, shapeBase.cc:3457-3485):
|
||||
// cloaked: mCloaked — drives client-side cloakLevel interpolation
|
||||
// fading: start a fade animation (fadeOut=direction, fadeTime=duration)
|
||||
// fadeVal: direct mFadeVal == 1.0 when not fading
|
||||
// CloakMask visibility (binary-verified, shapeBase.cc:3457-3485).
|
||||
// fadeVal (mFadeVal == 1.0): true = visible, false = invisible.
|
||||
// fading: fade animation (fadeOut=direction, fadeTime=duration).
|
||||
// cloaked: mCloaked (stealth/station pad effect — client-side render).
|
||||
// setCloakedState (FUN_005f0200): on client, does NOT snap mCloakLevel —
|
||||
// only sets mCloaked. advanceTime interpolates at rate dt*2 (0.5s).
|
||||
if (typeof data.cloaked === "boolean" && data.cloaked !== entity.cloaked) {
|
||||
const wasSet = entity.cloaked != null;
|
||||
entity.cloaked = data.cloaked;
|
||||
if (!wasSet && data.cloaked) {
|
||||
// First create with cloaked=true: start fully cloaked. The engine
|
||||
// technically starts at 0 and animates, but the ghost isn't rendered
|
||||
// during initial setup so players only ever see the cloaked state.
|
||||
entity.cloakLevel = 1;
|
||||
}
|
||||
// No snap for state changes — client interpolates via advanceFades().
|
||||
}
|
||||
if (data.fading === true && typeof data.fadeTime === "number") {
|
||||
const fadeOut = !!data.fadeOut;
|
||||
if (data.fadeTime <= 0) {
|
||||
entity.fadeVal = fadeOut ? 0 : 1;
|
||||
entity.fadeState = undefined;
|
||||
} else {
|
||||
entity.fadeVal = fadeOut ? 1 : 0;
|
||||
entity.fadeState = {
|
||||
fadeOut,
|
||||
fadeTime: data.fadeTime,
|
||||
elapsed: 0,
|
||||
};
|
||||
}
|
||||
} else if (typeof data.fadeVal === "boolean") {
|
||||
entity.fadeVal = data.fadeVal ? 1 : 0;
|
||||
entity.fadeState = undefined;
|
||||
}
|
||||
if (typeof data.action === "number") {
|
||||
entity.actionAnim = data.action;
|
||||
|
|
@ -1462,13 +1512,20 @@ 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.
|
||||
// MountedMask: track mount state for position derivation and animation.
|
||||
if (typeof data.mountObject === "number") {
|
||||
if (data.mountObject === -1) {
|
||||
// Unmounting — reset action to table anim (not sent by server).
|
||||
if (data.mountObject >= 0) {
|
||||
// Mounting on a vehicle/object.
|
||||
entity.mountObjectGhostIndex = data.mountObject;
|
||||
entity.mountNode =
|
||||
typeof data.mountNode === "number" ? data.mountNode : 0;
|
||||
} else {
|
||||
// Unmounting — clear mount state and reset action animation.
|
||||
// Server resets to RootAnim (table action 0) via onUnmount→
|
||||
// setActionThread, but table actions (0-6) are never sent
|
||||
// over the wire, so actionAnim would remain stale.
|
||||
entity.mountObjectGhostIndex = undefined;
|
||||
entity.mountNode = undefined;
|
||||
entity.actionAnim = undefined;
|
||||
entity.actionAtEnd = undefined;
|
||||
}
|
||||
|
|
@ -1685,6 +1742,67 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
* sparse), we apply basic gravity after a few ticks without a server
|
||||
* update to prevent items from flying upward indefinitely.
|
||||
*/
|
||||
/** Advance fade and cloak animations per tick, matching advanceTime. */
|
||||
protected advanceFades(): void {
|
||||
const dt = TICK_DURATION_MS / 1000;
|
||||
for (const entity of this.entities.values()) {
|
||||
// mFadeVal animation.
|
||||
const fs = entity.fadeState;
|
||||
if (fs) {
|
||||
fs.elapsed += dt;
|
||||
if (fs.elapsed >= fs.fadeTime) {
|
||||
entity.fadeVal = fs.fadeOut ? 0 : 1;
|
||||
entity.fadeState = undefined;
|
||||
} else {
|
||||
const t = fs.elapsed / fs.fadeTime;
|
||||
entity.fadeVal = fs.fadeOut ? 1 - t : t;
|
||||
}
|
||||
}
|
||||
// mCloakLevel interpolation (binary-verified: rate = dt * 2, 0.5s).
|
||||
if (entity.cloakLevel != null && entity.cloaked != null) {
|
||||
if (entity.cloaked) {
|
||||
entity.cloakLevel = Math.min(entity.cloakLevel + dt * 2, 1);
|
||||
} else {
|
||||
entity.cloakLevel = Math.max(entity.cloakLevel - dt * 2, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extrapolate the control vehicle's position each tick using velocity.
|
||||
* controlObjectData arrives sparsely (~10 of ~62 packets/sec). Between
|
||||
* updates, we integrate position from the last known velocity. */
|
||||
protected advanceControlVehicle(): void {
|
||||
if (!this.isPiloting || this.lastPilotGhostIndex == null) return;
|
||||
if (!this.lastVehiclePos || !this.lastVehicleVelocity) {
|
||||
if (this.tickCount % 100 === 0) {
|
||||
console.warn(
|
||||
"[advanceControlVehicle] piloting but missing data:",
|
||||
"pos:",
|
||||
!!this.lastVehiclePos,
|
||||
"vel:",
|
||||
!!this.lastVehicleVelocity,
|
||||
"ghost:",
|
||||
this.lastPilotGhostIndex,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vehicleId = this.resolveEntityIdForGhostIndex(
|
||||
this.lastPilotGhostIndex,
|
||||
);
|
||||
const entity = vehicleId ? this.entities.get(vehicleId) : undefined;
|
||||
if (!entity) return;
|
||||
|
||||
const dt = TICK_DURATION_MS / 1000;
|
||||
const [vx, vy, vz] = this.lastVehicleVelocity;
|
||||
this.lastVehiclePos[0] += vx * dt;
|
||||
this.lastVehiclePos[1] += vy * dt;
|
||||
this.lastVehiclePos[2] += vz * dt;
|
||||
entity.position = [...this.lastVehiclePos] as [number, number, number];
|
||||
}
|
||||
|
||||
protected advanceItems(): void {
|
||||
const dt = TICK_DURATION_MS / 1000;
|
||||
for (const entity of this.entities.values()) {
|
||||
|
|
@ -1695,7 +1813,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
!phys ||
|
||||
phys.atRest ||
|
||||
entity.isStaticItem ||
|
||||
entity.hidden ||
|
||||
entity.fadeVal === 0 ||
|
||||
!entity.position
|
||||
)
|
||||
continue;
|
||||
|
|
@ -1773,9 +1891,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// Verified against tribes2-engine Player::updateMove and Tribes2.exe.
|
||||
if (this.isPiloting) {
|
||||
if (data) {
|
||||
const nested = data.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const nested = data.controlObjectData as ParsedData | undefined;
|
||||
const ang = nested?.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
| undefined;
|
||||
|
|
@ -1804,9 +1920,18 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
pitch = this.lastVehiclePitch;
|
||||
}
|
||||
|
||||
// control.position falls back to compressionPoint, which is the vehicle's
|
||||
// position when piloting. This updates every packet (~20/s), not just
|
||||
// when controlObjectData is present (~1/s).
|
||||
const cameraPos: [number, number, number] = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
|
||||
this.camera = {
|
||||
time: timeSec,
|
||||
position: [control.position.x, control.position.y, control.position.z],
|
||||
position: cameraPos,
|
||||
rotation: yawPitchToQuaternion(
|
||||
yaw,
|
||||
clamp(pitch, -MAX_PITCH, MAX_PITCH),
|
||||
|
|
@ -1849,10 +1974,27 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
// Third-person: orbit the vehicle (if piloting) or the player.
|
||||
this.camera.mode = "third-person";
|
||||
if (this.isPiloting && this.lastPilotGhostIndex != null) {
|
||||
this.camera.orbitTargetId = this.resolveEntityIdForGhostIndex(
|
||||
const vehicleId = this.resolveEntityIdForGhostIndex(
|
||||
this.lastPilotGhostIndex,
|
||||
);
|
||||
this.camera.orbitDistance = 15;
|
||||
this.camera.orbitTargetId = vehicleId;
|
||||
// Use vehicle datablock's cameraMaxDist for orbit distance.
|
||||
const vEntity = vehicleId
|
||||
? this.entities.get(vehicleId)
|
||||
: undefined;
|
||||
const vDbData =
|
||||
vEntity?.dataBlockId != null
|
||||
? this.getDataBlockData(vEntity.dataBlockId)
|
||||
: undefined;
|
||||
this.camera.orbitDistance =
|
||||
typeof vDbData?.cameraMaxDist === "number"
|
||||
? vDbData.cameraMaxDist
|
||||
: 15;
|
||||
// Vertical offset from datablock (Torque Z = Three.js Y).
|
||||
this.camera.orbitOffset =
|
||||
typeof vDbData?.cameraOffset === "number"
|
||||
? vDbData.cameraOffset
|
||||
: 0;
|
||||
if (this.lastVehicleOrbitDir) {
|
||||
this.camera.orbitDirection = this.lastVehicleOrbitDir;
|
||||
}
|
||||
|
|
@ -1864,12 +2006,21 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
} else {
|
||||
this.camera.mode = "first-person";
|
||||
}
|
||||
if (this.controlPlayerGhostId) {
|
||||
// When piloting, use the vehicle for camera positioning (its Eye
|
||||
// node provides the cockpit viewpoint). Otherwise use the player.
|
||||
if (this.isPiloting && this.lastPilotGhostIndex != null) {
|
||||
this.camera.controlEntityId = this.resolveEntityIdForGhostIndex(
|
||||
this.lastPilotGhostIndex,
|
||||
);
|
||||
} else if (this.controlPlayerGhostId) {
|
||||
this.camera.controlEntityId = this.controlPlayerGhostId;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync control object positions from controlObjectData.
|
||||
// Sync control object positions. When piloting, control.position is
|
||||
// the compressionPoint (= vehicle position), updated every packet.
|
||||
// controlObjectData (with linMomentum, angPosition) arrives more
|
||||
// sparsely (~1/s); we use it for velocity and rotation.
|
||||
if (controlType === "player" && control.position) {
|
||||
if (this.isPiloting && this.lastPilotGhostIndex != null) {
|
||||
const vehicleId = this.resolveEntityIdForGhostIndex(
|
||||
|
|
@ -1879,31 +2030,25 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
? this.entities.get(vehicleId)
|
||||
: undefined;
|
||||
if (vehicleEntity) {
|
||||
const nested = data?.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (nested) {
|
||||
// Fresh position from controlObjectData (linPosition →
|
||||
// compressionPoint → control.position).
|
||||
vehicleEntity.position = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
this.lastVehiclePos = vehicleEntity.position.slice() as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
this.lastVehiclePosTime = timeSec;
|
||||
// compressionPoint provides position on every packet.
|
||||
vehicleEntity.position = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
this.lastVehiclePos = vehicleEntity.position.slice() as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
// Extract velocity from linMomentum for interpolation between
|
||||
// the sparse position updates (~10 of ~62 packets contain data).
|
||||
// Sparse controlObjectData provides velocity and rotation.
|
||||
const nested = data?.controlObjectData as ParsedData | undefined;
|
||||
if (nested) {
|
||||
const mom = nested.linMomentum as
|
||||
| { x: number; y: number; z: number }
|
||||
| undefined;
|
||||
if (mom && isValidPosition(mom)) {
|
||||
// linMomentum = mass * velocity; look up mass from datablock.
|
||||
const dbId = vehicleEntity.dataBlockId;
|
||||
const dbData =
|
||||
dbId != null ? this.getDataBlockData(dbId) : undefined;
|
||||
|
|
@ -1916,8 +2061,6 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
];
|
||||
vehicleEntity.velocity = this.lastVehicleVelocity;
|
||||
}
|
||||
|
||||
// Sync vehicle rotation from nested angPosition quaternion.
|
||||
const ang = nested.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
| undefined;
|
||||
|
|
@ -1925,22 +2068,6 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
const converted = torqueQuatToThreeJS(ang);
|
||||
if (converted) vehicleEntity.rotation = converted;
|
||||
}
|
||||
} else if (
|
||||
this.lastVehiclePos &&
|
||||
this.lastVehicleVelocity &&
|
||||
this.lastVehiclePosTime > 0
|
||||
) {
|
||||
// No nested data this packet — extrapolate from last known
|
||||
// position + velocity to avoid stutter.
|
||||
const dt = timeSec - this.lastVehiclePosTime;
|
||||
if (dt > 0 && dt < 1) {
|
||||
const [vx, vy, vz] = this.lastVehicleVelocity;
|
||||
vehicleEntity.position = [
|
||||
this.lastVehiclePos[0] + vx * dt,
|
||||
this.lastVehiclePos[1] + vy * dt,
|
||||
this.lastVehiclePos[2] + vz * dt,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.controlPlayerGhostId) {
|
||||
|
|
@ -2006,7 +2133,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
|
||||
protected getAbsoluteRotation(
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
): { yaw: number; pitch: number } | null {
|
||||
if (!data) return null;
|
||||
if (typeof data.rotationZ === "number" && typeof data.headX === "number") {
|
||||
|
|
@ -2322,7 +2449,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
? (this.targetRenderFlags.get(entity.targetId) ??
|
||||
entity.targetRenderFlags)
|
||||
: entity.targetRenderFlags;
|
||||
if (entity.type === "Player" && !entity.flagShape) {
|
||||
if (entity.type === "Player" && !entity.imageSlots?.[3]) {
|
||||
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
|
||||
}
|
||||
|
||||
|
|
@ -2336,11 +2463,12 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
dataBlockId: entity.dataBlockId,
|
||||
shapeHint: entity.shapeHint,
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
imageDataBlockIds: entity.imageDataBlockIds,
|
||||
imageSkinNames: entity.imageSkinNames,
|
||||
packShape: entity.packShape,
|
||||
flagShape: entity.flagShape,
|
||||
imageSlots: entity.imageSlots,
|
||||
mountObjectId:
|
||||
entity.mountObjectGhostIndex != null
|
||||
? this.entityIdByGhostIndex.get(entity.mountObjectGhostIndex)
|
||||
: undefined,
|
||||
mountNode: entity.mountNode,
|
||||
falling: entity.falling,
|
||||
jetting: entity.jetting,
|
||||
playerName: entity.playerName,
|
||||
|
|
@ -2366,7 +2494,11 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
actionAtEnd: entity.actionAtEnd,
|
||||
armAction: entity.armAction,
|
||||
damageState: entity.damageState,
|
||||
hidden: entity.hidden,
|
||||
// Fade and cloak are independent systems, passed separately so the
|
||||
// renderer can apply the correct visual treatment (texture replacement
|
||||
// for cloak, opacity-only for fade).
|
||||
fadeVal: entity.fadeVal ?? 1,
|
||||
cloakLevel: entity.cloakLevel ?? 0,
|
||||
faceViewer: entity.faceViewer,
|
||||
threads: entity.threads,
|
||||
explosionDataBlockId: entity.explosionDataBlockId,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
BlockTypePacket,
|
||||
DemoParser,
|
||||
} from "t2-demo-parser";
|
||||
import type { ParsedData } from "t2-demo-parser";
|
||||
import { ghostToSceneObject } from "../scene";
|
||||
import {
|
||||
toEntityType,
|
||||
|
|
@ -324,18 +325,15 @@ function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
|||
class StreamingPlayback extends StreamEngine {
|
||||
private readonly parser: DemoParser;
|
||||
private readonly initialBlock: {
|
||||
dataBlocks: Map<
|
||||
number,
|
||||
{ className: string; data: Record<string, unknown> }
|
||||
>;
|
||||
dataBlocks: Map<number, { className: string; data: ParsedData }>;
|
||||
initialGhosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}>;
|
||||
controlObjectGhostIndex: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
controlObjectData?: ParsedData;
|
||||
targetEntries: Array<{
|
||||
targetId: number;
|
||||
name?: string;
|
||||
|
|
@ -354,7 +352,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
taggedStrings: Map<number, string>;
|
||||
initialEvents: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}>;
|
||||
demoValues: string[];
|
||||
firstPerson: boolean;
|
||||
|
|
@ -418,13 +416,13 @@ class StreamingPlayback extends StreamEngine {
|
|||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(dataBlockId: number): Record<string, unknown> | undefined {
|
||||
getDataBlockData(dataBlockId: number): ParsedData | undefined {
|
||||
const initialBlock = this.initialBlock.dataBlocks.get(dataBlockId);
|
||||
if (initialBlock?.data) {
|
||||
return initialBlock.data;
|
||||
}
|
||||
const packetParser = this.parser.getPacketParser() as unknown as {
|
||||
dataBlockDataMap?: Map<number, Record<string, unknown>>;
|
||||
dataBlockDataMap?: Map<number, ParsedData>;
|
||||
};
|
||||
return packetParser.dataBlockDataMap?.get(dataBlockId);
|
||||
}
|
||||
|
|
@ -450,7 +448,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
return this.moveTicks * (TICK_DURATION_MS / 1000);
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(_data: Record<string, unknown> | undefined): {
|
||||
protected getCameraYawPitch(_data: ParsedData | undefined): {
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
} {
|
||||
|
|
@ -555,7 +553,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
: undefined;
|
||||
if (this.isPiloting) {
|
||||
const nested = this.initialBlock.controlObjectData?.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| ParsedData
|
||||
| undefined;
|
||||
const ang = nested?.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
|
|
@ -652,7 +650,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
const sceneObj = ghostToSceneObject(
|
||||
className,
|
||||
ghost.index,
|
||||
ghost.parsedData as Record<string, unknown>,
|
||||
ghost.parsedData as ParsedData,
|
||||
);
|
||||
if (sceneObj) entity.sceneData = sceneObj;
|
||||
}
|
||||
|
|
@ -777,7 +775,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
|
||||
const collectShapesFromExplosion = (expBlock: ParsedData) => {
|
||||
const shape = expBlock.dtsFileName as string | undefined;
|
||||
if (shape) shapes.add(shape);
|
||||
const subExplosions = expBlock.subExplosions as
|
||||
|
|
@ -864,6 +862,8 @@ class StreamingPlayback extends StreamEngine {
|
|||
this.tickCount = this.moveTicks;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
this.advanceControlVehicle();
|
||||
this.advanceFades();
|
||||
this.removeExpiredExplosions();
|
||||
this.updateCameraAndHud();
|
||||
return true;
|
||||
|
|
@ -1047,19 +1047,19 @@ class StreamingPlayback extends StreamEngine {
|
|||
private isPacketData(parsed: unknown): parsed is {
|
||||
gameState: {
|
||||
controlObjectGhostIndex?: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
controlObjectData?: ParsedData;
|
||||
compressionPoint?: Vec3;
|
||||
cameraFov?: number;
|
||||
};
|
||||
events: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}>;
|
||||
ghosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}>;
|
||||
} {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { BlockTypeMove, BlockTypePacket, DemoParser } from "t2-demo-parser";
|
||||
import type {
|
||||
ParsedData,
|
||||
NetStringEventData,
|
||||
RemoteCommandEventData,
|
||||
} from "t2-demo-parser";
|
||||
import { TICK_DURATION_MS } from "./entityClassification";
|
||||
import { stripTaggedStringMarkup } from "./streamHelpers";
|
||||
import type { TimelineEvent } from "../state/demoTimelineStore";
|
||||
|
|
@ -192,6 +197,7 @@ export async function scanDemoTimeline(
|
|||
const events: TimelineEvent[] = [];
|
||||
let moveTicks = 0;
|
||||
let seenMatchStart = false;
|
||||
let currentMissionName: string | null = null;
|
||||
let blockCount = 0;
|
||||
const totalBlocks = parser.blockCount;
|
||||
|
||||
|
|
@ -224,7 +230,7 @@ export async function scanDemoTimeline(
|
|||
const packet = block.parsed as {
|
||||
events?: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
parsedData?: ParsedData;
|
||||
}>;
|
||||
};
|
||||
if (!packet.events) continue;
|
||||
|
|
@ -237,8 +243,9 @@ export async function scanDemoTimeline(
|
|||
const type = evt.parsedData.type as string | undefined;
|
||||
|
||||
if (type === "NetStringEvent") {
|
||||
const id = evt.parsedData.id as number;
|
||||
const value = evt.parsedData.value as string | undefined;
|
||||
const nsData = evt.parsedData as NetStringEventData;
|
||||
const id = nsData.id;
|
||||
const value = nsData.value;
|
||||
if (value != null) {
|
||||
netStrings.set(id, value);
|
||||
}
|
||||
|
|
@ -254,13 +261,11 @@ export async function scanDemoTimeline(
|
|||
continue;
|
||||
}
|
||||
|
||||
const funcName = resolveNetString(
|
||||
evt.parsedData.funcName as string,
|
||||
netStrings,
|
||||
);
|
||||
const rcData = evt.parsedData as RemoteCommandEventData;
|
||||
const funcName = resolveNetString(rcData.funcName, netStrings);
|
||||
if (funcName !== "ServerMessage") continue;
|
||||
|
||||
const args = evt.parsedData.args as string[];
|
||||
const args = rcData.args;
|
||||
if (!args || args.length < 2) continue;
|
||||
|
||||
const msgType = resolveNetString(args[0], netStrings);
|
||||
|
|
@ -279,25 +284,45 @@ export async function scanDemoTimeline(
|
|||
}
|
||||
}
|
||||
|
||||
// Track current mission name from server info messages.
|
||||
if (msgTypeLower === "msgmissiondropinfo" && args.length >= 3) {
|
||||
// Wire: args[2]=$MissionDisplayName
|
||||
const name = stripTaggedStringMarkup(
|
||||
resolveNetString(args[2], netStrings),
|
||||
).trim();
|
||||
if (name) currentMissionName = name;
|
||||
}
|
||||
if (msgTypeLower === "msgloadinfo" && args.length >= 4) {
|
||||
// Wire: args[2]=$CurrentMission, args[3]=$MissionDisplayName
|
||||
const name = stripTaggedStringMarkup(
|
||||
resolveNetString(args[3], netStrings),
|
||||
).trim();
|
||||
if (name) currentMissionName = name;
|
||||
}
|
||||
|
||||
// Match start: MsgMissionStart is sent when the match actually begins
|
||||
// (after the countdown). MsgSystemClock is just the countdown timer.
|
||||
if (msgTypeLower === "msgmissionstart" && !seenMatchStart) {
|
||||
seenMatchStart = true;
|
||||
const suffix = currentMissionName ? ` (${currentMissionName})` : "";
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "match-start",
|
||||
description: "Match started",
|
||||
description: `Match started${suffix}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match ended.
|
||||
if (msgTypeLower === "msggameover") {
|
||||
const suffix = currentMissionName ? ` (${currentMissionName})` : "";
|
||||
events.push({
|
||||
timeSec,
|
||||
type: "match-end",
|
||||
description: "Match ended",
|
||||
description: `Match ended${suffix}`,
|
||||
});
|
||||
// Reset for the next match in the same demo.
|
||||
seenMatchStart = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ function positionedBase(entity: StreamEntity, spawnTime?: number) {
|
|||
position: entity.position,
|
||||
rotation: entity.rotation,
|
||||
velocity: entity.velocity,
|
||||
mountObjectId: entity.mountObjectId,
|
||||
mountNode: entity.mountNode,
|
||||
imageSlots: entity.imageSlots,
|
||||
keyframes: [
|
||||
{
|
||||
time: spawnTime ?? 0,
|
||||
|
|
@ -118,12 +121,7 @@ export function streamEntityToGameEntity(
|
|||
renderType: "Player",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
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,
|
||||
|
|
@ -234,10 +232,8 @@ export function streamEntityToGameEntity(
|
|||
? "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,
|
||||
|
|
@ -247,6 +243,8 @@ export function streamEntityToGameEntity(
|
|||
frozen: entity.frozen,
|
||||
maxSteeringAngle: entity.maxSteeringAngle,
|
||||
soundSlots: entity.soundSlots,
|
||||
fadeVal: entity.fadeVal,
|
||||
cloakLevel: entity.cloakLevel,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { createLiveParser, type PacketParser } from "t2-demo-parser";
|
||||
import type {
|
||||
ParsedData,
|
||||
RemoteCommandEventData,
|
||||
CRCChallengeEventData,
|
||||
GhostingMessageEventData,
|
||||
} from "t2-demo-parser";
|
||||
import { createLogger } from "../logger";
|
||||
import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
|
|
@ -74,7 +80,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(id: number): Record<string, unknown> | undefined {
|
||||
getDataBlockData(id: number): ParsedData | undefined {
|
||||
return this.packetParser.getDataBlockDataMap()?.get(id);
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +105,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
return this.currentTimeSec;
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(data: Record<string, unknown> | undefined): {
|
||||
protected getCameraYawPitch(data: ParsedData | undefined): {
|
||||
yaw: number;
|
||||
pitch: number;
|
||||
} {
|
||||
|
|
@ -202,9 +208,8 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
* Handle RemoteCommandEvents that require relay-side responses:
|
||||
* auth events, mission phase acknowledgments, etc.
|
||||
*/
|
||||
private handleRelayCommands(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "RemoteCommandEvent") return;
|
||||
const rawFuncName = parsedData.funcName as string;
|
||||
private handleRelayCommands(parsedData: RemoteCommandEventData): void {
|
||||
const rawFuncName = parsedData.funcName;
|
||||
if (!rawFuncName) return;
|
||||
const funcName = this.resolveNetString(rawFuncName);
|
||||
|
||||
|
|
@ -215,7 +220,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
"t2csri_decryptChallenge",
|
||||
];
|
||||
if (authCommands.includes(funcName)) {
|
||||
const rawArgs = (parsedData.args as string[]) ?? [];
|
||||
const rawArgs = parsedData.args ?? [];
|
||||
const args = rawArgs
|
||||
.map((a) => this.resolveNetString(a))
|
||||
.filter((a) => a !== "");
|
||||
|
|
@ -226,7 +231,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
|
||||
// Mission download phase acknowledgments — the server won't proceed
|
||||
// to ghosting until the client responds to each phase.
|
||||
const rawArgs = (parsedData.args as string[]) ?? [];
|
||||
const rawArgs = parsedData.args ?? [];
|
||||
const resolvedArgs = rawArgs.map((a) => this.resolveNetString(a));
|
||||
if (funcName === "MissionStartPhase1") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
|
|
@ -281,11 +286,10 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
}
|
||||
|
||||
/** Respond to CRCChallengeEvent — required for Phase 2 to begin. */
|
||||
private handleCRCChallenge(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "CRCChallengeEvent") return;
|
||||
const seed = parsedData.crcValue as number;
|
||||
const field1 = parsedData.field1 as number;
|
||||
const field2 = parsedData.field2 as number;
|
||||
private handleCRCChallenge(parsedData: CRCChallengeEventData): void {
|
||||
const seed = parsedData.crcValue;
|
||||
const field1 = parsedData.field1;
|
||||
const field2 = parsedData.field2;
|
||||
// field1 bit 0 = includeTextures (from $Host::CRCTextures)
|
||||
const includeTextures = (field1 & 1) !== 0;
|
||||
log.info(
|
||||
|
|
@ -307,10 +311,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
for (const [id, block] of dbMap) {
|
||||
const className = this.dataBlockClassNames.get(id);
|
||||
if (!className) continue;
|
||||
const shapeName = resolveShapeName(
|
||||
className,
|
||||
block as Record<string, unknown>,
|
||||
);
|
||||
const shapeName = resolveShapeName(className, block as ParsedData);
|
||||
datablocks.push({
|
||||
objectId: id,
|
||||
className,
|
||||
|
|
@ -327,11 +328,10 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
* The server sends this after activateGhosting(); the client must respond
|
||||
* with type 1 so the server sets mGhosting=true and begins sending ghosts.
|
||||
*/
|
||||
private handleGhostingMessage(parsedData: Record<string, unknown>): void {
|
||||
if (parsedData.type !== "GhostingMessageEvent") return;
|
||||
const message = parsedData.message as number;
|
||||
const sequence = parsedData.sequence as number;
|
||||
const ghostCount = parsedData.ghostCount as number;
|
||||
private handleGhostingMessage(parsedData: GhostingMessageEventData): void {
|
||||
const message = parsedData.message;
|
||||
const sequence = parsedData.sequence;
|
||||
const ghostCount = parsedData.ghostCount;
|
||||
log.info(
|
||||
"GhostingMessageEvent: message=%d sequence=%d ghostCount=%d",
|
||||
message,
|
||||
|
|
@ -417,10 +417,18 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
// Events
|
||||
for (const event of parsed.events) {
|
||||
if (event.parsedData) {
|
||||
this.handleRelayCommands(event.parsedData);
|
||||
this.handleCRCChallenge(event.parsedData);
|
||||
this.handleGhostingMessage(event.parsedData);
|
||||
const type = event.parsedData.type as string;
|
||||
if (type === "RemoteCommandEvent") {
|
||||
this.handleRelayCommands(
|
||||
event.parsedData as RemoteCommandEventData,
|
||||
);
|
||||
} else if (type === "CRCChallengeEvent") {
|
||||
this.handleCRCChallenge(event.parsedData as CRCChallengeEventData);
|
||||
} else if (type === "GhostingMessageEvent") {
|
||||
this.handleGhostingMessage(
|
||||
event.parsedData as GhostingMessageEventData,
|
||||
);
|
||||
}
|
||||
|
||||
// Always log RemoteCommandEvents (chat, server messages, HUD).
|
||||
if (type === "RemoteCommandEvent") {
|
||||
|
|
@ -453,7 +461,7 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
}
|
||||
if (shouldLog) {
|
||||
const dbData = event.parsedData.dataBlockData as
|
||||
| Record<string, unknown>
|
||||
| ParsedData
|
||||
| undefined;
|
||||
const shapeName = resolveShapeName(dbClassName ?? "", dbData);
|
||||
log.debug(
|
||||
|
|
@ -570,6 +578,8 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
this.tickCount++;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
this.advanceControlVehicle();
|
||||
this.advanceFades();
|
||||
|
||||
// Periodic status at milestones
|
||||
if (isMilestonePacket && this.tickCount > 1) {
|
||||
|
|
|
|||
|
|
@ -229,7 +229,13 @@ function buildShapeEntity(
|
|||
const barrelName = getProperty(object, "initialBarrel");
|
||||
if (barrelName) {
|
||||
const barrelDb = resolveDatablock(runtime, barrelName);
|
||||
entity.weaponShape = getProperty(barrelDb, "shapeFile");
|
||||
const turretShapeName = getProperty(barrelDb, "shapeFile");
|
||||
if (turretShapeName) {
|
||||
const mountPoint = Number(getProperty(barrelDb, "mountPoint")) || 0;
|
||||
entity.imageSlots = [
|
||||
{ shapeName: turretShapeName, mountPoint, dataBlockId: 0 },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ export function setupEffectTexture(tex: Texture): void {
|
|||
tex.magFilter = LinearFilter;
|
||||
tex.colorSpace = NoColorSpace;
|
||||
tex.flipY = false;
|
||||
tex.needsUpdate = true;
|
||||
if (tex.image) {
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function torqueVecToThree(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Matrix4, Quaternion } from "three";
|
||||
import type { ParsedData } from "t2-demo-parser";
|
||||
import type {
|
||||
StreamVisual,
|
||||
WeaponImageDataBlockState,
|
||||
|
|
@ -226,7 +227,7 @@ export function isQuatLike(value: unknown): value is {
|
|||
*/
|
||||
export function resolveShapeName(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
|
|
@ -245,7 +246,7 @@ export function resolveShapeName(
|
|||
}
|
||||
|
||||
export function getNumberField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
keys: readonly string[],
|
||||
): number | undefined {
|
||||
if (!data) return undefined;
|
||||
|
|
@ -257,7 +258,7 @@ export function getNumberField(
|
|||
}
|
||||
|
||||
export function getStringField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
keys: readonly string[],
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
|
@ -269,7 +270,7 @@ export function getStringField(
|
|||
}
|
||||
|
||||
export function getBooleanField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
keys: readonly string[],
|
||||
): boolean | undefined {
|
||||
if (!data) return undefined;
|
||||
|
|
@ -284,7 +285,7 @@ export function getBooleanField(
|
|||
|
||||
export function resolveTracerVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
|
|
@ -334,7 +335,7 @@ export function resolveTracerVisual(
|
|||
|
||||
export function resolveSpriteVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
|
|
@ -380,11 +381,9 @@ export function resolveSpriteVisual(
|
|||
* remap table.
|
||||
*/
|
||||
export function parseWeaponImageStates(
|
||||
blockData: Record<string, unknown>,
|
||||
blockData: ParsedData,
|
||||
): WeaponImageDataBlockState[] | undefined {
|
||||
const rawStates = blockData.states as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
const rawStates = blockData.states as Array<ParsedData> | undefined;
|
||||
if (!Array.isArray(rawStates) || rawStates.length === 0) return undefined;
|
||||
|
||||
return rawStates.map((s) => {
|
||||
|
|
@ -533,7 +532,7 @@ export function extractWavTag(text: string): {
|
|||
export type ControlObjectType = "camera" | "player";
|
||||
|
||||
export function detectControlObjectType(
|
||||
data: Record<string, unknown> | undefined,
|
||||
data: ParsedData | undefined,
|
||||
): ControlObjectType | null {
|
||||
if (!data) return null;
|
||||
if (typeof data.cameraMode === "number") return "camera";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import type { ParsedData } from "t2-demo-parser";
|
||||
import type { SceneObject } from "../scene/types";
|
||||
|
||||
/** A mounted image in one of 8 ShapeBase image slots. The mount bone
|
||||
* comes from the image datablock's mountPoint field, not the slot index. */
|
||||
export interface ImageSlot {
|
||||
shapeName: string;
|
||||
mountPoint: number;
|
||||
dataBlockId: number;
|
||||
skinName?: string;
|
||||
}
|
||||
|
||||
/** DTS animation thread state from ghost ThreadMask data. */
|
||||
export interface ThreadState {
|
||||
index: number;
|
||||
|
|
@ -98,11 +108,8 @@ export interface StreamEntity {
|
|||
dataBlock?: string;
|
||||
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)[];
|
||||
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
|
||||
imageSlots?: (ImageSlot | undefined)[];
|
||||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
|
|
@ -123,8 +130,11 @@ export interface StreamEntity {
|
|||
actionAnim?: number;
|
||||
actionAtEnd?: boolean;
|
||||
damageState?: number;
|
||||
/** ShapeBase hidden state (binaryCloak). True = invisible. */
|
||||
hidden?: boolean;
|
||||
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
|
||||
fadeVal?: number;
|
||||
/** Cloak level (0=visible, 1=fully cloaked). Separate from fadeVal so the
|
||||
* renderer can apply the cloak texture effect. */
|
||||
cloakLevel?: number;
|
||||
faceViewer?: boolean;
|
||||
/** DTS animation thread states from ghost ThreadMask data. */
|
||||
threads?: ThreadState[];
|
||||
|
|
@ -136,10 +146,10 @@ export interface StreamEntity {
|
|||
weaponImageState?: WeaponImageState;
|
||||
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
/** DTS shape name for the mounted pack (slot 2, Mount1 bone). */
|
||||
packShape?: string;
|
||||
/** DTS shape name for the carried flag (slot 3, Mount2 bone). */
|
||||
flagShape?: string;
|
||||
/** Entity ID of the object this entity is mounted on (vehicle, etc.). */
|
||||
mountObjectId?: string;
|
||||
/** Mount point node index on the mount target (0 = pilot). */
|
||||
mountNode?: number;
|
||||
/** Player skin (team skin like "base", "baseb"). */
|
||||
skinName?: string;
|
||||
/** Player preferred skin (chosen skin like "RandySavage"). */
|
||||
|
|
@ -207,6 +217,8 @@ export interface StreamCamera {
|
|||
orbitTargetId?: string;
|
||||
/** Orbit distance used for third-person camera positioning. */
|
||||
orbitDistance?: number;
|
||||
/** Vertical offset for orbit target (from VehicleData.cameraOffset). */
|
||||
orbitOffset?: number;
|
||||
/** Absolute control-object yaw in Torque radians (rotZ/rotationZ). */
|
||||
yaw?: number;
|
||||
/** Absolute control-object pitch in Torque radians (rotX/headX). */
|
||||
|
|
@ -332,7 +344,7 @@ export interface StreamingPlayback {
|
|||
/** DTS shape names for weapon effects (explosions) that should be preloaded. */
|
||||
getEffectShapes(): string[];
|
||||
/** Resolve a datablock by its numeric ID. */
|
||||
getDataBlockData(id: number): Record<string, unknown> | undefined;
|
||||
getDataBlockData(id: number): ParsedData | undefined;
|
||||
/**
|
||||
* Get TSShapeConstructor sequence entries for a shape (e.g. "heavy_male.dts").
|
||||
* Returns the raw sequence strings like `"heavy_male_root.dsq root"`.
|
||||
|
|
|
|||
|
|
@ -78,9 +78,10 @@ export function updateTerrainTextureShader({
|
|||
shader.uniforms[`albedo${i}`] = { value: tex };
|
||||
});
|
||||
|
||||
// Pass all alpha textures including mask0 for additive blending
|
||||
// Alpha masks are packed into RGB textures (3 masks per texture).
|
||||
const packedMaskCount = alphaTextures.length;
|
||||
alphaTextures.forEach((tex, i) => {
|
||||
shader.uniforms[`mask${i}`] = { value: tex };
|
||||
shader.uniforms[`maskPacked${i}`] = { value: tex };
|
||||
});
|
||||
|
||||
// Add visibility mask uniform if we have empty squares
|
||||
|
|
@ -123,27 +124,27 @@ vTerrainWorldPos = (modelMatrix * _terrainPos).xyz;`,
|
|||
);
|
||||
}
|
||||
|
||||
// Provide terrain UVs without setting MeshLambertMaterial.map (which would
|
||||
// allocate a texture unit for an unused `map` sampler). The geometry's UV
|
||||
// attribute maps [0,1] across each terrain tile.
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
varying vec2 vTerrainUv;`,
|
||||
);
|
||||
shader.vertexShader = shader.vertexShader.replace(
|
||||
"#include <uv_vertex>",
|
||||
`#include <uv_vertex>
|
||||
vTerrainUv = uv;`,
|
||||
);
|
||||
|
||||
// Declare our uniforms and color space functions at the top of the fragment shader
|
||||
shader.fragmentShader =
|
||||
`
|
||||
uniform sampler2D albedo0;
|
||||
uniform sampler2D albedo1;
|
||||
uniform sampler2D albedo2;
|
||||
uniform sampler2D albedo3;
|
||||
uniform sampler2D albedo4;
|
||||
uniform sampler2D albedo5;
|
||||
uniform sampler2D mask0;
|
||||
uniform sampler2D mask1;
|
||||
uniform sampler2D mask2;
|
||||
uniform sampler2D mask3;
|
||||
uniform sampler2D mask4;
|
||||
uniform sampler2D mask5;
|
||||
uniform float tiling0;
|
||||
uniform float tiling1;
|
||||
uniform float tiling2;
|
||||
uniform float tiling3;
|
||||
uniform float tiling4;
|
||||
uniform float tiling5;
|
||||
varying vec2 vTerrainUv;
|
||||
${Array.from({ length: layerCount }, (_, i) => `uniform sampler2D albedo${i};`).join("\n")}
|
||||
${Array.from({ length: packedMaskCount }, (_, i) => `uniform sampler2D maskPacked${i};`).join("\n")}
|
||||
${Array.from({ length: layerCount }, (_, i) => `uniform float tiling${i};`).join("\n")}
|
||||
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
|
||||
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
|
||||
uniform bool sunLightPointsDown;
|
||||
|
|
@ -168,7 +169,7 @@ float terrainShadowFactor = 1.0;
|
|||
clippingPlaceholder,
|
||||
`${clippingPlaceholder}
|
||||
// Early discard for invisible areas (before fog/lighting)
|
||||
float visibility = texture2D(visibilityMask, vMapUv).r;
|
||||
float visibility = texture2D(visibilityMask, vTerrainUv).r;
|
||||
if (visibility < 0.5) {
|
||||
discard;
|
||||
}
|
||||
|
|
@ -177,12 +178,12 @@ float terrainShadowFactor = 1.0;
|
|||
}
|
||||
|
||||
// Replace the default map sampling block with our layered blend.
|
||||
// We rely on vMapUv provided by USE_MAP.
|
||||
// vTerrainUv is computed from the geometry's UV attribute in the vertex shader.
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
"#include <map_fragment>",
|
||||
`
|
||||
// Sample base albedo layers (sRGB textures auto-decoded to linear by Three.js)
|
||||
vec2 baseUv = vMapUv;
|
||||
vec2 baseUv = vTerrainUv;
|
||||
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
|
||||
${
|
||||
layerCount > 1
|
||||
|
|
@ -210,16 +211,22 @@ float terrainShadowFactor = 1.0;
|
|||
: ""
|
||||
}
|
||||
|
||||
// Sample alpha masks for all layers (use R channel)
|
||||
// Sample alpha masks from packed RGB textures (3 masks per texture).
|
||||
// Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices),
|
||||
// but GPU linear filtering samples at texel centers. This offset aligns them.
|
||||
vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0);
|
||||
float a0 = texture2D(mask0, alphaUv).r;
|
||||
${layerCount > 1 ? `float a1 = texture2D(mask1, alphaUv).r;` : ""}
|
||||
${layerCount > 2 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
|
||||
${layerCount > 3 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
|
||||
${layerCount > 4 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
|
||||
${layerCount > 5 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""}
|
||||
vec3 maskRGB0 = texture2D(maskPacked0, alphaUv).rgb;
|
||||
float a0 = maskRGB0.r;
|
||||
${layerCount > 1 ? `float a1 = maskRGB0.g;` : ""}
|
||||
${layerCount > 2 ? `float a2 = maskRGB0.b;` : ""}
|
||||
${
|
||||
layerCount > 3
|
||||
? `vec3 maskRGB1 = texture2D(maskPacked1, alphaUv).rgb;
|
||||
float a3 = maskRGB1.r;`
|
||||
: ""
|
||||
}
|
||||
${layerCount > 4 ? `float a4 = maskRGB1.g;` : ""}
|
||||
${layerCount > 5 ? `float a5 = maskRGB1.b;` : ""}
|
||||
|
||||
// Torque-style additive weighted blending (blender.cc):
|
||||
// result = tex0 * alpha0 + tex1 * alpha1 + tex2 * alpha2 + ...
|
||||
|
|
@ -324,7 +331,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
|
|||
lightmap
|
||||
? `
|
||||
// Sample terrain lightmap for smooth NdotL
|
||||
vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
|
||||
vec2 lightmapUv = vTerrainUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
|
||||
float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r;
|
||||
|
||||
// Get sun and ambient colors from Three.js lights (these ARE sRGB values from mission file)
|
||||
|
|
@ -362,7 +369,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
|
|||
"#include <tonemapping_fragment>",
|
||||
`#if DEBUG_MODE
|
||||
// Debug mode: overlay green grid matching terrain grid squares (256x256)
|
||||
float gridIntensity = terrainDebugGrid(vMapUv, 256.0, 1.5);
|
||||
float gridIntensity = terrainDebugGrid(vTerrainUv, 256.0, 1.5);
|
||||
vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green
|
||||
gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.1);
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
NoColorSpace,
|
||||
RedFormat,
|
||||
RGBAFormat,
|
||||
RepeatWrapping,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
|
|
@ -141,34 +141,42 @@ export function setupTexture<T extends Texture>(
|
|||
}
|
||||
tex.magFilter = LinearFilter;
|
||||
|
||||
tex.needsUpdate = true;
|
||||
// Only mark for upload if the texture actually has image data. Textures
|
||||
// from loadTexture() get needsUpdate set in the load callback instead.
|
||||
if (tex.image) {
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a mask texture (single channel, linear color space).
|
||||
* Used for terrain blend masks and similar data textures.
|
||||
* Pack single-channel alpha masks into RGB textures (3 masks per texture).
|
||||
* Reduces sampler count from N to ceil(N/3). Each mask goes into the R, G,
|
||||
* or B channel. All masks must be 256×256.
|
||||
*/
|
||||
export function setupMask(data: Uint8Array): DataTexture {
|
||||
const tex = new DataTexture(
|
||||
data,
|
||||
256,
|
||||
256,
|
||||
RedFormat, // 1 channel
|
||||
UnsignedByteType, // 8-bit
|
||||
);
|
||||
|
||||
// Masks should stay linear
|
||||
tex.colorSpace = NoColorSpace;
|
||||
|
||||
// Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
|
||||
tex.wrapS = tex.wrapT = RepeatWrapping;
|
||||
tex.generateMipmaps = false; // if width/height are not powers of two
|
||||
tex.minFilter = LinearFilter; // avoid mips if generateMipmaps=false
|
||||
tex.magFilter = LinearFilter;
|
||||
|
||||
tex.needsUpdate = true;
|
||||
|
||||
return tex;
|
||||
export function packMasksRGB(masks: Uint8Array[], size = 256): DataTexture[] {
|
||||
const packed: DataTexture[] = [];
|
||||
for (let i = 0; i < masks.length; i += 3) {
|
||||
const r = masks[i];
|
||||
const g = masks[i + 1];
|
||||
const b = masks[i + 2];
|
||||
const pixels = size * size;
|
||||
const rgba = new Uint8Array(pixels * 4);
|
||||
for (let j = 0; j < pixels; j++) {
|
||||
rgba[j * 4] = r[j];
|
||||
rgba[j * 4 + 1] = g ? g[j] : 0;
|
||||
rgba[j * 4 + 2] = b ? b[j] : 0;
|
||||
rgba[j * 4 + 3] = 255;
|
||||
}
|
||||
const tex = new DataTexture(rgba, size, size, RGBAFormat, UnsignedByteType);
|
||||
tex.colorSpace = NoColorSpace;
|
||||
tex.wrapS = tex.wrapT = RepeatWrapping;
|
||||
tex.generateMipmaps = false;
|
||||
tex.minFilter = LinearFilter;
|
||||
tex.magFilter = LinearFilter;
|
||||
tex.needsUpdate = true;
|
||||
packed.push(tex);
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue