t2-mapper/src/stream/StreamEngine.ts
2026-03-09 12:38:40 -07:00

1868 lines
63 KiB
TypeScript

import { ghostToSceneObject } from "../scene";
import { getTerrainHeightAt } from "../terrainHeight";
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,
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,
TeamScore,
WeaponsHudSlot,
WeaponImageState,
WeaponImageDataBlockState,
} from "./types";
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;
headPitch?: number;
headYaw?: number;
targetRenderFlags?: number;
carryingFlag?: boolean;
/** Item physics simulation state (dropped weapons/items). */
itemPhysics?: {
velocity: [number, number, number];
atRest: boolean;
elasticity: number;
friction: number;
gravityMod: number;
};
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[] = [];
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;
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[] = [];
protected playerRoster = new Map<number, { name: string; teamId: number }>();
// ── 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 = [];
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;
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();
this.nextExplosionId = 0;
}
// ── 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
);
} 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;
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);
const name = stripTaggedStringMarkup(value);
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) {
this.targetNames.set(targetId, stripTaggedStringMarkup(resolved));
} 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;
if (team != null) entity.sensorGroup = team;
if (rf != null) entity.targetRenderFlags = rf;
}
}
}
return;
}
if (
type === "SetSensorGroupEvent" ||
eventName === "SetSensorGroupEvent"
) {
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"
) {
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]),
);
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;
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;
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;
} 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;
}
// Flag tracking
const flagImage = images.find((img) => img.index === 3);
if (flagImage) {
const hasFlag = !!flagImage.dataBlockId && flagImage.dataBlockId > 0;
entity.carryingFlag = hasFlag;
if (entity.targetId != null && entity.targetId >= 0) {
const prev = this.targetRenderFlags.get(entity.targetId) ?? 0;
const updated = hasFlag ? prev | 0x2 : prev & ~0x2;
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,
);
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];
}
}
// Item physics: when the server sends a position update with
// atRest=false and a velocity, start client-side physics simulation.
if (entity.type === "Item") {
const atRest = data.atRest as boolean | undefined;
if (atRest === false && isVec3Like(data.velocity)) {
const blockData =
entity.dataBlockId != null
? this.getDataBlockData(entity.dataBlockId)
: undefined;
entity.itemPhysics = {
velocity: [data.velocity.x, data.velocity.y, data.velocity.z],
atRest: false,
elasticity: getNumberField(blockData, ["elasticity"]) ?? 0.2,
friction: getNumberField(blockData, ["friction"]) ?? 0.6,
gravityMod: getNumberField(blockData, ["gravityMod"]) ?? 1.0,
};
} 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,
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). */
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;
// Gravity: Tribes 2 uses -20 m/s² (Torque Z-up).
v[2] += -20 * phys.gravityMod * dt;
p[0] += v[0] * dt;
p[1] += v[1] * dt;
p[2] += v[2] * dt;
// Terrain collision (flat normal approximation: [0, 0, 1])
const groundZ = getTerrainHeightAt(p[0], p[1]);
if (groundZ != null && p[2] < groundZ) {
p[2] = groundZ;
const bd = Math.abs(v[2]); // normal impact speed
v[2] = bd * phys.elasticity; // reflect with restitution
// Friction: reduce horizontal speed proportional to impact
const friction = bd * phys.friction;
const hSpeed = Math.sqrt(v[0] * v[0] + v[1] * v[1]);
if (hSpeed > 0) {
const scale = Math.max(0, 1 - friction / hSpeed);
v[0] *= scale;
v[1] *= scale;
}
// At-rest check
const speed = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (speed < 0.15) {
v[0] = v[1] = v[2] = 0;
phys.atRest = true;
}
}
}
}
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) {
const { yaw, pitch } = this.getCameraYawPitch(data);
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 {
this.camera.mode = "first-person";
if (control.ghostIndex >= 0) {
this.controlPlayerGhostId =
this.resolveEntityIdForGhostIndex(control.ghostIndex);
}
if (this.controlPlayerGhostId) {
this.camera.controlEntityId = this.controlPlayerGhostId;
}
}
// Sync control player position
if (
controlType === "player" &&
!this.isPiloting &&
this.controlPlayerGhostId &&
control.position
) {
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);
}
}
} 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 ──
protected pushChatMessage(msg: ChatMessage): void {
this.chatMessages.push(msg);
if (this.chatMessages.length > 200) {
this.chatMessages.splice(0, this.chatMessages.length - 200);
}
}
protected handleServerMessage(args: string[]): void {
if (args.length < 2) return;
const msgType = this.resolveNetString(args[0]);
if (msgType === "MsgTeamScoreIs" && args.length >= 4) {
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) {
const teamIdx = parseInt(this.resolveNetString(args[2]), 10);
const teamName = stripTaggedStringMarkup(this.resolveNetString(args[3]));
const score = parseInt(this.resolveNetString(args[5]), 10);
if (!isNaN(teamIdx)) {
const teamId = teamIdx + 1;
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) {
const clientId = parseInt(this.resolveNetString(args[2]), 10);
const name = stripTaggedStringMarkup(this.resolveNetString(args[3]));
if (!isNaN(clientId)) {
const existing = this.playerRoster.get(clientId);
this.playerRoster.set(clientId, {
name,
teamId: existing?.teamId ?? 0,
});
this.onRosterChanged();
}
} else if (msgType === "MsgClientDrop" && args.length >= 3) {
const clientId = parseInt(this.resolveNetString(args[2]), 10);
if (!isNaN(clientId)) {
this.playerRoster.delete(clientId);
this.onRosterChanged();
}
} else if (msgType === "MsgClientJoinTeam" && args.length >= 4) {
const clientId = parseInt(this.resolveNetString(args[2]), 10);
const teamId = parseInt(this.resolveNetString(args[3]), 10);
if (!isNaN(clientId) && !isNaN(teamId)) {
const existing = this.playerRoster.get(clientId);
if (existing) {
existing.teamId = teamId;
} else {
this.playerRoster.set(clientId, { name: "", teamId });
}
this.onRosterChanged();
}
}
}
/** 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)
: 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,
playerName: entity.playerName,
targetRenderFlags: renderFlags,
iffColor:
(entity.type === "Player" ||
((renderFlags ?? 0) & 0x2) !== 0) &&
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;
}
/** Build HUD arrays for snapshot. */
protected buildHudState(): {
weaponsHud: { slots: WeaponsHudSlot[]; activeIndex: number };
inventoryHud: { slots: InventoryHudSlot[]; activeSlot: number };
backpackHud: BackpackHudState | null;
teamScores: TeamScore[];
} {
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);
}
for (const ts of teamScores) {
ts.playerCount = teamCounts.get(ts.teamId) ?? 0;
}
return { weaponsHud, inventoryHud, backpackHud, teamScores };
}
/** Build filtered chat and audio event arrays for the current time. */
protected buildTimeFilteredEvents(timeSec: number): {
chatMessages: ChatMessage[];
audioEvents: PendingAudioEvent[];
} {
const chatMessages = this.chatMessages.filter(
(m) => m.timeSec > timeSec - 15,
);
const audioEvents = this.audioEvents.filter(
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
);
return { chatMessages, audioEvents };
}
}