"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(initialReplay); const [fileName, setFileName] = useState(initialFileName); const [error, setError] = useState(null); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); const [time, setTime] = useState(0); const [score, setScore] = useState({ red: 0, blue: 0 }); const [dragOver, setDragOver] = useState(false); const canvasRef = useRef(null); const cutsRef = useRef( initialReplay ? discontinuityTimes(initialReplay.events) : [], ); const timeRef = useRef(0); const playingRef = useRef(false); const speedRef = useRef(1); const lastTsRef = useRef(null); const eventCursorRef = useRef(0); const replayRef = useRef(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 (
{!replay ? ( ) : ( <>

{fileName ?? `${replay.matchId}.json`}

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

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

{error}

) : null}
{/* Event markers */}
{goalEvents.map((e, i) => ( 0 ? (e.t / replay.duration) * 100 : 0}%`, backgroundColor: eventMarkerColor(e), }} /> ))}
{ setPlaying(false); seekTo(Number(e.target.value)); }} className="w-full accent-emerald-500" aria-label="Replay timeline" />
{formatReplayTime(time)} / {formatReplayTime(replay.duration)}
Speed {SPEEDS.map((s) => ( ))}
{goalEvents.length > 0 ? (
Goals {goalEvents.map((e, i) => { const scorer = e.team === "Red" ? "Blue" : e.team === "Blue" ? "Red" : "?"; return ( ); })}
) : null}
)}
); }