File size: 5,118 Bytes
95e3d2a
 
437403c
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437403c
 
31d3580
 
 
 
437403c
1632a07
6c84e50
 
 
 
 
437403c
6c84e50
437403c
31d3580
 
 
 
 
 
 
 
437403c
 
 
 
31d3580
1632a07
 
 
 
 
 
 
 
 
 
 
 
 
 
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437403c
 
 
31d3580
437403c
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95e3d2a
31d3580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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;
}