Files
kickkingsadmin/src/components/replay-viewer.tsx
T
2026-09-06 14:04:16 +00:00

707 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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[]>(
initialReplay ? discontinuityTimes(initialReplay.events) : [],
);
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>(initialReplay);
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;.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 ?? `${replay.matchId}.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>
);
}