Spaces:
Running
Running
| 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<string, Promise<RoundWorld>>(); | |
| // 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<WorldFrame[]> { | |
| const base = chunkBaseUrl(chunk); | |
| const rows = await fetchParquetRows<TickRow>(`${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<RoundWorld> { | |
| const key = `${matchId}/${mapName}/${round}`; | |
| const cached = cache.get(key); | |
| if (cached) return cached; | |
| const promise = (async () => { | |
| const byPlayer = new Map<number, PreviewChunk[]>(); | |
| 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; | |
| } | |