t2-mapper/src/stream/StreamEngine.ts

2233 lines
80 KiB
TypeScript
Raw Normal View History

2026-03-09 12:38:40 -07:00
import { ghostToSceneObject } from "../scene";
import type { SceneObject } from "../scene/types";
import {
linearProjectileClassNames,
ballisticProjectileClassNames,
seekerProjectileClassNames,
toEntityType,
toEntityId,
IFF_GREEN,
IFF_RED,
TICK_DURATION_MS,
} from "./entityClassification";
import {
clamp,
MAX_PITCH,
CameraMode_OrbitObject,
yawPitchToQuaternion,
playerYawToQuaternion,
torqueQuatToThreeJS,
2026-03-09 23:19:14 -07:00
torqueQuatHeading,
torqueQuatPitch,
2026-03-09 12:38:40 -07:00
isValidPosition,
isVec3Like,
isQuatLike,
resolveShapeName,
getNumberField,
resolveTracerVisual,
resolveSpriteVisual,
parseWeaponImageStates,
stripTaggedStringMarkup,
detectColorCode,
extractWavTag,
detectControlObjectType,
} from "./streamHelpers";
import type { Vec3 } from "./streamHelpers";
import type {
BackpackHudState,
ChatSegment,
ChatMessage,
ThreadState,
StreamVisual,
StreamCamera,
StreamEntity,
StreamSnapshot,
StreamingPlayback,
InventoryHudSlot,
PendingAudioEvent,
2026-03-14 17:12:37 -07:00
PlayerRosterEntry,
2026-03-09 12:38:40 -07:00
TeamScore,
WeaponsHudSlot,
WeaponImageState,
WeaponImageDataBlockState,
} from "./types";
import { createLogger } from "../logger";
const log = createLogger("StreamEngine");
2026-03-09 12:38:40 -07:00
export type { Vec3 };
// ── Internal mutable entity type ──
export interface MutableEntity {
id: string;
ghostIndex: number;
className: string;
/** Move tick when this ghost instance first entered scope. */
spawnTick: number;
type: string;
dataBlockId?: number;
shapeHint?: string;
dataBlock?: string;
visual?: StreamVisual;
direction?: [number, number, number];
weaponShape?: string;
playerName?: string;
position?: [number, number, number];
rotation: [number, number, number, number];
velocity?: [number, number, number];
health?: number;
energy?: number;
maxEnergy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
targetId?: number;
projectilePhysics?: "linear" | "ballistic" | "seeker";
simulatedVelocity?: [number, number, number];
gravityMod?: number;
explosionShape?: string;
explosionLifetimeTicks?: number;
hasExploded?: boolean;
isExplosion?: boolean;
expiryTick?: number;
faceViewer?: boolean;
explosionDataBlockId?: number;
maintainEmitterId?: number;
sensorGroup?: number;
threads?: ThreadState[];
weaponImageState?: WeaponImageState;
weaponImageStates?: WeaponImageDataBlockState[];
weaponImageStatesDbId?: number;
2026-03-09 23:19:14 -07:00
packShape?: string;
flagShape?: string;
2026-03-09 23:19:14 -07:00
falling?: boolean;
jetting?: boolean;
2026-03-09 12:38:40 -07:00
headPitch?: number;
headYaw?: number;
targetRenderFlags?: number;
carryingFlag?: boolean;
/** Item velocity interpolation state (dropped weapons/items).
* The real Tribes 2 client does NOT simulate physics (gravity/collision)
* for items it just interpolates position using server-sent velocity
* until the next server update arrives. */
2026-03-09 12:38:40 -07:00
itemPhysics?: {
velocity: [number, number, number];
atRest: boolean;
};
label?: string;
audioFileName?: string;
audioVolume?: number;
audioIs3D?: boolean;
audioIsLooping?: boolean;
audioMinDistance?: number;
audioMaxDistance?: number;
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
sceneData?: SceneObject;
}
export type RuntimeControlObject = {
ghostIndex: number;
data?: Record<string, unknown>;
position?: Vec3;
};
/** Minimal interface for the parser registry (ghost/event class lookup). */
export interface ParserRegistry {
getGhostParser(classId: number): { name: string } | undefined;
getEventParser(classId: number): { name: string } | undefined;
}
/** Minimal interface for ghost tracking (class name by ghost index). */
export interface GhostTrackerLike {
getGhost(ghostIndex: number): { className: string } | undefined;
clear?(): void;
}
/**
* Shared engine that processes parsed packet data (events, ghosts, control
* object state) and maintains all game state. Subclasses provide the data
* source: DemoParser blocks for demo playback, or PacketParser for live.
*/
export abstract class StreamEngine implements StreamingPlayback {
// ── Parser infrastructure (set by subclass constructors) ──
protected registry!: ParserRegistry;
protected ghostTracker!: GhostTrackerLike;
// ── Entities ──
protected entities = new Map<string, MutableEntity>();
protected entityIdByGhostIndex = new Map<number, string>();
// ── Tick / time ──
protected tickCount = 0;
// ── Camera ──
protected camera: StreamCamera | null = null;
// ── Chat & audio ──
protected chatMessages: ChatMessage[] = [];
2026-03-09 23:19:14 -07:00
protected chatMessageIdCounter = 0;
private _chatGen = 0;
private _chatSnapshotGen = -1;
private _chatSnapshot: ChatMessage[] = [];
2026-03-09 12:38:40 -07:00
protected audioEvents: PendingAudioEvent[] = [];
// ── Net strings ──
protected netStrings = new Map<number, string>();
// ── Target system ──
protected targetNames = new Map<number, string>();
protected targetTeams = new Map<number, number>();
protected targetRenderFlags = new Map<number, number>();
/** Deferred nameTag→targetId for TargetInfoEvents that arrived before their NetStringEvent. */
protected pendingNameTags = new Map<number, number>();
protected sensorGroupColors = new Map<
number,
Map<number, { r: number; g: number; b: number }>
>();
protected playerSensorGroup = 0;
// ── Control object ──
protected lastStatus = { health: 1, energy: 1 };
protected latestControl: RuntimeControlObject = { ghostIndex: -1 };
protected controlPlayerGhostId?: string;
protected lastControlType: "camera" | "player" = "camera";
protected isPiloting = false;
2026-03-09 23:19:14 -07:00
protected lastPilotGhostIndex?: number;
protected lastVehicleHeading = 0;
protected lastVehiclePitch = 0;
protected lastVehicleOrbitDir?: [number, number, number];
/** Vehicle velocity in Torque space (estimated from linMomentum/mass). */
protected lastVehicleVelocity?: [number, number, number];
/** Time (sec) of last vehicle position update from controlObjectData. */
protected lastVehiclePosTime = 0;
/** Last known vehicle position in Torque space for extrapolation. */
protected lastVehiclePos?: [number, number, number];
protected firstPerson = true;
2026-03-09 12:38:40 -07:00
protected lastCameraMode?: number;
protected lastOrbitGhostIndex?: number;
protected lastOrbitDistance?: number;
protected latestFov = 90;
// ── HUD state ──
protected weaponsHud = { slots: new Map<number, number>(), activeIndex: -1 };
protected backpackHud = { packIndex: -1, active: false, text: "" };
protected inventoryHud = { slots: new Map<number, number>(), activeSlot: -1 };
protected teamScores: TeamScore[] = [];
2026-03-14 17:12:37 -07:00
protected playerRoster = new Map<
number,
{ name: string; teamId: number; score: number; ping: number; packetLoss: number }
>();
/** Stream time (seconds) when the clock was last set. */
protected clockAnchorStreamSec: number | null = null;
/** Duration in ms passed to setTime (0 = count-up, >0 = count-down). */
protected clockDurationMs: number = 0;
2026-03-09 12:38:40 -07:00
// ── Mission info (from server messages) ──
/** Mission display name (e.g. "Riverdance"), from MsgMissionDropInfo/MsgLoadInfo. */
missionDisplayName: string | null = null;
/** Game type display name (e.g. "Capture the Flag"), from MsgMissionDropInfo/MsgLoadInfo. */
missionTypeDisplayName: string | null = null;
/** Game class name (e.g. "CTFGame"), from MsgClientReady. */
gameClassName: string | null = null;
/** Server name from MsgMissionDropInfo. */
serverDisplayName: string | null = null;
/** Server-assigned name of the connected/recording player. */
connectedPlayerName: string | null = null;
2026-03-14 17:12:37 -07:00
/** Client ID of the connected player (from MsgClientJoin "Welcome" message). */
connectedClientId: number | null = null;
/** Called when mission info changes (mission name, game type, etc.). */
onMissionInfoChange?: () => void;
2026-03-09 12:38:40 -07:00
// ── Explosions ──
protected nextExplosionId = 0;
// ── Abstract methods ──
/** Resolve datablock data by numeric ID. */
abstract getDataBlockData(id: number): Record<string, unknown> | undefined;
/** Get TSShapeConstructor sequence entries for a shape name. */
abstract getShapeConstructorSequences(
shapeName: string,
): string[] | undefined;
/** Get the current playback time in seconds. */
protected abstract getTimeSec(): number;
/**
* Get camera yaw/pitch for this tick. Demo accumulates from move deltas;
* live reads from server-provided rotation.
*/
protected abstract getCameraYawPitch(
data: Record<string, unknown> | undefined,
): { yaw: number; pitch: number };
/** DTS shape names for weapon effects that should be preloaded. */
abstract getEffectShapes(): string[];
// ── Ghost/entity resolution (shared, uses registry + ghostTracker) ──
protected resolveGhostClassName(
ghostIndex: number,
classId: number | undefined,
): string | undefined {
if (typeof classId === "number") {
const fromClassId = this.registry.getGhostParser(classId)?.name;
if (fromClassId) return fromClassId;
}
const entityId = this.entityIdByGhostIndex.get(ghostIndex);
if (entityId) {
const entity = this.entities.get(entityId);
if (entity?.className) return entity.className;
}
const trackerGhost = this.ghostTracker.getGhost(ghostIndex);
if (trackerGhost?.className) return trackerGhost.className;
return undefined;
}
protected resolveEntityIdForGhostIndex(
ghostIndex: number,
): string | undefined {
const byMap = this.entityIdByGhostIndex.get(ghostIndex);
if (byMap) return byMap;
const trackerGhost = this.ghostTracker.getGhost(ghostIndex);
if (trackerGhost) return toEntityId(trackerGhost.className, ghostIndex);
return undefined;
}
// ── StreamingPlayback interface ──
abstract reset(): void;
abstract getSnapshot(): StreamSnapshot;
abstract stepToTime(
targetTimeSec: number,
maxMoveTicks?: number,
): StreamSnapshot;
// ── Shared reset logic ──
protected resetSharedState(): void {
this.entities.clear();
this.entityIdByGhostIndex.clear();
this.tickCount = 0;
this.camera = null;
this.chatMessages = [];
2026-03-09 23:19:14 -07:00
this.chatMessageIdCounter = 0;
this._chatGen = 0;
this._chatSnapshotGen = -1;
this._chatSnapshot = [];
2026-03-09 12:38:40 -07:00
this.audioEvents = [];
this.netStrings.clear();
this.targetNames.clear();
this.targetTeams.clear();
this.targetRenderFlags.clear();
this.sensorGroupColors.clear();
this.playerSensorGroup = 0;
this.lastStatus = { health: 1, energy: 1 };
this.latestControl = { ghostIndex: -1 };
this.controlPlayerGhostId = undefined;
this.lastControlType = "camera";
this.isPiloting = false;
2026-03-09 23:19:14 -07:00
this.lastPilotGhostIndex = undefined;
this.lastVehicleHeading = 0;
this.lastVehiclePitch = 0;
this.lastVehicleOrbitDir = undefined;
this.lastVehicleVelocity = undefined;
this.lastVehiclePosTime = 0;
this.lastVehiclePos = undefined;
this.firstPerson = true;
2026-03-09 12:38:40 -07:00
this.lastCameraMode = undefined;
this.lastOrbitGhostIndex = undefined;
this.lastOrbitDistance = undefined;
this.latestFov = 90;
this.weaponsHud = { slots: new Map(), activeIndex: -1 };
this.backpackHud = { packIndex: -1, active: false, text: "" };
this.inventoryHud = { slots: new Map(), activeSlot: -1 };
this.teamScores = [];
this.playerRoster.clear();
2026-03-14 17:12:37 -07:00
this.clockAnchorStreamSec = null;
this.clockDurationMs = 0;
2026-03-09 12:38:40 -07:00
this.nextExplosionId = 0;
this.missionDisplayName = null;
this.missionTypeDisplayName = null;
this.gameClassName = null;
this.serverDisplayName = null;
2026-03-14 17:12:37 -07:00
// Note: connectedPlayerName and connectedClientId are NOT cleared here —
// they are connection-level state set once from the "Welcome" MsgClientJoin,
// and should persist across mission changes.
2026-03-09 12:38:40 -07:00
}
// ── Net string resolution ──
protected resolveNetString(s: string): string {
if (s.length >= 2 && s.charCodeAt(0) === 1) {
const id = parseInt(s.slice(1), 10);
if (Number.isFinite(id)) return this.netStrings.get(id) ?? s;
}
return s;
}
protected formatRemoteArgs(template: string, args: string[]): string {
let resolved = this.resolveNetString(template);
for (let i = 0; i < args.length; i++) {
const placeholder = `%${i + 1}`;
if (resolved.includes(placeholder)) {
resolved = resolved.replaceAll(
placeholder,
stripTaggedStringMarkup(this.resolveNetString(args[i])),
);
}
}
resolved = resolved.replace(/%\d+/g, "");
return stripTaggedStringMarkup(resolved);
}
// ── Control object processing ──
protected processControlObject(gameState: {
controlObjectGhostIndex?: number;
controlObjectData?: Record<string, unknown>;
compressionPoint?: Vec3;
cameraFov?: number;
}): void {
const controlData = gameState.controlObjectData;
const prevControl = this.latestControl;
const nextGhostIndex =
typeof gameState.controlObjectGhostIndex === "number"
? gameState.controlObjectGhostIndex
: prevControl.ghostIndex;
const compressionPoint = gameState.compressionPoint;
const controlPosition = isValidPosition(controlData?.position as Vec3)
? (controlData?.position as Vec3)
: isValidPosition(compressionPoint)
? compressionPoint
: prevControl.position;
this.latestControl = {
ghostIndex: nextGhostIndex,
data: controlData,
position: controlPosition,
};
if (nextGhostIndex !== prevControl.ghostIndex) {
const entityId = this.entityIdByGhostIndex.get(nextGhostIndex);
const entity = entityId ? this.entities.get(entityId) : undefined;
if (entity?.sensorGroup != null && entity.sensorGroup > 0) {
this.playerSensorGroup = entity.sensorGroup;
}
}
if (controlData) {
const detected = detectControlObjectType(controlData);
if (detected) this.lastControlType = detected;
if (this.lastControlType === "player") {
this.isPiloting = !!(
controlData.pilot || controlData.controlObjectGhost != null
);
if (
this.isPiloting &&
typeof controlData.controlObjectGhost === "number"
) {
2026-03-09 23:19:14 -07:00
this.lastPilotGhostIndex = controlData.controlObjectGhost;
} else if (!this.isPiloting) {
this.lastPilotGhostIndex = undefined;
this.lastVehicleHeading = 0;
this.lastVehiclePitch = 0;
this.lastVehicleOrbitDir = undefined;
this.lastVehicleVelocity = undefined;
this.lastVehiclePosTime = 0;
this.lastVehiclePos = undefined;
}
2026-03-09 12:38:40 -07:00
} else {
this.isPiloting = false;
if (typeof controlData.cameraMode === "number") {
this.lastCameraMode = controlData.cameraMode;
if (controlData.cameraMode === CameraMode_OrbitObject) {
if (typeof controlData.orbitObjectGhostIndex === "number") {
this.lastOrbitGhostIndex = controlData.orbitObjectGhostIndex;
}
const minOrbit = controlData.minOrbitDist as number | undefined;
const maxOrbit = controlData.maxOrbitDist as number | undefined;
const curOrbit = controlData.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.lastOrbitGhostIndex = undefined;
this.lastOrbitDistance = undefined;
}
}
}
}
if (gameState.cameraFov !== undefined) {
this.latestFov = gameState.cameraFov;
}
}
// ── Event processing ──
protected processEvent(
event: { classId: number; parsedData?: Record<string, unknown> },
eventName: string | undefined,
): void {
const data = event.parsedData;
if (!data) return;
const type = data.type as string | undefined;
// GhostAlwaysObjectEvent — scope-always objects (terrain, sky, interiors).
// These arrive as events, not ghost updates, but contain full ghost data.
// Create entities from them so scene infrastructure renders.
if (type === "GhostAlwaysObjectEvent") {
const ghostIndex = data.ghostIndex as number | undefined;
const classId = data.classId as number | undefined;
const objectData = data.objectData as Record<string, unknown> | undefined;
const hasData = data._hasObjectData as boolean | undefined;
2026-03-13 23:00:08 -07:00
const className =
typeof classId === "number"
? (this.registry.getGhostParser(classId)?.name ??
`classId=${classId}`)
: "?";
log.debug(
"GhostAlwaysObjectEvent: ghost=%d class=%s hasData=%s %s",
ghostIndex,
className,
hasData,
2026-03-13 23:00:08 -07:00
objectData
? `keys=[${Object.keys(objectData).join(",")}]`
: "(no data)",
);
2026-03-09 12:38:40 -07:00
if (ghostIndex != null && classId != null) {
this.processGhostUpdate({
index: ghostIndex,
type: "create",
classId,
parsedData: objectData,
});
}
return;
}
if (type === "NetStringEvent" || eventName === "NetStringEvent") {
const id = data.id as number;
const value = data.value as string;
if (id != null && typeof value === "string") {
this.netStrings.set(id, value);
// Resolve any TargetInfoEvents that were waiting for this string.
const pendingTargetId = this.pendingNameTags.get(id);
if (pendingTargetId != null) {
this.pendingNameTags.delete(id);
2026-03-14 17:12:37 -07:00
const name = stripTaggedStringMarkup(value).trim();
2026-03-09 12:38:40 -07:00
this.targetNames.set(pendingTargetId, name);
for (const entity of this.entities.values()) {
if (entity.targetId === pendingTargetId) {
entity.playerName = name;
}
}
}
}
return;
}
if (type === "TargetInfoEvent" || eventName === "TargetInfoEvent") {
const targetId = data.targetId as number | undefined;
const nameTag = data.nameTag as number | undefined;
if (targetId != null && nameTag != null) {
const resolved = this.netStrings.get(nameTag);
if (resolved) {
2026-03-14 17:12:37 -07:00
this.targetNames.set(targetId, stripTaggedStringMarkup(resolved).trim());
2026-03-09 12:38:40 -07:00
} else {
// NetStringEvent hasn't arrived yet — defer resolution.
this.pendingNameTags.set(nameTag, targetId);
}
}
const sensorGroup = data.sensorGroup as number | undefined;
if (targetId != null && sensorGroup != null) {
this.targetTeams.set(targetId, sensorGroup);
}
const renderFlags = data.renderFlags as number | undefined;
if (targetId != null && renderFlags != null) {
this.targetRenderFlags.set(targetId, renderFlags);
}
// Apply all known target info to existing entities.
if (targetId != null) {
const name = this.targetNames.get(targetId);
const team = this.targetTeams.get(targetId);
const rf = this.targetRenderFlags.get(targetId);
for (const entity of this.entities.values()) {
if (entity.targetId === targetId) {
if (name) {
entity.playerName = name;
}
2026-03-09 12:38:40 -07:00
if (team != null) entity.sensorGroup = team;
if (rf != null) entity.targetRenderFlags = rf;
}
}
}
return;
}
if (type === "SetSensorGroupEvent" || eventName === "SetSensorGroupEvent") {
2026-03-09 12:38:40 -07:00
const sg = data.sensorGroup as number | undefined;
if (sg != null) this.playerSensorGroup = sg;
return;
}
if (
type === "SensorGroupColorEvent" ||
eventName === "SensorGroupColorEvent"
) {
const sg = data.sensorGroup as number;
const colors = data.colors as
| Array<{
index: number;
r?: number;
g?: number;
b?: number;
default?: boolean;
}>
| undefined;
if (colors) {
let map = this.sensorGroupColors.get(sg);
if (!map) {
map = new Map();
this.sensorGroupColors.set(sg, map);
}
for (const c of colors) {
if (c.default) {
map.delete(c.index);
} else {
map.set(c.index, { r: c.r ?? 0, g: c.g ?? 0, b: c.b ?? 0 });
}
}
}
return;
}
if (type === "RemoteCommandEvent" || eventName === "RemoteCommandEvent") {
2026-03-09 12:38:40 -07:00
const funcName = this.resolveNetString(data.funcName as string);
const args = data.args as string[];
const timeSec = this.getTimeSec();
if (funcName === "ChatMessage" && args.length >= 4) {
const rawTemplate = this.resolveNetString(args[3]);
const colorCode = detectColorCode(rawTemplate);
const sender = args[4]
? stripTaggedStringMarkup(this.resolveNetString(args[4]))
: "";
const rawText = this.formatRemoteArgs(args[3], args.slice(4));
if (rawText) {
const colonIdx = rawText.indexOf(": ");
const text = colonIdx >= 0 ? rawText.slice(colonIdx + 2) : rawText;
const { text: displayText, wavPath } = extractWavTag(text);
let soundPath: string | undefined;
let soundPitch: number | undefined;
if (wavPath) {
const voice = this.resolveNetString(args[1]);
soundPath = voice ? `voice/${voice}/${wavPath}.wav` : wavPath;
const pitchStr = this.resolveNetString(args[2]);
if (pitchStr) {
const p = parseFloat(pitchStr);
if (Number.isFinite(p))
soundPitch = Math.max(0.5, Math.min(2.0, p));
}
}
const cc = colorCode ?? 0;
this.pushChatMessage({
timeSec,
sender,
text: displayText,
kind: "chat",
colorCode: cc,
segments: [
{
text: sender ? `${sender}: ${displayText}` : displayText,
colorCode: cc,
},
],
soundPath,
soundPitch,
});
}
} else if (funcName === "CannedChatMessage" && args.length >= 6) {
const cannedColorCode = detectColorCode(this.resolveNetString(args[1]));
2026-03-09 12:38:40 -07:00
const name = stripTaggedStringMarkup(this.resolveNetString(args[2]));
const keys = stripTaggedStringMarkup(this.resolveNetString(args[4]));
const rawText = this.formatRemoteArgs(args[1], args.slice(2));
if (rawText) {
const { wavPath } = extractWavTag(rawText);
const voiceLine = extractWavTag(
stripTaggedStringMarkup(this.resolveNetString(args[3])),
).text;
let soundPath: string | undefined;
let soundPitch: number | undefined;
if (wavPath) {
const voice = this.resolveNetString(args[5]);
soundPath = voice ? `voice/${voice}/${wavPath}.wav` : wavPath;
if (args[6]) {
const p = parseFloat(this.resolveNetString(args[6]));
if (Number.isFinite(p))
soundPitch = Math.max(0.5, Math.min(2.0, p));
}
}
const cc = cannedColorCode ?? 0;
const cannedSegments: ChatSegment[] = [];
if (keys) cannedSegments.push({ text: `[${keys}] `, colorCode: 0 });
cannedSegments.push({
text: name ? `${name}: ${voiceLine}` : voiceLine,
colorCode: cc,
});
this.pushChatMessage({
timeSec,
sender: name,
text: voiceLine,
kind: "chat",
colorCode: cc,
segments: cannedSegments,
soundPath,
soundPitch,
});
}
} else if (funcName === "ServerMessage" && args.length >= 2) {
this.handleServerMessage(args);
const rawTemplate = this.resolveNetString(args[1]);
const serverColorCode = detectColorCode(rawTemplate);
const rawText = this.formatRemoteArgs(args[1], args.slice(2));
if (rawText) {
const { text, wavPath } = extractWavTag(rawText);
const scc = serverColorCode ?? 0;
this.pushChatMessage({
timeSec,
sender: "",
text,
kind: "server",
colorCode: scc,
segments: [{ text, colorCode: scc }],
soundPath: wavPath ?? undefined,
});
}
} else {
this.handleHudRemoteCommand(funcName, args);
}
return;
}
if (
type === "Sim3DAudioEvent" ||
type === "Sim2DAudioEvent" ||
eventName === "Sim3DAudioEvent" ||
eventName === "Sim2DAudioEvent"
) {
const profileId = data.profileId as number;
if (typeof profileId === "number") {
const timeSec = this.getTimeSec();
const is3D =
type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent";
const position = is3D ? (data.position as Vec3 | undefined) : undefined;
2026-03-09 12:38:40 -07:00
this.audioEvents.push({ profileId, position, timeSec });
if (this.audioEvents.length > 100) {
this.audioEvents.splice(0, this.audioEvents.length - 100);
}
}
}
}
// ── Ghost processing ──
protected processGhostUpdate(ghost: {
index: number;
type: "create" | "update" | "delete";
classId?: number;
parsedData?: Record<string, unknown>;
}): void {
const ghostIndex = ghost.index;
const prevEntityId = this.entityIdByGhostIndex.get(ghostIndex);
// Spawn explosion for projectiles being removed
if (prevEntityId) {
const prevEntity = this.entities.get(prevEntityId);
if (
prevEntity &&
prevEntity.type === "Projectile" &&
!prevEntity.hasExploded &&
prevEntity.explosionDataBlockId != null &&
prevEntity.position &&
(ghost.type === "delete" || ghost.type === "create")
) {
this.spawnExplosion(prevEntity, [...prevEntity.position] as [
number,
number,
number,
]);
}
}
if (ghost.type === "delete") {
if (prevEntityId) {
this.removeSoundSlotEntities(prevEntityId);
this.entities.delete(prevEntityId);
this.entityIdByGhostIndex.delete(ghostIndex);
}
return;
}
const className = this.resolveGhostClassName(ghostIndex, ghost.classId);
if (!className) {
if (ghost.type === "create") {
throw new Error(
`No ghost parser for classId ${ghost.classId} (ghost index ${ghostIndex})`,
);
}
return;
}
const entityId = toEntityId(className, ghostIndex);
if (prevEntityId && prevEntityId !== entityId) {
this.removeSoundSlotEntities(prevEntityId);
this.entities.delete(prevEntityId);
}
let entity: MutableEntity;
const existingEntity = this.entities.get(entityId);
if (existingEntity && ghost.type === "create") {
this.removeSoundSlotEntities(entityId);
existingEntity.spawnTick = this.tickCount;
this.resetEntity(existingEntity);
entity = existingEntity;
} else if (existingEntity) {
entity = existingEntity;
} else {
entity = {
id: entityId,
ghostIndex,
className,
spawnTick: this.tickCount,
type: toEntityType(className),
rotation: [0, 0, 0, 1],
};
this.entities.set(entityId, entity);
}
entity.ghostIndex = ghostIndex;
entity.className = className;
entity.type = toEntityType(className);
this.entityIdByGhostIndex.set(ghostIndex, entityId);
this.applyGhostData(entity, ghost.parsedData);
// Only set sceneData on ghost creates — updates contain sparse fields
// that would overwrite the initial data with defaults (e.g. empty
// interiorFile, identity transform).
if (ghost.type === "create" && ghost.parsedData) {
const sceneObj = ghostToSceneObject(
className,
ghostIndex,
ghost.parsedData as Record<string, unknown>,
);
if (sceneObj) entity.sceneData = sceneObj;
}
}
protected resetEntity(entity: MutableEntity): void {
entity.rotation = [0, 0, 0, 1];
entity.hasExploded = undefined;
entity.explosionShape = undefined;
entity.explosionLifetimeTicks = undefined;
entity.faceViewer = undefined;
entity.simulatedVelocity = undefined;
entity.projectilePhysics = undefined;
entity.gravityMod = undefined;
entity.direction = undefined;
entity.velocity = undefined;
entity.position = undefined;
entity.dataBlock = undefined;
entity.dataBlockId = undefined;
entity.shapeHint = undefined;
entity.visual = undefined;
entity.targetId = undefined;
entity.targetRenderFlags = undefined;
entity.carryingFlag = undefined;
entity.sensorGroup = undefined;
entity.playerName = undefined;
entity.weaponShape = undefined;
2026-03-09 23:19:14 -07:00
entity.packShape = undefined;
entity.falling = undefined;
entity.jetting = undefined;
2026-03-09 12:38:40 -07:00
entity.weaponImageState = undefined;
entity.weaponImageStates = undefined;
entity.weaponImageStatesDbId = undefined;
entity.itemPhysics = undefined;
entity.threads = undefined;
entity.headPitch = undefined;
entity.headYaw = undefined;
entity.health = undefined;
entity.energy = undefined;
entity.maxEnergy = undefined;
entity.damageState = undefined;
entity.actionAnim = undefined;
entity.actionAtEnd = undefined;
entity.explosionDataBlockId = undefined;
entity.maintainEmitterId = undefined;
}
// ── Apply ghost data ──
protected applyGhostData(
entity: MutableEntity,
rawData: Record<string, unknown> | undefined,
): void {
if (!rawData) return;
const data = rawData;
const dataBlockId = data.dataBlockId as number | undefined;
if (dataBlockId != null) {
entity.dataBlockId = dataBlockId;
const blockData = this.getDataBlockData(dataBlockId);
const shapeName = resolveShapeName(entity.className, blockData);
entity.visual =
resolveTracerVisual(entity.className, blockData) ??
resolveSpriteVisual(entity.className, blockData);
if (typeof shapeName === "string") {
entity.shapeHint = shapeName;
entity.dataBlock = shapeName;
}
if (
entity.type === "Player" &&
typeof blockData?.maxEnergy === "number"
) {
entity.maxEnergy = blockData.maxEnergy;
}
// Classify projectile physics
if (entity.type === "Projectile") {
if (linearProjectileClassNames.has(entity.className)) {
entity.projectilePhysics = "linear";
} else if (ballisticProjectileClassNames.has(entity.className)) {
entity.projectilePhysics = "ballistic";
entity.gravityMod = getNumberField(blockData, ["gravityMod"]) ?? 1.0;
2026-03-09 12:38:40 -07:00
} else if (seekerProjectileClassNames.has(entity.className)) {
entity.projectilePhysics = "seeker";
}
}
// Resolve explosion shape
if (entity.type === "Projectile" && entity.explosionDataBlockId == null) {
const info = this.resolveExplosionInfo(dataBlockId);
if (info) {
entity.explosionShape = info.shape;
entity.faceViewer = info.faceViewer;
entity.explosionLifetimeTicks = info.lifetimeTicks;
entity.explosionDataBlockId = info.explosionDataBlockId;
}
}
// Trail emitter
if (entity.type === "Projectile" && entity.maintainEmitterId == null) {
const trailEmitterId = blockData?.baseEmitter as number | null;
if (typeof trailEmitterId === "number" && trailEmitterId > 0) {
entity.maintainEmitterId = trailEmitterId;
}
}
}
// Weapon images (Player)
if (entity.type === "Player") {
const images = data.images as
| Array<{
index?: number;
dataBlockId?: number;
triggerDown?: boolean;
ammo?: boolean;
loaded?: boolean;
target?: boolean;
wet?: boolean;
fireCount?: number;
}>
| undefined;
if (Array.isArray(images) && images.length > 0) {
const weaponImage = images.find((img) => img.index === 0);
if (weaponImage?.dataBlockId && weaponImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(weaponImage.dataBlockId);
const weaponShape = resolveShapeName("ShapeBaseImageData", blockData);
if (weaponShape) {
const mountPoint = blockData?.mountPoint as number | undefined;
if (
(mountPoint == null || mountPoint <= 0) &&
!/pack_/i.test(weaponShape)
) {
entity.weaponShape = weaponShape;
}
}
const prev = entity.weaponImageState;
entity.weaponImageState = {
dataBlockId: weaponImage.dataBlockId,
triggerDown: weaponImage.triggerDown ?? prev?.triggerDown ?? false,
ammo: weaponImage.ammo ?? prev?.ammo ?? true,
loaded: weaponImage.loaded ?? prev?.loaded ?? true,
target: weaponImage.target ?? prev?.target ?? false,
wet: weaponImage.wet ?? prev?.wet ?? false,
fireCount: weaponImage.fireCount ?? prev?.fireCount ?? 0,
};
if (
blockData &&
entity.weaponImageStatesDbId !== weaponImage.dataBlockId
) {
entity.weaponImageStates = parseWeaponImageStates(blockData);
entity.weaponImageStatesDbId = weaponImage.dataBlockId;
}
} else if (weaponImage && !weaponImage.dataBlockId) {
entity.weaponShape = undefined;
entity.weaponImageState = undefined;
entity.weaponImageStates = undefined;
}
2026-03-09 23:19:14 -07:00
// Pack image (slot 2 = $BackpackSlot, mountPoint 1 = Mount1)
const packImage = images.find((img) => img.index === 2);
if (packImage?.dataBlockId && packImage.dataBlockId > 0) {
const blockData = this.getDataBlockData(packImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.packShape = shape;
}
} else if (packImage && !packImage.dataBlockId) {
entity.packShape = undefined;
}
// Flag image (slot 3 = $FlagSlot, mountPoint 2 = Mount2)
2026-03-09 12:38:40 -07:00
const flagImage = images.find((img) => img.index === 3);
if (flagImage?.dataBlockId && flagImage.dataBlockId > 0) {
entity.carryingFlag = true;
const blockData = this.getDataBlockData(flagImage.dataBlockId);
const shape = resolveShapeName("ShapeBaseImageData", blockData);
if (shape) {
entity.flagShape = shape;
}
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev | 0x2;
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
} else if (flagImage && !flagImage.dataBlockId) {
entity.carryingFlag = false;
entity.flagShape = undefined;
2026-03-09 12:38:40 -07:00
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = prev & ~0x2;
2026-03-09 12:38:40 -07:00
if (updated !== prev) {
this.targetRenderFlags.set(entity.targetId, updated);
entity.targetRenderFlags = updated;
}
}
}
}
}
// Position
const position = isValidPosition(data.position as Vec3)
? (data.position as Vec3)
: isValidPosition(data.initialPosition as Vec3)
? (data.initialPosition as Vec3)
: isValidPosition(data.explodePosition as Vec3)
? (data.explodePosition as Vec3)
: isValidPosition(data.endPoint as Vec3)
? (data.endPoint as Vec3)
: isValidPosition(
(data.transform as { position?: Vec3 } | undefined)?.position,
)
? ((data.transform as { position: Vec3 }).position as Vec3)
: undefined;
if (position) {
entity.position = [position.x, position.y, position.z];
// Sync any sound-slot child entities with the new position.
this.updateSoundSlotPositions(entity);
}
// Direction
const direction = isVec3Like(data.direction) ? data.direction : undefined;
if (direction) {
entity.direction = [direction.x, direction.y, direction.z];
}
// Rotation
if (entity.type === "Player" && typeof data.rotationZ === "number") {
entity.rotation = playerYawToQuaternion(data.rotationZ);
}
// Head pitch/yaw
if (entity.type === "Player") {
if (typeof data.headX === "number") entity.headPitch = data.headX;
if (typeof data.headZ === "number") entity.headYaw = data.headZ;
}
if (isQuatLike(data.angPosition)) {
const converted = torqueQuatToThreeJS(data.angPosition);
if (converted) entity.rotation = converted;
} else if (
isQuatLike(
(data.transform as { rotation?: unknown } | undefined)?.rotation,
)
) {
const converted = torqueQuatToThreeJS(
(
data.transform as {
rotation: { x: number; y: number; z: number; w: number };
}
).rotation,
2026-03-09 12:38:40 -07:00
);
if (converted) entity.rotation = converted;
} else if (
entity.type === "Item" &&
typeof (data.rotation as { angle?: unknown } | undefined)?.angle ===
"number"
) {
const rot = data.rotation as { angle: number; zSign?: number };
entity.rotation = playerYawToQuaternion((rot.zSign ?? 1) * rot.angle);
} else if (entity.type === "Projectile") {
const vec =
(data.velocity as Vec3 | undefined) ??
(data.direction as Vec3 | undefined) ??
(isValidPosition(data.initialPosition as Vec3) &&
isValidPosition(data.endPos as Vec3)
? {
x: (data.endPos as Vec3).x - (data.initialPosition as Vec3).x,
y: (data.endPos as Vec3).y - (data.initialPosition as Vec3).y,
z: (data.endPos as Vec3).z - (data.initialPosition as Vec3).z,
}
: undefined);
if (isVec3Like(vec) && (vec.x !== 0 || vec.y !== 0)) {
entity.rotation = playerYawToQuaternion(Math.atan2(vec.x, vec.y));
}
}
// Velocity
if (isVec3Like(data.velocity)) {
entity.velocity = [data.velocity.x, data.velocity.y, data.velocity.z];
if (!entity.direction) {
entity.direction = [data.velocity.x, data.velocity.y, data.velocity.z];
}
}
2026-03-09 23:19:14 -07:00
// Movement state flags (from Player MoveMask ghost data).
if (typeof data.moveFlag0 === "boolean") entity.falling = data.moveFlag0;
if (typeof data.moveFlag1 === "boolean") entity.jetting = data.moveFlag1;
// Item velocity interpolation: the Tribes 2 client does NOT simulate
// physics (gravity/collision) for items. It interpolates position using
// server-sent velocity until the next server update or atRest=true.
2026-03-09 12:38:40 -07:00
if (entity.type === "Item") {
const atRest = data.atRest as boolean | undefined;
if (atRest === false && isVec3Like(data.velocity)) {
entity.itemPhysics = {
velocity: [data.velocity.x, data.velocity.y, data.velocity.z],
atRest: false,
};
} else if (atRest === true) {
entity.itemPhysics = undefined;
}
}
// Projectile simulation velocity
if (entity.projectilePhysics) {
if (entity.projectilePhysics === "linear") {
const blockData =
entity.dataBlockId != null
? this.getDataBlockData(entity.dataBlockId)
: undefined;
const dryVelocity =
getNumberField(blockData, [
"dryVelocity",
"muzzleVelocity",
"bulletVelocity",
]) ?? 80;
const dir = entity.direction ?? [0, 1, 0];
let vx = dir[0] * dryVelocity;
let vy = dir[1] * dryVelocity;
let vz = dir[2] * dryVelocity;
const excessVel = data.excessVel as number | undefined;
const excessDir = data.excessDir as Vec3 | undefined;
if (
typeof excessVel === "number" &&
excessVel > 0 &&
isVec3Like(excessDir)
) {
vx += excessDir.x * excessVel;
vy += excessDir.y * excessVel;
vz += excessDir.z * excessVel;
}
entity.simulatedVelocity = [vx, vy, vz];
} else if (isVec3Like(data.velocity)) {
entity.simulatedVelocity = [
data.velocity.x,
data.velocity.y,
data.velocity.z,
];
}
// Fast-forward by currTick
const currTick = data.currTick as number | undefined;
if (
typeof currTick === "number" &&
currTick > 0 &&
entity.simulatedVelocity &&
entity.position
) {
const dt = (TICK_DURATION_MS / 1000) * currTick;
const v = entity.simulatedVelocity;
entity.position[0] += v[0] * dt;
entity.position[1] += v[1] * dt;
entity.position[2] += v[2] * dt;
if (entity.projectilePhysics === "ballistic") {
const g = -9.81 * (entity.gravityMod ?? 1);
entity.position[2] += 0.5 * g * dt * dt;
v[2] += g * dt;
}
}
}
// Explosion detection
const explodePos = isValidPosition(data.explodePosition as Vec3)
? (data.explodePosition as Vec3)
: isValidPosition(data.explodePoint as Vec3)
? (data.explodePoint as Vec3)
: undefined;
if (
entity.type === "Projectile" &&
!entity.hasExploded &&
explodePos &&
entity.explosionDataBlockId != null
) {
this.spawnExplosion(entity, [explodePos.x, explodePos.y, explodePos.z]);
}
// Damage
if (typeof data.damageLevel === "number") {
entity.health = clamp(1 - data.damageLevel, 0, 1);
}
if (typeof data.damageState === "number") {
entity.damageState = data.damageState;
}
if (typeof data.action === "number") {
entity.actionAnim = data.action;
entity.actionAtEnd = !!data.actionAtEnd;
}
// Threads
if (Array.isArray(data.threads)) {
const incoming = data.threads as ThreadState[];
if (entity.threads) {
const merged = [...entity.threads];
for (const t of incoming) {
const existingIdx = merged.findIndex((m) => m.index === t.index);
if (existingIdx >= 0) merged[existingIdx] = t;
else merged.push(t);
}
entity.threads = merged;
} else {
entity.threads = incoming;
}
}
if (typeof data.energy === "number") {
entity.energy = clamp(data.energy, 0, 1);
}
// Target system
if (typeof data.targetId === "number") {
entity.targetId = data.targetId;
const playerName = this.targetNames.get(data.targetId);
if (playerName) entity.playerName = playerName;
const team = this.targetTeams.get(data.targetId);
if (team != null) {
entity.sensorGroup = team;
if (
entity.ghostIndex === this.latestControl.ghostIndex &&
this.lastControlType === "player"
) {
this.playerSensorGroup = team;
}
}
const renderFlags = this.targetRenderFlags.get(data.targetId);
if (renderFlags != null) entity.targetRenderFlags = renderFlags;
}
// Ghost-level audio — spawn/remove synthetic AudioEmitter children
const sounds = data.sounds as
| Array<{ index: number; playing: boolean; profileId?: number }>
| undefined;
if (Array.isArray(sounds)) {
this.syncSoundSlotEntities(entity, sounds);
}
// WayPoint ghost fields
if (entity.className === "WayPoint") {
if (typeof data.name === "string") entity.label = data.name;
}
// AudioEmitter ghost fields
if (entity.className === "AudioEmitter") {
if (typeof data.filename === "string")
entity.audioFileName = data.filename;
if (typeof data.volume === "number") entity.audioVolume = data.volume;
if (typeof data.is3D === "boolean") entity.audioIs3D = data.is3D;
if (typeof data.isLooping === "boolean")
entity.audioIsLooping = data.isLooping;
if (typeof data.minDistance === "number")
entity.audioMinDistance = data.minDistance;
if (typeof data.maxDistance === "number")
entity.audioMaxDistance = data.maxDistance;
if (typeof data.minLoopGap === "number")
entity.audioMinLoopGap = data.minLoopGap;
if (typeof data.maxLoopGap === "number")
entity.audioMaxLoopGap = data.maxLoopGap;
}
}
// ── Sound slot entities ──
/**
* Sync synthetic AudioEmitter entities for a parent ghost's sound slots.
* Each active sound slot spawns a child entity; inactive slots remove theirs.
*/
protected syncSoundSlotEntities(
parent: MutableEntity,
sounds: Array<{ index: number; playing: boolean; profileId?: number }>,
): void {
for (const s of sounds) {
const childId = `${parent.id}:sound:${s.index}`;
if (s.playing && typeof s.profileId === "number") {
// Resolve AudioProfile → AudioDescription
const profileBlock = this.getDataBlockData(s.profileId);
const rawFilename = profileBlock?.filename as string | undefined;
if (!rawFilename) continue;
const filename = rawFilename.endsWith(".wav")
? rawFilename
: `${rawFilename}.wav`;
const descId = profileBlock.description as number | undefined;
const descBlock =
descId != null ? this.getDataBlockData(descId) : undefined;
const existing = this.entities.get(childId);
if (existing) {
// Update position to track parent
existing.position = parent.position;
} else {
// Create synthetic AudioEmitter entity
this.entities.set(childId, {
id: childId,
ghostIndex: parent.ghostIndex,
className: "AudioEmitter",
type: "AudioEmitter",
spawnTick: this.tickCount,
position: parent.position,
rotation: [0, 0, 0, 1],
audioFileName: filename,
audioVolume: (descBlock?.volume as number) ?? 1,
audioIs3D: (descBlock?.is3D as boolean) ?? true,
audioIsLooping: (descBlock?.isLooping as boolean) ?? false,
audioMinDistance: (descBlock?.referenceDistance as number) ?? 20,
2026-03-09 12:38:40 -07:00
audioMaxDistance: (descBlock?.maxDistance as number) ?? 100,
audioMinLoopGap: (descBlock?.minLoopGap as number) ?? 0,
audioMaxLoopGap: (descBlock?.maxLoopGap as number) ?? 0,
});
}
} else {
// Sound stopped — remove synthetic entity
this.entities.delete(childId);
}
}
}
/** Update positions of sound-slot children to track their parent. */
protected updateSoundSlotPositions(parent: MutableEntity): void {
for (let i = 0; i < 4; i++) {
const child = this.entities.get(`${parent.id}:sound:${i}`);
if (child) child.position = parent.position;
}
}
/** Remove any synthetic sound-slot entities belonging to a parent entity. */
protected removeSoundSlotEntities(parentId: string): void {
for (let i = 0; i < 4; i++) {
this.entities.delete(`${parentId}:sound:${i}`);
}
}
// ── Explosion spawning ──
protected resolveExplosionInfo(projDataBlockId: number):
| {
shape?: string;
faceViewer: boolean;
lifetimeTicks: number;
explosionDataBlockId: number;
}
| undefined {
const projBlock = this.getDataBlockData(projDataBlockId);
if (!projBlock) return undefined;
const explosionId = projBlock.explosion as number | undefined;
if (explosionId == null) return undefined;
const expBlock = this.getDataBlockData(explosionId);
if (!expBlock) return undefined;
const shape = (expBlock.dtsFileName as string | undefined) || undefined;
const lifetimeTicks = (expBlock.lifetimeMS as number | undefined) ?? 31;
return {
shape,
faceViewer: expBlock.faceViewer !== false && expBlock.faceViewer !== 0,
lifetimeTicks,
explosionDataBlockId: explosionId,
};
}
protected spawnExplosion(
projectile: MutableEntity,
position: [number, number, number],
): void {
projectile.hasExploded = true;
const lifetimeTicks = projectile.explosionLifetimeTicks ?? 31;
const fxId = `fx_${this.nextExplosionId++}`;
const fxEntity: MutableEntity = {
id: fxId,
ghostIndex: -1,
className: "Explosion",
spawnTick: this.tickCount,
type: "Explosion",
dataBlock: projectile.explosionShape,
explosionDataBlockId: projectile.explosionDataBlockId,
position,
rotation: [0, 0, 0, 1],
isExplosion: true,
faceViewer: projectile.faceViewer !== false,
expiryTick: this.tickCount + lifetimeTicks,
};
this.entities.set(fxId, fxEntity);
// Spawn sub-explosion entities
if (projectile.explosionDataBlockId != null) {
const expBlock = this.getDataBlockData(projectile.explosionDataBlockId);
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) continue;
const subShape =
(subBlock.dtsFileName as string | undefined) || undefined;
if (!subShape) continue;
const subLifetimeTicks =
(subBlock.lifetimeMS as number | undefined) ?? 31;
const offset = (subBlock.offset as number | undefined) ?? 0;
const angle = Math.random() * Math.PI * 2;
const subPos: [number, number, number] = [
position[0] + Math.cos(angle) * offset,
position[1] + Math.sin(angle) * offset,
position[2],
];
const subFxId = `fx_${this.nextExplosionId++}`;
const subFxEntity: MutableEntity = {
id: subFxId,
ghostIndex: -1,
className: "Explosion",
spawnTick: this.tickCount,
type: "Explosion",
dataBlock: subShape,
explosionDataBlockId: subId,
position: subPos,
rotation: [0, 0, 0, 1],
isExplosion: true,
faceViewer:
subBlock.faceViewer !== false && subBlock.faceViewer !== 0,
expiryTick: this.tickCount + subLifetimeTicks,
};
this.entities.set(subFxId, subFxEntity);
}
}
}
// Stop the projectile
projectile.position = undefined;
projectile.simulatedVelocity = undefined;
}
// ── Per-tick physics simulation ──
/** Advance projectile positions by one tick using their simulated velocity. */
protected advanceProjectiles(): void {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
if (!entity.simulatedVelocity || !entity.position) continue;
const v = entity.simulatedVelocity;
const p = entity.position;
if (entity.projectilePhysics === "ballistic") {
v[2] += -9.81 * (entity.gravityMod ?? 1) * dt;
}
p[0] += v[0] * dt;
p[1] += v[1] * dt;
p[2] += v[2] * dt;
if (v[0] !== 0 || v[1] !== 0) {
entity.rotation = playerYawToQuaternion(Math.atan2(v[0], v[1]));
}
}
}
/** Advance dropped item physics (gravity, terrain collision, friction). */
/** Advance item positions using server-sent velocity (no gravity/collision).
* The real Tribes 2 client just interpolates; physics runs server-side. */
2026-03-09 12:38:40 -07:00
protected advanceItems(): void {
const dt = TICK_DURATION_MS / 1000;
for (const entity of this.entities.values()) {
const phys = entity.itemPhysics;
if (!phys || phys.atRest || !entity.position) continue;
const v = phys.velocity;
const p = entity.position;
p[0] += v[0] * dt;
p[1] += v[1] * dt;
p[2] += v[2] * dt;
}
}
protected removeExpiredExplosions(): void {
for (const [id, entity] of this.entities) {
if (
entity.isExplosion &&
entity.expiryTick != null &&
this.tickCount >= entity.expiryTick
) {
this.entities.delete(id);
}
}
}
// ── Camera and HUD ──
protected updateCameraAndHud(): void {
const control = this.latestControl;
const timeSec = this.getTimeSec();
const data = control.data;
const controlType = this.lastControlType;
this.removeExpiredExplosions();
if (control.position) {
2026-03-09 23:19:14 -07:00
let { yaw, pitch } = this.getCameraYawPitch(data);
// When piloting a vehicle (without freelook), mouse yaw goes to
// vehicle steering (mRot.z) and the player's head rotation (mHead)
// decays by 50% per tick — the camera is locked to the vehicle.
// Use the vehicle's heading directly instead of move-accumulated yaw.
// Verified against tribes2-engine Player::updateMove and Tribes2.exe.
if (this.isPiloting) {
if (data) {
const nested = data.controlObjectData as
| Record<string, unknown>
| undefined;
const ang = nested?.angPosition as
| { x: number; y: number; z: number; w: number }
| undefined;
if (ang && typeof ang.w === "number") {
this.lastVehicleHeading = torqueQuatHeading(ang);
this.lastVehiclePitch = torqueQuatPitch(ang);
// Compute pullback direction from full quaternion (preserves roll).
// ShapeBase::getCameraTransform pulls back along the eye's -Y axis.
// In Torque space, forward is +Y. Transform +Y by the quaternion,
// convert to Three.js, then negate for pullback.
const threeQ = torqueQuatToThreeJS(ang);
if (threeQ) {
// Rotate Three.js forward (+X, since model default is +X) by the
// converted quaternion: v' = q * v * q^-1.
// For unit vector (1,0,0), this simplifies to:
const [qx, qy, qz, qw] = threeQ;
const fx = 1 - 2 * (qy * qy + qz * qz);
const fy = 2 * (qx * qy + qz * qw);
const fz = 2 * (qx * qz - qy * qw);
// Pullback = -forward
this.lastVehicleOrbitDir = [-fx, -fy, -fz];
}
}
}
yaw = this.lastVehicleHeading;
pitch = this.lastVehiclePitch;
}
2026-03-09 12:38:40 -07:00
this.camera = {
time: timeSec,
position: [control.position.x, control.position.y, control.position.z],
rotation: yawPitchToQuaternion(
yaw,
clamp(pitch, -MAX_PITCH, MAX_PITCH),
),
fov: this.latestFov,
mode: "observer",
yaw,
pitch,
};
if (controlType === "camera") {
const cameraMode =
typeof data?.cameraMode === "number"
? data.cameraMode
: this.lastCameraMode;
if (cameraMode === CameraMode_OrbitObject) {
this.camera.mode = "third-person";
if (typeof this.lastOrbitDistance === "number") {
this.camera.orbitDistance = this.lastOrbitDistance;
}
const orbitIndex =
typeof data?.orbitObjectGhostIndex === "number"
? (data.orbitObjectGhostIndex as number)
: this.lastOrbitGhostIndex;
if (typeof orbitIndex === "number" && orbitIndex >= 0) {
this.camera.orbitTargetId =
this.resolveEntityIdForGhostIndex(orbitIndex);
}
} else {
this.camera.mode = "observer";
}
} else {
2026-03-09 23:19:14 -07:00
// Player control object.
2026-03-09 12:38:40 -07:00
if (control.ghostIndex >= 0) {
this.controlPlayerGhostId = this.resolveEntityIdForGhostIndex(
control.ghostIndex,
);
2026-03-09 12:38:40 -07:00
}
2026-03-09 23:19:14 -07:00
if (!this.firstPerson) {
// Third-person: orbit the vehicle (if piloting) or the player.
this.camera.mode = "third-person";
if (this.isPiloting && this.lastPilotGhostIndex != null) {
this.camera.orbitTargetId = this.resolveEntityIdForGhostIndex(
this.lastPilotGhostIndex,
);
2026-03-09 23:19:14 -07:00
this.camera.orbitDistance = 15;
if (this.lastVehicleOrbitDir) {
this.camera.orbitDirection = this.lastVehicleOrbitDir;
}
} else {
this.camera.orbitTargetId = this.controlPlayerGhostId;
// Player datablock cameraMaxDist is typically 3.
this.camera.orbitDistance = 3;
}
} else {
this.camera.mode = "first-person";
}
2026-03-09 12:38:40 -07:00
if (this.controlPlayerGhostId) {
this.camera.controlEntityId = this.controlPlayerGhostId;
}
}
2026-03-09 23:19:14 -07:00
// Sync control object positions from controlObjectData.
if (controlType === "player" && control.position) {
if (this.isPiloting && this.lastPilotGhostIndex != null) {
const vehicleId = this.resolveEntityIdForGhostIndex(
this.lastPilotGhostIndex,
);
2026-03-09 23:19:14 -07:00
const vehicleEntity = vehicleId
? this.entities.get(vehicleId)
: undefined;
if (vehicleEntity) {
const nested = data?.controlObjectData as
| Record<string, unknown>
| undefined;
if (nested) {
// Fresh position from controlObjectData (linPosition →
// compressionPoint → control.position).
vehicleEntity.position = [
control.position.x,
control.position.y,
control.position.z,
];
this.lastVehiclePos = vehicleEntity.position.slice() as [
number,
number,
number,
];
2026-03-09 23:19:14 -07:00
this.lastVehiclePosTime = timeSec;
// Extract velocity from linMomentum for interpolation between
// the sparse position updates (~10 of ~62 packets contain data).
const mom = nested.linMomentum as
| { x: number; y: number; z: number }
| undefined;
if (mom && isValidPosition(mom)) {
// linMomentum = mass * velocity; look up mass from datablock.
const dbId = vehicleEntity.dataBlockId;
const dbData =
dbId != null ? this.getDataBlockData(dbId) : undefined;
2026-03-09 23:19:14 -07:00
const mass = (dbData?.mass as number) ?? 200;
const invMass = mass > 0 ? 1 / mass : 1 / 200;
this.lastVehicleVelocity = [
mom.x * invMass,
mom.y * invMass,
mom.z * invMass,
];
vehicleEntity.velocity = this.lastVehicleVelocity;
}
// Sync vehicle rotation from nested angPosition quaternion.
const ang = nested.angPosition as
| { x: number; y: number; z: number; w: number }
| undefined;
if (ang && typeof ang.w === "number") {
const converted = torqueQuatToThreeJS(ang);
if (converted) vehicleEntity.rotation = converted;
}
} else if (
this.lastVehiclePos &&
this.lastVehicleVelocity &&
this.lastVehiclePosTime > 0
) {
// No nested data this packet — extrapolate from last known
// position + velocity to avoid stutter.
const dt = timeSec - this.lastVehiclePosTime;
if (dt > 0 && dt < 1) {
const [vx, vy, vz] = this.lastVehicleVelocity;
vehicleEntity.position = [
this.lastVehiclePos[0] + vx * dt,
this.lastVehiclePos[1] + vy * dt,
this.lastVehiclePos[2] + vz * dt,
];
}
}
}
} else if (this.controlPlayerGhostId) {
const ghostEntity = this.entities.get(this.controlPlayerGhostId);
if (ghostEntity) {
ghostEntity.position = [
control.position.x,
control.position.y,
control.position.z,
];
ghostEntity.rotation = playerYawToQuaternion(yaw);
ghostEntity.headPitch = this.getControlPlayerHeadPitch(pitch);
// Sync velocity from controlObjectData. Ghost updates skip the
// control player (MoveMask is not read), so velocity and state
// flags must come from here for movement animation selection.
const vel = data?.velocity as
| { x: number; y: number; z: number }
| undefined;
if (isVec3Like(vel)) {
ghostEntity.velocity = [vel.x, vel.y, vel.z];
// Approximate mFalling: engine sets it when no ground contact
// and vz < sFallingThreshold (-10). controlObjectData lacks
// the explicit flag, so use the velocity heuristic.
ghostEntity.falling = vel.z < -10;
}
}
2026-03-09 12:38:40 -07:00
}
}
} else if (this.camera) {
this.camera = {
...this.camera,
time: timeSec,
fov: this.latestFov,
};
}
// Health/energy status
const status = { health: 1, energy: 1 };
if (this.camera?.mode === "first-person") {
const controlGhostId = this.controlPlayerGhostId;
const ghostEntity = controlGhostId
? this.entities.get(controlGhostId)
: undefined;
status.health = ghostEntity?.health ?? 1;
const coEnergyLevel = data?.energyLevel;
if (typeof coEnergyLevel === "number") {
const maxEnergy = ghostEntity?.maxEnergy ?? 60;
if (maxEnergy > 0)
status.energy = clamp(coEnergyLevel / maxEnergy, 0, 1);
} else {
status.energy = ghostEntity?.energy ?? 1;
}
} else if (
this.camera?.mode === "third-person" &&
this.camera.orbitTargetId
) {
const orbitEntity = this.entities.get(this.camera.orbitTargetId);
status.health = orbitEntity?.health ?? 1;
status.energy = orbitEntity?.energy ?? 1;
}
this.lastStatus = status;
}
/** Compute headPitch for the control player ghost. Subclasses can override. */
protected getControlPlayerHeadPitch(pitch: number): number {
return clamp(pitch / MAX_PITCH, -1, 1);
}
protected getAbsoluteRotation(
data: Record<string, unknown> | undefined,
): { yaw: number; pitch: number } | null {
if (!data) return null;
if (typeof data.rotationZ === "number" && typeof data.headX === "number") {
return { yaw: data.rotationZ, pitch: data.headX };
}
if (typeof data.rotZ === "number" && typeof data.rotX === "number") {
return { yaw: data.rotZ, pitch: data.rotX };
}
return null;
}
// ── IFF color ──
protected resolveIffColor(
targetSensorGroup: number,
): { r: number; g: number; b: number } | undefined {
if (this.playerSensorGroup === 0) return undefined;
const colorMap = this.sensorGroupColors.get(this.playerSensorGroup);
if (colorMap) {
const color = colorMap.get(targetSensorGroup);
if (color) return color;
}
if (targetSensorGroup === this.playerSensorGroup) return IFF_GREEN;
if (targetSensorGroup !== 0) return IFF_RED;
return undefined;
}
// ── Chat + HUD ──
2026-03-09 23:19:14 -07:00
protected pushChatMessage(msg: Omit<ChatMessage, "id">): void {
this.chatMessages.push({ ...msg, id: ++this.chatMessageIdCounter });
2026-03-09 12:38:40 -07:00
if (this.chatMessages.length > 200) {
this.chatMessages.splice(0, this.chatMessages.length - 200);
}
this._chatGen++;
2026-03-09 12:38:40 -07:00
}
protected handleServerMessage(args: string[]): void {
if (args.length < 2) return;
const msgType = this.resolveNetString(args[0]);
2026-03-14 17:12:37 -07:00
if (
(msgType === "MsgTeamScoreIs" || msgType === "MsgTeamScore") &&
args.length >= 4
) {
2026-03-09 12:38:40 -07:00
const teamId = parseInt(this.resolveNetString(args[2]), 10);
const newScore = parseInt(this.resolveNetString(args[3]), 10);
if (!isNaN(teamId) && !isNaN(newScore)) {
const entry = this.teamScores.find((t) => t.teamId === teamId);
if (entry) {
entry.score = newScore;
this.onTeamScoresChanged();
}
}
} else if (msgType === "MsgCTFAddTeam" && args.length >= 6) {
2026-03-14 17:12:37 -07:00
// Wire order: args[2]=teamId (1-based), args[3]=teamName,
// args[4]=flagStatus, args[5]=teamScore
const teamId = parseInt(this.resolveNetString(args[2]), 10);
2026-03-09 12:38:40 -07:00
const teamName = stripTaggedStringMarkup(this.resolveNetString(args[3]));
const score = parseInt(this.resolveNetString(args[5]), 10);
2026-03-14 17:12:37 -07:00
if (!isNaN(teamId) && teamId > 0) {
2026-03-09 12:38:40 -07:00
const existing = this.teamScores.find((t) => t.teamId === teamId);
if (existing) {
existing.name = teamName;
existing.score = isNaN(score) ? existing.score : score;
} else {
this.teamScores.push({
teamId,
name: teamName,
score: isNaN(score) ? 0 : score,
playerCount: 0,
});
}
this.onTeamScoresChanged();
}
} else if (msgType === "MsgClientJoin" && args.length >= 4) {
// Wire order: args[2]=clientName, args[3]=clientId, args[4]=targetId
const name = stripTaggedStringMarkup(
this.resolveNetString(args[2]),
).trim();
const clientId = parseInt(this.resolveNetString(args[3]), 10);
2026-03-09 12:38:40 -07:00
if (!isNaN(clientId)) {
2026-03-14 17:12:37 -07:00
// The real client (message.cs handleClientJoin) creates a fresh
// ScriptObject with score=0, overwriting any previous entry.
2026-03-09 12:38:40 -07:00
this.playerRoster.set(clientId, {
name,
2026-03-14 17:12:37 -07:00
teamId: 0,
score: 0,
ping: 0,
packetLoss: 0,
2026-03-09 12:38:40 -07:00
});
this.onRosterChanged();
}
2026-03-13 18:04:02 -07:00
// Detect our own join: the server sends "Welcome to Tribes2" in the
// format string (args[1]) only for the joining client. This is the same
// technique the T2 community's player_support.cs uses.
if (!this.connectedPlayerName && name) {
2026-03-13 18:04:02 -07:00
const msgFormat = stripTaggedStringMarkup(
this.resolveNetString(args[1]),
);
if (msgFormat.includes("Welcome to Tribes")) {
this.connectedPlayerName = name;
2026-03-14 17:12:37 -07:00
this.connectedClientId = clientId;
2026-03-13 18:04:02 -07:00
this.onMissionInfoChange?.();
}
}
2026-03-14 17:12:37 -07:00
} else if (msgType === "MsgClientDrop" && args.length >= 4) {
// Wire order: args[2]=clientName, args[3]=clientId
const clientId = parseInt(this.resolveNetString(args[3]), 10);
2026-03-09 12:38:40 -07:00
if (!isNaN(clientId)) {
this.playerRoster.delete(clientId);
this.onRosterChanged();
}
2026-03-14 17:12:37 -07:00
} else if (msgType === "MsgClientJoinTeam" && args.length >= 6) {
// Wire order: args[2]=clientName, args[3]=teamName, args[4]=clientId, args[5]=teamId
const clientId = parseInt(this.resolveNetString(args[4]), 10);
const teamId = parseInt(this.resolveNetString(args[5]), 10);
2026-03-09 12:38:40 -07:00
if (!isNaN(clientId) && !isNaN(teamId)) {
const existing = this.playerRoster.get(clientId);
if (existing) {
existing.teamId = teamId;
} else {
2026-03-14 17:12:37 -07:00
this.playerRoster.set(clientId, {
name: "",
teamId,
score: 0,
ping: 0,
packetLoss: 0,
});
2026-03-09 12:38:40 -07:00
}
this.onRosterChanged();
}
2026-03-14 17:12:37 -07:00
} else if (msgType === "MsgPlayerScore" && args.length >= 5) {
// Wire order: args[2]=clientId, args[3]=score, args[4]=ping, args[5]=packetLoss
// Only update existing roster entries — the real client (scoreList.cs
// handlePlayerScore) warns and ignores scores for unknown clients.
const clientId = parseInt(this.resolveNetString(args[2]), 10);
if (!isNaN(clientId)) {
const existing = this.playerRoster.get(clientId);
if (existing) {
const score = parseInt(this.resolveNetString(args[3]), 10);
const ping = parseInt(this.resolveNetString(args[4]), 10);
const packetLoss = parseInt(
this.resolveNetString(args[5] ?? ""),
10,
);
if (!isNaN(score)) existing.score = score;
if (!isNaN(ping)) existing.ping = ping;
if (!isNaN(packetLoss)) existing.packetLoss = packetLoss;
this.onRosterChanged();
}
}
} else if (msgType === "MsgSystemClock" && args.length >= 4) {
// Wire order: args[2]=timeLimitMinutes, args[3]=timeRemainingMS
// The real client calls clockHud.setTime(timeRemainingMS / 60000).
// setTime(0) → count-up clock (pre-match elapsed).
// setTime(N) → count-down clock (N minutes remaining).
const timeRemainingMS = parseFloat(this.resolveNetString(args[3]));
this.clockAnchorStreamSec = this.getTimeSec();
this.clockDurationMs = Number.isFinite(timeRemainingMS)
? timeRemainingMS
: 0;
} else if (msgType === "MsgMissionDropInfo" && args.length >= 5) {
// messageClient(%cl, 'MsgMissionDropInfo', ..., $MissionDisplayName, $MissionTypeDisplayName, $ServerName)
const missionDisplayName = stripTaggedStringMarkup(
this.resolveNetString(args[2]),
);
const missionTypeDisplayName = stripTaggedStringMarkup(
this.resolveNetString(args[3]),
);
const serverDisplayName = stripTaggedStringMarkup(
this.resolveNetString(args[4]),
);
log.info(
"mission drop info: mission=%s gameType=%s server=%s",
missionDisplayName,
missionTypeDisplayName,
serverDisplayName,
);
this.missionDisplayName = missionDisplayName || this.missionDisplayName;
this.missionTypeDisplayName =
missionTypeDisplayName || this.missionTypeDisplayName;
this.serverDisplayName = serverDisplayName || this.serverDisplayName;
this.onMissionInfoChange?.();
} else if (msgType === "MsgLoadInfo" && args.length >= 5) {
// messageClient(%cl, 'MsgLoadInfo', "", $CurrentMission, $MissionDisplayName, $MissionTypeDisplayName)
const missionDisplayName = stripTaggedStringMarkup(
this.resolveNetString(args[3]),
);
const missionTypeDisplayName = stripTaggedStringMarkup(
this.resolveNetString(args[4]),
);
log.info(
"load info: mission=%s gameType=%s",
missionDisplayName,
missionTypeDisplayName,
);
this.missionDisplayName = missionDisplayName || this.missionDisplayName;
this.missionTypeDisplayName =
missionTypeDisplayName || this.missionTypeDisplayName;
this.onMissionInfoChange?.();
} else if (msgType === "MsgClientReady" && args.length >= 3) {
// messageClient(%cl, 'MsgClientReady', "", %game.class)
const gameClassName = this.resolveNetString(args[2]);
log.info("client ready: gameClass=%s", gameClassName);
this.gameClassName = gameClassName || this.gameClassName;
this.onMissionInfoChange?.();
2026-03-09 12:38:40 -07:00
}
}
/** Hook for subclasses to react to team score changes (e.g. generation counters). */
protected onTeamScoresChanged(): void {}
/** Hook for subclasses to react to roster changes (e.g. generation counters). */
protected onRosterChanged(): void {}
protected handleHudRemoteCommand(funcName: string, args: string[]): void {
if (funcName === "setWeaponsHudItem" && args.length >= 3) {
const slot = parseInt(args[0], 10);
const ammo = parseInt(args[1], 10);
const add = args[2] === "1" || args[2] === "true";
if (!isNaN(slot)) {
if (add) this.weaponsHud.slots.set(slot, isNaN(ammo) ? -1 : ammo);
else this.weaponsHud.slots.delete(slot);
this.onWeaponsHudChanged();
}
} else if (funcName === "setWeaponsHudAmmo" && args.length >= 2) {
const slot = parseInt(args[0], 10);
const ammo = parseInt(args[1], 10);
if (!isNaN(slot)) {
this.weaponsHud.slots.set(slot, isNaN(ammo) ? -1 : ammo);
this.onWeaponsHudChanged();
}
} else if (funcName === "setWeaponsHudActive" && args.length >= 1) {
const slot = parseInt(args[0], 10);
this.weaponsHud.activeIndex = isNaN(slot) ? -1 : slot;
if (!isNaN(slot) && slot >= 0 && !this.weaponsHud.slots.has(slot)) {
this.weaponsHud.slots.set(slot, -1);
}
this.onWeaponsHudChanged();
} else if (funcName === "setWeaponsHudClearAll") {
this.weaponsHud.slots.clear();
this.weaponsHud.activeIndex = -1;
this.onWeaponsHudChanged();
} else if (funcName === "setBackpackHudItem" && args.length >= 2) {
const num = parseInt(args[0], 10);
const add = args[1] === "1" || args[1] === "true";
if (add && !isNaN(num)) {
this.backpackHud.packIndex = num;
this.backpackHud.active = false;
this.backpackHud.text = "";
} else {
this.backpackHud.packIndex = -1;
this.backpackHud.active = false;
this.backpackHud.text = "";
}
} else if (funcName === "setSatchelArmed") {
this.backpackHud.active = true;
} else if (
funcName === "setCloakIconOn" ||
funcName === "setRepairPackIconOn" ||
funcName === "setShieldIconOn" ||
funcName === "setSenJamIconOn"
) {
this.backpackHud.active = true;
} else if (
funcName === "setCloakIconOff" ||
funcName === "setRepairPackIconOff" ||
funcName === "setShieldIconOff" ||
funcName === "setSenJamIconOff"
) {
this.backpackHud.active = false;
} else if (funcName === "updatePackText" && args.length >= 1) {
this.backpackHud.text = args[0] ?? "";
} else if (funcName === "setInventoryHudItem" && args.length >= 3) {
const slot = parseInt(args[0], 10);
const amount = parseInt(args[1], 10);
const add = args[2] === "1" || args[2] === "true";
if (!isNaN(slot)) {
if (add && !isNaN(amount)) this.inventoryHud.slots.set(slot, amount);
else this.inventoryHud.slots.delete(slot);
this.onInventoryHudChanged();
}
} else if (funcName === "setInventoryHudAmount" && args.length >= 2) {
const slot = parseInt(args[0], 10);
const amount = parseInt(args[1], 10);
if (!isNaN(slot) && !isNaN(amount)) {
this.inventoryHud.slots.set(slot, amount);
this.onInventoryHudChanged();
}
} else if (funcName === "setInventoryHudClearAll") {
this.inventoryHud.slots.clear();
this.inventoryHud.activeSlot = -1;
this.onInventoryHudChanged();
}
}
/** Hook for subclasses to react to weapons HUD changes (e.g. generation counters). */
protected onWeaponsHudChanged(): void {}
/** Hook for subclasses to react to inventory HUD changes (e.g. generation counters). */
protected onInventoryHudChanged(): void {}
// ── Snapshot building ──
/** Build entity list for snapshot, optionally filtering with a predicate. */
protected buildEntityList(
shouldInclude?: (entity: MutableEntity) => boolean,
): StreamEntity[] {
const entities: StreamEntity[] = [];
for (const entity of this.entities.values()) {
if (shouldInclude && !shouldInclude(entity)) continue;
let renderFlags =
entity.targetId != null && entity.targetId >= 0
? (this.targetRenderFlags.get(entity.targetId) ??
entity.targetRenderFlags)
2026-03-09 12:38:40 -07:00
: entity.targetRenderFlags;
if (entity.type === "Player" && !entity.carryingFlag) {
renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags;
}
entities.push({
id: entity.id,
type: entity.type,
visual: entity.visual,
direction: entity.direction,
ghostIndex: entity.ghostIndex,
className: entity.className,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
dataBlock: entity.dataBlock,
weaponShape: entity.weaponShape,
2026-03-09 23:19:14 -07:00
packShape: entity.packShape,
flagShape: entity.flagShape,
2026-03-09 23:19:14 -07:00
falling: entity.falling,
jetting: entity.jetting,
2026-03-09 12:38:40 -07:00
playerName: entity.playerName,
targetRenderFlags: renderFlags,
iffColor:
(entity.type === "Player" || ((renderFlags ?? 0) & 0x2) !== 0) &&
2026-03-09 12:38:40 -07:00
entity.sensorGroup != null
? this.resolveIffColor(entity.sensorGroup)
: undefined,
position:
entity.position &&
(entity.simulatedVelocity ||
(entity.itemPhysics && !entity.itemPhysics.atRest))
? ([...entity.position] as [number, number, number])
: entity.position,
rotation: entity.rotation,
velocity: entity.velocity,
health: entity.health,
energy: entity.energy,
actionAnim: entity.actionAnim,
actionAtEnd: entity.actionAtEnd,
damageState: entity.damageState,
faceViewer: entity.faceViewer,
threads: entity.threads,
explosionDataBlockId: entity.explosionDataBlockId,
maintainEmitterId: entity.maintainEmitterId,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
label: entity.label,
audioFileName: entity.audioFileName,
audioVolume: entity.audioVolume,
audioIs3D: entity.audioIs3D,
audioIsLooping: entity.audioIsLooping,
audioMinDistance: entity.audioMinDistance,
audioMaxDistance: entity.audioMaxDistance,
audioMinLoopGap: entity.audioMinLoopGap,
audioMaxLoopGap: entity.audioMaxLoopGap,
sceneData: entity.sceneData,
});
}
return entities;
}
2026-03-14 17:12:37 -07:00
/**
* Compute the match clock value in ms, mirroring HudClockCtrl's actualTimeMS.
* Negative = counting down (remaining), positive = counting up (elapsed).
* Returns null if no clock has been set.
*/
protected computeMatchClockMs(timeSec: number): number | null {
if (this.clockAnchorStreamSec == null) return null;
const elapsedMs = (timeSec - this.clockAnchorStreamSec) * 1000;
// actualTimeMS = -clockDurationMs + elapsed
// duration=0 → positive (count-up), duration>0 → starts negative (count-down)
return -this.clockDurationMs + elapsedMs;
}
2026-03-09 12:38:40 -07:00
/** Build HUD arrays for snapshot. */
protected buildHudState(): {
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
backpackHud: BackpackHudState | null;
teamScores: TeamScore[];
2026-03-14 17:12:37 -07:00
playerRoster: PlayerRosterEntry[];
2026-03-09 12:38:40 -07:00
} {
const weaponsHud = {
slots: Array.from(this.weaponsHud.slots.entries()).map(
([index, ammo]): WeaponsHudSlot => ({ index, ammo }),
),
activeIndex: this.weaponsHud.activeIndex,
};
const inventoryHud = {
slots: Array.from(this.inventoryHud.slots.entries()).map(
([slot, count]): InventoryHudSlot => ({ slot, count }),
),
activeSlot: this.inventoryHud.activeSlot,
};
const backpackHud: BackpackHudState | null =
this.backpackHud.packIndex >= 0 ? { ...this.backpackHud } : null;
const 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);
2026-03-09 12:38:40 -07:00
}
for (const ts of teamScores) {
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
}
2026-03-14 17:12:37 -07:00
const playerRoster: PlayerRosterEntry[] = [];
for (const [clientId, entry] of this.playerRoster) {
playerRoster.push({ clientId, ...entry });
}
return { weaponsHud, inventoryHud, backpackHud, teamScores, playerRoster };
2026-03-09 12:38:40 -07:00
}
/** Build filtered chat and audio event arrays for the current time. */
protected buildTimeFilteredEvents(timeSec: number): {
chatMessages: ChatMessage[];
audioEvents: PendingAudioEvent[];
} {
if (this._chatSnapshotGen !== this._chatGen) {
this._chatSnapshot = this.chatMessages.slice();
this._chatSnapshotGen = this._chatGen;
}
const chatMessages = this._chatSnapshot;
2026-03-09 12:38:40 -07:00
const audioEvents = this.audioEvents.filter(
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
);
return { chatMessages, audioEvents };
}
}