import type { PreviewChunk } from '$lib/types'; import type { FetchOpts } from '$lib/api/hub'; import { fetchParquetRows } from '$lib/api/parquet'; // One player's pose at one sampled tick. Round-relative `t` (seconds). export type WorldFrame = { t: number; player: number; // dataset slot 0..9 steamid: string; name: string; team_num: number; is_alive: boolean; health: number; armor_value: number; X: number; Y: number; Z: number; yaw?: number; }; export type PlayerSeries = { player: number; frames: WorldFrame[]; }; export type RoundWorld = { players: PlayerSeries[]; duration: number; }; const cache = new Map>(); // preview_video.src is ".../round=R/player=N/video.preview.mp4" — strip the // filename to get the per-POV dir we'll resolve sibling assets against. function chunkBaseUrl(chunk: PreviewChunk): string { return chunk.preview_video.src.replace(/\/[^/]+$/, ''); } type TickRow = { t: number; x: number; y: number; z: number; yaw?: number; team_num: number; health: number; armor_value: number; is_alive: boolean; }; async function loadChunkFrames( chunk: PreviewChunk, player: number, opts: FetchOpts ): Promise { const base = chunkBaseUrl(chunk); const rows = await fetchParquetRows(`${base}/ticks.parquet`, opts); const steamid = `slot-${player}`; const name = `Player ${player}`; return rows.map((r) => ({ t: r.t, player, steamid, name, team_num: r.team_num, is_alive: r.is_alive, health: r.health, armor_value: r.armor_value, X: r.x, Y: r.y, Z: r.z, yaw: r.yaw })); } export async function loadRoundWorld( matchId: number, mapName: string, round: number, chunks: PreviewChunk[], opts: FetchOpts = {} ): Promise { const key = `${matchId}/${mapName}/${round}`; const cached = cache.get(key); if (cached) return cached; const promise = (async () => { const byPlayer = new Map(); for (const c of chunks) { if (c.round !== round) continue; if (!byPlayer.has(c.player)) byPlayer.set(c.player, []); byPlayer.get(c.player)!.push(c); } // If we were asked to load a round whose chunks haven't arrived yet, // don't poison the cache with an empty result — let the next call // (with chunks) actually fetch. if (byPlayer.size === 0) { cache.delete(key); return { players: [], duration: 0 } as RoundWorld; } const players: PlayerSeries[] = await Promise.all( Array.from(byPlayer.entries()).map(async ([player, list]) => { // opencs2_dataset has one ticks.parquet per (round, player); pick // the lowest-chunk-index row defensively in case an upstream still // emits multiple. list.sort((a, b) => a.chunk_index - b.chunk_index); const frames = await loadChunkFrames(list[0], player, opts); return { player, frames }; }) ); const duration = players.reduce( (m, p) => Math.max(m, p.frames[p.frames.length - 1]?.t ?? 0), 0 ); return { players, duration }; })().catch((err) => { cache.delete(key); throw err; }); cache.set(key, promise); return promise; } function nearestLeq(frames: WorldFrame[], t: number): number { let lo = 0; let hi = frames.length - 1; let best = -1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (frames[mid].t <= t) { best = mid; lo = mid + 1; } else { hi = mid - 1; } } return best; } // Shortest-arc lerp so a player turning across the ±180° boundary doesn't // spin the long way around between sampled ticks. function lerpAngle(a: number, b: number, alpha: number): number { const d = ((b - a + 540) % 360) - 180 || 0; return a + d * alpha; } export type Snapshot = { player: number; steamid: string; name: string; team_num: number; is_alive: boolean; health: number; X: number; Y: number; Z: number; yaw?: number; }; export function snapshotAt(world: RoundWorld, t: number): Snapshot[] { const out: Snapshot[] = []; for (const series of world.players) { const frames = series.frames; if (!frames.length) continue; const i = nearestLeq(frames, t); const cur = i < 0 ? frames[0] : frames[i]; const next = i >= 0 && i + 1 < frames.length ? frames[i + 1] : null; // If we've run off the end of this player's series (typically: died // mid-round and their chunks stop), drop them after a small grace // window so the last-known position doesn't linger forever. const last = frames[frames.length - 1]; if (!next && t > last.t + 2) continue; let X = cur.X; let Y = cur.Y; let Z = cur.Z; let yaw = cur.yaw; if (next && next.t > cur.t) { const alpha = Math.max(0, Math.min(1, (t - cur.t) / (next.t - cur.t))); X = cur.X + (next.X - cur.X) * alpha; Y = cur.Y + (next.Y - cur.Y) * alpha; Z = cur.Z + (next.Z - cur.Z) * alpha; if (cur.yaw !== undefined && next.yaw !== undefined) { yaw = lerpAngle(cur.yaw, next.yaw, alpha); } } out.push({ player: cur.player, steamid: cur.steamid, name: cur.name, team_num: cur.team_num, is_alive: cur.is_alive, health: cur.health, X, Y, Z, yaw }); } return out; }