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; 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(); protected entityIdByGhostIndex = new Map(); // ── 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(); // ── Target system ── protected targetNames = new Map(); protected targetTeams = new Map(); protected targetRenderFlags = new Map(); /** Deferred nameTag→targetId for TargetInfoEvents that arrived before their NetStringEvent. */ protected pendingNameTags = new Map(); protected sensorGroupColors = new Map< number, Map >(); 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(), activeIndex: -1 }; protected backpackHud = { packIndex: -1, active: false, text: "" }; protected inventoryHud = { slots: new Map(), activeSlot: -1 }; protected teamScores: TeamScore[] = []; protected playerRoster = new Map(); // ── Explosions ── protected nextExplosionId = 0; // ── Abstract methods ── /** Resolve datablock data by numeric ID. */ abstract getDataBlockData(id: number): Record | 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 | 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; 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 }, 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 | 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; }): 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, ); 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 | 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 | 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(); 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 }; } }