replay wip

This commit is contained in:
2026-07-29 12:47:29 +05:30
parent 6f7af751e1
commit aca0c17e4d
9 changed files with 1451 additions and 65 deletions
-65
View File
@@ -601,9 +601,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -620,9 +617,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -639,9 +633,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -658,9 +649,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -677,9 +665,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -696,9 +681,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -715,9 +697,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -734,9 +713,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -753,9 +729,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -778,9 +751,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -803,9 +773,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -828,9 +795,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -853,9 +817,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -878,9 +839,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -903,9 +861,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -928,9 +883,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1140,9 +1092,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1159,9 +1108,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1178,9 +1124,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1197,9 +1140,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4757,7 +4697,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -5178,7 +5117,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@@ -5395,7 +5333,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5693,7 +5630,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -5757,7 +5693,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-simple-maps": { "node_modules/react-simple-maps": {
+291
View File
@@ -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 frames `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 frames 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 events `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 events `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 `00`).
4. Reset the event-fire cursor to that time so SFX dont spam.
### Scoreboard without scrubbing
Start at `00`. On each `goal` event, set scores from that events `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
+50
View File
@@ -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>
);
}
+9
View File
@@ -564,6 +564,15 @@ export function AdminDashboard({
System logs System logs
</Link> </Link>
) : null} ) : null}
{pageAccess.matches || pageAccess.analysis ? (
<Link
href="/replays"
className={tabClass(false)}
scroll={false}
>
Replays
</Link>
) : null}
{isAdmin ? ( {isAdmin ? (
<Link <Link
href="/settings" href="/settings"
+704
View File
@@ -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/&#123;matchId&#125;_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>
);
}
+1
View File
@@ -10,6 +10,7 @@ const PAGE_LABELS: Record<string, string> = {
"ledger-book": "Ledger book", "ledger-book": "Ledger book",
"match-log": "Match log", "match-log": "Match log",
"matchmaker-logs": "Matchmaker logs", "matchmaker-logs": "Matchmaker logs",
"replay-viewer": "Replay viewer",
}; };
export function pageAccessLabel(page: string): string { export function pageAccessLabel(page: string): string {
+147
View File
@@ -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);
}
+150
View File
@@ -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 };
+99
View File
@@ -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),
};