replay wip
This commit is contained in:
Generated
-65
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
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"
|
||||||
|
|||||||
@@ -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",
|
"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 {
|
||||||
|
|||||||
@@ -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