blanchon's picture
Trust ticks.parquet `t` and `event_seconds`
1632a07
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;
}