"use client"; import { useState, useEffect, useMemo, useCallback } from "react"; import { PatchesPuzzle, Region } from "@/lib/generators/patches"; import { createRng, shuffle } from "@/lib/rng"; import WinBanner from "./WinBanner"; interface Props { puzzle: PatchesPuzzle; date: string; onSolve?: (elapsed: number) => void; } const STORAGE_KEY = (d: string) => `patches-${d}`; /** Canonical key for shape comparison: sorted "r,c" list. */ function shapeKey(cells: [number, number][]): string { return cells .map(([r, c]) => `${r},${c}`) .sort() .join("|"); } /** Mini polyomino preview rendered as a small grid. */ function ShapePreview({ relCells, previewRows, previewCols, color, cellPx, faded, }: { relCells: [number, number][]; previewRows: number; previewCols: number; color: string; cellPx: number; faded?: boolean; }) { const filled = new Set(relCells.map(([r, c]) => `${r},${c}`)); return (
{Array.from({ length: previewRows }, (_, r) => Array.from({ length: previewCols }, (_, c) => (
)) )}
); } export default function PatchesBoard({ puzzle, date, onSolve }: Props) { const { size, regions, grid } = puzzle; /* ── Responsive cell size ───────────────────────────────────────────────── */ const [CELL, setCELL] = useState(() => typeof window === "undefined" ? 56 : Math.min(58, Math.floor((window.innerWidth - 40) / size)) ); useEffect(() => { const update = () => setCELL(Math.min(58, Math.floor((window.innerWidth - 40) / size))); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [size]); /* ── Lookup maps ────────────────────────────────────────────────────────── */ const regionById = useMemo(() => new Map(regions.map(r => [r.id, r])), [regions]); // Canonical shape key per region const shapeKeys = useMemo(() => { const m = new Map(); for (const reg of regions) m.set(reg.id, shapeKey(reg.relCells)); return m; }, [regions]); // Piece tray order — shuffled deterministically per date const trayPieces = useMemo(() => { const rng = createRng(date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 7777); return shuffle([...regions], rng); }, [regions, date]); /* ── State ──────────────────────────────────────────────────────────────── */ // placement: Map const [placement, setPlacement] = useState>(() => { if (typeof window !== "undefined") { try { const s = localStorage.getItem(STORAGE_KEY(date)); if (s) return new Map(JSON.parse(s) as [number, number][]); } catch { /* */ } } return new Map(); }); const [selectedId, setSelectedId] = useState(null); const [errorSlot, setErrorSlot] = useState(null); // regionId to flash red const [won, setWon] = useState(false); const [elapsed, setElapsed] = useState(0); const [t0] = useState(() => Date.now()); /* ── Timer ──────────────────────────────────────────────────────────────── */ useEffect(() => { if (won) return; const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500); return () => clearInterval(id); }, [won, t0]); /* ── Persist & win detection ────────────────────────────────────────────── */ useEffect(() => { localStorage.setItem(STORAGE_KEY(date), JSON.stringify([...placement])); if (!won && placement.size === regions.length) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } }, [placement, date, won, regions.length, onSolve, t0]); /* ── Helpers ────────────────────────────────────────────────────────────── */ const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; const placedPieceIds = useMemo(() => new Set(placement.values()), [placement]); /* ── Interaction ────────────────────────────────────────────────────────── */ const handleGridTap = useCallback((slotId: number) => { if (won) return; if (selectedId === null) { // No piece selected — remove piece from this slot (return to tray) if (placement.has(slotId)) { setPlacement(prev => { const m = new Map(prev); m.delete(slotId); return m; }); } return; } // Piece is selected if (placement.get(slotId) === selectedId) { // Tap the slot that already has this piece → deselect setSelectedId(null); return; } // Shape validation const slotShape = shapeKeys.get(slotId)!; const pieceShape = shapeKeys.get(selectedId)!; if (slotShape !== pieceShape) { // Wrong shape → error flash setErrorSlot(slotId); setTimeout(() => setErrorSlot(null), 500); return; } // Valid — place piece (move from any previous slot, replace any existing piece in target) setPlacement(prev => { const m = new Map(prev); // Remove selected piece from wherever it was for (const [slot, piece] of m) { if (piece === selectedId) { m.delete(slot); break; } } m.set(slotId, selectedId); return m; }); setSelectedId(null); }, [won, selectedId, placement, shapeKeys]); const handleTrayTap = useCallback((pieceId: number) => { if (won) return; setSelectedId(prev => prev === pieceId ? null : pieceId); }, [won]); const reset = () => { setPlacement(new Map()); setSelectedId(null); setWon(false); }; const undoLast = () => { if (placement.size === 0) return; const entries = [...placement]; setPlacement(new Map(entries.slice(0, -1))); }; /* ── Cell border style ──────────────────────────────────────────────────── */ function getCellStyle(r: number, c: number, slotId: number) { const THICK = "2.5px solid #1f2937"; const THIN = "1px solid #e5e7eb"; const top = r === 0 || grid[r - 1][c] !== slotId ? THICK : THIN; const bottom = r === size - 1 || grid[r + 1][c] !== slotId ? THICK : THIN; const left = c === 0 || grid[r][c - 1] !== slotId ? THICK : THIN; const right = c === size - 1 || grid[r][c + 1] !== slotId ? THICK : THIN; return { borderTop: top, borderBottom: bottom, borderLeft: left, borderRight: right }; } /* ── Render ─────────────────────────────────────────────────────────────── */ return (
{/* Header row */}
{placement.size} / {regions.length} pièces {fmt(elapsed)}
{won && } {/* Grid */}
{Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => { const slotId = grid[r][c]; const placedPieceId = placement.get(slotId); const placedRegion = placedPieceId !== undefined ? regionById.get(placedPieceId) : undefined; const isError = errorSlot === slotId; // Highlight: when a piece is selected, subtly mark slots with matching shape const selectedShape = selectedId !== null ? shapeKeys.get(selectedId) : null; const isMatchingSlot = selectedShape !== null && shapeKeys.get(slotId) === selectedShape && !placement.has(slotId); let bg = "#f9fafb"; if (placedRegion) bg = placedRegion.color; else if (isError) bg = "#fee2e2"; else if (isMatchingSlot) bg = "#eff6ff"; const borders = getCellStyle(r, c, slotId); return (
handleGridTap(slotId)} style={{ width: CELL, height: CELL, boxSizing: "border-box", backgroundColor: bg, ...borders, cursor: won ? "default" : "pointer", transition: "background-color 0.12s", animation: isError ? "shake 0.35s ease" : undefined, }} /> ); }) )}
{/* Selected piece label */} {selectedId !== null && !won ? (

Pièce sélectionnée — touche la zone correspondante

) : !won ? (

Sélectionne une pièce, puis place-la dans la zone qui lui correspond

) : null} {/* Piece tray */}
{trayPieces.map(piece => { const isPlaced = placedPieceIds.has(piece.id); const isSelected = selectedId === piece.id; const maxDim = Math.max(piece.previewRows, piece.previewCols); const cellPx = Math.min(14, Math.floor(52 / maxDim)); return ( ); })}
{/* Controls */} {!won && (
)}
); }