mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-22 05:40:59 +00:00
begin live server support
This commit is contained in:
parent
0c9ddb476a
commit
e4ae265184
368 changed files with 17756 additions and 7738 deletions
1868
src/stream/StreamEngine.ts
Normal file
1868
src/stream/StreamEngine.ts
Normal file
File diff suppressed because it is too large
Load diff
948
src/stream/demoStreaming.ts
Normal file
948
src/stream/demoStreaming.ts
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
import {
|
||||
BlockTypeInfo,
|
||||
BlockTypeMove,
|
||||
BlockTypePacket,
|
||||
DemoParser,
|
||||
} from "t2-demo-parser";
|
||||
import { ghostToSceneObject } from "../scene";
|
||||
import {
|
||||
toEntityType,
|
||||
toEntityId,
|
||||
TICK_DURATION_MS,
|
||||
} from "./entityClassification";
|
||||
import {
|
||||
clamp,
|
||||
MAX_PITCH,
|
||||
isValidPosition,
|
||||
stripTaggedStringMarkup,
|
||||
detectControlObjectType,
|
||||
parseColorSegments,
|
||||
backpackBitmapToIndex,
|
||||
} from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
import type {
|
||||
StreamRecording,
|
||||
StreamSnapshot,
|
||||
TeamScore,
|
||||
WeaponsHudSlot,
|
||||
InventoryHudSlot,
|
||||
BackpackHudState,
|
||||
} from "./types";
|
||||
import { StreamEngine, type MutableEntity } from "./StreamEngine";
|
||||
|
||||
function extractMissionInfo(demoValues: string[]): {
|
||||
missionName: string | null;
|
||||
gameType: string | null;
|
||||
} {
|
||||
let missionName: string | null = null;
|
||||
let gameType: string | null = null;
|
||||
|
||||
for (let i = 0; i < demoValues.length; i++) {
|
||||
if (demoValues[i] !== "readplayerinfo") continue;
|
||||
const value = demoValues[i + 1];
|
||||
if (!value) continue;
|
||||
|
||||
if (value.startsWith("2\t")) {
|
||||
const fields = value.split("\t");
|
||||
if (fields[4]) {
|
||||
missionName = fields[4];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("3\t")) {
|
||||
const fields = value.split("\t");
|
||||
if (fields[2]) {
|
||||
gameType = fields[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { missionName, gameType };
|
||||
}
|
||||
|
||||
interface ParsedDemoValues {
|
||||
weaponsHud: { slots: Map<number, number>; activeIndex: number } | null;
|
||||
backpackHud: { packIndex: number; active: boolean; text: string } | null;
|
||||
inventoryHud: {
|
||||
slots: Map<number, number>;
|
||||
activeSlot: number;
|
||||
} | null;
|
||||
teamScores: TeamScore[];
|
||||
playerRoster: Map<number, { name: string; teamId: number }>;
|
||||
chatMessages: string[];
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the $DemoValue[] array to extract initial HUD state.
|
||||
*
|
||||
* Sections are written sequentially by saveDemoSettings/getState in
|
||||
* recordings.cs: MISC, PLAYERLIST, RETICLE, BACKPACK, WEAPON, INVENTORY,
|
||||
* SCORE, CLOCK, CHAT, GRAVITY.
|
||||
*/
|
||||
function parseDemoValues(demoValues: string[]): ParsedDemoValues {
|
||||
const result: ParsedDemoValues = {
|
||||
weaponsHud: null,
|
||||
backpackHud: null,
|
||||
inventoryHud: null,
|
||||
teamScores: [],
|
||||
playerRoster: new Map(),
|
||||
chatMessages: [],
|
||||
gravity: -20,
|
||||
};
|
||||
if (!demoValues.length) return result;
|
||||
|
||||
let idx = 0;
|
||||
const next = () => {
|
||||
const v = demoValues[idx++];
|
||||
return v === "<BLANK>" ? "" : (v ?? "");
|
||||
};
|
||||
|
||||
// MISC: 1 value
|
||||
next();
|
||||
|
||||
// PLAYERLIST: count + count entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const playerCount = parseInt(next(), 10) || 0;
|
||||
const playerCountByTeam = new Map<number, number>();
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const name = fields[0] ?? "";
|
||||
const clientId = parseInt(fields[2], 10);
|
||||
const teamId = parseInt(fields[4], 10);
|
||||
if (!isNaN(clientId) && !isNaN(teamId)) {
|
||||
result.playerRoster.set(clientId, { name, teamId });
|
||||
}
|
||||
if (!isNaN(teamId) && teamId > 0) {
|
||||
playerCountByTeam.set(teamId, (playerCountByTeam.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// RETICLE: 1 value
|
||||
if (idx >= demoValues.length) return result;
|
||||
next();
|
||||
|
||||
// BACKPACK: 1 value (bitmap TAB visible TAB text TAB textVisible TAB pack)
|
||||
if (idx >= demoValues.length) return result;
|
||||
{
|
||||
const backpackVal = next();
|
||||
const fields = backpackVal.split("\t");
|
||||
const bitmap = fields[0] ?? "";
|
||||
const visible = fields[1] === "1" || fields[1] === "true";
|
||||
const text = fields[2] ?? "";
|
||||
const pack = fields[4] === "1" || fields[4] === "true";
|
||||
if (visible && bitmap) {
|
||||
const packIndex = backpackBitmapToIndex(bitmap);
|
||||
result.backpackHud = { packIndex, active: pack, text };
|
||||
}
|
||||
}
|
||||
|
||||
// WEAPON: header + count bitmap entries + slotCount slot entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const weaponHeader = next().split("\t");
|
||||
const weaponCount = parseInt(weaponHeader[4], 10) || 0;
|
||||
const weaponSlotCount = parseInt(weaponHeader[5], 10) || 0;
|
||||
const weaponActive = parseInt(weaponHeader[6], 10);
|
||||
|
||||
for (let i = 0; i < weaponCount; i++) next();
|
||||
|
||||
const slots = new Map<number, number>();
|
||||
for (let i = 0; i < weaponSlotCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const slotId = parseInt(fields[0], 10);
|
||||
const ammo = parseInt(fields[1], 10);
|
||||
if (!isNaN(slotId)) {
|
||||
slots.set(slotId, isNaN(ammo) ? -1 : ammo);
|
||||
}
|
||||
}
|
||||
result.weaponsHud = {
|
||||
slots,
|
||||
activeIndex: isNaN(weaponActive) ? -1 : weaponActive,
|
||||
};
|
||||
|
||||
// INVENTORY: header + count bitmap entries + slotCount slot entries
|
||||
if (idx >= demoValues.length) return result;
|
||||
const invHeader = next().split("\t");
|
||||
const invCount = parseInt(invHeader[4], 10) || 0;
|
||||
const invSlotCount = parseInt(invHeader[5], 10) || 0;
|
||||
const invActive = parseInt(invHeader[6], 10);
|
||||
for (let i = 0; i < invCount; i++) next();
|
||||
{
|
||||
const invSlots = new Map<number, number>();
|
||||
for (let i = 0; i < invSlotCount; i++) {
|
||||
const fields = next().split("\t");
|
||||
const slotId = parseInt(fields[0], 10);
|
||||
const count = parseInt(fields[1], 10);
|
||||
if (!isNaN(slotId) && !isNaN(count) && count > 0) {
|
||||
invSlots.set(slotId, count);
|
||||
}
|
||||
}
|
||||
if (invSlots.size > 0) {
|
||||
result.inventoryHud = {
|
||||
slots: invSlots,
|
||||
activeSlot: isNaN(invActive) ? -1 : invActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// SCORE: header (visible TAB gameType TAB objCount) + objCount entries.
|
||||
if (idx >= demoValues.length) return result;
|
||||
const scoreHeader = next().split("\t");
|
||||
const gameType = scoreHeader[1] ?? "";
|
||||
const objCount = parseInt(scoreHeader[2], 10) || 0;
|
||||
const scoreObjs: string[] = [];
|
||||
for (let i = 0; i < objCount; i++) scoreObjs.push(next());
|
||||
|
||||
if (gameType === "CTFGame" && objCount >= 8) {
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const base = t * 4;
|
||||
const teamId = t + 1;
|
||||
result.teamScores.push({
|
||||
teamId,
|
||||
name: scoreObjs[base] ?? "",
|
||||
score: parseInt(scoreObjs[base + 1], 10) || 0,
|
||||
playerCount: playerCountByTeam.get(teamId) ?? 0,
|
||||
});
|
||||
}
|
||||
} else if (gameType === "TR2Game" && objCount >= 4) {
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const base = t * 2;
|
||||
const teamId = t + 1;
|
||||
result.teamScores.push({
|
||||
teamId,
|
||||
name: scoreObjs[base + 1] ?? "",
|
||||
score: parseInt(scoreObjs[base], 10) || 0,
|
||||
playerCount: playerCountByTeam.get(teamId) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CLOCK: 1 value
|
||||
if (idx >= demoValues.length) return result;
|
||||
next();
|
||||
|
||||
// CHAT: always 10 entries
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (idx >= demoValues.length) break;
|
||||
const line = next();
|
||||
if (line) {
|
||||
result.chatMessages.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// GRAVITY: 1 value
|
||||
if (idx < demoValues.length) {
|
||||
const g = parseFloat(next());
|
||||
if (Number.isFinite(g)) {
|
||||
result.gravity = g;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class StreamingPlayback extends StreamEngine {
|
||||
private readonly parser: DemoParser;
|
||||
private readonly initialBlock: {
|
||||
dataBlocks: Map<
|
||||
number,
|
||||
{ className: string; data: Record<string, unknown> }
|
||||
>;
|
||||
initialGhosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
controlObjectGhostIndex: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
targetEntries: Array<{
|
||||
targetId: number;
|
||||
name?: string;
|
||||
sensorGroup: number;
|
||||
targetData: number;
|
||||
}>;
|
||||
sensorGroupColors: Array<{
|
||||
group: number;
|
||||
targetGroup: number;
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}>;
|
||||
taggedStrings: Map<number, string>;
|
||||
initialEvents: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
demoValues: string[];
|
||||
};
|
||||
// Demo-specific: move delta tracking for V12-style camera rotation
|
||||
private moveTicks = 0;
|
||||
private absoluteYaw = 0;
|
||||
private absolutePitch = 0;
|
||||
private lastAbsYaw = 0;
|
||||
private lastAbsPitch = 0;
|
||||
private exhausted = false;
|
||||
|
||||
// Generation counters for derived-array caching in buildSnapshot().
|
||||
private _teamScoresGen = 0;
|
||||
private _rosterGen = 0;
|
||||
private _weaponsHudGen = 0;
|
||||
private _inventoryHudGen = 0;
|
||||
|
||||
// Cached snapshot
|
||||
private _cachedSnapshot: StreamSnapshot | null = null;
|
||||
private _cachedSnapshotTick = -1;
|
||||
|
||||
// Cached derived arrays
|
||||
private _snap: {
|
||||
teamScoresGen: number;
|
||||
rosterGen: number;
|
||||
teamScores: TeamScore[];
|
||||
weaponsHudGen: number;
|
||||
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
|
||||
inventoryHudGen: number;
|
||||
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
|
||||
backpackPackIndex: number;
|
||||
backpackActive: boolean;
|
||||
backpackText: string;
|
||||
backpackHud: BackpackHudState | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(parser: DemoParser) {
|
||||
super();
|
||||
this.parser = parser;
|
||||
this.registry = parser.getRegistry();
|
||||
this.ghostTracker = parser.getGhostTracker();
|
||||
const initial = parser.initialBlock;
|
||||
this.initialBlock = {
|
||||
dataBlocks: initial.dataBlocks,
|
||||
initialGhosts: initial.initialGhosts,
|
||||
controlObjectGhostIndex: initial.controlObjectGhostIndex,
|
||||
controlObjectData: initial.controlObjectData,
|
||||
targetEntries: initial.targetEntries,
|
||||
sensorGroupColors: initial.sensorGroupColors,
|
||||
taggedStrings: initial.taggedStrings,
|
||||
initialEvents: initial.initialEvents,
|
||||
demoValues: initial.demoValues,
|
||||
};
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(dataBlockId: number): Record<string, unknown> | 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>>;
|
||||
};
|
||||
return packetParser.dataBlockDataMap?.get(dataBlockId);
|
||||
}
|
||||
|
||||
private _shapeConstructorCache: Map<string, string[]> | null = null;
|
||||
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined {
|
||||
if (!this._shapeConstructorCache) {
|
||||
this._shapeConstructorCache = new Map();
|
||||
for (const [, db] of this.initialBlock.dataBlocks) {
|
||||
if (db.className !== "TSShapeConstructor" || !db.data) continue;
|
||||
const shape = db.data.shape as string | undefined;
|
||||
const seqs = db.data.sequences as string[] | undefined;
|
||||
if (shape && seqs) {
|
||||
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._shapeConstructorCache.get(shapeName.toLowerCase());
|
||||
}
|
||||
|
||||
protected getTimeSec(): number {
|
||||
return this.moveTicks * (TICK_DURATION_MS / 1000);
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(
|
||||
_data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number } {
|
||||
const hasMoves = !this.isPiloting && this.lastControlType === "player";
|
||||
const yaw = hasMoves ? this.absoluteYaw : this.lastAbsYaw;
|
||||
const pitch = hasMoves ? this.absolutePitch : this.lastAbsPitch;
|
||||
|
||||
if (hasMoves) {
|
||||
this.lastAbsYaw = yaw;
|
||||
this.lastAbsPitch = pitch;
|
||||
}
|
||||
|
||||
return { yaw, pitch };
|
||||
}
|
||||
|
||||
protected getControlPlayerHeadPitch(_pitch: number): number {
|
||||
return clamp(this.absolutePitch / MAX_PITCH, -1, 1);
|
||||
}
|
||||
|
||||
// ── Generation counter hooks ──
|
||||
|
||||
protected onTeamScoresChanged(): void {
|
||||
this._teamScoresGen++;
|
||||
}
|
||||
|
||||
protected onRosterChanged(): void {
|
||||
this._rosterGen++;
|
||||
}
|
||||
|
||||
protected onWeaponsHudChanged(): void {
|
||||
this._weaponsHudGen++;
|
||||
}
|
||||
|
||||
protected onInventoryHudChanged(): void {
|
||||
this._inventoryHudGen++;
|
||||
}
|
||||
|
||||
// ── StreamingPlayback interface ──
|
||||
|
||||
reset(): void {
|
||||
this.parser.reset();
|
||||
// parser.reset() creates a fresh GhostTracker internally — refresh our
|
||||
// reference so resolveGhostClassName doesn't use the stale one.
|
||||
this.ghostTracker = this.parser.getGhostTracker();
|
||||
this._cachedSnapshot = null;
|
||||
this._cachedSnapshotTick = -1;
|
||||
this._snap = null;
|
||||
|
||||
this.resetSharedState();
|
||||
|
||||
// Seed net strings from initial block
|
||||
for (const [id, value] of this.initialBlock.taggedStrings) {
|
||||
this.netStrings.set(id, value);
|
||||
}
|
||||
for (const entry of this.initialBlock.targetEntries) {
|
||||
if (entry.name) {
|
||||
this.targetNames.set(
|
||||
entry.targetId,
|
||||
stripTaggedStringMarkup(entry.name),
|
||||
);
|
||||
}
|
||||
this.targetTeams.set(entry.targetId, entry.sensorGroup);
|
||||
this.targetRenderFlags.set(entry.targetId, entry.targetData);
|
||||
}
|
||||
// Seed IFF color table from the initial block.
|
||||
for (const c of this.initialBlock.sensorGroupColors) {
|
||||
let map = this.sensorGroupColors.get(c.group);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this.sensorGroupColors.set(c.group, map);
|
||||
}
|
||||
map.set(c.targetGroup, { r: c.r, g: c.g, b: c.b });
|
||||
}
|
||||
|
||||
// Demo-specific state
|
||||
this.moveTicks = 0;
|
||||
this.absoluteYaw = 0;
|
||||
this.absolutePitch = 0;
|
||||
this.lastAbsYaw = 0;
|
||||
this.lastAbsPitch = 0;
|
||||
this.lastControlType =
|
||||
detectControlObjectType(this.initialBlock.controlObjectData) ?? "player";
|
||||
this.isPiloting =
|
||||
this.lastControlType === "player"
|
||||
? !!(
|
||||
this.initialBlock.controlObjectData?.pilot ||
|
||||
this.initialBlock.controlObjectData?.controlObjectGhost != null
|
||||
)
|
||||
: false;
|
||||
this.lastCameraMode =
|
||||
this.lastControlType === "camera" &&
|
||||
typeof this.initialBlock.controlObjectData?.cameraMode === "number"
|
||||
? this.initialBlock.controlObjectData.cameraMode
|
||||
: undefined;
|
||||
this.lastOrbitGhostIndex =
|
||||
this.lastControlType === "camera" &&
|
||||
typeof this.initialBlock.controlObjectData?.orbitObjectGhostIndex ===
|
||||
"number"
|
||||
? this.initialBlock.controlObjectData.orbitObjectGhostIndex
|
||||
: undefined;
|
||||
if (this.lastControlType === "camera") {
|
||||
const minOrbit = this.initialBlock.controlObjectData?.minOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
const maxOrbit = this.initialBlock.controlObjectData?.maxOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
const curOrbit = this.initialBlock.controlObjectData?.curOrbitDist as
|
||||
| number
|
||||
| undefined;
|
||||
if (
|
||||
typeof minOrbit === "number" &&
|
||||
typeof maxOrbit === "number" &&
|
||||
Number.isFinite(minOrbit) &&
|
||||
Number.isFinite(maxOrbit)
|
||||
) {
|
||||
this.lastOrbitDistance = Math.max(0, maxOrbit - minOrbit);
|
||||
} else if (typeof curOrbit === "number" && Number.isFinite(curOrbit)) {
|
||||
this.lastOrbitDistance = Math.max(0, curOrbit);
|
||||
} else {
|
||||
this.lastOrbitDistance = undefined;
|
||||
}
|
||||
} else {
|
||||
this.lastOrbitDistance = undefined;
|
||||
}
|
||||
const initialAbsRot = this.getAbsoluteRotation(
|
||||
this.initialBlock.controlObjectData,
|
||||
);
|
||||
if (initialAbsRot) {
|
||||
this.absoluteYaw = initialAbsRot.yaw;
|
||||
this.absolutePitch = initialAbsRot.pitch;
|
||||
this.lastAbsYaw = initialAbsRot.yaw;
|
||||
this.lastAbsPitch = initialAbsRot.pitch;
|
||||
}
|
||||
this.exhausted = false;
|
||||
this.latestFov = 100;
|
||||
this.latestControl = {
|
||||
ghostIndex: this.initialBlock.controlObjectGhostIndex,
|
||||
data: this.initialBlock.controlObjectData,
|
||||
position: isValidPosition(
|
||||
this.initialBlock.controlObjectData?.position as Vec3,
|
||||
)
|
||||
? (this.initialBlock.controlObjectData?.position as Vec3)
|
||||
: undefined,
|
||||
};
|
||||
this.controlPlayerGhostId =
|
||||
this.lastControlType === "player" &&
|
||||
this.initialBlock.controlObjectGhostIndex >= 0
|
||||
? toEntityId("Player", this.initialBlock.controlObjectGhostIndex)
|
||||
: undefined;
|
||||
|
||||
for (const ghost of this.initialBlock.initialGhosts) {
|
||||
if (ghost.type !== "create" || ghost.classId == null) continue;
|
||||
const className = this.registry.getGhostParser(ghost.classId)?.name;
|
||||
if (!className) {
|
||||
throw new Error(
|
||||
`No ghost parser for classId ${ghost.classId} (ghost index ${ghost.index})`,
|
||||
);
|
||||
}
|
||||
const id = toEntityId(className, ghost.index);
|
||||
const entity: MutableEntity = {
|
||||
id,
|
||||
ghostIndex: ghost.index,
|
||||
className,
|
||||
spawnTick: 0,
|
||||
type: toEntityType(className),
|
||||
rotation: [0, 0, 0, 1],
|
||||
};
|
||||
this.applyGhostData(entity, ghost.parsedData);
|
||||
if (ghost.parsedData) {
|
||||
const sceneObj = ghostToSceneObject(
|
||||
className,
|
||||
ghost.index,
|
||||
ghost.parsedData as Record<string, unknown>,
|
||||
);
|
||||
if (sceneObj) entity.sceneData = sceneObj;
|
||||
}
|
||||
this.entities.set(id, entity);
|
||||
this.entityIdByGhostIndex.set(ghost.index, id);
|
||||
}
|
||||
|
||||
// Derive playerSensorGroup from the control player entity
|
||||
if (
|
||||
this.playerSensorGroup === 0 &&
|
||||
this.lastControlType === "player" &&
|
||||
this.latestControl.ghostIndex >= 0
|
||||
) {
|
||||
const ctrlId = this.entityIdByGhostIndex.get(
|
||||
this.latestControl.ghostIndex,
|
||||
);
|
||||
const ctrlEntity = ctrlId ? this.entities.get(ctrlId) : undefined;
|
||||
if (ctrlEntity?.sensorGroup != null && ctrlEntity.sensorGroup > 0) {
|
||||
this.playerSensorGroup = ctrlEntity.sensorGroup;
|
||||
}
|
||||
}
|
||||
|
||||
// Process initial events
|
||||
for (const evt of this.initialBlock.initialEvents) {
|
||||
const eventName = this.registry.getEventParser(evt.classId)?.name;
|
||||
if (eventName === "SetSensorGroupEvent" && evt.parsedData) {
|
||||
const sg = evt.parsedData.sensorGroup as number | undefined;
|
||||
if (sg != null) this.playerSensorGroup = sg;
|
||||
} else if (eventName === "RemoteCommandEvent" && evt.parsedData) {
|
||||
const funcName = this.resolveNetString(
|
||||
evt.parsedData.funcName as string,
|
||||
);
|
||||
const args = evt.parsedData.args as string[];
|
||||
if (funcName === "ServerMessage") {
|
||||
this.handleServerMessage(args);
|
||||
}
|
||||
this.handleHudRemoteCommand(funcName, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed HUD state from demoValues
|
||||
const parsed = parseDemoValues(this.initialBlock.demoValues);
|
||||
if (parsed.weaponsHud) {
|
||||
this.weaponsHud.slots = parsed.weaponsHud.slots;
|
||||
this.weaponsHud.activeIndex = parsed.weaponsHud.activeIndex;
|
||||
}
|
||||
if (parsed.backpackHud) {
|
||||
this.backpackHud.packIndex = parsed.backpackHud.packIndex;
|
||||
this.backpackHud.active = parsed.backpackHud.active;
|
||||
this.backpackHud.text = parsed.backpackHud.text;
|
||||
}
|
||||
if (parsed.inventoryHud) {
|
||||
this.inventoryHud.slots = parsed.inventoryHud.slots;
|
||||
this.inventoryHud.activeSlot = parsed.inventoryHud.activeSlot;
|
||||
}
|
||||
this.teamScores = parsed.teamScores;
|
||||
this.playerRoster = new Map(parsed.playerRoster);
|
||||
// Seed chat messages from demoValues
|
||||
for (const rawLine of parsed.chatMessages) {
|
||||
const segments = parseColorSegments(rawLine);
|
||||
if (!segments.length) continue;
|
||||
const fullText = segments.map((s) => s.text).join("");
|
||||
if (!fullText.trim()) continue;
|
||||
const primaryColor = segments[0].colorCode;
|
||||
const hasChatColor = segments.some(
|
||||
(s) => s.colorCode === 3 || s.colorCode === 4,
|
||||
);
|
||||
const isPlayerChat = hasChatColor && fullText.includes(": ");
|
||||
if (isPlayerChat) {
|
||||
const colonIdx = fullText.indexOf(": ");
|
||||
this.chatMessages.push({
|
||||
timeSec: 0,
|
||||
sender: fullText.slice(0, colonIdx),
|
||||
text: fullText.slice(colonIdx + 2),
|
||||
kind: "chat",
|
||||
colorCode: primaryColor,
|
||||
segments,
|
||||
});
|
||||
} else {
|
||||
this.chatMessages.push({
|
||||
timeSec: 0,
|
||||
sender: "",
|
||||
text: fullText,
|
||||
kind: "server",
|
||||
colorCode: primaryColor,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCameraAndHud();
|
||||
}
|
||||
|
||||
getSnapshot(): StreamSnapshot {
|
||||
if (
|
||||
this._cachedSnapshot &&
|
||||
this._cachedSnapshotTick === this.moveTicks
|
||||
) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const collectShapesFromExplosion = (expBlock: Record<string, unknown>) => {
|
||||
const shape = expBlock.dtsFileName as string | undefined;
|
||||
if (shape) shapes.add(shape);
|
||||
const subExplosions = expBlock.subExplosions as
|
||||
| (number | null)[]
|
||||
| undefined;
|
||||
if (Array.isArray(subExplosions)) {
|
||||
for (const subId of subExplosions) {
|
||||
if (subId == null) continue;
|
||||
const subBlock = this.getDataBlockData(subId);
|
||||
if (subBlock?.dtsFileName) {
|
||||
shapes.add(subBlock.dtsFileName as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const [, block] of this.initialBlock.dataBlocks) {
|
||||
const explosionId = block.data?.explosion as number | undefined;
|
||||
if (explosionId == null) continue;
|
||||
const expBlock = this.getDataBlockData(explosionId);
|
||||
if (expBlock) collectShapesFromExplosion(expBlock);
|
||||
}
|
||||
return [...shapes];
|
||||
}
|
||||
|
||||
stepToTime(
|
||||
targetTimeSec: number,
|
||||
maxMoveTicks = Number.POSITIVE_INFINITY,
|
||||
): StreamSnapshot {
|
||||
const safeTargetSec = Number.isFinite(targetTimeSec)
|
||||
? Math.max(0, targetTimeSec)
|
||||
: 0;
|
||||
const targetTicks = Math.floor((safeTargetSec * 1000) / TICK_DURATION_MS);
|
||||
|
||||
let didReset = false;
|
||||
if (targetTicks < this.moveTicks) {
|
||||
this.reset();
|
||||
didReset = true;
|
||||
}
|
||||
|
||||
const wasExhausted = this.exhausted;
|
||||
let movesProcessed = 0;
|
||||
while (
|
||||
!this.exhausted &&
|
||||
this.moveTicks < targetTicks &&
|
||||
movesProcessed < maxMoveTicks
|
||||
) {
|
||||
if (!this.stepOneMoveTick()) {
|
||||
break;
|
||||
}
|
||||
movesProcessed += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
movesProcessed === 0 &&
|
||||
!didReset &&
|
||||
wasExhausted === this.exhausted &&
|
||||
this._cachedSnapshot &&
|
||||
this._cachedSnapshotTick === this.moveTicks
|
||||
) {
|
||||
return this._cachedSnapshot;
|
||||
}
|
||||
|
||||
const snapshot = this.buildSnapshot();
|
||||
this._cachedSnapshot = snapshot;
|
||||
this._cachedSnapshotTick = this.moveTicks;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ── Demo block processing ──
|
||||
|
||||
private stepOneMoveTick(): boolean {
|
||||
while (true) {
|
||||
const block = this.parser.nextBlock();
|
||||
if (!block) {
|
||||
this.exhausted = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.handleBlock(block);
|
||||
|
||||
if (block.type === BlockTypeMove) {
|
||||
this.moveTicks += 1;
|
||||
this.tickCount = this.moveTicks;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
this.removeExpiredExplosions();
|
||||
this.updateCameraAndHud();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlock(block: { type: number; parsed?: unknown }): void {
|
||||
if (block.type === BlockTypePacket && this.isPacketData(block.parsed)) {
|
||||
const packet = block.parsed;
|
||||
|
||||
// Process control object
|
||||
this.processControlObject(packet.gameState);
|
||||
|
||||
// Apply ghost rotation to absolute tracking. This must happen before
|
||||
// the next move delta so that our tracking stays calibrated to V12.
|
||||
const controlData = packet.gameState.controlObjectData;
|
||||
if (controlData) {
|
||||
const absRot = this.getAbsoluteRotation(controlData);
|
||||
if (absRot) {
|
||||
this.absoluteYaw = absRot.yaw;
|
||||
this.absolutePitch = absRot.pitch;
|
||||
this.lastAbsYaw = absRot.yaw;
|
||||
this.lastAbsPitch = absRot.pitch;
|
||||
}
|
||||
}
|
||||
|
||||
for (const evt of packet.events) {
|
||||
const eventName = this.registry.getEventParser(evt.classId)?.name;
|
||||
this.processEvent(evt, eventName);
|
||||
}
|
||||
|
||||
for (const ghost of packet.ghosts) {
|
||||
this.processGhostUpdate(ghost);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === BlockTypeInfo && this.isInfoData(block.parsed)) {
|
||||
if (Number.isFinite(block.parsed.value2)) {
|
||||
this.latestFov = block.parsed.value2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === BlockTypeMove && this.isMoveData(block.parsed)) {
|
||||
// Replicate V12 Player::updateMove(): apply delta then wrap/clamp.
|
||||
this.absoluteYaw += block.parsed.yaw ?? 0;
|
||||
const TWO_PI = Math.PI * 2;
|
||||
this.absoluteYaw =
|
||||
((this.absoluteYaw % TWO_PI) + TWO_PI) % TWO_PI;
|
||||
this.absolutePitch = clamp(
|
||||
this.absolutePitch + (block.parsed.pitch ?? 0),
|
||||
-MAX_PITCH,
|
||||
MAX_PITCH,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build snapshot (with generation-counter caching) ──
|
||||
|
||||
private buildSnapshot(): StreamSnapshot {
|
||||
const entities = this.buildEntityList();
|
||||
const timeSec = this.getTimeSec();
|
||||
const prev = this._snap;
|
||||
|
||||
const { chatMessages, audioEvents } =
|
||||
this.buildTimeFilteredEvents(timeSec);
|
||||
|
||||
const weaponsHud =
|
||||
prev && prev.weaponsHudGen === this._weaponsHudGen
|
||||
? prev.weaponsHud
|
||||
: {
|
||||
slots: Array.from(this.weaponsHud.slots.entries()).map(
|
||||
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
|
||||
),
|
||||
activeIndex: this.weaponsHud.activeIndex,
|
||||
};
|
||||
|
||||
const inventoryHud =
|
||||
prev && prev.inventoryHudGen === this._inventoryHudGen
|
||||
? prev.inventoryHud
|
||||
: {
|
||||
slots: Array.from(this.inventoryHud.slots.entries()).map(
|
||||
([slot, count]): InventoryHudSlot => ({ slot, count }),
|
||||
),
|
||||
activeSlot: this.inventoryHud.activeSlot,
|
||||
};
|
||||
|
||||
const backpackHud =
|
||||
prev &&
|
||||
prev.backpackPackIndex === this.backpackHud.packIndex &&
|
||||
prev.backpackActive === this.backpackHud.active &&
|
||||
prev.backpackText === this.backpackHud.text
|
||||
? prev.backpackHud
|
||||
: this.backpackHud.packIndex >= 0
|
||||
? { ...this.backpackHud }
|
||||
: null;
|
||||
|
||||
let teamScores: TeamScore[];
|
||||
if (
|
||||
prev &&
|
||||
prev.teamScoresGen === this._teamScoresGen &&
|
||||
prev.rosterGen === this._rosterGen
|
||||
) {
|
||||
teamScores = prev.teamScores;
|
||||
} else {
|
||||
teamScores = this.teamScores.map((ts) => ({ ...ts }));
|
||||
const teamCounts = new Map<number, number>();
|
||||
for (const { teamId } of this.playerRoster.values()) {
|
||||
if (teamId > 0) {
|
||||
teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const ts of teamScores) {
|
||||
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
this._snap = {
|
||||
teamScoresGen: this._teamScoresGen,
|
||||
rosterGen: this._rosterGen,
|
||||
teamScores,
|
||||
weaponsHudGen: this._weaponsHudGen,
|
||||
weaponsHud,
|
||||
inventoryHudGen: this._inventoryHudGen,
|
||||
inventoryHud,
|
||||
backpackPackIndex: this.backpackHud.packIndex,
|
||||
backpackActive: this.backpackHud.active,
|
||||
backpackText: this.backpackHud.text,
|
||||
backpackHud,
|
||||
};
|
||||
|
||||
return {
|
||||
timeSec,
|
||||
exhausted: this.exhausted,
|
||||
camera: this.camera,
|
||||
entities,
|
||||
controlPlayerGhostId: this.controlPlayerGhostId,
|
||||
playerSensorGroup: this.playerSensorGroup,
|
||||
status: this.lastStatus,
|
||||
chatMessages,
|
||||
audioEvents,
|
||||
weaponsHud,
|
||||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Type guards ──
|
||||
|
||||
private isPacketData(parsed: unknown): parsed is {
|
||||
gameState: {
|
||||
controlObjectGhostIndex?: number;
|
||||
controlObjectData?: Record<string, unknown>;
|
||||
compressionPoint?: Vec3;
|
||||
cameraFov?: number;
|
||||
};
|
||||
events: Array<{
|
||||
classId: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
ghosts: Array<{
|
||||
index: number;
|
||||
type: "create" | "update" | "delete";
|
||||
classId?: number;
|
||||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
} {
|
||||
return (
|
||||
!!parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"gameState" in parsed &&
|
||||
"events" in parsed &&
|
||||
"ghosts" in parsed
|
||||
);
|
||||
}
|
||||
|
||||
private isMoveData(
|
||||
parsed: unknown,
|
||||
): parsed is { yaw?: number; pitch?: number } {
|
||||
return !!parsed && typeof parsed === "object" && "yaw" in parsed;
|
||||
}
|
||||
|
||||
private isInfoData(parsed: unknown): parsed is { value2: number } {
|
||||
return (
|
||||
!!parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"value2" in parsed &&
|
||||
typeof (parsed as { value2?: unknown }).value2 === "number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDemoStreamingRecording(
|
||||
data: ArrayBuffer,
|
||||
): Promise<StreamRecording> {
|
||||
const parser = new DemoParser(new Uint8Array(data));
|
||||
const { header, initialBlock } = await parser.load();
|
||||
const { missionName: infoMissionName, gameType } = extractMissionInfo(
|
||||
initialBlock.demoValues,
|
||||
);
|
||||
|
||||
return {
|
||||
source: "demo",
|
||||
duration: header.demoLengthMs / 1000,
|
||||
missionName: infoMissionName ?? initialBlock.missionName ?? null,
|
||||
gameType,
|
||||
streamingPlayback: new StreamingPlayback(parser),
|
||||
};
|
||||
}
|
||||
185
src/stream/entityBridge.ts
Normal file
185
src/stream/entityBridge.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import type { StreamEntity } from "./types";
|
||||
import type {
|
||||
GameEntity,
|
||||
ShapeEntity,
|
||||
PlayerEntity,
|
||||
ForceFieldBareEntity,
|
||||
ExplosionEntity,
|
||||
TracerEntity,
|
||||
SpriteEntity,
|
||||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import type { SceneTSStatic } from "../scene/types";
|
||||
|
||||
/** Common fields extracted from a StreamEntity for positioned game entities. */
|
||||
function positionedBase(entity: StreamEntity, spawnTime?: number) {
|
||||
return {
|
||||
id: entity.id,
|
||||
className: entity.className ?? entity.type,
|
||||
ghostIndex: entity.ghostIndex,
|
||||
dataBlockId: entity.dataBlockId,
|
||||
shapeHint: entity.shapeHint,
|
||||
spawnTime,
|
||||
position: entity.position,
|
||||
rotation: entity.rotation,
|
||||
velocity: entity.velocity,
|
||||
keyframes: [
|
||||
{
|
||||
time: spawnTime ?? 0,
|
||||
position: entity.position ?? [0, 0, 0] as [number, number, number],
|
||||
rotation: entity.rotation ?? [0, 0, 0, 1] as [number, number, number, number],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a StreamEntity to a GameEntity for the entity store. */
|
||||
export function streamEntityToGameEntity(
|
||||
entity: StreamEntity,
|
||||
spawnTime?: number,
|
||||
): GameEntity {
|
||||
// Scene infrastructure — routed from sceneData
|
||||
if (entity.sceneData) {
|
||||
const base = {
|
||||
id: entity.id,
|
||||
className: entity.className ?? entity.type,
|
||||
ghostIndex: entity.ghostIndex,
|
||||
dataBlockId: entity.dataBlockId,
|
||||
shapeHint: entity.shapeHint,
|
||||
spawnTime,
|
||||
};
|
||||
switch (entity.sceneData.className) {
|
||||
case "TerrainBlock":
|
||||
return { ...base, renderType: "TerrainBlock", terrainData: entity.sceneData };
|
||||
case "InteriorInstance":
|
||||
return { ...base, renderType: "InteriorInstance", interiorData: entity.sceneData };
|
||||
case "Sky":
|
||||
return { ...base, renderType: "Sky", skyData: entity.sceneData };
|
||||
case "Sun":
|
||||
return { ...base, renderType: "Sun", sunData: entity.sceneData };
|
||||
case "WaterBlock":
|
||||
return { ...base, renderType: "WaterBlock", waterData: entity.sceneData };
|
||||
case "MissionArea":
|
||||
return { ...base, renderType: "MissionArea", missionAreaData: entity.sceneData };
|
||||
case "TSStatic":
|
||||
// TSStatic is rendered as a shape — extract shapeName from scene data.
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Shape",
|
||||
shapeName: (entity.sceneData as SceneTSStatic).shapeName,
|
||||
shapeType: "TSStatic",
|
||||
dataBlock: entity.dataBlock,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
}
|
||||
|
||||
// Projectile visuals
|
||||
if (entity.visual?.kind === "tracer") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Tracer",
|
||||
visual: entity.visual,
|
||||
dataBlock: entity.dataBlock,
|
||||
direction: entity.direction,
|
||||
} satisfies TracerEntity;
|
||||
}
|
||||
if (entity.visual?.kind === "sprite") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Sprite",
|
||||
visual: entity.visual,
|
||||
} satisfies SpriteEntity;
|
||||
}
|
||||
|
||||
// Player
|
||||
if (entity.type === "Player") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Player",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
playerName: entity.playerName,
|
||||
iffColor: entity.iffColor,
|
||||
threads: entity.threads,
|
||||
weaponImageState: entity.weaponImageState,
|
||||
weaponImageStates: entity.weaponImageStates,
|
||||
headPitch: entity.headPitch,
|
||||
headYaw: entity.headYaw,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
} satisfies PlayerEntity;
|
||||
}
|
||||
|
||||
// Explosion
|
||||
if (entity.type === "Explosion") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Explosion",
|
||||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
explosionDataBlockId: entity.explosionDataBlockId,
|
||||
faceViewer: entity.faceViewer,
|
||||
} satisfies ExplosionEntity;
|
||||
}
|
||||
|
||||
// Force field
|
||||
if (entity.className === "ForceFieldBare") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "ForceFieldBare",
|
||||
} satisfies ForceFieldBareEntity;
|
||||
}
|
||||
|
||||
// Audio emitter
|
||||
if (entity.className === "AudioEmitter") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "AudioEmitter",
|
||||
audioFileName: entity.audioFileName,
|
||||
audioVolume: entity.audioVolume,
|
||||
audioIs3D: entity.audioIs3D,
|
||||
audioIsLooping: entity.audioIsLooping ?? true,
|
||||
audioMinDistance: entity.audioMinDistance,
|
||||
audioMaxDistance: entity.audioMaxDistance,
|
||||
audioMinLoopGap: entity.audioMinLoopGap,
|
||||
audioMaxLoopGap: entity.audioMaxLoopGap,
|
||||
} satisfies AudioEmitterEntity;
|
||||
}
|
||||
|
||||
// WayPoint
|
||||
if (entity.className === "WayPoint") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "WayPoint",
|
||||
label: entity.label,
|
||||
} satisfies WayPointEntity;
|
||||
}
|
||||
|
||||
// Camera
|
||||
if (entity.className === "Camera") {
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Camera",
|
||||
} satisfies CameraEntity;
|
||||
}
|
||||
|
||||
// Default: generic DTS shape
|
||||
return {
|
||||
...positionedBase(entity, spawnTime),
|
||||
renderType: "Shape",
|
||||
shapeName: entity.dataBlock,
|
||||
shapeType:
|
||||
entity.className === "Turret"
|
||||
? "Turret"
|
||||
: entity.className === "Item"
|
||||
? "Item"
|
||||
: "StaticShape",
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
threads: entity.threads,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
iffColor: entity.iffColor,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
69
src/stream/entityClassification.ts
Normal file
69
src/stream/entityClassification.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/** Class names for vehicle ghosts. */
|
||||
export const vehicleClassNames = new Set([
|
||||
"FlyingVehicle",
|
||||
"HoverVehicle",
|
||||
"WheeledVehicle",
|
||||
]);
|
||||
|
||||
/** All projectile class names. */
|
||||
export const projectileClassNames = new Set([
|
||||
"BombProjectile",
|
||||
"EnergyProjectile",
|
||||
"FlareProjectile",
|
||||
"GrenadeProjectile",
|
||||
"LinearFlareProjectile",
|
||||
"LinearProjectile",
|
||||
"Projectile",
|
||||
"SeekerProjectile",
|
||||
"TracerProjectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes with linear (constant-velocity) physics. */
|
||||
export const linearProjectileClassNames = new Set([
|
||||
"LinearProjectile",
|
||||
"TracerProjectile",
|
||||
"LinearFlareProjectile",
|
||||
"Projectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes with ballistic (gravity-affected) physics. */
|
||||
export const ballisticProjectileClassNames = new Set([
|
||||
"GrenadeProjectile",
|
||||
"EnergyProjectile",
|
||||
"FlareProjectile",
|
||||
"BombProjectile",
|
||||
]);
|
||||
|
||||
/** Projectile classes that use seeking (homing) physics. */
|
||||
export const seekerProjectileClassNames = new Set(["SeekerProjectile"]);
|
||||
|
||||
/** Deployable/placed object class names. */
|
||||
export const deployableClassNames = new Set([
|
||||
"StaticShape",
|
||||
"ScopeAlwaysShape",
|
||||
"Turret",
|
||||
"BeaconObject",
|
||||
"ForceFieldBare",
|
||||
]);
|
||||
|
||||
/** Map a ghost class name to a high-level entity type string. */
|
||||
export function toEntityType(className: string): string {
|
||||
if (className === "Player") return "Player";
|
||||
if (vehicleClassNames.has(className)) return "Vehicle";
|
||||
if (className === "Item") return "Item";
|
||||
if (projectileClassNames.has(className)) return "Projectile";
|
||||
if (deployableClassNames.has(className)) return "Deployable";
|
||||
return "Ghost";
|
||||
}
|
||||
|
||||
/** Generate a stable entity ID from ghost class name and index. */
|
||||
export function toEntityId(className: string, ghostIndex: number): string {
|
||||
return `${className}_${ghostIndex}`;
|
||||
}
|
||||
|
||||
/** Tribes 2 default IFF colors (sRGB 0-255). */
|
||||
export const IFF_GREEN = Object.freeze({ r: 0, g: 255, b: 0 });
|
||||
export const IFF_RED = Object.freeze({ r: 255, g: 0, b: 0 });
|
||||
|
||||
/** Torque engine tick duration in milliseconds. */
|
||||
export const TICK_DURATION_MS = 32;
|
||||
555
src/stream/liveStreaming.ts
Normal file
555
src/stream/liveStreaming.ts
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
import {
|
||||
createLiveParser,
|
||||
type PacketParser,
|
||||
} from "t2-demo-parser";
|
||||
import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
import type { StreamSnapshot } from "./types";
|
||||
import { StreamEngine } from "./StreamEngine";
|
||||
import type { RelayClient } from "./relayClient";
|
||||
|
||||
// ── Player list entry ──
|
||||
|
||||
export interface PlayerListEntry {
|
||||
targetId: number;
|
||||
name: string;
|
||||
sensorGroup: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts live game packets from a relay connection into the
|
||||
* StreamingPlayback interface used by the existing rendering pipeline.
|
||||
*/
|
||||
export class LiveStreamAdapter extends StreamEngine {
|
||||
private packetParser: PacketParser;
|
||||
relay: RelayClient;
|
||||
|
||||
private currentTimeSec = 0;
|
||||
private connectSynced = false;
|
||||
private _snapshot: StreamSnapshot | null = null;
|
||||
private _snapshotTick = -1;
|
||||
private _ready = false;
|
||||
/** Class names for datablocks, tracked from SimDataBlockEvents. */
|
||||
private dataBlockClassNames = new Map<number, string>();
|
||||
|
||||
/** Called once when the first ghost entity is created. */
|
||||
onReady?: () => void;
|
||||
|
||||
constructor(relay: RelayClient) {
|
||||
super();
|
||||
this.relay = relay;
|
||||
const { registry, ghostTracker, packetParser } = createLiveParser();
|
||||
this.packetParser = packetParser;
|
||||
this.ghostTracker = ghostTracker;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
// ── StreamEngine abstract implementations ──
|
||||
|
||||
getDataBlockData(id: number): Record<string, unknown> | undefined {
|
||||
return this.packetParser.getDataBlockDataMap()?.get(id);
|
||||
}
|
||||
|
||||
private _shapeConstructorCache: Map<string, string[]> | null = null;
|
||||
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined {
|
||||
// Rebuild cache each call since datablocks arrive incrementally.
|
||||
this._shapeConstructorCache = new Map();
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
if (!dbMap) return undefined;
|
||||
for (const [, block] of dbMap) {
|
||||
const shape = block.shape as string | undefined;
|
||||
const seqs = block.sequences as string[] | undefined;
|
||||
if (shape && seqs) {
|
||||
this._shapeConstructorCache.set(shape.toLowerCase(), seqs);
|
||||
}
|
||||
}
|
||||
return this._shapeConstructorCache.get(shapeName.toLowerCase());
|
||||
}
|
||||
|
||||
protected getTimeSec(): number {
|
||||
return this.currentTimeSec;
|
||||
}
|
||||
|
||||
protected getCameraYawPitch(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number } {
|
||||
const absRot = this.getAbsoluteRotation(data);
|
||||
return absRot ?? { yaw: 0, pitch: 0 };
|
||||
}
|
||||
|
||||
getEffectShapes(): string[] {
|
||||
const shapes = new Set<string>();
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
if (!dbMap) return [];
|
||||
for (const [, block] of dbMap) {
|
||||
const explosionId = block.explosion as number | undefined;
|
||||
if (explosionId == null) continue;
|
||||
const expBlock = dbMap.get(explosionId);
|
||||
if (expBlock?.dtsFileName) {
|
||||
shapes.add(expBlock.dtsFileName as string);
|
||||
}
|
||||
}
|
||||
return [...shapes];
|
||||
}
|
||||
|
||||
// ── StreamingPlayback interface ──
|
||||
|
||||
reset(): void {
|
||||
this.resetSharedState();
|
||||
this.ghostTracker.clear?.();
|
||||
this.currentTimeSec = 0;
|
||||
this._snapshot = null;
|
||||
this._snapshotTick = -1;
|
||||
this.dataBlockClassNames.clear();
|
||||
this.observerMode = "fly";
|
||||
}
|
||||
|
||||
getSnapshot(): StreamSnapshot {
|
||||
if (this._snapshot && this._snapshotTick === this.tickCount) {
|
||||
return this._snapshot;
|
||||
}
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
stepToTime(
|
||||
targetTimeSec: number,
|
||||
_maxMoveTicks?: number,
|
||||
): StreamSnapshot {
|
||||
this.currentTimeSec = targetTimeSec;
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
// ── Live-specific: connect sequence sync ──
|
||||
|
||||
private syncConnectSequence(data: Uint8Array): void {
|
||||
if (this.connectSynced || data.length < 1) return;
|
||||
this.connectSynced = true;
|
||||
const connectSeqBit = (data[0] >> 1) & 1;
|
||||
// The browser parser is a passive observer — it never sends packets
|
||||
// (the relay handles all outgoing UDP traffic). Set lastSendSeq very
|
||||
// high so the parser's ack validation (lastSendSeq < highestAck →
|
||||
// reject) never fires. Without this, the parser rejects any packet
|
||||
// where the server acks relay-sent sequences (e.g. auth events).
|
||||
this.packetParser.setConnectionProtocolState({
|
||||
lastSeqRecvdAtSend: new Array(32).fill(0),
|
||||
lastSeqRecvd: 0,
|
||||
highestAckedSeq: 0,
|
||||
lastSendSeq: 0x1fffffff,
|
||||
ackMask: 0,
|
||||
connectSequence: connectSeqBit,
|
||||
lastRecvAckAck: 0,
|
||||
connectionEstablished: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Live-specific: feed raw packet ──
|
||||
|
||||
feedPacket(data: Uint8Array): void {
|
||||
this.syncConnectSequence(data);
|
||||
this.processPacket(data);
|
||||
}
|
||||
|
||||
// ── Live-specific: auth event detection ──
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if (!rawFuncName) return;
|
||||
const funcName = this.resolveNetString(rawFuncName);
|
||||
|
||||
// T2csri auth events → forward to relay for crypto processing.
|
||||
const authCommands = [
|
||||
"t2csri_pokeClient",
|
||||
"t2csri_getChallengeChunk",
|
||||
"t2csri_decryptChallenge",
|
||||
];
|
||||
if (authCommands.includes(funcName)) {
|
||||
const rawArgs = (parsedData.args as string[]) ?? [];
|
||||
const args = rawArgs
|
||||
.map((a) => this.resolveNetString(a))
|
||||
.filter((a) => a !== "");
|
||||
console.log(`[live] auth event: ${funcName}`, args);
|
||||
this.relay.sendAuthEvent(funcName, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 resolvedArgs = rawArgs.map((a) => this.resolveNetString(a));
|
||||
if (funcName === "MissionStartPhase1") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 1, seq=${seq}`);
|
||||
this.relay.sendCommand("MissionStartPhase1Done", [seq]);
|
||||
} else if (funcName === "MissionStartPhase2") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 2 (datablocks), seq=${seq}`);
|
||||
this.relay.sendCommand("MissionStartPhase2Done", [seq]);
|
||||
} else if (funcName === "MissionStartPhase3") {
|
||||
const seq = resolvedArgs[0] ?? "";
|
||||
console.log(`[live] mission phase 3 (ghosting), seq=${seq}`);
|
||||
// Send an empty favorites list then acknowledge phase 3.
|
||||
this.relay.sendCommand("setClientFav", [""]);
|
||||
this.relay.sendCommand("MissionStartPhase3Done", [seq]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// field1 bit 0 = includeTextures (from $Host::CRCTextures)
|
||||
const includeTextures = (field1 & 1) !== 0;
|
||||
console.log(
|
||||
`[live] CRC challenge: seed=0x${(seed >>> 0).toString(16)} ` +
|
||||
`f1=0x${(field1 >>> 0).toString(16)} f2=0x${(field2 >>> 0).toString(16)} ` +
|
||||
`includeTextures=${includeTextures}`,
|
||||
);
|
||||
|
||||
// Collect datablocks for relay-side CRC computation over game files.
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
const datablocks: { objectId: number; className: string; shapeName: string }[] = [];
|
||||
if (dbMap) {
|
||||
for (const [id, block] of dbMap) {
|
||||
const className = this.dataBlockClassNames.get(id);
|
||||
if (!className) continue;
|
||||
const shapeName = resolveShapeName(className, block as Record<string, unknown>);
|
||||
datablocks.push({
|
||||
objectId: id,
|
||||
className,
|
||||
shapeName: shapeName ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`[live] CRC: sending ${datablocks.length} datablocks for computation`);
|
||||
this.relay.sendCRCCompute(seed, field2, datablocks, includeTextures);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to GhostingMessageEvent type 0 (GhostAlwaysDone).
|
||||
* 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;
|
||||
console.log(
|
||||
`[live] GhostingMessageEvent: message=${message} sequence=${sequence} ghostCount=${ghostCount}`,
|
||||
);
|
||||
if (message === 0) {
|
||||
// GhostAlwaysDone → send type 1 acknowledgment
|
||||
console.log(`[live] Sending ghost ack (type 1) for sequence ${sequence}`);
|
||||
this.relay.sendGhostAck(sequence, ghostCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side observer camera mode. In "fly" mode, trigger 0 (fire) would
|
||||
* make the server assign a team — so we must NEVER send fire in fly mode.
|
||||
* Jump (trigger 2) transitions between modes.
|
||||
*/
|
||||
observerMode: "fly" | "follow" = "fly";
|
||||
|
||||
/** Enter follow mode (from fly) or cycle to next player (in follow). */
|
||||
cycleObserveNext(): void {
|
||||
if (this.observerMode === "fly") {
|
||||
// Jump trigger enters observerFollow from observerFly
|
||||
console.log("[live] observer: fly → follow (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "follow";
|
||||
} else {
|
||||
// Fire trigger cycles to next player in observerFollow
|
||||
console.log("[live] observer: cycle next (fire trigger)");
|
||||
this.sendTrigger(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle between follow and free-fly observer modes. */
|
||||
toggleObserverMode(): void {
|
||||
if (this.observerMode === "fly") {
|
||||
// Jump trigger enters observerFollow from observerFly
|
||||
console.log("[live] observer: fly → follow (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "follow";
|
||||
} else {
|
||||
// Jump trigger returns to observerFly from observerFollow
|
||||
console.log("[live] observer: follow → fly (jump trigger)");
|
||||
this.sendTrigger(2);
|
||||
this.observerMode = "fly";
|
||||
}
|
||||
}
|
||||
|
||||
private sendTrigger(index: number): void {
|
||||
const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] =
|
||||
[false, false, false, false, false, false];
|
||||
trigger[index] = true;
|
||||
this.relay.sendMove({
|
||||
x: 0, y: 0, z: 0,
|
||||
yaw: 0, pitch: 0, roll: 0,
|
||||
trigger,
|
||||
freeLook: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the player list (for observer cycling UI). */
|
||||
getPlayerList(): PlayerListEntry[] {
|
||||
const entries: PlayerListEntry[] = [];
|
||||
for (const [targetId, name] of this.targetNames) {
|
||||
const sg = this.targetTeams.get(targetId) ?? 0;
|
||||
entries.push({ targetId, name, sensorGroup: sg });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Packet processing ──
|
||||
|
||||
private processPacket(data: Uint8Array): void {
|
||||
try {
|
||||
const rejectedBefore = this.packetParser.protocolRejected;
|
||||
const noDispatchBefore = this.packetParser.protocolNoDispatch;
|
||||
const parsed = this.packetParser.parsePacket(data);
|
||||
const wasRejected = this.packetParser.protocolRejected > rejectedBefore;
|
||||
const wasNoDispatch = this.packetParser.protocolNoDispatch > noDispatchBefore;
|
||||
|
||||
if (wasRejected || wasNoDispatch) {
|
||||
console.warn(
|
||||
`[live] packet #${this.tickCount} ${wasRejected ? "REJECTED" : "no-dispatch"}: ${data.length} bytes` +
|
||||
` (total rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch})`,
|
||||
);
|
||||
}
|
||||
|
||||
const isEarlyPacket = this.tickCount < 20;
|
||||
const isMilestonePacket = this.tickCount % 100 === 0;
|
||||
const shouldLog = isEarlyPacket || isMilestonePacket;
|
||||
|
||||
if (shouldLog) {
|
||||
console.log(
|
||||
`[live] packet #${this.tickCount}: ${parsed.events.length} events, ${parsed.ghosts.length} ghosts, ${data.length} bytes` +
|
||||
(parsed.gameState.controlObjectGhostIndex !== undefined
|
||||
? `, control=${parsed.gameState.controlObjectGhostIndex}`
|
||||
: "") +
|
||||
(parsed.gameState.cameraFov !== undefined
|
||||
? `, fov=${parsed.gameState.cameraFov}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Control object state
|
||||
this.processControlObject(parsed.gameState);
|
||||
|
||||
// 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;
|
||||
|
||||
// Log events in early packets
|
||||
if (isEarlyPacket) {
|
||||
if (type !== "NetStringEvent") {
|
||||
console.log(
|
||||
`[live] event: ${type}`,
|
||||
type === "RemoteCommandEvent"
|
||||
? { funcName: this.resolveNetString(event.parsedData.funcName as string ?? "") }
|
||||
: type === "SimDataBlockEvent"
|
||||
? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track SimDataBlockEvent class names for CRC computation.
|
||||
if (type === "SimDataBlockEvent") {
|
||||
const dbId = event.parsedData.objectId as number | undefined;
|
||||
const dbClassName = event.parsedData.dataBlockClassName as string | undefined;
|
||||
if (dbId != null && dbClassName) {
|
||||
this.dataBlockClassNames.set(dbId, dbClassName);
|
||||
}
|
||||
if (shouldLog) {
|
||||
const dbData = event.parsedData.dataBlockData as Record<string, unknown> | undefined;
|
||||
const shapeName = resolveShapeName(dbClassName ?? "", dbData);
|
||||
console.log(
|
||||
`[live] datablock: id=${dbId} class=${dbClassName ?? "?"}` +
|
||||
(shapeName ? ` shape=${shapeName}` : ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eventName = this.registry.getEventParser(event.classId)?.name;
|
||||
this.processEvent(event, eventName);
|
||||
|
||||
// Log net strings in early packets
|
||||
if (isEarlyPacket && type === "NetStringEvent") {
|
||||
const id = event.parsedData.id as number;
|
||||
const value = event.parsedData.value as string;
|
||||
if (id != null && typeof value === "string") {
|
||||
console.log(`[live] netString #${id} = "${value.length > 60 ? value.slice(0, 60) + "…" : value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log target info
|
||||
if (type === "TargetInfoEvent") {
|
||||
const targetId = event.parsedData.targetId as number | undefined;
|
||||
const nameTag = event.parsedData.nameTag as number | undefined;
|
||||
if (targetId != null && nameTag != null) {
|
||||
const resolved = this.netStrings.get(nameTag);
|
||||
if (resolved) {
|
||||
const name = stripTaggedStringMarkup(resolved);
|
||||
console.log(`[live] target #${targetId}: "${name}" team=${event.parsedData.sensorGroup ?? "?"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log sensor group changes
|
||||
if (type === "SetSensorGroupEvent") {
|
||||
const sg = event.parsedData.sensorGroup as number | undefined;
|
||||
if (sg != null) {
|
||||
console.log(`[live] sensor group changed: → ${sg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log sensor group colors
|
||||
if (type === "SensorGroupColorEvent") {
|
||||
const sg = event.parsedData.sensorGroup as number;
|
||||
const colors = event.parsedData.colors as Array<unknown> | undefined;
|
||||
if (colors) {
|
||||
console.log(
|
||||
`[live] sensor group colors: group=${sg}, ${colors.length} entries`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ghosts
|
||||
for (const ghost of parsed.ghosts) {
|
||||
if (ghost.type === "create") {
|
||||
const pos = ghost.parsedData?.position as Vec3 | undefined;
|
||||
const hasPos = pos && typeof pos.x === "number" && typeof pos.y === "number" && typeof pos.z === "number";
|
||||
const className = this.resolveGhostClassName(ghost.index, ghost.classId);
|
||||
console.log(
|
||||
`[live] ghost create: #${ghost.index} ${className ?? "?"}` +
|
||||
(hasPos ? ` at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})` : "") +
|
||||
` (${this.entities.size + 1} entities total)`,
|
||||
);
|
||||
if (!this._ready) {
|
||||
this._ready = true;
|
||||
this.onReady?.();
|
||||
}
|
||||
} else if (ghost.type === "delete") {
|
||||
const prevEntityId = this.entityIdByGhostIndex.get(ghost.index);
|
||||
const prevEntity = prevEntityId ? this.entities.get(prevEntityId) : undefined;
|
||||
if (this.tickCount < 50 || this.tickCount % 200 === 0) {
|
||||
console.log(
|
||||
`[live] ghost delete: #${ghost.index} ${prevEntity?.className ?? "?"}` +
|
||||
` (${this.entities.size - 1} entities remaining)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.processGhostUpdate(ghost);
|
||||
}
|
||||
|
||||
this.tickCount++;
|
||||
this.advanceProjectiles();
|
||||
this.advanceItems();
|
||||
|
||||
// Periodic status at milestones
|
||||
if (isMilestonePacket && this.tickCount > 1) {
|
||||
const dbMap = this.packetParser.getDataBlockDataMap();
|
||||
console.log(
|
||||
`[live] status @ tick ${this.tickCount}: ${this.entities.size} entities, ` +
|
||||
`${dbMap?.size ?? 0} datablocks, ` +
|
||||
`rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Entity count milestones
|
||||
const entityCount = this.entities.size;
|
||||
if (
|
||||
this.tickCount === 1 ||
|
||||
(entityCount > 0 && entityCount % 25 === 0 && this.tickCount < 100)
|
||||
) {
|
||||
const types = new Map<string, number>();
|
||||
for (const e of this.entities.values()) {
|
||||
types.set(e.type, (types.get(e.type) ?? 0) + 1);
|
||||
}
|
||||
const summary = [...types.entries()]
|
||||
.map(([t, c]) => `${t}=${c}`)
|
||||
.join(" ");
|
||||
console.log(
|
||||
`[live] entity count: ${entityCount} (${summary})`,
|
||||
);
|
||||
}
|
||||
|
||||
this.updateCameraAndHud();
|
||||
|
||||
// Log camera position for early packets
|
||||
if (this.tickCount <= 5 && this.camera) {
|
||||
const [cx, cy, cz] = this.camera.position;
|
||||
console.log(
|
||||
`[live] camera: mode=${this.camera.mode} pos=(${cx.toFixed(1)}, ${cy.toFixed(1)}, ${cz.toFixed(1)}) fov=${this.camera.fov}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorContext = {
|
||||
tickCount: this.tickCount,
|
||||
entityCount: this.entities.size,
|
||||
dataLength: data.length,
|
||||
controlGhost: this.latestControl.ghostIndex,
|
||||
connectSynced: this.connectSynced,
|
||||
};
|
||||
console.error("Failed to process live packet:", e, errorContext);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build snapshot ──
|
||||
|
||||
private buildSnapshot(): StreamSnapshot {
|
||||
const entities = this.buildEntityList();
|
||||
const timeSec = this.currentTimeSec;
|
||||
const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec);
|
||||
const { weaponsHud, inventoryHud, backpackHud, teamScores } =
|
||||
this.buildHudState();
|
||||
|
||||
// Default observer camera if none exists
|
||||
if (!this.camera) {
|
||||
this.camera = {
|
||||
time: timeSec,
|
||||
position: [0, 0, 200],
|
||||
rotation: [0, 0, 0, 1],
|
||||
fov: 90,
|
||||
mode: "observer",
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot: StreamSnapshot = {
|
||||
timeSec,
|
||||
exhausted: false,
|
||||
camera: this.camera,
|
||||
entities,
|
||||
controlPlayerGhostId: this.controlPlayerGhostId,
|
||||
playerSensorGroup: this.playerSensorGroup,
|
||||
status: this.lastStatus,
|
||||
chatMessages,
|
||||
audioEvents,
|
||||
weaponsHud,
|
||||
backpackHud,
|
||||
inventoryHud,
|
||||
teamScores,
|
||||
};
|
||||
|
||||
this._snapshot = snapshot;
|
||||
this._snapshotTick = this.tickCount;
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
297
src/stream/missionEntityBridge.ts
Normal file
297
src/stream/missionEntityBridge.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import type { TorqueObject, TorqueRuntime } from "../torqueScript";
|
||||
import type {
|
||||
GameEntity,
|
||||
ShapeEntity,
|
||||
ForceFieldBareEntity,
|
||||
AudioEmitterEntity,
|
||||
CameraEntity,
|
||||
WayPointEntity,
|
||||
} from "../state/gameEntityTypes";
|
||||
import { getPosition, getProperty, getScale } from "../mission";
|
||||
import {
|
||||
terrainFromMis,
|
||||
interiorFromMis,
|
||||
skyFromMis,
|
||||
sunFromMis,
|
||||
missionAreaFromMis,
|
||||
waterBlockFromMis,
|
||||
} from "../scene/misToScene";
|
||||
|
||||
/** Resolve a named datablock from the runtime. */
|
||||
function resolveDatablock(
|
||||
runtime: TorqueRuntime,
|
||||
name: string | undefined,
|
||||
): TorqueObject | undefined {
|
||||
if (!name) return undefined;
|
||||
return runtime.state.datablocks.get(name);
|
||||
}
|
||||
|
||||
/** Handles TorqueScript's various truthy representations. */
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
const lower = value.toLowerCase();
|
||||
return lower !== "0" && lower !== "false" && lower !== "";
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
function parseColor3(colorStr: string): [number, number, number] {
|
||||
const parts = colorStr.split(" ").map((s) => parseFloat(s));
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
||||
function parseRotationToQuat(
|
||||
rotationStr: string,
|
||||
): [number, number, number, number] {
|
||||
const [ax, ay, az, angleDeg] = rotationStr.split(" ").map(parseFloat);
|
||||
// Convert Torque axis-angle to Three.js quaternion (with coordinate swap
|
||||
// and angle negation matching getRotation() in mission.ts).
|
||||
const halfRad = (-(angleDeg || 0) * Math.PI) / 360;
|
||||
const s = Math.sin(halfRad);
|
||||
const c = Math.cos(halfRad);
|
||||
const len = Math.sqrt(
|
||||
(ay || 0) * (ay || 0) + (az || 0) * (az || 0) + (ax || 0) * (ax || 0),
|
||||
);
|
||||
if (len < 1e-8) return [0, 0, 0, 1];
|
||||
// Three.js quaternion [x, y, z, w] with Torque→Three axis swap (x→y, y→z, z→x)
|
||||
return [
|
||||
((ay || 0) / len) * s,
|
||||
((az || 0) / len) * s,
|
||||
((ax || 0) / len) * s,
|
||||
c,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GameEntity from a mission TorqueObject. Returns null if the
|
||||
* object's className is not a renderable entity type.
|
||||
*/
|
||||
export function buildGameEntityFromMission(
|
||||
object: TorqueObject,
|
||||
runtime: TorqueRuntime,
|
||||
teamId?: number,
|
||||
): GameEntity | null {
|
||||
const className = object._className;
|
||||
const id = `mission_${object._id}`;
|
||||
const position = getPosition(object);
|
||||
const scale = getScale(object);
|
||||
const rotStr = object.rotation ?? "1 0 0 0";
|
||||
const rotation = parseRotationToQuat(rotStr);
|
||||
const datablockName = getProperty(object, "dataBlock") ?? "";
|
||||
const datablock = resolveDatablock(runtime, datablockName);
|
||||
const missionTypesList = getProperty(object, "missionTypesList");
|
||||
|
||||
const base = {
|
||||
id,
|
||||
className,
|
||||
runtimeObject: object,
|
||||
missionTypesList,
|
||||
};
|
||||
const posBase = { ...base, position, rotation, scale };
|
||||
|
||||
switch (className) {
|
||||
// Scene infrastructure
|
||||
case "TerrainBlock":
|
||||
return { ...base, renderType: "TerrainBlock", terrainData: terrainFromMis(object) };
|
||||
case "InteriorInstance":
|
||||
return { ...base, renderType: "InteriorInstance", interiorData: interiorFromMis(object) };
|
||||
case "Sky":
|
||||
return { ...base, renderType: "Sky", skyData: skyFromMis(object) };
|
||||
case "Sun":
|
||||
return { ...base, renderType: "Sun", sunData: sunFromMis(object) };
|
||||
case "WaterBlock":
|
||||
return { ...base, renderType: "WaterBlock", waterData: waterBlockFromMis(object) };
|
||||
case "MissionArea":
|
||||
return { ...base, renderType: "MissionArea", missionAreaData: missionAreaFromMis(object) };
|
||||
|
||||
// Shapes
|
||||
case "StaticShape":
|
||||
case "Item":
|
||||
case "Turret":
|
||||
case "TSStatic":
|
||||
return buildShapeEntity(posBase, object, datablock, runtime, className, teamId, datablockName);
|
||||
|
||||
// Force field
|
||||
case "ForceFieldBare":
|
||||
return buildForceFieldEntity(posBase, object, datablock, scale);
|
||||
|
||||
// Audio
|
||||
case "AudioEmitter":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "AudioEmitter",
|
||||
audioFileName: getProperty(object, "fileName") ?? undefined,
|
||||
audioVolume: parseFloat(getProperty(object, "volume")) || 1,
|
||||
audioIs3D: (getProperty(object, "is3D") ?? "0") !== "0",
|
||||
audioIsLooping: (getProperty(object, "isLooping") ?? "0") !== "0",
|
||||
audioMinDistance: parseFloat(getProperty(object, "minDistance")) || 1,
|
||||
audioMaxDistance: parseFloat(getProperty(object, "maxDistance")) || 1,
|
||||
audioMinLoopGap: parseFloat(getProperty(object, "minLoopGap")) || 0,
|
||||
audioMaxLoopGap: parseFloat(getProperty(object, "maxLoopGap")) || 0,
|
||||
} satisfies AudioEmitterEntity;
|
||||
|
||||
case "Camera":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "Camera",
|
||||
cameraDataBlock: datablockName || undefined,
|
||||
} satisfies CameraEntity;
|
||||
|
||||
case "WayPoint":
|
||||
return {
|
||||
...posBase,
|
||||
renderType: "WayPoint",
|
||||
label: getProperty(object, "name") || undefined,
|
||||
} satisfies WayPointEntity;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildShapeEntity(
|
||||
posBase: {
|
||||
id: string;
|
||||
className: string;
|
||||
runtimeObject: unknown;
|
||||
missionTypesList?: string;
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
},
|
||||
object: TorqueObject,
|
||||
datablock: TorqueObject | undefined,
|
||||
runtime: TorqueRuntime,
|
||||
className: string,
|
||||
teamId: number | undefined,
|
||||
datablockName: string,
|
||||
): ShapeEntity {
|
||||
const shapeName = className === "TSStatic"
|
||||
? getProperty(object, "shapeName")
|
||||
: getProperty(datablock, "shapeFile");
|
||||
const shapeType =
|
||||
className === "Turret" ? "Turret"
|
||||
: className === "Item" ? "Item"
|
||||
: className === "TSStatic" ? "TSStatic"
|
||||
: "StaticShape";
|
||||
|
||||
const entity: ShapeEntity = {
|
||||
...posBase,
|
||||
renderType: "Shape",
|
||||
shapeName,
|
||||
shapeType,
|
||||
dataBlock: datablockName || undefined,
|
||||
teamId,
|
||||
};
|
||||
|
||||
if (className === "Item") {
|
||||
entity.rotate = isTruthy(
|
||||
getProperty(object, "rotate") ?? getProperty(datablock, "rotate"),
|
||||
);
|
||||
}
|
||||
|
||||
if (className === "Turret") {
|
||||
const barrelName = getProperty(object, "initialBarrel");
|
||||
if (barrelName) {
|
||||
const barrelDb = resolveDatablock(runtime, barrelName);
|
||||
entity.barrelShapeName = getProperty(barrelDb, "shapeFile");
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
function buildForceFieldEntity(
|
||||
posBase: {
|
||||
id: string;
|
||||
className: string;
|
||||
runtimeObject: unknown;
|
||||
missionTypesList?: string;
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number, number];
|
||||
scale?: [number, number, number];
|
||||
},
|
||||
object: TorqueObject,
|
||||
datablock: TorqueObject | undefined,
|
||||
rawScale: [number, number, number] | undefined,
|
||||
): ForceFieldBareEntity {
|
||||
const colorStr = getProperty(datablock, "color");
|
||||
const color = colorStr
|
||||
? parseColor3(colorStr)
|
||||
: ([1, 1, 1] as [number, number, number]);
|
||||
const baseTranslucency =
|
||||
parseFloat(getProperty(datablock, "baseTranslucency")) || 1;
|
||||
const numFrames = parseInt(getProperty(datablock, "numFrames"), 10) || 1;
|
||||
const framesPerSec = parseFloat(getProperty(datablock, "framesPerSec")) || 1;
|
||||
const scrollSpeed = parseFloat(getProperty(datablock, "scrollSpeed")) || 0;
|
||||
const umapping = parseFloat(getProperty(datablock, "umapping")) || 1;
|
||||
const vmapping = parseFloat(getProperty(datablock, "vmapping")) || 1;
|
||||
|
||||
const textures: string[] = [];
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
const texturePath = getProperty(datablock, `texture${i}`);
|
||||
if (texturePath) {
|
||||
textures.push(texturePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ForceFieldBare uses "scale" as box dimensions, not as a transform scale.
|
||||
const dimensions = rawScale ?? [1, 1, 1];
|
||||
|
||||
return {
|
||||
...posBase,
|
||||
scale: undefined, // Don't apply scale as a group transform
|
||||
renderType: "ForceFieldBare",
|
||||
forceFieldData: {
|
||||
textures,
|
||||
color,
|
||||
baseTranslucency,
|
||||
numFrames,
|
||||
framesPerSec,
|
||||
scrollSpeed,
|
||||
umapping,
|
||||
vmapping,
|
||||
dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a TorqueObject tree and extract all GameEntities.
|
||||
* Respects team assignment from SimGroup hierarchy.
|
||||
*/
|
||||
export function walkMissionTree(
|
||||
root: TorqueObject,
|
||||
runtime: TorqueRuntime,
|
||||
teamId?: number,
|
||||
): GameEntity[] {
|
||||
const entities: GameEntity[] = [];
|
||||
|
||||
// Determine team from SimGroup hierarchy
|
||||
let currentTeam = teamId;
|
||||
if (root._className === "SimGroup") {
|
||||
if (root._name?.toLowerCase() === "teams") {
|
||||
currentTeam = undefined;
|
||||
} else if (currentTeam === undefined && root._name) {
|
||||
const match = root._name.match(/^team(\d+)$/i);
|
||||
if (match) {
|
||||
currentTeam = parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to build entity for this object
|
||||
const entity = buildGameEntityFromMission(root, runtime, currentTeam);
|
||||
if (entity) {
|
||||
entities.push(entity);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
if (root._children) {
|
||||
for (const child of root._children) {
|
||||
entities.push(...walkMissionTree(child, runtime, currentTeam));
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
458
src/stream/playbackUtils.ts
Normal file
458
src/stream/playbackUtils.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import {
|
||||
AnimationClip,
|
||||
AnimationMixer,
|
||||
ClampToEdgeWrapping,
|
||||
Group,
|
||||
LinearFilter,
|
||||
Matrix4,
|
||||
MeshLambertMaterial,
|
||||
NoColorSpace,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import type {
|
||||
BufferGeometry,
|
||||
Material,
|
||||
MeshStandardMaterial,
|
||||
Texture,
|
||||
} from "three";
|
||||
import {
|
||||
createMaterialFromFlags,
|
||||
applyShapeShaderModifications,
|
||||
} from "../components/GenericShape";
|
||||
import { isOrganicShape } from "../components/ShapeInfoProvider";
|
||||
import {
|
||||
loadIflAtlas,
|
||||
getFrameIndexForTime,
|
||||
updateAtlasFrame,
|
||||
} from "../components/useIflTexture";
|
||||
import { getHullBoneIndices, filterGeometryByVertexGroups } from "../meshUtils";
|
||||
import { loadTexture, setupTexture } from "../textureUtils";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import type { Keyframe } from "./types";
|
||||
|
||||
/** Fallback eye height when the player model isn't loaded or has no Cam node. */
|
||||
export const DEFAULT_EYE_HEIGHT = 2.1;
|
||||
|
||||
/** Torque's animation crossfade duration (seconds). */
|
||||
export const ANIM_TRANSITION_TIME = 0.25;
|
||||
|
||||
export const STREAM_TICK_MS = 32;
|
||||
export const STREAM_TICK_SEC = STREAM_TICK_MS / 1000;
|
||||
|
||||
// ── Temp vectors / quaternions (module-level to avoid per-frame alloc) ──
|
||||
|
||||
const _tracerOrientI = new Vector3();
|
||||
const _tracerOrientK = new Vector3();
|
||||
const _tracerOrientMat = new Matrix4();
|
||||
const _upY = new Vector3(0, 1, 0);
|
||||
|
||||
/** ShapeRenderer's 90° Y rotation and its inverse, used for mount transforms. */
|
||||
export const _r90 = new Quaternion().setFromAxisAngle(
|
||||
new Vector3(0, 1, 0),
|
||||
Math.PI / 2,
|
||||
);
|
||||
export const _r90inv = _r90.clone().invert();
|
||||
|
||||
// ── Pure functions ──
|
||||
|
||||
/**
|
||||
* Torque/Tribes stores camera FOV as horizontal degrees, while Three.js
|
||||
* PerspectiveCamera.fov expects vertical degrees.
|
||||
*/
|
||||
export function torqueHorizontalFovToThreeVerticalFov(
|
||||
torqueFovDeg: number,
|
||||
aspect: number,
|
||||
): number {
|
||||
const safeAspect = Number.isFinite(aspect) && aspect > 0.000001 ? aspect : 4 / 3;
|
||||
const clampedFov = Math.max(0.01, Math.min(179.99, torqueFovDeg));
|
||||
const hRad = (clampedFov * Math.PI) / 180;
|
||||
const vRad = 2 * Math.atan(Math.tan(hRad / 2) / safeAspect);
|
||||
return (vRad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
export function setupEffectTexture(tex: Texture): void {
|
||||
tex.wrapS = ClampToEdgeWrapping;
|
||||
tex.wrapT = ClampToEdgeWrapping;
|
||||
tex.minFilter = LinearFilter;
|
||||
tex.magFilter = LinearFilter;
|
||||
tex.colorSpace = NoColorSpace;
|
||||
tex.flipY = false;
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function torqueVecToThree(
|
||||
v: [number, number, number],
|
||||
out: Vector3,
|
||||
): Vector3 {
|
||||
return out.set(v[1], v[2], v[0]);
|
||||
}
|
||||
|
||||
export function setQuaternionFromDir(dir: Vector3, out: Quaternion): void {
|
||||
// Equivalent to MathUtils::createOrientFromDir in Torque:
|
||||
// column1 = direction, with Torque up-vector converted to Three up-vector.
|
||||
_tracerOrientI.crossVectors(dir, _upY);
|
||||
if (_tracerOrientI.lengthSq() < 1e-8) {
|
||||
_tracerOrientI.set(-1, 0, 0);
|
||||
}
|
||||
_tracerOrientI.normalize();
|
||||
_tracerOrientK.crossVectors(_tracerOrientI, dir).normalize();
|
||||
|
||||
_tracerOrientMat.set(
|
||||
_tracerOrientI.x,
|
||||
dir.x,
|
||||
_tracerOrientK.x,
|
||||
0,
|
||||
_tracerOrientI.y,
|
||||
dir.y,
|
||||
_tracerOrientK.y,
|
||||
0,
|
||||
_tracerOrientI.z,
|
||||
dir.z,
|
||||
_tracerOrientK.z,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
out.setFromRotationMatrix(_tracerOrientMat);
|
||||
}
|
||||
|
||||
/** Binary search for the keyframe at or before the given time. */
|
||||
export function getKeyframeAtTime(
|
||||
keyframes: Keyframe[],
|
||||
time: number,
|
||||
): Keyframe | null {
|
||||
if (keyframes.length === 0) return null;
|
||||
if (time <= keyframes[0].time) return keyframes[0];
|
||||
if (time >= keyframes[keyframes.length - 1].time)
|
||||
return keyframes[keyframes.length - 1];
|
||||
|
||||
let lo = 0;
|
||||
let hi = keyframes.length - 1;
|
||||
while (hi - lo > 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keyframes[mid].time <= time) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return keyframes[lo];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a shape scene, apply the "Root" idle animation at t=0, and return the
|
||||
* world-space transform of the named node. This evaluates the skeleton at its
|
||||
* idle pose rather than using the collapsed bind pose.
|
||||
*/
|
||||
export function getPosedNodeTransform(
|
||||
scene: Group,
|
||||
animations: AnimationClip[],
|
||||
nodeName: string,
|
||||
overrideClipNames?: string[],
|
||||
): { position: Vector3; quaternion: Quaternion } | null {
|
||||
const clone = scene.clone(true);
|
||||
|
||||
const rootClip = animations.find((a) => a.name === "Root");
|
||||
if (rootClip) {
|
||||
const mixer = new AnimationMixer(clone);
|
||||
mixer.clipAction(rootClip).play();
|
||||
// Play override clips (e.g. arm pose) which replace bone transforms
|
||||
// on the bones they animate, at clip midpoint (neutral pose).
|
||||
if (overrideClipNames) {
|
||||
for (const name of overrideClipNames) {
|
||||
const clip = animations.find(
|
||||
(a) => a.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (clip) {
|
||||
const action = mixer.clipAction(clip);
|
||||
action.time = clip.duration / 2;
|
||||
action.setEffectiveTimeScale(0);
|
||||
action.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
mixer.setTime(0);
|
||||
}
|
||||
|
||||
clone.updateMatrixWorld(true);
|
||||
|
||||
let position: Vector3 | null = null;
|
||||
let quaternion: Quaternion | null = null;
|
||||
clone.traverse((n) => {
|
||||
if (!position && n.name === nodeName) {
|
||||
position = new Vector3();
|
||||
quaternion = new Quaternion();
|
||||
n.getWorldPosition(position);
|
||||
n.getWorldQuaternion(quaternion);
|
||||
}
|
||||
});
|
||||
|
||||
if (!position || !quaternion) return null;
|
||||
return { position, quaternion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth vertex normals across co-located split vertices (same position, different
|
||||
* UVs). Matches the technique used by ShapeModel for consistent lighting.
|
||||
*/
|
||||
export function smoothVertexNormals(geometry: BufferGeometry): void {
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const posAttr = geometry.attributes.position;
|
||||
const normAttr = geometry.attributes.normal;
|
||||
if (!posAttr || !normAttr) return;
|
||||
|
||||
const positions = posAttr.array as Float32Array;
|
||||
const normals = normAttr.array as Float32Array;
|
||||
|
||||
// Build map of position -> vertex indices at that position.
|
||||
const positionMap = new Map<string, number[]>();
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
const key = `${positions[i * 3].toFixed(4)},${positions[i * 3 + 1].toFixed(4)},${positions[i * 3 + 2].toFixed(4)}`;
|
||||
if (!positionMap.has(key)) {
|
||||
positionMap.set(key, []);
|
||||
}
|
||||
positionMap.get(key)!.push(i);
|
||||
}
|
||||
|
||||
// Average normals for vertices at the same position.
|
||||
for (const indices of positionMap.values()) {
|
||||
if (indices.length > 1) {
|
||||
let nx = 0,
|
||||
ny = 0,
|
||||
nz = 0;
|
||||
for (const idx of indices) {
|
||||
nx += normals[idx * 3];
|
||||
ny += normals[idx * 3 + 1];
|
||||
nz += normals[idx * 3 + 2];
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
if (len > 0) {
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
nz /= len;
|
||||
}
|
||||
for (const idx of indices) {
|
||||
normals[idx * 3] = nx;
|
||||
normals[idx * 3 + 1] = ny;
|
||||
normals[idx * 3 + 2] = nz;
|
||||
}
|
||||
}
|
||||
}
|
||||
normAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
export interface ShapeMaterialResult {
|
||||
material: Material;
|
||||
/** Back-face material for organic/translucent two-pass rendering. */
|
||||
backMaterial?: Material;
|
||||
/** For IFL materials: loads atlas, configures texture, sets up animation. */
|
||||
initialize?: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a PBR MeshStandardMaterial with a diffuse-only Lambert/Basic material
|
||||
* matching the Tribes 2 material pipeline. Textures are loaded asynchronously
|
||||
* from URLs (GLB files don't embed texture data; they store a resource_path in
|
||||
* material userData instead).
|
||||
*/
|
||||
export function replaceWithShapeMaterial(
|
||||
mat: MeshStandardMaterial,
|
||||
vis: number,
|
||||
isOrganic = false,
|
||||
): ShapeMaterialResult {
|
||||
const resourcePath: string | undefined = mat.userData?.resource_path;
|
||||
const flagNames = new Set<string>(mat.userData?.flag_names ?? []);
|
||||
|
||||
if (!resourcePath) {
|
||||
// No texture path — plain Lambert fallback with fog/lighting shaders.
|
||||
const fallback = new MeshLambertMaterial({
|
||||
color: mat.color,
|
||||
side: 2, // DoubleSide
|
||||
reflectivity: 0,
|
||||
});
|
||||
applyShapeShaderModifications(fallback);
|
||||
return { material: fallback };
|
||||
}
|
||||
|
||||
// IFL materials need async atlas loading — create with null map to avoid
|
||||
// "Resource not found" warnings from textureToUrl, and return an initializer
|
||||
// that loads the atlas and sets up per-frame animation.
|
||||
if (flagNames.has("IflMaterial")) {
|
||||
const result = createMaterialFromFlags(
|
||||
mat, null, flagNames, isOrganic, vis,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
const material = result[1];
|
||||
return {
|
||||
material,
|
||||
backMaterial: result[0],
|
||||
initialize: (mesh, getTime) =>
|
||||
initializeIflMaterial(material, resourcePath, mesh, getTime),
|
||||
};
|
||||
}
|
||||
return {
|
||||
material: result,
|
||||
initialize: (mesh, getTime) =>
|
||||
initializeIflMaterial(result, resourcePath, mesh, getTime),
|
||||
};
|
||||
}
|
||||
|
||||
// Load texture via ImageBitmapLoader (decodes off main thread). The returned
|
||||
// Texture is empty initially and gets populated when the image arrives;
|
||||
// Three.js re-renders automatically once loaded.
|
||||
const url = textureToUrl(resourcePath);
|
||||
const texture = loadTexture(url);
|
||||
const isTranslucent = flagNames.has("Translucent");
|
||||
if (isOrganic || isTranslucent) {
|
||||
setupTexture(texture, { disableMipmaps: true });
|
||||
} else {
|
||||
setupTexture(texture);
|
||||
}
|
||||
|
||||
const result = createMaterialFromFlags(
|
||||
mat, texture, flagNames, isOrganic, vis,
|
||||
);
|
||||
if (Array.isArray(result)) {
|
||||
return { material: result[1], backMaterial: result[0] };
|
||||
}
|
||||
return { material: result };
|
||||
}
|
||||
|
||||
export interface IflInitializer {
|
||||
mesh: Object3D;
|
||||
initialize: (mesh: Object3D, getTime: () => number) => Promise<() => void>;
|
||||
}
|
||||
|
||||
async function initializeIflMaterial(
|
||||
material: Material,
|
||||
resourcePath: string,
|
||||
mesh: Object3D,
|
||||
getTime: () => number,
|
||||
): Promise<() => void> {
|
||||
const iflPath = `textures/${resourcePath}.ifl`;
|
||||
const atlas = await loadIflAtlas(iflPath);
|
||||
|
||||
(material as any).map = atlas.texture;
|
||||
material.needsUpdate = true;
|
||||
|
||||
let disposed = false;
|
||||
const prevOnBeforeRender = mesh.onBeforeRender;
|
||||
mesh.onBeforeRender = function (this: any, ...args: any[]) {
|
||||
prevOnBeforeRender?.apply(this, args);
|
||||
if (disposed) return;
|
||||
updateAtlasFrame(atlas, getFrameIndexForTime(atlas, getTime()));
|
||||
};
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
mesh.onBeforeRender = prevOnBeforeRender ?? (() => {});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process a cloned shape scene: hide collision/hull geometry, smooth
|
||||
* normals, and replace PBR materials with diffuse-only Lambert materials.
|
||||
* Returns IFL initializers for any IFL materials found.
|
||||
*/
|
||||
export function processShapeScene(
|
||||
scene: Object3D,
|
||||
shapeName?: string,
|
||||
): IflInitializer[] {
|
||||
const iflInitializers: IflInitializer[] = [];
|
||||
const isOrganic = shapeName ? isOrganicShape(shapeName) : false;
|
||||
|
||||
// Find skeleton for hull bone filtering.
|
||||
let skeleton: any = null;
|
||||
scene.traverse((n: any) => {
|
||||
if (!skeleton && n.skeleton) skeleton = n.skeleton;
|
||||
});
|
||||
const hullBoneIndices = skeleton
|
||||
? getHullBoneIndices(skeleton)
|
||||
: new Set<number>();
|
||||
|
||||
// Collect back-face meshes to add after traversal (can't modify during traverse).
|
||||
const backFaceMeshes: Array<{ parent: Object3D; mesh: any }> = [];
|
||||
|
||||
scene.traverse((node: any) => {
|
||||
if (!node.isMesh) return;
|
||||
|
||||
// Hide unwanted nodes: hull geometry, unassigned materials.
|
||||
if (node.name.match(/^Hulk/i) || node.material?.name === "Unassigned") {
|
||||
node.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide vis-animated meshes (default vis < 0.01) but DON'T skip material
|
||||
// replacement — they need correct textures for when they become visible
|
||||
// (e.g. disc launcher's Disc mesh toggles visibility via state machine).
|
||||
const hasVisSequence = !!node.userData?.vis_sequence;
|
||||
if ((node.userData?.vis ?? 1) < 0.01) {
|
||||
node.visible = false;
|
||||
}
|
||||
|
||||
// Filter hull-influenced triangles and smooth normals.
|
||||
if (node.geometry) {
|
||||
let geometry = filterGeometryByVertexGroups(
|
||||
node.geometry,
|
||||
hullBoneIndices,
|
||||
);
|
||||
geometry = geometry.clone();
|
||||
smoothVertexNormals(geometry);
|
||||
node.geometry = geometry;
|
||||
}
|
||||
|
||||
// Replace PBR materials with diffuse-only Lambert materials.
|
||||
// For vis-animated meshes, use vis=1 so the material is fully opaque —
|
||||
// their visibility is toggled via node.visible, not material opacity.
|
||||
const vis: number = hasVisSequence ? 1 : (node.userData?.vis ?? 1);
|
||||
if (Array.isArray(node.material)) {
|
||||
node.material = node.material.map((m: MeshStandardMaterial) => {
|
||||
const result = replaceWithShapeMaterial(m, vis, isOrganic);
|
||||
if (result.initialize) {
|
||||
iflInitializers.push({ mesh: node, initialize: result.initialize });
|
||||
}
|
||||
if (result.backMaterial && node.parent) {
|
||||
const backMesh = node.clone();
|
||||
backMesh.material = result.backMaterial;
|
||||
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
|
||||
}
|
||||
return result.material;
|
||||
});
|
||||
} else if (node.material) {
|
||||
const result = replaceWithShapeMaterial(node.material, vis, isOrganic);
|
||||
if (result.initialize) {
|
||||
iflInitializers.push({ mesh: node, initialize: result.initialize });
|
||||
}
|
||||
node.material = result.material;
|
||||
if (result.backMaterial && node.parent) {
|
||||
const backMesh = node.clone();
|
||||
backMesh.material = result.backMaterial;
|
||||
backFaceMeshes.push({ parent: node.parent, mesh: backMesh });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add back-face meshes for two-pass organic/translucent rendering.
|
||||
for (const { parent, mesh } of backFaceMeshes) {
|
||||
parent.add(mesh);
|
||||
}
|
||||
|
||||
return iflInitializers;
|
||||
}
|
||||
|
||||
export function entityTypeColor(type: string): string {
|
||||
switch (type.toLowerCase()) {
|
||||
case "player":
|
||||
return "#00ff88";
|
||||
case "vehicle":
|
||||
return "#ff8800";
|
||||
case "projectile":
|
||||
return "#ff0044";
|
||||
case "deployable":
|
||||
return "#ffcc00";
|
||||
default:
|
||||
return "#8888ff";
|
||||
}
|
||||
}
|
||||
70
src/stream/playerAnimation.ts
Normal file
70
src/stream/playerAnimation.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Movement animation selection logic replicating Torque's
|
||||
* Player::pickActionAnimation() (player.cc:2280).
|
||||
*/
|
||||
/** Torque falling threshold: Z velocity below this = falling. */
|
||||
const FALLING_THRESHOLD = -10;
|
||||
/** Minimum velocity dot product to count as intentional movement. */
|
||||
const MOVE_THRESHOLD = 0.1;
|
||||
export interface MoveAnimationResult {
|
||||
/** Engine alias name (e.g. "root", "run", "back", "side", "fall"). */
|
||||
animation: string;
|
||||
/** 1 for forward playback, -1 for reversed (right strafe). */
|
||||
timeScale: number;
|
||||
}
|
||||
/**
|
||||
* Extract body yaw (Torque rotationZ) from a Three.js quaternion produced by
|
||||
* `playerYawToQuaternion()`. That function builds a Y-axis rotation:
|
||||
* qy = sin(-rotZ / 2), qw = cos(-rotZ / 2)
|
||||
* So: rotZ = -2 * atan2(qy, qw)
|
||||
*/
|
||||
function quaternionToBodyYaw(q: [number, number, number, number]): number {
|
||||
return -2 * Math.atan2(q[1], q[3]);
|
||||
}
|
||||
/**
|
||||
* Pick the movement animation for a player based on their velocity and
|
||||
* body orientation, matching Torque's pickActionAnimation().
|
||||
*
|
||||
* @param velocity Torque world-space velocity [x, y, z], or undefined for idle.
|
||||
* @param rotation Three.js quaternion from playerYawToQuaternion().
|
||||
*/
|
||||
export function pickMoveAnimation(
|
||||
velocity: [number, number, number] | undefined,
|
||||
rotation: [number, number, number, number],
|
||||
): MoveAnimationResult {
|
||||
if (!velocity) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
const [vx, vy, vz] = velocity;
|
||||
// Falling: Torque Z velocity below threshold.
|
||||
if (vz < FALLING_THRESHOLD) {
|
||||
return { animation: "fall", timeScale: 1 };
|
||||
}
|
||||
// Convert world velocity to player object space using body yaw.
|
||||
const yaw = quaternionToBodyYaw(rotation);
|
||||
const cosY = Math.cos(yaw);
|
||||
const sinY = Math.sin(yaw);
|
||||
// Torque object space: localY = forward, localX = right.
|
||||
const localX = vx * cosY + vy * sinY;
|
||||
const localY = -vx * sinY + vy * cosY;
|
||||
// Pick direction with largest dot product.
|
||||
const forwardDot = localY;
|
||||
const backDot = -localY;
|
||||
const leftDot = -localX;
|
||||
const rightDot = localX;
|
||||
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
|
||||
if (maxDot < MOVE_THRESHOLD) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
if (maxDot === forwardDot) {
|
||||
return { animation: "run", timeScale: 1 };
|
||||
}
|
||||
if (maxDot === backDot) {
|
||||
return { animation: "back", timeScale: 1 };
|
||||
}
|
||||
if (maxDot === leftDot) {
|
||||
return { animation: "side", timeScale: 1 };
|
||||
}
|
||||
// Right strafe: same Side animation, reversed.
|
||||
return { animation: "side", timeScale: -1 };
|
||||
}
|
||||
197
src/stream/relayClient.ts
Normal file
197
src/stream/relayClient.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type {
|
||||
ClientMessage,
|
||||
ClientMove,
|
||||
ServerMessage,
|
||||
ServerInfo,
|
||||
ConnectionStatus,
|
||||
} from "../../relay/types";
|
||||
|
||||
export type RelayEventHandler = {
|
||||
onOpen?: () => void;
|
||||
onStatus?: (status: ConnectionStatus, message?: string, connectSequence?: number, mapName?: string) => void;
|
||||
onServerList?: (servers: ServerInfo[]) => void;
|
||||
onGamePacket?: (data: Uint8Array) => void;
|
||||
/** Relay↔T2 server RTT. */
|
||||
onPing?: (ms: number) => void;
|
||||
/** Browser↔relay WebSocket RTT. */
|
||||
onWsPing?: (ms: number) => void;
|
||||
onError?: (message: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSocket client that connects to the relay server.
|
||||
* Handles JSON control messages and binary game packet forwarding.
|
||||
*/
|
||||
export class RelayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: RelayEventHandler;
|
||||
private url: string;
|
||||
private _connected = false;
|
||||
private wsPingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private smoothedWsPing = 0;
|
||||
|
||||
constructor(url: string, handlers: RelayEventHandler) {
|
||||
this.url = url;
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.ws = new WebSocket(this.url);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[relay] WebSocket connected to", this.url);
|
||||
this._connected = true;
|
||||
this.startWsPing();
|
||||
this.handlers.onOpen?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
// Binary message — game packet from server
|
||||
this.handlers.onGamePacket?.(new Uint8Array(event.data));
|
||||
} else {
|
||||
// JSON control message
|
||||
try {
|
||||
const message: ServerMessage = JSON.parse(event.data as string);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse relay message:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[relay] WebSocket disconnected");
|
||||
this._connected = false;
|
||||
this.stopWsPing();
|
||||
this.handlers.onClose?.();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
console.error("[relay] WebSocket error");
|
||||
this.handlers.onError?.("WebSocket connection error");
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(message: ServerMessage): void {
|
||||
switch (message.type) {
|
||||
case "serverList":
|
||||
this.handlers.onServerList?.(message.servers);
|
||||
break;
|
||||
case "status":
|
||||
this.handlers.onStatus?.(message.status, message.message, message.connectSequence, message.mapName);
|
||||
break;
|
||||
case "ping":
|
||||
this.handlers.onPing?.(message.ms);
|
||||
break;
|
||||
case "wsPong": {
|
||||
const rtt = Date.now() - message.ts;
|
||||
this.smoothedWsPing =
|
||||
this.smoothedWsPing === 0
|
||||
? rtt
|
||||
: this.smoothedWsPing * 0.5 + rtt * 0.5;
|
||||
this.handlers.onWsPing?.(Math.round(this.smoothedWsPing));
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
this.handlers.onError?.(message.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request the server list from the master server. */
|
||||
listServers(): void {
|
||||
this.send({ type: "listServers" });
|
||||
}
|
||||
|
||||
/** Send a WebSocket ping to measure browser↔relay RTT. */
|
||||
sendWsPing(): void {
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
}
|
||||
|
||||
/** Join a specific game server. */
|
||||
joinServer(address: string): void {
|
||||
console.log("[relay] Joining server:", address);
|
||||
this.send({ type: "joinServer", address });
|
||||
}
|
||||
|
||||
/** Disconnect from the current game server. */
|
||||
disconnectServer(): void {
|
||||
this.send({ type: "disconnect" });
|
||||
}
|
||||
|
||||
/** Forward a T2csri auth event to the relay. */
|
||||
sendAuthEvent(command: string, args: string[]): void {
|
||||
this.send({ type: "sendCommand", command, args });
|
||||
}
|
||||
|
||||
/** Send a commandToServer through the relay. */
|
||||
sendCommand(command: string, args: string[]): void {
|
||||
this.send({ type: "sendCommand", command, args });
|
||||
}
|
||||
|
||||
/** Send a CRC challenge response through the relay (legacy echo). */
|
||||
sendCRCResponse(crcValue: number, field1: number, field2: number): void {
|
||||
this.send({ type: "sendCRCResponse", crcValue, field1, field2 });
|
||||
}
|
||||
|
||||
/** Send datablock info for relay-side CRC computation over game files. */
|
||||
sendCRCCompute(
|
||||
seed: number,
|
||||
field2: number,
|
||||
datablocks: { objectId: number; className: string; shapeName: string }[],
|
||||
includeTextures: boolean,
|
||||
): void {
|
||||
this.send({ type: "sendCRCCompute", seed, field2, includeTextures, datablocks });
|
||||
}
|
||||
|
||||
/** Send a GhostAlwaysDone acknowledgment through the relay. */
|
||||
sendGhostAck(sequence: number, ghostCount: number): void {
|
||||
this.send({ type: "sendGhostAck", sequence, ghostCount });
|
||||
}
|
||||
|
||||
/** Send a move struct to the relay for forwarding to the game server. */
|
||||
sendMove(move: ClientMove): void {
|
||||
this.send({ type: "sendMove", move });
|
||||
}
|
||||
|
||||
/** Close the WebSocket connection entirely. */
|
||||
close(): void {
|
||||
this.stopWsPing();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
private startWsPing(): void {
|
||||
this.smoothedWsPing = 0;
|
||||
// Send immediately so we have a measurement before the server list arrives.
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
this.wsPingInterval = setInterval(() => {
|
||||
this.send({ type: "wsPing", ts: Date.now() });
|
||||
}, 7000);
|
||||
}
|
||||
|
||||
private stopWsPing(): void {
|
||||
if (this.wsPingInterval != null) {
|
||||
clearInterval(this.wsPingInterval);
|
||||
this.wsPingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: ClientMessage): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn("[relay] send dropped (ws not open):", message.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
468
src/stream/streamHelpers.ts
Normal file
468
src/stream/streamHelpers.ts
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import { Matrix4, Quaternion } from "three";
|
||||
import type {
|
||||
StreamVisual,
|
||||
WeaponImageDataBlockState,
|
||||
ChatSegment,
|
||||
} from "./types";
|
||||
import { projectileClassNames } from "./entityClassification";
|
||||
|
||||
export type Vec3 = { x: number; y: number; z: number };
|
||||
|
||||
// ── Math helpers ──
|
||||
|
||||
const _rotMat = new Matrix4();
|
||||
const _rotQuat = new Quaternion();
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const MAX_PITCH = Math.PI * 0.494;
|
||||
export const CameraMode_OrbitObject = 3;
|
||||
|
||||
/**
|
||||
* Build a Three.js quaternion from Torque observer yaw/pitch angles.
|
||||
* Uses a shared Matrix4/Quaternion to avoid per-frame allocations.
|
||||
*/
|
||||
export function yawPitchToQuaternion(
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
): [number, number, number, number] {
|
||||
const sx = Math.sin(pitch);
|
||||
const cx = Math.cos(pitch);
|
||||
const sz = Math.sin(yaw);
|
||||
const cz = Math.cos(yaw);
|
||||
|
||||
_rotMat.set(
|
||||
-sz, cz * sx, -cz * cx, 0,
|
||||
0, cx, sx, 0,
|
||||
cz, sz * sx, -sz * cx, 0,
|
||||
0, 0, 0, 1,
|
||||
);
|
||||
|
||||
_rotQuat.setFromRotationMatrix(_rotMat);
|
||||
return [_rotQuat.x, _rotQuat.y, _rotQuat.z, _rotQuat.w];
|
||||
}
|
||||
|
||||
/** Player body rotation: yaw only, around Three.js Y axis. */
|
||||
export function playerYawToQuaternion(
|
||||
rotZ: number,
|
||||
): [number, number, number, number] {
|
||||
const halfAngle = -rotZ / 2;
|
||||
return [0, Math.sin(halfAngle), 0, Math.cos(halfAngle)];
|
||||
}
|
||||
|
||||
/** Convert a Torque quaternion (x-right, y-forward, z-up) to Three.js. */
|
||||
export function torqueQuatToThreeJS(q: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}): [number, number, number, number] | null {
|
||||
if (
|
||||
!Number.isFinite(q.x) ||
|
||||
!Number.isFinite(q.y) ||
|
||||
!Number.isFinite(q.z) ||
|
||||
!Number.isFinite(q.w)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Axis swizzle (x,y,z)->(y,z,x) and inverted rotation direction.
|
||||
const x = -q.y;
|
||||
const y = -q.z;
|
||||
const z = -q.x;
|
||||
const w = q.w;
|
||||
|
||||
const lenSq = x * x + y * y + z * z + w * w;
|
||||
if (lenSq <= 1e-12) return null;
|
||||
|
||||
const invLen = 1 / Math.sqrt(lenSq);
|
||||
return [x * invLen, y * invLen, z * invLen, w * invLen];
|
||||
}
|
||||
|
||||
// ── Position / type guards ──
|
||||
|
||||
export function isValidPosition(
|
||||
pos: { x: number; y: number; z: number } | undefined | null,
|
||||
): pos is { x: number; y: number; z: number } {
|
||||
return (
|
||||
pos != null &&
|
||||
Number.isFinite(pos.x) &&
|
||||
Number.isFinite(pos.y) &&
|
||||
Number.isFinite(pos.z)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVec3Like(
|
||||
value: unknown,
|
||||
): value is { x: number; y: number; z: number } {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { x?: unknown }).x === "number" &&
|
||||
typeof (value as { y?: unknown }).y === "number" &&
|
||||
typeof (value as { z?: unknown }).z === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isQuatLike(value: unknown): value is {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
} {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { x?: unknown }).x === "number" &&
|
||||
typeof (value as { y?: unknown }).y === "number" &&
|
||||
typeof (value as { z?: unknown }).z === "number" &&
|
||||
typeof (value as { w?: unknown }).w === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── DataBlock field accessors ──
|
||||
|
||||
/**
|
||||
* Resolve the DTS shape path from a datablock's parsed data.
|
||||
* Accepts either a ghost className (e.g. "LinearProjectile") or a datablock
|
||||
* className (e.g. "LinearProjectileData") to determine which field holds the
|
||||
* shape path.
|
||||
*/
|
||||
export function resolveShapeName(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
let value: unknown;
|
||||
if (
|
||||
projectileClassNames.has(className) ||
|
||||
className.endsWith("ProjectileData")
|
||||
) {
|
||||
value = data.projectileShapeName;
|
||||
} else if (className === "DebrisData") {
|
||||
value = data.shapeFileName;
|
||||
} else {
|
||||
value = data.shapeName;
|
||||
}
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function getNumberField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): number | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStringField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): string | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBooleanField(
|
||||
data: Record<string, unknown> | undefined,
|
||||
keys: readonly string[],
|
||||
): boolean | undefined {
|
||||
if (!data) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = data[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Visual resolution ──
|
||||
|
||||
export function resolveTracerVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
const texture =
|
||||
getStringField(data, ["tracerTex0", "textureName0", "texture0"]) ?? "";
|
||||
const hasTracerHints =
|
||||
className === "TracerProjectile" ||
|
||||
(texture.length > 0 && getNumberField(data, ["tracerLength"]) != null);
|
||||
if (!hasTracerHints || !texture) return undefined;
|
||||
|
||||
const crossTexture = getStringField(data, [
|
||||
"tracerTex1",
|
||||
"textureName1",
|
||||
"texture1",
|
||||
]);
|
||||
|
||||
const tracerLength = getNumberField(data, ["tracerLength"]) ?? 10;
|
||||
const canonicalTracerWidth = getNumberField(data, ["tracerWidth"]);
|
||||
const aliasTracerWidth = getNumberField(data, ["tracerAlpha"]);
|
||||
const tracerWidth =
|
||||
canonicalTracerWidth != null &&
|
||||
(getNumberField(data, ["crossViewAng"]) != null ||
|
||||
canonicalTracerWidth <= 0.7)
|
||||
? canonicalTracerWidth
|
||||
: (aliasTracerWidth ?? canonicalTracerWidth ?? 0.5);
|
||||
const crossViewAng =
|
||||
getNumberField(data, ["crossViewAng", "crossViewFraction"]) ??
|
||||
(typeof data.tracerWidth === "number" && data.tracerWidth > 0.7
|
||||
? data.tracerWidth
|
||||
: 0.98);
|
||||
const crossSize =
|
||||
getNumberField(data, ["crossSize", "muzzleVelocity"]) ?? 0.45;
|
||||
const renderCross =
|
||||
getBooleanField(data, ["renderCross", "proximityRadius"]) ?? true;
|
||||
|
||||
return {
|
||||
kind: "tracer",
|
||||
texture,
|
||||
crossTexture,
|
||||
tracerLength,
|
||||
tracerWidth,
|
||||
crossViewAng,
|
||||
crossSize,
|
||||
renderCross,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSpriteVisual(
|
||||
className: string,
|
||||
data: Record<string, unknown> | undefined,
|
||||
): StreamVisual | undefined {
|
||||
if (!data) return undefined;
|
||||
|
||||
if (className === "LinearFlareProjectile") {
|
||||
const texture = getStringField(data, ["smokeTexture", "flareTexture"]);
|
||||
if (!texture) return undefined;
|
||||
const color = data.flareColor as
|
||||
| { r: number; g: number; b: number }
|
||||
| undefined;
|
||||
const size = getNumberField(data, ["size"]) ?? 0.5;
|
||||
return {
|
||||
kind: "sprite",
|
||||
texture,
|
||||
color: color
|
||||
? { r: color.r, g: color.g, b: color.b }
|
||||
: { r: 1, g: 1, b: 1 },
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
if (className === "FlareProjectile") {
|
||||
const texture = getStringField(data, ["flareTexture"]);
|
||||
if (!texture) return undefined;
|
||||
const size = getNumberField(data, ["size"]) ?? 4.0;
|
||||
return {
|
||||
kind: "sprite",
|
||||
texture,
|
||||
color: { r: 1, g: 0.9, b: 0.5 },
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── Weapon image state parsing ──
|
||||
|
||||
/**
|
||||
* Parse weapon image state machine from a ShapeBaseImageData datablock.
|
||||
*
|
||||
* CRITICAL: The parser's field names for transitions are MISALIGNED with
|
||||
* the actual engine packing order. See demoStreaming.ts for details on the
|
||||
* remap table.
|
||||
*/
|
||||
export function parseWeaponImageStates(
|
||||
blockData: Record<string, unknown>,
|
||||
): WeaponImageDataBlockState[] | undefined {
|
||||
const rawStates = blockData.states as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
if (!Array.isArray(rawStates) || rawStates.length === 0) return undefined;
|
||||
|
||||
return rawStates.map((s) => {
|
||||
const remap = (v: unknown): number => {
|
||||
const n = v as number;
|
||||
if (n == null) return -1;
|
||||
return n - 1;
|
||||
};
|
||||
|
||||
return {
|
||||
name: (s.name as string) ?? "",
|
||||
transitionOnNotLoaded: remap(s.transitionOnAmmo),
|
||||
transitionOnLoaded: remap(s.transitionOnNoAmmo),
|
||||
transitionOnNoAmmo: remap(s.transitionOnTarget),
|
||||
transitionOnAmmo: remap(s.transitionOnNoTarget),
|
||||
transitionOnNoTarget: remap(s.transitionOnWet),
|
||||
transitionOnTarget: remap(s.transitionOnNotWet),
|
||||
transitionOnNotWet: remap(s.transitionOnTriggerUp),
|
||||
transitionOnWet: remap(s.transitionOnTriggerDown),
|
||||
transitionOnTriggerUp: remap(s.transitionOnTimeout),
|
||||
transitionOnTriggerDown: remap(s.transitionGeneric0In),
|
||||
transitionOnTimeout: remap(s.transitionGeneric0Out),
|
||||
timeoutValue: s.timeoutValue as number | undefined,
|
||||
waitForTimeout: (s.waitForTimeout as boolean) ?? false,
|
||||
fire: (s.fire as boolean) ?? false,
|
||||
sequence: s.sequence as number | undefined,
|
||||
spin: (s.spin as number) ?? 0,
|
||||
direction: (s.direction as boolean) ?? true,
|
||||
scaleAnimation: (s.scaleAnimation as boolean) ?? false,
|
||||
loaded: (s.loaded as number) ?? 0,
|
||||
soundDataBlockId: (s.sound as number) ?? -1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Chat / text helpers ──
|
||||
|
||||
/** Strip non-printable Torque tagged string markup from a string. */
|
||||
export function stripTaggedStringMarkup(s: string): string {
|
||||
let stripped = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s.charCodeAt(i) >= 0x20) stripped += s[i];
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-to-fontColors-index remap table from the Torque V12 renderer (dgl.cc).
|
||||
*
|
||||
* TorqueScript `\cN` escapes are encoded via `collapseRemap` in scan.l,
|
||||
* producing byte values that skip \t (0x9), \n (0xa), and \r (0xd).
|
||||
*/
|
||||
const BYTE_TO_COLOR_INDEX: Record<number, number> = {
|
||||
0x2: 0, 0x3: 1, 0x4: 2, 0x5: 3, 0x6: 4,
|
||||
0x7: 5, 0x8: 6, 0xb: 7, 0xc: 8, 0xe: 9,
|
||||
};
|
||||
|
||||
const BYTE_COLOR_RESET = 0x0f;
|
||||
const BYTE_COLOR_PUSH = 0x10;
|
||||
const BYTE_COLOR_POP = 0x11;
|
||||
|
||||
/**
|
||||
* Extract the leading Torque \c color index (0–9) from a tagged string.
|
||||
*/
|
||||
export function detectColorCode(s: string): number | undefined {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
const colorIndex = BYTE_TO_COLOR_INDEX[code];
|
||||
if (colorIndex !== undefined) return colorIndex;
|
||||
if (code >= 0x20) return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Parse a raw Torque HudMessageVector line into colored segments. */
|
||||
export function parseColorSegments(raw: string): ChatSegment[] {
|
||||
const segments: ChatSegment[] = [];
|
||||
let currentColor = 0;
|
||||
let currentText = "";
|
||||
let inTaggedString = false;
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const code = raw.charCodeAt(i);
|
||||
|
||||
if (code === BYTE_COLOR_PUSH) {
|
||||
inTaggedString = true;
|
||||
continue;
|
||||
}
|
||||
if (code === BYTE_COLOR_POP) {
|
||||
inTaggedString = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inTaggedString) {
|
||||
if (code >= 0x20) currentText += raw[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const colorIndex = BYTE_TO_COLOR_INDEX[code];
|
||||
if (colorIndex !== undefined) {
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
currentText = "";
|
||||
}
|
||||
currentColor = colorIndex;
|
||||
} else if (code === BYTE_COLOR_RESET) {
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
currentText = "";
|
||||
}
|
||||
currentColor = 0;
|
||||
} else if (code >= 0x20) {
|
||||
currentText += raw[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
segments.push({ text: currentText, colorCode: currentColor });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Extract an embedded `~w<path>` sound tag from a message string. */
|
||||
export function extractWavTag(
|
||||
text: string,
|
||||
): { text: string; wavPath: string | null } {
|
||||
const idx = text.indexOf("~w");
|
||||
if (idx === -1) return { text, wavPath: null };
|
||||
return {
|
||||
text: text.substring(0, idx),
|
||||
wavPath: text.substring(idx + 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Control object detection ──
|
||||
|
||||
export type ControlObjectType = "camera" | "player";
|
||||
|
||||
export function detectControlObjectType(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): ControlObjectType | null {
|
||||
if (!data) return null;
|
||||
if (typeof data.cameraMode === "number") return "camera";
|
||||
if (typeof data.rotationZ === "number") return "player";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Backpack HUD ──
|
||||
|
||||
const BACKPACK_BITMAP_TO_INDEX = new Map<string, number>([
|
||||
["gui/hud_new_packammo", 0],
|
||||
["gui/hud_new_packcloak", 1],
|
||||
["gui/hud_new_packenergy", 2],
|
||||
["gui/hud_new_packrepair", 3],
|
||||
["gui/hud_new_packsatchel", 4],
|
||||
["gui/hud_new_packshield", 5],
|
||||
["gui/hud_new_packinventory", 6],
|
||||
["gui/hud_new_packmotionsens", 7],
|
||||
["gui/hud_new_packradar", 8],
|
||||
["gui/hud_new_packturretout", 9],
|
||||
["gui/hud_new_packturretin", 10],
|
||||
["gui/hud_new_packsensjam", 11],
|
||||
["gui/hud_new_packturret", 12],
|
||||
["gui/hud_satchel_unarmed", 18],
|
||||
]);
|
||||
|
||||
export function backpackBitmapToIndex(bitmap: string): number {
|
||||
const lower = bitmap.toLowerCase();
|
||||
for (const [key, val] of BACKPACK_BITMAP_TO_INDEX) {
|
||||
if (key === lower) return val;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
288
src/stream/types.ts
Normal file
288
src/stream/types.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import type { SceneObject } from "../scene/types";
|
||||
|
||||
/** DTS animation thread state from ghost ThreadMask data. */
|
||||
export interface ThreadState {
|
||||
index: number;
|
||||
sequence: number;
|
||||
state: number;
|
||||
forward: boolean;
|
||||
atEnd: boolean;
|
||||
}
|
||||
|
||||
export interface WeaponImageState {
|
||||
dataBlockId: number;
|
||||
triggerDown: boolean;
|
||||
ammo: boolean;
|
||||
loaded: boolean;
|
||||
target: boolean;
|
||||
wet: boolean;
|
||||
fireCount: number;
|
||||
}
|
||||
|
||||
export interface WeaponImageDataBlockState {
|
||||
name: string;
|
||||
transitionOnLoaded: number;
|
||||
transitionOnNotLoaded: number;
|
||||
transitionOnAmmo: number;
|
||||
transitionOnNoAmmo: number;
|
||||
transitionOnTarget: number;
|
||||
transitionOnNoTarget: number;
|
||||
transitionOnWet: number;
|
||||
transitionOnNotWet: number;
|
||||
transitionOnTriggerUp: number;
|
||||
transitionOnTriggerDown: number;
|
||||
transitionOnTimeout: number;
|
||||
timeoutValue?: number;
|
||||
waitForTimeout: boolean;
|
||||
fire: boolean;
|
||||
sequence?: number;
|
||||
spin: number;
|
||||
direction: boolean;
|
||||
scaleAnimation: boolean;
|
||||
loaded: number;
|
||||
/** AudioProfile datablock ID for the state's entry sound, or -1 if none. */
|
||||
soundDataBlockId: number;
|
||||
}
|
||||
|
||||
export interface Keyframe {
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation: [number, number, number, number];
|
||||
/** Camera FOV in degrees (camera entity keyframes only). */
|
||||
fov?: number;
|
||||
/** Velocity in Torque world space [x, y, z]. */
|
||||
velocity?: [number, number, number];
|
||||
/** Normalized health (0 = dead, 1 = full). Derived from ghost damageLevel. */
|
||||
health?: number;
|
||||
/** Normalized energy (0 = empty, 1 = full). Derived from ghost energyPercent. */
|
||||
energy?: number;
|
||||
/** Torque DamageState: 0 = Enabled, 1 = Disabled (dead), 2 = Destroyed. */
|
||||
damageState?: number;
|
||||
/** Action animation index from ghost ActionMask (indices >= 7 are non-table
|
||||
* actions like death animations). */
|
||||
actionAnim?: number;
|
||||
/** True when the action animation has reached its final frame. */
|
||||
actionAtEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface TracerVisual {
|
||||
kind: "tracer";
|
||||
/** Main tracer streak texture (e.g. "special/tracer00"). */
|
||||
texture: string;
|
||||
/** Edge-on cross section texture (e.g. "special/tracercross"). */
|
||||
crossTexture?: string;
|
||||
tracerLength: number;
|
||||
tracerWidth: number;
|
||||
crossViewAng: number;
|
||||
crossSize: number;
|
||||
renderCross: boolean;
|
||||
}
|
||||
|
||||
export interface SpriteVisual {
|
||||
kind: "sprite";
|
||||
/** Sprite texture (e.g. "flarebase"). */
|
||||
texture: string;
|
||||
/** sRGB tint color from the datablock (e.g. flareColor). */
|
||||
color: { r: number; g: number; b: number };
|
||||
/** Billboard size in world units. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type StreamVisual = TracerVisual | SpriteVisual;
|
||||
|
||||
export interface StreamEntity {
|
||||
id: string;
|
||||
type: string;
|
||||
dataBlock?: string;
|
||||
visual?: StreamVisual;
|
||||
direction?: [number, number, number];
|
||||
weaponShape?: string;
|
||||
playerName?: string;
|
||||
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
/** Target render flags bitmask from the Target Manager. */
|
||||
targetRenderFlags?: number;
|
||||
ghostIndex?: number;
|
||||
className?: string;
|
||||
dataBlockId?: number;
|
||||
shapeHint?: string;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position?: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation?: [number, number, number, number];
|
||||
/** Velocity in Torque world space [x, y, z]. */
|
||||
velocity?: [number, number, number];
|
||||
health?: number;
|
||||
energy?: number;
|
||||
actionAnim?: number;
|
||||
actionAtEnd?: boolean;
|
||||
damageState?: number;
|
||||
faceViewer?: boolean;
|
||||
/** DTS animation thread states from ghost ThreadMask data. */
|
||||
threads?: ThreadState[];
|
||||
/** Numeric ID of the ExplosionData datablock (for particle effect resolution). */
|
||||
explosionDataBlockId?: number;
|
||||
/** Numeric ID of the ParticleEmitterData for in-flight trail particles. */
|
||||
maintainEmitterId?: number;
|
||||
/** Weapon image condition flags from ghost ImageMask data. */
|
||||
weaponImageState?: WeaponImageState;
|
||||
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
/** Head pitch for blend animations, normalized [-1,1]. -1 = max down, 1 = max up. */
|
||||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
|
||||
headYaw?: number;
|
||||
/** WayPoint display label. */
|
||||
label?: string;
|
||||
// AudioEmitter ghost fields
|
||||
audioFileName?: string;
|
||||
audioVolume?: number;
|
||||
audioIs3D?: boolean;
|
||||
audioIsLooping?: boolean;
|
||||
audioMinDistance?: number;
|
||||
audioMaxDistance?: number;
|
||||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
/** Scene infrastructure data (terrain, interior, sky, etc.). */
|
||||
sceneData?: SceneObject;
|
||||
}
|
||||
|
||||
export interface StreamCamera {
|
||||
/** Timestamp in seconds for the current camera state. */
|
||||
time: number;
|
||||
/** Position in Torque space [x, y, z]. */
|
||||
position: [number, number, number];
|
||||
/** Quaternion in Three.js space [x, y, z, w]. */
|
||||
rotation: [number, number, number, number];
|
||||
fov: number;
|
||||
mode: "first-person" | "third-person" | "observer";
|
||||
controlEntityId?: string;
|
||||
orbitTargetId?: string;
|
||||
/** Orbit distance used for third-person camera positioning. */
|
||||
orbitDistance?: number;
|
||||
/** Absolute control-object yaw in Torque radians (rotZ/rotationZ). */
|
||||
yaw?: number;
|
||||
/** Absolute control-object pitch in Torque radians (rotX/headX). */
|
||||
pitch?: number;
|
||||
}
|
||||
|
||||
/** A colored text segment from inline \c color switching. */
|
||||
export interface ChatSegment {
|
||||
text: string;
|
||||
/** Torque \c color index (0–9) from the GuiChatHudProfile fontColors palette. */
|
||||
colorCode: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
timeSec: number;
|
||||
sender: string;
|
||||
text: string;
|
||||
kind: "chat" | "server";
|
||||
/**
|
||||
* Torque \c color index (0–9) from the GuiChatHudProfile fontColors palette.
|
||||
* 0=default/death, 1=join/drop, 2=gameplay/flags, 3=team chat, 4=global chat,
|
||||
* 6=player name, 7=tribe tag, 8=smurf name, 9=bot name.
|
||||
*/
|
||||
colorCode?: number;
|
||||
/** Colored text segments for inline color switching in rendered text. */
|
||||
segments?: ChatSegment[];
|
||||
/** Audio file path from ~w tag (e.g. "fx/misc/flag_taken.wav"). */
|
||||
soundPath?: string;
|
||||
/** Pitch multiplier for voice chat (default 1.0). */
|
||||
soundPitch?: number;
|
||||
}
|
||||
|
||||
export interface WeaponsHudSlot {
|
||||
/** HUD slot index (0–17), matching the $WeaponsHudData table. */
|
||||
index: number;
|
||||
/** Ammo count, or -1 for infinite (energy weapons). */
|
||||
ammo: number;
|
||||
}
|
||||
|
||||
export interface TeamScore {
|
||||
teamId: number;
|
||||
name: string;
|
||||
score: number;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export interface BackpackHudState {
|
||||
/** Index into the $BackpackHudData table, or -1 if no pack. */
|
||||
packIndex: number;
|
||||
/** Whether the pack is currently activated/armed. */
|
||||
active: boolean;
|
||||
/** Optional text overlay (e.g. sensor pack counts). */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InventoryHudSlot {
|
||||
/** Display slot (0=grenade, 1=mine, 2=beacon, 3=repairkit). */
|
||||
slot: number;
|
||||
/** Item count. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PendingAudioEvent {
|
||||
profileId: number;
|
||||
position?: { x: number; y: number; z: number };
|
||||
timeSec: number;
|
||||
}
|
||||
|
||||
export interface StreamSnapshot {
|
||||
timeSec: number;
|
||||
exhausted: boolean;
|
||||
camera: StreamCamera | null;
|
||||
entities: StreamEntity[];
|
||||
controlPlayerGhostId?: string;
|
||||
/** Recording player's sensor group (team number). */
|
||||
playerSensorGroup: number;
|
||||
status: { health: number; energy: number };
|
||||
chatMessages: ChatMessage[];
|
||||
/** One-shot audio events from Sim3DAudioEvent / Sim2DAudioEvent. */
|
||||
audioEvents: PendingAudioEvent[];
|
||||
/** Weapons HUD state from inventory RemoteCommandEvents. */
|
||||
weaponsHud: {
|
||||
/** Weapon slots present in the player's inventory, in HUD index order. */
|
||||
slots: WeaponsHudSlot[];
|
||||
/** Currently active (selected) HUD slot index, or -1 if none. */
|
||||
activeIndex: number;
|
||||
};
|
||||
/** Backpack/pack HUD state from RemoteCommandEvents. */
|
||||
backpackHud: BackpackHudState | null;
|
||||
/** Inventory HUD state (grenades, mines, beacons, repair kits). */
|
||||
inventoryHud: {
|
||||
slots: InventoryHudSlot[];
|
||||
activeSlot: number;
|
||||
};
|
||||
/** Team scores aggregated from the PLAYERLIST demoValues section. */
|
||||
teamScores: TeamScore[];
|
||||
}
|
||||
|
||||
export interface StreamingPlayback {
|
||||
reset(): void;
|
||||
getSnapshot(): StreamSnapshot;
|
||||
stepToTime(targetTimeSec: number, maxMoveTicks?: number): StreamSnapshot;
|
||||
/** 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;
|
||||
/**
|
||||
* Get TSShapeConstructor sequence entries for a shape (e.g. "heavy_male.dts").
|
||||
* Returns the raw sequence strings like `"heavy_male_root.dsq root"`.
|
||||
*/
|
||||
getShapeConstructorSequences(shapeName: string): string[] | undefined;
|
||||
}
|
||||
|
||||
export interface StreamRecording {
|
||||
/** "demo" for .rec file playback, "live" for live server observation. */
|
||||
source: "demo" | "live";
|
||||
duration: number;
|
||||
/** Mission name (e.g. "S5-WoodyMyrk"). */
|
||||
missionName: string | null;
|
||||
/** Game type display name (e.g. "Capture the Flag"). */
|
||||
gameType: string | null;
|
||||
/** Streaming parser session for tick-driven playback. */
|
||||
streamingPlayback: StreamingPlayback;
|
||||
}
|
||||
322
src/stream/weaponStateMachine.ts
Normal file
322
src/stream/weaponStateMachine.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import type {
|
||||
WeaponImageDataBlockState,
|
||||
WeaponImageState,
|
||||
} from "./types";
|
||||
|
||||
/** Transition index sentinel: -1 means "no transition defined". */
|
||||
const NO_TRANSITION = -1;
|
||||
|
||||
/** Max transitions per tick to prevent infinite loops from misconfigured datablocks. */
|
||||
const MAX_TRANSITIONS_PER_TICK = 32;
|
||||
|
||||
/** Torque SpinState enum values from ShapeBaseImageData (shapeBase.h). */
|
||||
const SPIN_STOP = 1; // NoSpin
|
||||
const SPIN_UP = 2; // SpinUp
|
||||
const SPIN_DOWN = 3; // SpinDown
|
||||
const SPIN_FULL = 4; // FullSpin
|
||||
|
||||
export interface WeaponAnimState {
|
||||
/** Name of the current animation sequence to play (lowercase), or null. */
|
||||
sequenceName: string | null;
|
||||
/** Whether the current state is a fire state. */
|
||||
isFiring: boolean;
|
||||
/** Spin thread timeScale (0 = stopped, 1 = full speed). */
|
||||
spinTimeScale: number;
|
||||
/** Whether the animation should play in reverse. */
|
||||
reverse: boolean;
|
||||
/** Whether the animation timeScale should be scaled to the timeout. */
|
||||
scaleAnimation: boolean;
|
||||
/** The timeout value of the current state (for timeScale calculation). */
|
||||
timeoutValue: number;
|
||||
/** True when a state transition occurred this tick. */
|
||||
transitioned: boolean;
|
||||
/** AudioProfile datablock IDs for sounds that should play this tick.
|
||||
* In the engine, every state entry triggers its stateSound; a single tick
|
||||
* can chain through multiple states, so multiple sounds may fire. */
|
||||
soundDataBlockIds: number[];
|
||||
/** Index of the current state in the state machine. */
|
||||
stateIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side weapon image state machine replicating the Torque C++ logic from
|
||||
* `ShapeBase::updateImageState` / `ShapeBase::setImageState`. The server sends
|
||||
* only condition flags (trigger, ammo, loaded, wet, target) and a fireCount;
|
||||
* the client runs its own copy of the state machine to determine which
|
||||
* animation to play.
|
||||
*/
|
||||
export class WeaponImageStateMachine {
|
||||
private states: WeaponImageDataBlockState[];
|
||||
private seqIndexToName: string[];
|
||||
private currentStateIndex = 0;
|
||||
private delayTime = 0;
|
||||
private lastFireCount = -1;
|
||||
private spinTimeScale = 0;
|
||||
|
||||
constructor(
|
||||
states: WeaponImageDataBlockState[],
|
||||
seqIndexToName: string[],
|
||||
) {
|
||||
this.states = states;
|
||||
this.seqIndexToName = seqIndexToName;
|
||||
if (states.length > 0) {
|
||||
this.delayTime = states[0].timeoutValue ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
get stateIndex(): number {
|
||||
return this.currentStateIndex;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.currentStateIndex = 0;
|
||||
this.delayTime = this.states.length > 0
|
||||
? (this.states[0].timeoutValue ?? 0)
|
||||
: 0;
|
||||
this.lastFireCount = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the state machine by `dt` seconds using the given condition flags.
|
||||
* Returns the animation state to apply this frame.
|
||||
*/
|
||||
tick(dt: number, flags: WeaponImageState): WeaponAnimState {
|
||||
if (this.states.length === 0) {
|
||||
return {
|
||||
sequenceName: null,
|
||||
isFiring: false,
|
||||
spinTimeScale: 0,
|
||||
reverse: false,
|
||||
scaleAnimation: false,
|
||||
timeoutValue: 0,
|
||||
transitioned: false,
|
||||
soundDataBlockIds: [],
|
||||
stateIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
// Detect fire count changes — forces a resync to the Fire state.
|
||||
// The server increments fireCount each time it fires; if our state machine
|
||||
// has diverged, this brings us back in sync.
|
||||
const fireCountChanged =
|
||||
this.lastFireCount >= 0 && flags.fireCount !== this.lastFireCount;
|
||||
this.lastFireCount = flags.fireCount;
|
||||
|
||||
const soundDataBlockIds: number[] = [];
|
||||
|
||||
if (fireCountChanged) {
|
||||
const fireIdx = this.states.findIndex((s) => s.fire);
|
||||
if (fireIdx >= 0 && fireIdx !== this.currentStateIndex) {
|
||||
this.currentStateIndex = fireIdx;
|
||||
this.delayTime = this.states[fireIdx].timeoutValue ?? 0;
|
||||
// Fire count resync is a state entry — play its sound.
|
||||
const fireSound = this.states[fireIdx].soundDataBlockId;
|
||||
if (fireSound >= 0) soundDataBlockIds.push(fireSound);
|
||||
}
|
||||
}
|
||||
|
||||
this.delayTime -= dt;
|
||||
|
||||
let transitioned = fireCountChanged;
|
||||
|
||||
// Per-tick transition evaluation (C++ updateImageState): check conditions
|
||||
// and timeout when delayTime <= 0 or waitForTimeout is false.
|
||||
let nextState = this.evaluateTickTransitions(flags);
|
||||
|
||||
// Process transitions. Self-transitions just reset delayTime (matching
|
||||
// the C++ setImageState early return path). Different-state transitions
|
||||
// run full entry logic including recursive entry transitions.
|
||||
let transitionsThisTick = 0;
|
||||
while (nextState >= 0 && transitionsThisTick < MAX_TRANSITIONS_PER_TICK) {
|
||||
transitionsThisTick++;
|
||||
transitioned = true;
|
||||
|
||||
if (nextState === this.currentStateIndex) {
|
||||
// Self-transition (C++ setImageState self-transition path):
|
||||
// reset delayTime only; skip entry transitions and spin handling.
|
||||
this.delayTime = this.states[nextState].timeoutValue ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// Transition to a different state (C++ setImageState normal path).
|
||||
const lastSpin = this.states[this.currentStateIndex].spin;
|
||||
const lastDelay = this.delayTime;
|
||||
this.currentStateIndex = nextState;
|
||||
const newTimeout = this.states[nextState].timeoutValue ?? 0;
|
||||
this.delayTime = newTimeout;
|
||||
|
||||
// Every state entry plays its sound (C++ setImageState).
|
||||
const entrySound = this.states[nextState].soundDataBlockId;
|
||||
if (entrySound >= 0) soundDataBlockIds.push(entrySound);
|
||||
|
||||
// Spin handling on state entry (C++ setImageState spin switch).
|
||||
const newSpin = this.states[nextState].spin;
|
||||
switch (newSpin) {
|
||||
case SPIN_STOP:
|
||||
this.spinTimeScale = 0;
|
||||
break;
|
||||
case SPIN_FULL:
|
||||
this.spinTimeScale = 1;
|
||||
break;
|
||||
case SPIN_UP:
|
||||
// Partial ramp reversal from SpinDown: adjust delayTime so the ramp
|
||||
// starts from the current barrel speed.
|
||||
if (lastSpin === SPIN_DOWN && newTimeout > 0) {
|
||||
this.delayTime *= 1 - lastDelay / newTimeout;
|
||||
}
|
||||
break;
|
||||
case SPIN_DOWN:
|
||||
// Partial ramp reversal from SpinUp.
|
||||
if (lastSpin === SPIN_UP && newTimeout > 0) {
|
||||
this.delayTime *= 1 - lastDelay / newTimeout;
|
||||
}
|
||||
break;
|
||||
// SPIN_IGNORE (0): preserve spinTimeScale.
|
||||
}
|
||||
|
||||
// Entry transitions: check conditions immediately (no waitForTimeout,
|
||||
// no timeout). Matches C++ setImageState's recursive condition checks.
|
||||
nextState = this.evaluateEntryTransitions(flags);
|
||||
}
|
||||
|
||||
// Per-tick spin update (C++ updateImageState spin switch).
|
||||
// In C++, FullSpin/NoSpin/IgnoreSpin are no-ops here (set on state entry
|
||||
// in setImageState). But our fireCount resync path bypasses the transition
|
||||
// loop, so we must handle FullSpin and NoSpin per-tick as a fallback.
|
||||
const state = this.states[this.currentStateIndex];
|
||||
const timeout = state.timeoutValue ?? 0;
|
||||
switch (state.spin) {
|
||||
case SPIN_STOP:
|
||||
this.spinTimeScale = 0;
|
||||
break;
|
||||
case SPIN_UP:
|
||||
this.spinTimeScale = timeout > 0
|
||||
? Math.max(0, 1 - this.delayTime / timeout)
|
||||
: 1;
|
||||
break;
|
||||
case SPIN_FULL:
|
||||
this.spinTimeScale = 1;
|
||||
break;
|
||||
case SPIN_DOWN:
|
||||
this.spinTimeScale = timeout > 0
|
||||
? Math.max(0, this.delayTime / timeout)
|
||||
: 0;
|
||||
break;
|
||||
// SPIN_IGNORE (0): leave spinTimeScale unchanged.
|
||||
}
|
||||
|
||||
return {
|
||||
sequenceName: this.resolveSequenceName(state),
|
||||
isFiring: state.fire,
|
||||
spinTimeScale: this.spinTimeScale,
|
||||
reverse: !state.direction,
|
||||
scaleAnimation: state.scaleAnimation,
|
||||
timeoutValue: state.timeoutValue ?? 0,
|
||||
transitioned,
|
||||
soundDataBlockIds,
|
||||
stateIndex: this.currentStateIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tick transition evaluation (C++ updateImageState).
|
||||
* Respects waitForTimeout: only evaluates when delayTime has elapsed
|
||||
* or the state doesn't require waiting. Includes timeout transition.
|
||||
*
|
||||
* V12 engine priority order: loaded, ammo, target, wet, trigger, timeout.
|
||||
*/
|
||||
private evaluateTickTransitions(flags: WeaponImageState): number {
|
||||
const state = this.states[this.currentStateIndex];
|
||||
const timedOut = this.delayTime <= 0;
|
||||
const canTransition = timedOut || !state.waitForTimeout;
|
||||
|
||||
if (!canTransition) return -1;
|
||||
|
||||
const cond = this.evaluateConditions(state, flags);
|
||||
if (cond !== -1) return cond;
|
||||
|
||||
// timeout (only when delayTime has elapsed)
|
||||
if (timedOut) {
|
||||
const timeoutTarget = state.transitionOnTimeout;
|
||||
if (timeoutTarget !== NO_TRANSITION) {
|
||||
return timeoutTarget;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry transition evaluation (C++ setImageState).
|
||||
* Fires immediately on state entry — ignores waitForTimeout and does NOT
|
||||
* check timeout transition.
|
||||
*/
|
||||
private evaluateEntryTransitions(flags: WeaponImageState): number {
|
||||
const state = this.states[this.currentStateIndex];
|
||||
return this.evaluateConditions(state, flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate condition-based transitions in V12 priority order:
|
||||
* loaded, ammo, target, wet, trigger.
|
||||
*
|
||||
* Matches C++ updateImageState: no self-transition guard. If a condition
|
||||
* resolves to the current state, setImageState handles it as a
|
||||
* self-transition (just resets delayTime).
|
||||
*/
|
||||
private evaluateConditions(
|
||||
state: WeaponImageDataBlockState,
|
||||
flags: WeaponImageState,
|
||||
): number {
|
||||
// loaded
|
||||
const loadedTarget = flags.loaded
|
||||
? state.transitionOnLoaded
|
||||
: state.transitionOnNotLoaded;
|
||||
if (loadedTarget !== NO_TRANSITION) {
|
||||
return loadedTarget;
|
||||
}
|
||||
|
||||
// ammo
|
||||
const ammoTarget = flags.ammo
|
||||
? state.transitionOnAmmo
|
||||
: state.transitionOnNoAmmo;
|
||||
if (ammoTarget !== NO_TRANSITION) {
|
||||
return ammoTarget;
|
||||
}
|
||||
|
||||
// target
|
||||
const targetTarget = flags.target
|
||||
? state.transitionOnTarget
|
||||
: state.transitionOnNoTarget;
|
||||
if (targetTarget !== NO_TRANSITION) {
|
||||
return targetTarget;
|
||||
}
|
||||
|
||||
// wet
|
||||
const wetTarget = flags.wet
|
||||
? state.transitionOnWet
|
||||
: state.transitionOnNotWet;
|
||||
if (wetTarget !== NO_TRANSITION) {
|
||||
return wetTarget;
|
||||
}
|
||||
|
||||
// trigger
|
||||
const triggerTarget = flags.triggerDown
|
||||
? state.transitionOnTriggerDown
|
||||
: state.transitionOnTriggerUp;
|
||||
if (triggerTarget !== NO_TRANSITION) {
|
||||
return triggerTarget;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Resolve a state's sequence index to a clip name via the GLB metadata. */
|
||||
private resolveSequenceName(
|
||||
state: WeaponImageDataBlockState,
|
||||
): string | null {
|
||||
if (state.sequence == null || state.sequence < 0) return null;
|
||||
const name = this.seqIndexToName[state.sequence];
|
||||
return name ?? null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue