replay wip
This commit is contained in:
Generated
-65
@@ -601,9 +601,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -620,9 +617,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -639,9 +633,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -658,9 +649,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -677,9 +665,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -696,9 +681,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -715,9 +697,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -734,9 +713,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,9 +729,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -778,9 +751,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -803,9 +773,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -828,9 +795,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -853,9 +817,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -878,9 +839,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -903,9 +861,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -928,9 +883,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1140,9 +1092,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1159,9 +1108,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1178,9 +1124,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1197,9 +1140,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4757,7 +4697,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5178,7 +5117,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -5395,7 +5333,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5693,7 +5630,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -5757,7 +5693,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-simple-maps": {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Match Replay JSON Format
|
||||
|
||||
Guide for implementing the admin-panel replay viewer. Files are written by the Unity dedicated server (and practice host) to disk only.
|
||||
|
||||
## Where files live
|
||||
|
||||
```
|
||||
{ApplicationDirectory}/Logs/{matchId}_replay.json
|
||||
```
|
||||
|
||||
| Match type | `matchId` in filename / JSON | Notes |
|
||||
|------------|------------------------------|--------|
|
||||
| Ranked / dedicated | Positive matchmaker id (e.g. `228`) | Same id as `Logs/228.txt` |
|
||||
| Practice / tutorial | Negative unix timestamp (e.g. `-1769539200`) | `isPractice: true`; unique per session |
|
||||
|
||||
Example paths:
|
||||
|
||||
- `Logs/228_replay.json`
|
||||
- `Logs/-1769539200_replay.json`
|
||||
|
||||
## Top-level object
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"matchId": 228,
|
||||
"isPractice": false,
|
||||
"fixedDeltaTime": 0.02,
|
||||
"duration": 184.32,
|
||||
"entities": [ /* ... */ ],
|
||||
"frames": [ /* ... */ ],
|
||||
"events": [ /* ... */ ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `version` | int | Schema version. Currently `1`. |
|
||||
| `matchId` | int | Match id (positive = ranked, negative = practice). |
|
||||
| `isPractice` | bool | `true` for localhost practice / tutorial host. |
|
||||
| `fixedDeltaTime` | float | Physics step used while recording (typically `0.02` → ~50 Hz). |
|
||||
| `duration` | float | Length of the recording in seconds (`t` of last sample / end). |
|
||||
| `entities` | array | Ball + pucks. Order defines pose array indices in every frame. |
|
||||
| `frames` | array | Time-ordered pose samples (~every FixedUpdate). |
|
||||
| `events` | array | Time-ordered discrete events (hits, goals, resets). |
|
||||
|
||||
Time `t` everywhere is **seconds since recording start** (match start), not wall-clock UTC.
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
```json
|
||||
{ "id": 0, "type": "ball", "team": "" }
|
||||
{ "id": 1, "type": "puck", "team": "Red" }
|
||||
{ "id": 2, "type": "puck", "team": "Blue" }
|
||||
```
|
||||
|
||||
| Field | Type | Values |
|
||||
|-------|------|--------|
|
||||
| `id` | int | Stable index `0 .. N-1`. Matches array index in each frame’s `x`/`y`/`vx`/`vy`. |
|
||||
| `type` | string | `"ball"` or `"puck"` |
|
||||
| `team` | string | `"Red"`, `"Blue"`, or `""` for ball |
|
||||
|
||||
Entity list is fixed for the whole file. **Index `i` in a frame’s pose arrays always refers to `entities[i]`.**
|
||||
|
||||
Typical order: ball first, then pucks sorted by team then spawn position.
|
||||
|
||||
---
|
||||
|
||||
## Frames (motion samples)
|
||||
|
||||
```json
|
||||
{
|
||||
"t": 12.40,
|
||||
"x": [0.0, 1.2, -0.5],
|
||||
"y": [0.1, -0.3, 2.0],
|
||||
"vx": [0.0, 3.1, -1.0],
|
||||
"vy": [0.2, 0.0, 0.5]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `t` | float | Sample time in seconds (monotonic, increasing). |
|
||||
| `x`, `y` | float[] | World position per entity (Unity 2D world units). |
|
||||
| `vx`, `vy` | float[] | Linear velocity per entity at that sample (world units / second). |
|
||||
|
||||
- Array length always equals `entities.length`.
|
||||
- Samples are dense (~`1 / fixedDeltaTime` Hz). Do **not** treat them as render frames.
|
||||
|
||||
### Coordinate system
|
||||
|
||||
Unity 2D world space:
|
||||
|
||||
- Origin at field center
|
||||
- `+X` right, `+Y` up (as in Unity)
|
||||
- Scale matches the in-game pitch (see game `fieldSize` / art for admin canvas mapping)
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
```json
|
||||
{ "t": 12.4, "type": "hit", "kind": "ball_wall", "vol": 0.6, "id": 0, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
|
||||
{ "t": 30.1, "type": "goal", "kind": "", "vol": 0, "id": -1, "team": "Blue", "redScore": 1, "blueScore": 0, "kickoff": false }
|
||||
{ "t": 32.2, "type": "reset", "kind": "", "vol": 0, "id": -1, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
|
||||
```
|
||||
|
||||
All event objects share the same fields (Unity `JsonUtility` shape). Unused fields are empty / zero / `-1`.
|
||||
|
||||
| Field | Used by | Description |
|
||||
|-------|---------|-------------|
|
||||
| `t` | all | Event time in seconds. |
|
||||
| `type` | all | `"hit"` \| `"goal"` \| `"reset"` |
|
||||
| `kind` | `hit` | `"puck_puck"` \| `"ball_puck"` \| `"ball_wall"` |
|
||||
| `vol` | `hit` | SFX volume `0..1` (from relative impact speed). |
|
||||
| `id` | `hit` | Entity id involved (spatial cue). `-1` if unknown. |
|
||||
| `team` | `goal` | **Conceding** side / goal owner (`"Red"` or `"Blue"`). The other team scored. |
|
||||
| `redScore` | `goal` | Score after this goal. |
|
||||
| `blueScore` | `goal` | Score after this goal. |
|
||||
| `kickoff` | `goal`, `reset` | Kickoff-goal / kickoff-style reset when `true`. |
|
||||
|
||||
### Event semantics
|
||||
|
||||
| `type` | Meaning for the viewer |
|
||||
|--------|-------------------------|
|
||||
| `hit` | Play collision SFX at `vol`. Optional: flash near entity `id`. |
|
||||
| `goal` | Goal scored against `team`. Update scoreboard to `redScore`/`blueScore`. Play goal SFX. **Discontinuity** — do not interpolate motion across this time. |
|
||||
| `reset` | Pieces are lerping / teleporting back. **Discontinuity** — snap poses; do not blend across. |
|
||||
|
||||
First-to-3 wins; a completed match usually ends shortly after a goal that makes a score `3`.
|
||||
|
||||
---
|
||||
|
||||
## Playback contract (required for smooth motion)
|
||||
|
||||
Recording is FixedUpdate (~50 Hz). Snapping happens if the viewer steps sample-by-sample without blending. Follow this:
|
||||
|
||||
### 1. Drive time with wall clock
|
||||
|
||||
```text
|
||||
playbackTime += (deltaMs / 1000) * playbackSpeed
|
||||
playbackTime = clamp(playbackTime, 0, duration)
|
||||
```
|
||||
|
||||
Use `requestAnimationFrame` (or equivalent). Do **not** advance one JSON frame per rAF tick.
|
||||
|
||||
### 2. Interpolate poses between samples
|
||||
|
||||
Binary-search `frames` for neighbors where:
|
||||
|
||||
```text
|
||||
frames[i].t <= playbackTime < frames[i+1].t
|
||||
```
|
||||
|
||||
Then for each entity index `e`:
|
||||
|
||||
**Minimum (good):** linear lerp of `x`/`y`:
|
||||
|
||||
```text
|
||||
u = (playbackTime - t0) / (t1 - t0)
|
||||
x = lerp(x0[e], x1[e], u)
|
||||
y = lerp(y0[e], y1[e], u)
|
||||
```
|
||||
|
||||
**Recommended (smooth under acceleration):** cubic Hermite using velocities:
|
||||
|
||||
```text
|
||||
dt = t1 - t0
|
||||
u = (playbackTime - t0) / dt
|
||||
u2 = u*u
|
||||
u3 = u2*u
|
||||
|
||||
h00 = 2*u3 - 3*u2 + 1
|
||||
h10 = u3 - 2*u2 + u
|
||||
h01 = -2*u3 + 3*u2
|
||||
h11 = u3 - u2
|
||||
|
||||
x = h00*x0 + h10*dt*vx0 + h01*x1 + h11*dt*vx1
|
||||
y = h00*y0 + h10*dt*vy0 + h01*y1 + h11*dt*vy1
|
||||
```
|
||||
|
||||
(Same for each entity index.)
|
||||
|
||||
### 3. Snap across discontinuities
|
||||
|
||||
Treat every `goal` and `reset` event as a cut:
|
||||
|
||||
- When `playbackTime` crosses that event’s `t`, **do not** interpolate from the previous sample across the cut.
|
||||
- Seek to the frame at or immediately after `t` and snap positions.
|
||||
|
||||
Practical approach: build a sorted list of discontinuity times from events where `type === "goal" || type === "reset"`. If `t0` and `t1` straddle a discontinuity, snap to the post-cut sample instead of lerping.
|
||||
|
||||
### 4. Fire events once when crossed
|
||||
|
||||
Keep `lastEventIndex` (or last fired `t`). When `playbackTime` advances past an event’s `t`, fire it once:
|
||||
|
||||
- `hit` → SFX by `kind` / `vol`
|
||||
- `goal` → score UI + goal audio
|
||||
- `reset` → optional reset cue (usually silent)
|
||||
|
||||
On seek / scrub: reset event cursor; either skip SFX or replay only from the new time forward.
|
||||
|
||||
---
|
||||
|
||||
## Suggested viewer pipeline
|
||||
|
||||
```text
|
||||
Load JSON
|
||||
→ map entities to sprites (ball / red pucks / blue pucks)
|
||||
→ sort events by t (already ordered, but verify)
|
||||
→ each animation frame:
|
||||
advance playbackTime
|
||||
resolve poses (Hermite + discontinuity snaps)
|
||||
draw entities
|
||||
dispatch newly crossed events
|
||||
```
|
||||
|
||||
### Seeking / scrubbing
|
||||
|
||||
1. Set `playbackTime` to the scrub value.
|
||||
2. Find the nearest frame (or interpolate as above).
|
||||
3. Recompute score from the last `goal` event with `t <= playbackTime` (else `0–0`).
|
||||
4. Reset the event-fire cursor to that time so SFX don’t spam.
|
||||
|
||||
### Scoreboard without scrubbing
|
||||
|
||||
Start at `0–0`. On each `goal` event, set scores from that event’s `redScore` / `blueScore`.
|
||||
|
||||
---
|
||||
|
||||
## Minimal TypeScript types
|
||||
|
||||
```ts
|
||||
export interface ReplayFile {
|
||||
version: number;
|
||||
matchId: number;
|
||||
isPractice: boolean;
|
||||
fixedDeltaTime: number;
|
||||
duration: number;
|
||||
entities: ReplayEntity[];
|
||||
frames: ReplayFrame[];
|
||||
events: ReplayEvent[];
|
||||
}
|
||||
|
||||
export interface ReplayEntity {
|
||||
id: number;
|
||||
type: "ball" | "puck" | string;
|
||||
team: "Red" | "Blue" | "" | string;
|
||||
}
|
||||
|
||||
export interface ReplayFrame {
|
||||
t: number;
|
||||
x: number[];
|
||||
y: number[];
|
||||
vx: number[];
|
||||
vy: number[];
|
||||
}
|
||||
|
||||
export interface ReplayEvent {
|
||||
t: number;
|
||||
type: "hit" | "goal" | "reset" | string;
|
||||
kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
|
||||
vol: number;
|
||||
id: number; // entity id, or -1
|
||||
team: "Red" | "Blue" | "" | string; // conceding team on goal
|
||||
redScore: number;
|
||||
blueScore: number;
|
||||
kickoff: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for a correct viewer
|
||||
|
||||
- [ ] Time driven by real elapsed time × speed, not frame index
|
||||
- [ ] Poses interpolated between samples (Hermite preferred)
|
||||
- [ ] No interpolation across `goal` / `reset`
|
||||
- [ ] Entity index `i` always matches `entities[i]`
|
||||
- [ ] Hit SFX use `kind` + `vol`
|
||||
- [ ] Goal `team` means **conceding** side
|
||||
- [ ] Practice files filtered via `isPractice` or negative `matchId`
|
||||
|
||||
## Out of scope (not in the JSON)
|
||||
|
||||
- Player names / user ids (use matchmaker / match API by `matchId` for ranked games)
|
||||
- Camera / UI layout
|
||||
- Launch aim lines / turn timer
|
||||
- Network latency or client prediction
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ReplayViewer } from "@/components/replay-viewer";
|
||||
import { canReadPage } from "@/lib/auth/permissions";
|
||||
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||
import { requireSession } from "@/lib/auth/require-session";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Replay viewer · Kick Kings Admin",
|
||||
};
|
||||
|
||||
export default async function ReplaysPage() {
|
||||
const account = await requireSession();
|
||||
if (
|
||||
!canReadPage(account, "matches") &&
|
||||
!canReadPage(account, "analysis")
|
||||
) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
await logPageAccess(account, "replay-viewer");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
|
||||
<div className="flex gap-1.5 pr-2" aria-hidden="true">
|
||||
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
|
||||
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
|
||||
<span className="text-emerald-400/90">soccar</span>
|
||||
<span className="text-zinc-500"> — </span>
|
||||
<span className="text-zinc-100">replay viewer</span>
|
||||
</p>
|
||||
<Link
|
||||
href="/?tab=matches"
|
||||
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
|
||||
>
|
||||
Back to matches
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<ReplayViewer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -564,6 +564,15 @@ export function AdminDashboard({
|
||||
System logs
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches || pageAccess.analysis ? (
|
||||
<Link
|
||||
href="/replays"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Replays
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/settings"
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { parseReplayJsonText } from "@/lib/replay-parse";
|
||||
import {
|
||||
discontinuityTimes,
|
||||
eventCursorAfter,
|
||||
formatReplayTime,
|
||||
resolvePoses,
|
||||
scoreAtTime,
|
||||
} from "@/lib/replay-playback";
|
||||
import type { ReplayEvent, ReplayFile } from "@/types/replay";
|
||||
import {
|
||||
REPLAY_BOTTOM_GOAL,
|
||||
REPLAY_FIELD_BOUNDS,
|
||||
REPLAY_PITCH_BOUNDS,
|
||||
REPLAY_TOP_GOAL,
|
||||
type ReplayGoalBox,
|
||||
} from "@/types/replay";
|
||||
|
||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
||||
|
||||
/** Padding around the field rect inside the canvas (CSS px). */
|
||||
const VIEW_PAD = 12;
|
||||
|
||||
type ViewRect = {
|
||||
ox: number;
|
||||
oy: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
/** Letterbox the fixed field into the canvas, preserving aspect. */
|
||||
function fieldViewRect(canvasW: number, canvasH: number): ViewRect {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
const availW = Math.max(1, canvasW - VIEW_PAD * 2);
|
||||
const availH = Math.max(1, canvasH - VIEW_PAD * 2);
|
||||
const scale = Math.min(availW / worldW, availH / worldH);
|
||||
const w = worldW * scale;
|
||||
const h = worldH * scale;
|
||||
return {
|
||||
ox: (canvasW - w) / 2,
|
||||
oy: (canvasH - h) / 2,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
function worldToCanvas(
|
||||
x: number,
|
||||
y: number,
|
||||
view: ViewRect,
|
||||
): { cx: number; cy: number } {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
return {
|
||||
cx: view.ox + ((x - minX) / worldW) * view.w,
|
||||
cy: view.oy + ((maxY - y) / worldH) * view.h, // +Y up → canvas Y down
|
||||
};
|
||||
}
|
||||
|
||||
function fillWorldRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
view: ViewRect,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
fill: string,
|
||||
) {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fillRect(
|
||||
Math.min(a.cx, b.cx),
|
||||
Math.min(a.cy, b.cy),
|
||||
Math.abs(b.cx - a.cx),
|
||||
Math.abs(b.cy - a.cy),
|
||||
);
|
||||
}
|
||||
|
||||
function strokeWorldRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
view: ViewRect,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
stroke: string,
|
||||
lineWidth = 2,
|
||||
) {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeRect(
|
||||
Math.min(a.cx, b.cx),
|
||||
Math.min(a.cy, b.cy),
|
||||
Math.abs(b.cx - a.cx),
|
||||
Math.abs(b.cy - a.cy),
|
||||
);
|
||||
}
|
||||
|
||||
function goalExtents(g: ReplayGoalBox) {
|
||||
const halfW = g.width / 2;
|
||||
const halfH = g.height / 2;
|
||||
return {
|
||||
left: g.x - halfW,
|
||||
right: g.x + halfW,
|
||||
bottom: g.y - halfH,
|
||||
top: g.y + halfH,
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Optional preloaded replay (e.g. later: server file by match id). */
|
||||
initialReplay?: ReplayFile | null;
|
||||
initialFileName?: string | null;
|
||||
};
|
||||
|
||||
function entityFill(type: string, team: string): string {
|
||||
if (type === "ball") return "#f4f0e6";
|
||||
if (team === "Red") return "#e5484d";
|
||||
if (team === "Blue") return "#3b82f6";
|
||||
return "#a1a1aa";
|
||||
}
|
||||
|
||||
function entityStroke(type: string, team: string): string {
|
||||
if (type === "ball") return "#c4b89a";
|
||||
if (team === "Red") return "#9f1239";
|
||||
if (team === "Blue") return "#1d4ed8";
|
||||
return "#52525b";
|
||||
}
|
||||
|
||||
function drawPitch(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
view: ViewRect,
|
||||
) {
|
||||
const pitch = REPLAY_PITCH_BOUNDS;
|
||||
const field = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = field.maxX - field.minX;
|
||||
const worldH = field.maxY - field.minY;
|
||||
|
||||
ctx.fillStyle = "#0d1117";
|
||||
ctx.fillRect(0, 0, canvasW, canvasH);
|
||||
|
||||
// Playable pitch + goal boxes (view letterboxes to include goals)
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
pitch.minX,
|
||||
pitch.minY,
|
||||
pitch.maxX,
|
||||
pitch.maxY,
|
||||
"#1a3d2b",
|
||||
);
|
||||
|
||||
const topG = goalExtents(REPLAY_TOP_GOAL);
|
||||
const botG = goalExtents(REPLAY_BOTTOM_GOAL);
|
||||
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
pitch.maxY,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"#1a3d2b",
|
||||
);
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
pitch.minY,
|
||||
"#1a3d2b",
|
||||
);
|
||||
|
||||
// Subtle horizontal stripes on the playable pitch
|
||||
const stripes = 10;
|
||||
for (let i = 0; i < stripes; i++) {
|
||||
if (i % 2 === 0) continue;
|
||||
const y0 =
|
||||
pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * (i + 1);
|
||||
const y1 = pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * i;
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
pitch.minX,
|
||||
y0,
|
||||
pitch.maxX,
|
||||
y1,
|
||||
"rgba(255,255,255,0.03)",
|
||||
);
|
||||
}
|
||||
|
||||
const strokeLine = (
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
width = 2,
|
||||
) => {
|
||||
const a = worldToCanvas(x0, y0, view);
|
||||
const b = worldToCanvas(x1, y1, view);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.cx, a.cy);
|
||||
ctx.lineTo(b.cx, b.cy);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.4)";
|
||||
ctx.lineWidth = width;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// Sidelines
|
||||
strokeLine(pitch.minX, pitch.minY, pitch.minX, pitch.maxY, 2.5);
|
||||
strokeLine(pitch.maxX, pitch.minY, pitch.maxX, pitch.maxY, 2.5);
|
||||
|
||||
// Top end: end-line with goal mouth notch
|
||||
strokeLine(pitch.minX, pitch.maxY, topG.left, pitch.maxY, 2.5);
|
||||
strokeLine(topG.right, pitch.maxY, pitch.maxX, pitch.maxY, 2.5);
|
||||
strokeLine(topG.left, pitch.maxY, topG.left, topG.top, 2.5);
|
||||
strokeLine(topG.right, pitch.maxY, topG.right, topG.top, 2.5);
|
||||
strokeLine(topG.left, topG.top, topG.right, topG.top, 2.5);
|
||||
|
||||
// Bottom end: end-line with goal mouth notch
|
||||
strokeLine(pitch.minX, pitch.minY, botG.left, pitch.minY, 2.5);
|
||||
strokeLine(botG.right, pitch.minY, pitch.maxX, pitch.minY, 2.5);
|
||||
strokeLine(botG.left, pitch.minY, botG.left, botG.bottom, 2.5);
|
||||
strokeLine(botG.right, pitch.minY, botG.right, botG.bottom, 2.5);
|
||||
strokeLine(botG.left, botG.bottom, botG.right, botG.bottom, 2.5);
|
||||
|
||||
// Center line + circle
|
||||
strokeLine(pitch.minX, 0, pitch.maxX, 0, 2);
|
||||
const r = Math.min(worldW, worldH) * 0.14;
|
||||
const c = worldToCanvas(0, 0, view);
|
||||
const rx = (r / worldW) * view.w;
|
||||
const ry = (r / worldH) * view.h;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(c.cx, c.cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.35)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Goal fills (exact size/position)
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
topG.bottom,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"rgba(229, 72, 77, 0.45)",
|
||||
);
|
||||
strokeWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
topG.left,
|
||||
topG.bottom,
|
||||
topG.right,
|
||||
topG.top,
|
||||
"rgba(229, 72, 77, 0.85)",
|
||||
1.5,
|
||||
);
|
||||
fillWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
botG.top,
|
||||
"rgba(59, 130, 246, 0.45)",
|
||||
);
|
||||
strokeWorldRect(
|
||||
ctx,
|
||||
view,
|
||||
botG.left,
|
||||
botG.bottom,
|
||||
botG.right,
|
||||
botG.top,
|
||||
"rgba(59, 130, 246, 0.85)",
|
||||
1.5,
|
||||
);
|
||||
}
|
||||
|
||||
function drawEntities(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
replay: ReplayFile,
|
||||
poses: { x: number; y: number }[],
|
||||
view: ViewRect,
|
||||
) {
|
||||
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
|
||||
const worldW = maxX - minX;
|
||||
const worldH = maxY - minY;
|
||||
const unit = Math.min(view.w / worldW, view.h / worldH);
|
||||
const ballR = Math.max(4, unit * 0.12);
|
||||
const puckR = Math.max(6, unit * 0.18);
|
||||
|
||||
for (let i = 0; i < replay.entities.length; i++) {
|
||||
const ent = replay.entities[i]!;
|
||||
const pose = poses[i];
|
||||
if (!pose) continue;
|
||||
const { cx, cy } = worldToCanvas(pose.x, pose.y, view);
|
||||
const r = ent.type === "ball" ? ballR : puckR;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = entityFill(ent.type, ent.team);
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = entityStroke(ent.type, ent.team);
|
||||
ctx.stroke();
|
||||
|
||||
if (ent.type === "ball") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(0,0,0,0.12)";
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function eventMarkerColor(e: ReplayEvent): string {
|
||||
if (e.type === "goal") {
|
||||
return e.team === "Red" ? "#3b82f6" : "#e5484d";
|
||||
}
|
||||
if (e.type === "reset") return "#a1a1aa";
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
export function ReplayViewer({
|
||||
initialReplay = null,
|
||||
initialFileName = null,
|
||||
}: Props) {
|
||||
const [replay, setReplay] = useState<ReplayFile | null>(initialReplay);
|
||||
const [fileName, setFileName] = useState<string | null>(initialFileName);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [time, setTime] = useState(0);
|
||||
const [score, setScore] = useState({ red: 0, blue: 0 });
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const cutsRef = useRef<number[]>([]);
|
||||
const timeRef = useRef(0);
|
||||
const playingRef = useRef(false);
|
||||
const speedRef = useRef(1);
|
||||
const lastTsRef = useRef<number | null>(null);
|
||||
const eventCursorRef = useRef(0);
|
||||
const replayRef = useRef<ReplayFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
timeRef.current = time;
|
||||
}, [time]);
|
||||
useEffect(() => {
|
||||
playingRef.current = playing;
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
speedRef.current = speed;
|
||||
}, [speed]);
|
||||
useEffect(() => {
|
||||
replayRef.current = replay;
|
||||
}, [replay]);
|
||||
|
||||
const loadReplay = useCallback((file: ReplayFile, name: string) => {
|
||||
setReplay(file);
|
||||
setFileName(name);
|
||||
setError(null);
|
||||
setTime(0);
|
||||
timeRef.current = 0;
|
||||
setPlaying(false);
|
||||
setScore({ red: 0, blue: 0 });
|
||||
cutsRef.current = discontinuityTimes(file.events);
|
||||
eventCursorRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const onFileText = useCallback(
|
||||
(text: string, name: string) => {
|
||||
try {
|
||||
const parsed = parseReplayJsonText(text);
|
||||
loadReplay(parsed, name);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to parse replay");
|
||||
setReplay(null);
|
||||
setFileName(null);
|
||||
}
|
||||
},
|
||||
[loadReplay],
|
||||
);
|
||||
|
||||
const onPickFile = useCallback(
|
||||
(file: File | null) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = typeof reader.result === "string" ? reader.result : "";
|
||||
onFileText(text, file.name);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read file");
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[onFileText],
|
||||
);
|
||||
|
||||
const seekTo = useCallback((t: number) => {
|
||||
const r = replayRef.current;
|
||||
if (!r) return;
|
||||
const clamped = Math.min(Math.max(0, t), r.duration);
|
||||
timeRef.current = clamped;
|
||||
setTime(clamped);
|
||||
setScore(scoreAtTime(r.events, clamped));
|
||||
eventCursorRef.current = eventCursorAfter(r.events, clamped);
|
||||
}, []);
|
||||
|
||||
const paint = useEffectEvent(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const r = replayRef.current;
|
||||
if (!canvas || !r) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.clientWidth;
|
||||
const cssH = canvas.clientHeight;
|
||||
if (cssW < 2 || cssH < 2) return;
|
||||
|
||||
const needW = Math.floor(cssW * dpr);
|
||||
const needH = Math.floor(cssH * dpr);
|
||||
if (canvas.width !== needW || canvas.height !== needH) {
|
||||
canvas.width = needW;
|
||||
canvas.height = needH;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const view = fieldViewRect(cssW, cssH);
|
||||
const poses = resolvePoses(
|
||||
r.frames,
|
||||
r.entities.length,
|
||||
timeRef.current,
|
||||
cutsRef.current,
|
||||
);
|
||||
drawPitch(ctx, cssW, cssH, view);
|
||||
drawEntities(ctx, r, poses, view);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = (ts: number) => {
|
||||
const r = replayRef.current;
|
||||
if (r && playingRef.current) {
|
||||
if (lastTsRef.current == null) lastTsRef.current = ts;
|
||||
const dt = (ts - lastTsRef.current) / 1000;
|
||||
lastTsRef.current = ts;
|
||||
let next = timeRef.current + dt * speedRef.current;
|
||||
if (next >= r.duration) {
|
||||
next = r.duration;
|
||||
playingRef.current = false;
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
// Fire events crossed this frame (SFX later; update score for goals)
|
||||
const events = r.events;
|
||||
let cursor = eventCursorRef.current;
|
||||
while (cursor < events.length && events[cursor]!.t <= next) {
|
||||
const e = events[cursor]!;
|
||||
if (e.type === "goal") {
|
||||
setScore({ red: e.redScore, blue: e.blueScore });
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
eventCursorRef.current = cursor;
|
||||
|
||||
timeRef.current = next;
|
||||
setTime(next);
|
||||
} else {
|
||||
lastTsRef.current = null;
|
||||
}
|
||||
paint();
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [paint]);
|
||||
|
||||
const goalEvents =
|
||||
replay?.events.filter((e) => e.type === "goal") ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{!replay ? (
|
||||
<label
|
||||
className={[
|
||||
"flex flex-1 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-16 text-center transition",
|
||||
dragOver
|
||||
? "border-emerald-500 bg-emerald-500/10"
|
||||
: "border-zinc-600 bg-zinc-900/40 hover:border-zinc-400 hover:bg-zinc-900/70",
|
||||
].join(" ")}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPickFile(f);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<p className="text-base font-medium text-zinc-100">
|
||||
Drop a replay JSON file here
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-sm text-zinc-400">
|
||||
From the game server:{" "}
|
||||
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
|
||||
Logs/{matchId}_replay.json
|
||||
</code>
|
||||
</p>
|
||||
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
|
||||
Choose file
|
||||
</span>
|
||||
{error ? (
|
||||
<p className="mt-4 max-w-lg text-sm text-red-400">{error}</p>
|
||||
) : null}
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm text-zinc-300">
|
||||
{fileName ?? `match_${replay.matchId}_replay.json`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Match {replay.matchId}
|
||||
{replay.isPractice ? " · practice" : ""}
|
||||
{" · "}
|
||||
{replay.entities.length} entities
|
||||
{" · "}
|
||||
{replay.frames.length.toLocaleString("en-US")} samples
|
||||
{" · "}
|
||||
v{replay.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-sm tabular-nums">
|
||||
<span className="text-[#e5484d]">{score.red}</span>
|
||||
<span className="text-zinc-500">–</span>
|
||||
<span className="text-[#3b82f6]">{score.blue}</span>
|
||||
</div>
|
||||
<label className="cursor-pointer rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700">
|
||||
Load other…
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="relative min-h-[280px] flex-1 overflow-hidden rounded-xl border border-zinc-700 bg-[#0d1117] shadow-inner">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-label="Replay field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-zinc-700 bg-[#161b22] p-3">
|
||||
<div className="relative px-1 pt-3">
|
||||
{/* Event markers */}
|
||||
<div className="pointer-events-none absolute inset-x-1 top-0 h-3">
|
||||
{goalEvents.map((e, i) => (
|
||||
<span
|
||||
key={`${e.t}-${i}`}
|
||||
title={`Goal @ ${formatReplayTime(e.t)}`}
|
||||
className="absolute top-0 h-2.5 w-1 -translate-x-1/2 rounded-sm"
|
||||
style={{
|
||||
left: `${replay.duration > 0 ? (e.t / replay.duration) * 100 : 0}%`,
|
||||
backgroundColor: eventMarkerColor(e),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={replay.duration || 1}
|
||||
step={0.01}
|
||||
value={Math.min(time, replay.duration)}
|
||||
onChange={(e) => {
|
||||
setPlaying(false);
|
||||
seekTo(Number(e.target.value));
|
||||
}}
|
||||
className="w-full accent-emerald-500"
|
||||
aria-label="Replay timeline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (timeRef.current >= (replay.duration || 0)) {
|
||||
seekTo(0);
|
||||
}
|
||||
setPlaying((p) => !p);
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
|
||||
>
|
||||
{playing ? "Pause" : "Play"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false);
|
||||
seekTo(0);
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<span className="ml-1 font-mono text-xs tabular-nums text-zinc-400">
|
||||
{formatReplayTime(time)} / {formatReplayTime(replay.duration)}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1">
|
||||
<span className="mr-1 text-xs text-zinc-500">Speed</span>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
className={[
|
||||
"rounded px-2 py-1 font-mono text-xs",
|
||||
speed === s
|
||||
? "bg-emerald-600/30 text-emerald-300"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goalEvents.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-zinc-800 pt-2">
|
||||
<span className="mr-1 self-center text-[11px] uppercase tracking-wide text-zinc-500">
|
||||
Goals
|
||||
</span>
|
||||
{goalEvents.map((e, i) => {
|
||||
const scorer =
|
||||
e.team === "Red"
|
||||
? "Blue"
|
||||
: e.team === "Blue"
|
||||
? "Red"
|
||||
: "?";
|
||||
return (
|
||||
<button
|
||||
key={`${e.t}-jump-${i}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaying(false);
|
||||
seekTo(Math.max(0, e.t - 0.05));
|
||||
}}
|
||||
className="rounded border border-zinc-700 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-300 hover:border-zinc-500"
|
||||
>
|
||||
{formatReplayTime(e.t)} · {scorer} ({e.redScore}–
|
||||
{e.blueScore})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const PAGE_LABELS: Record<string, string> = {
|
||||
"ledger-book": "Ledger book",
|
||||
"match-log": "Match log",
|
||||
"matchmaker-logs": "Matchmaker logs",
|
||||
"replay-viewer": "Replay viewer",
|
||||
};
|
||||
|
||||
export function pageAccessLabel(page: string): string {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
ReplayEntity,
|
||||
ReplayEvent,
|
||||
ReplayFile,
|
||||
ReplayFrame,
|
||||
} from "@/types/replay";
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asNumber(v: unknown, label: string): number {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
throw new Error(`Invalid ${label}: expected a finite number`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function asBoolean(v: unknown, label: string): boolean {
|
||||
if (typeof v !== "boolean") {
|
||||
throw new Error(`Invalid ${label}: expected a boolean`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function asString(v: unknown, label: string): string {
|
||||
if (typeof v !== "string") {
|
||||
throw new Error(`Invalid ${label}: expected a string`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function asNumberArray(v: unknown, label: string, len: number): number[] {
|
||||
if (!Array.isArray(v)) {
|
||||
throw new Error(`Invalid ${label}: expected an array`);
|
||||
}
|
||||
if (v.length !== len) {
|
||||
throw new Error(
|
||||
`Invalid ${label}: expected length ${len}, got ${v.length}`,
|
||||
);
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const n = v[i];
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) {
|
||||
throw new Error(`Invalid ${label}[${i}]: expected a finite number`);
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseEntity(raw: unknown, index: number): ReplayEntity {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error(`entities[${index}] must be an object`);
|
||||
}
|
||||
return {
|
||||
id: asNumber(raw.id, `entities[${index}].id`),
|
||||
type: asString(raw.type, `entities[${index}].type`),
|
||||
team: asString(raw.team, `entities[${index}].team`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrame(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
entityCount: number,
|
||||
): ReplayFrame {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error(`frames[${index}] must be an object`);
|
||||
}
|
||||
return {
|
||||
t: asNumber(raw.t, `frames[${index}].t`),
|
||||
x: asNumberArray(raw.x, `frames[${index}].x`, entityCount),
|
||||
y: asNumberArray(raw.y, `frames[${index}].y`, entityCount),
|
||||
vx: asNumberArray(raw.vx, `frames[${index}].vx`, entityCount),
|
||||
vy: asNumberArray(raw.vy, `frames[${index}].vy`, entityCount),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvent(raw: unknown, index: number): ReplayEvent {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error(`events[${index}] must be an object`);
|
||||
}
|
||||
return {
|
||||
t: asNumber(raw.t, `events[${index}].t`),
|
||||
type: asString(raw.type, `events[${index}].type`),
|
||||
kind: asString(raw.kind ?? "", `events[${index}].kind`),
|
||||
vol: asNumber(raw.vol ?? 0, `events[${index}].vol`),
|
||||
id: asNumber(raw.id ?? -1, `events[${index}].id`),
|
||||
team: asString(raw.team ?? "", `events[${index}].team`),
|
||||
redScore: asNumber(raw.redScore ?? 0, `events[${index}].redScore`),
|
||||
blueScore: asNumber(raw.blueScore ?? 0, `events[${index}].blueScore`),
|
||||
kickoff: asBoolean(raw.kickoff ?? false, `events[${index}].kickoff`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse and lightly validate a replay JSON value from disk / upload. */
|
||||
export function parseReplayFile(raw: unknown): ReplayFile {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Replay root must be a JSON object");
|
||||
}
|
||||
|
||||
const entitiesRaw = raw.entities;
|
||||
if (!Array.isArray(entitiesRaw) || entitiesRaw.length === 0) {
|
||||
throw new Error("Replay must include a non-empty entities array");
|
||||
}
|
||||
const entities = entitiesRaw.map(parseEntity);
|
||||
|
||||
const framesRaw = raw.frames;
|
||||
if (!Array.isArray(framesRaw) || framesRaw.length === 0) {
|
||||
throw new Error("Replay must include a non-empty frames array");
|
||||
}
|
||||
const frames = framesRaw.map((f, i) => parseFrame(f, i, entities.length));
|
||||
|
||||
const eventsRaw = raw.events;
|
||||
if (!Array.isArray(eventsRaw)) {
|
||||
throw new Error("Replay must include an events array");
|
||||
}
|
||||
const events = eventsRaw.map(parseEvent);
|
||||
|
||||
const duration = asNumber(raw.duration, "duration");
|
||||
if (duration < 0) {
|
||||
throw new Error("duration must be >= 0");
|
||||
}
|
||||
|
||||
return {
|
||||
version: asNumber(raw.version, "version"),
|
||||
matchId: asNumber(raw.matchId, "matchId"),
|
||||
isPractice: asBoolean(raw.isPractice ?? false, "isPractice"),
|
||||
fixedDeltaTime: asNumber(raw.fixedDeltaTime, "fixedDeltaTime"),
|
||||
duration,
|
||||
entities,
|
||||
frames,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReplayJsonText(text: string): ReplayFile {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new Error("File is not valid JSON");
|
||||
}
|
||||
return parseReplayFile(raw);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
ReplayEvent,
|
||||
ReplayFrame,
|
||||
ReplayPose,
|
||||
} from "@/types/replay";
|
||||
|
||||
function lerp(a: number, b: number, u: number): number {
|
||||
return a + (b - a) * u;
|
||||
}
|
||||
|
||||
function hermite(
|
||||
p0: number,
|
||||
v0: number,
|
||||
p1: number,
|
||||
v1: number,
|
||||
dt: number,
|
||||
u: number,
|
||||
): number {
|
||||
const u2 = u * u;
|
||||
const u3 = u2 * u;
|
||||
const h00 = 2 * u3 - 3 * u2 + 1;
|
||||
const h10 = u3 - 2 * u2 + u;
|
||||
const h01 = -2 * u3 + 3 * u2;
|
||||
const h11 = u3 - u2;
|
||||
return h00 * p0 + h10 * dt * v0 + h01 * p1 + h11 * dt * v1;
|
||||
}
|
||||
|
||||
/** Sorted discontinuity times from goal / reset events. */
|
||||
export function discontinuityTimes(events: ReplayEvent[]): number[] {
|
||||
const out: number[] = [];
|
||||
for (const e of events) {
|
||||
if (e.type === "goal" || e.type === "reset") {
|
||||
out.push(e.t);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a - b);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cut time in (t0, t1], or null if none. */
|
||||
function cutBetween(t0: number, t1: number, cuts: number[]): number | null {
|
||||
for (const c of cuts) {
|
||||
if (c > t0 && c <= t1) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest i such that frames[i].t <= time.
|
||||
* Returns 0 if time is before the first sample.
|
||||
*/
|
||||
export function frameIndexAtOrBefore(
|
||||
frames: ReplayFrame[],
|
||||
time: number,
|
||||
): number {
|
||||
let lo = 0;
|
||||
let hi = frames.length - 1;
|
||||
if (time <= frames[0]!.t) return 0;
|
||||
if (time >= frames[hi]!.t) return hi;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (frames[mid]!.t <= time) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** First event index with t > time (cursor after last fired). */
|
||||
export function eventCursorAfter(events: ReplayEvent[], time: number): number {
|
||||
let lo = 0;
|
||||
let hi = events.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (events[mid]!.t <= time) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
export function scoreAtTime(
|
||||
events: ReplayEvent[],
|
||||
time: number,
|
||||
): { red: number; blue: number } {
|
||||
let red = 0;
|
||||
let blue = 0;
|
||||
for (const e of events) {
|
||||
if (e.t > time) break;
|
||||
if (e.type === "goal") {
|
||||
red = e.redScore;
|
||||
blue = e.blueScore;
|
||||
}
|
||||
}
|
||||
return { red, blue };
|
||||
}
|
||||
|
||||
export function resolvePoses(
|
||||
frames: ReplayFrame[],
|
||||
entityCount: number,
|
||||
time: number,
|
||||
cuts: number[],
|
||||
): ReplayPose[] {
|
||||
const poses: ReplayPose[] = Array.from({ length: entityCount }, () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
}));
|
||||
|
||||
if (frames.length === 0 || entityCount === 0) return poses;
|
||||
|
||||
const i = frameIndexAtOrBefore(frames, time);
|
||||
const a = frames[i]!;
|
||||
|
||||
if (i >= frames.length - 1 || time <= a.t) {
|
||||
for (let e = 0; e < entityCount; e++) {
|
||||
poses[e] = { x: a.x[e]!, y: a.y[e]! };
|
||||
}
|
||||
return poses;
|
||||
}
|
||||
|
||||
const b = frames[i + 1]!;
|
||||
const dt = b.t - a.t;
|
||||
const cut = cutBetween(a.t, b.t, cuts);
|
||||
if (dt <= 1e-9 || cut != null) {
|
||||
// No blend across goal/reset: pre-cut → sample a, post-cut → sample b.
|
||||
const snap = cut != null && time >= cut ? b : a;
|
||||
for (let e = 0; e < entityCount; e++) {
|
||||
poses[e] = { x: snap.x[e]!, y: snap.y[e]! };
|
||||
}
|
||||
return poses;
|
||||
}
|
||||
|
||||
const u = (time - a.t) / dt;
|
||||
for (let e = 0; e < entityCount; e++) {
|
||||
poses[e] = {
|
||||
x: hermite(a.x[e]!, a.vx[e]!, b.x[e]!, b.vx[e]!, dt, u),
|
||||
y: hermite(a.y[e]!, a.vy[e]!, b.y[e]!, b.vy[e]!, dt, u),
|
||||
};
|
||||
}
|
||||
return poses;
|
||||
}
|
||||
|
||||
export function formatReplayTime(seconds: number): string {
|
||||
const s = Math.max(0, seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s - m * 60;
|
||||
const whole = Math.floor(rem);
|
||||
const frac = Math.floor((rem - whole) * 10);
|
||||
return `${m}:${String(whole).padStart(2, "0")}.${frac}`;
|
||||
}
|
||||
|
||||
export { lerp };
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface ReplayFile {
|
||||
version: number;
|
||||
matchId: number;
|
||||
isPractice: boolean;
|
||||
fixedDeltaTime: number;
|
||||
duration: number;
|
||||
entities: ReplayEntity[];
|
||||
frames: ReplayFrame[];
|
||||
events: ReplayEvent[];
|
||||
}
|
||||
|
||||
export interface ReplayEntity {
|
||||
id: number;
|
||||
type: "ball" | "puck" | string;
|
||||
team: "Red" | "Blue" | "" | string;
|
||||
}
|
||||
|
||||
export interface ReplayFrame {
|
||||
t: number;
|
||||
x: number[];
|
||||
y: number[];
|
||||
vx: number[];
|
||||
vy: number[];
|
||||
}
|
||||
|
||||
export interface ReplayEvent {
|
||||
t: number;
|
||||
type: "hit" | "goal" | "reset" | string;
|
||||
kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
|
||||
vol: number;
|
||||
id: number;
|
||||
team: "Red" | "Blue" | "" | string;
|
||||
redScore: number;
|
||||
blueScore: number;
|
||||
kickoff: boolean;
|
||||
}
|
||||
|
||||
export type ReplayPose = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type ReplayWorldBounds = {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
/** Axis-aligned goal box in Unity world space (center + size). */
|
||||
export type ReplayGoalBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playable pitch corners (origin at center, +Y up):
|
||||
* TL (-2.1, 4.3) TR (2.1, 4.3)
|
||||
* BL (-2.1, -4.3) BR (2.1, -4.3)
|
||||
*/
|
||||
export const REPLAY_PITCH_BOUNDS: ReplayWorldBounds = {
|
||||
minX: -2.1,
|
||||
maxX: 2.1,
|
||||
minY: -4.3,
|
||||
maxY: 4.3,
|
||||
};
|
||||
|
||||
export const REPLAY_TOP_GOAL: ReplayGoalBox = {
|
||||
x: 0,
|
||||
y: 4.87,
|
||||
width: 1.92,
|
||||
height: 0.61,
|
||||
};
|
||||
|
||||
export const REPLAY_BOTTOM_GOAL: ReplayGoalBox = {
|
||||
x: 0,
|
||||
y: -4.9135,
|
||||
width: 1.92,
|
||||
height: 0.61,
|
||||
};
|
||||
|
||||
function goalMinY(g: ReplayGoalBox): number {
|
||||
return g.y - g.height / 2;
|
||||
}
|
||||
|
||||
function goalMaxY(g: ReplayGoalBox): number {
|
||||
return g.y + g.height / 2;
|
||||
}
|
||||
|
||||
/** Full view extents: pitch plus goal boxes beyond the end lines. */
|
||||
export const REPLAY_FIELD_BOUNDS: ReplayWorldBounds = {
|
||||
minX: REPLAY_PITCH_BOUNDS.minX,
|
||||
maxX: REPLAY_PITCH_BOUNDS.maxX,
|
||||
minY: goalMinY(REPLAY_BOTTOM_GOAL),
|
||||
maxY: goalMaxY(REPLAY_TOP_GOAL),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user