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
+9
View File
@@ -564,6 +564,15 @@ export function AdminDashboard({
System logs
</Link>
) : null}
{pageAccess.matches || pageAccess.analysis ? (
<Link
href="/replays"
className={tabClass(false)}
scroll={false}
>
Replays
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
+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>
);
}