From aca0c17e4d1e09c2594c1e88acaae1a99e7aa38b Mon Sep 17 00:00:00 2001 From: Sewmina Date: Wed, 29 Jul 2026 12:47:29 +0530 Subject: [PATCH] replay wip --- package-lock.json | 65 --- schemas/replays.md | 291 ++++++++++++ src/app/replays/page.tsx | 50 ++ src/components/admin-dashboard.tsx | 9 + src/components/replay-viewer.tsx | 704 +++++++++++++++++++++++++++++ src/lib/auth/page-access-labels.ts | 1 + src/lib/replay-parse.ts | 147 ++++++ src/lib/replay-playback.ts | 150 ++++++ src/types/replay.ts | 99 ++++ 9 files changed, 1451 insertions(+), 65 deletions(-) create mode 100644 schemas/replays.md create mode 100644 src/app/replays/page.tsx create mode 100644 src/components/replay-viewer.tsx create mode 100644 src/lib/replay-parse.ts create mode 100644 src/lib/replay-playback.ts create mode 100644 src/types/replay.ts diff --git a/package-lock.json b/package-lock.json index 65a50dc..ad3f680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/schemas/replays.md b/schemas/replays.md new file mode 100644 index 0000000..827f0f5 --- /dev/null +++ b/schemas/replays.md @@ -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 diff --git a/src/app/replays/page.tsx b/src/app/replays/page.tsx new file mode 100644 index 0000000..ba6d29c --- /dev/null +++ b/src/app/replays/page.tsx @@ -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 ( +
+
+ +

+ soccar + + replay viewer +

+ + Back to matches + +
+ +
+ +
+
+ ); +} diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index a6540e9..1812284 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -564,6 +564,15 @@ export function AdminDashboard({ System logs ) : null} + {pageAccess.matches || pageAccess.analysis ? ( + + Replays + + ) : null} {isAdmin ? ( { + 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(initialReplay); + const [fileName, setFileName] = useState(initialFileName); + const [error, setError] = useState(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(null); + const cutsRef = useRef([]); + const timeRef = useRef(0); + const playingRef = useRef(false); + const speedRef = useRef(1); + const lastTsRef = useRef(null); + const eventCursorRef = useRef(0); + const replayRef = useRef(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 ( +
+ {!replay ? ( + + ) : ( + <> +
+
+

+ {fileName ?? `match_${replay.matchId}_replay.json`} +

+

+ Match {replay.matchId} + {replay.isPractice ? " · practice" : ""} + {" · "} + {replay.entities.length} entities + {" · "} + {replay.frames.length.toLocaleString("en-US")} samples + {" · "} + v{replay.version} +

+
+
+
+ {score.red} + + {score.blue} +
+ +
+
+ + {error ? ( +

{error}

+ ) : null} + +
+ +
+ +
+
+ {/* Event markers */} +
+ {goalEvents.map((e, i) => ( + 0 ? (e.t / replay.duration) * 100 : 0}%`, + backgroundColor: eventMarkerColor(e), + }} + /> + ))} +
+ { + setPlaying(false); + seekTo(Number(e.target.value)); + }} + className="w-full accent-emerald-500" + aria-label="Replay timeline" + /> +
+ +
+ + + + {formatReplayTime(time)} / {formatReplayTime(replay.duration)} + +
+ Speed + {SPEEDS.map((s) => ( + + ))} +
+
+ + {goalEvents.length > 0 ? ( +
+ + Goals + + {goalEvents.map((e, i) => { + const scorer = + e.team === "Red" + ? "Blue" + : e.team === "Blue" + ? "Red" + : "?"; + return ( + + ); + })} +
+ ) : null} +
+ + )} +
+ ); +} diff --git a/src/lib/auth/page-access-labels.ts b/src/lib/auth/page-access-labels.ts index f84bc6b..210ece4 100644 --- a/src/lib/auth/page-access-labels.ts +++ b/src/lib/auth/page-access-labels.ts @@ -10,6 +10,7 @@ const PAGE_LABELS: Record = { "ledger-book": "Ledger book", "match-log": "Match log", "matchmaker-logs": "Matchmaker logs", + "replay-viewer": "Replay viewer", }; export function pageAccessLabel(page: string): string { diff --git a/src/lib/replay-parse.ts b/src/lib/replay-parse.ts new file mode 100644 index 0000000..92959f0 --- /dev/null +++ b/src/lib/replay-parse.ts @@ -0,0 +1,147 @@ +import type { + ReplayEntity, + ReplayEvent, + ReplayFile, + ReplayFrame, +} from "@/types/replay"; + +function isRecord(v: unknown): v is Record { + 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); +} diff --git a/src/lib/replay-playback.ts b/src/lib/replay-playback.ts new file mode 100644 index 0000000..c05f944 --- /dev/null +++ b/src/lib/replay-playback.ts @@ -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 }; diff --git a/src/types/replay.ts b/src/types/replay.ts new file mode 100644 index 0000000..ed98842 --- /dev/null +++ b/src/types/replay.ts @@ -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), +}; +