"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { PatchesPuzzle, Region } from "@/lib/generators/patches"; import WinBanner from "./WinBanner"; interface Props { puzzle: PatchesPuzzle; date: string; onSolve?: (elapsed: number) => void; } const MAX_CELL = 72; const STORAGE_KEY = (d: string) => `patches-${d}`; /** Tiny pixel-art preview of the polyomino shape. */ function ShapePreview({ relCells, previewRows, previewCols, color, cellPx, }: { relCells: [number, number][]; previewRows: number; previewCols: number; color: string; cellPx: number; }) { const filled = new Set(relCells.map(([r, c]) => `${r},${c}`)); const gap = 1; return (
{Array.from({ length: previewRows }, (_, r) => Array.from({ length: previewCols }, (_, c) => (
)) )}
); } function checkWin(userGrid: number[][], puzzle: PatchesPuzzle): boolean { const { size, grid } = puzzle; for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (userGrid[r][c] !== grid[r][c]) return false; return true; } export default function PatchesBoard({ puzzle, date, onSolve }: Props) { const { size, regions, grid } = puzzle; const [CELL, setCELL] = useState(() => typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) ); const hintCellSet = useMemo( () => new Set(regions.map(reg => `${reg.hintCell[0]},${reg.hintCell[1]}`)), [regions] ); const regionById = useMemo( () => new Map(regions.map(reg => [reg.id, reg])), [regions] ); const hintCellToRegion = useMemo(() => { const m = new Map(); for (const reg of regions) m.set(`${reg.hintCell[0]},${reg.hintCell[1]}`, reg); return m; }, [regions]); const initGrid = useCallback((): number[][] => { if (typeof window !== "undefined") { const s = localStorage.getItem(STORAGE_KEY(date)); if (s) return JSON.parse(s); } const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1)); for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id; return g; }, [date, regions, size]); const [userGrid, setUserGrid] = useState(initGrid); const [activeId, setActiveId] = useState(null); // active region for painting const [won, setWon] = useState(false); const [elapsed, setElapsed] = useState(0); const [t0] = useState(() => Date.now()); const history = useRef([]); // Painting state const painting = useRef(false); const paintMode = useRef<"add" | "erase">("add"); const boardRef = useRef(null); const userGridSnap = useRef(userGrid); useEffect(() => { userGridSnap.current = userGrid; }, [userGrid]); useEffect(() => { const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [size]); useEffect(() => { if (won) return; const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500); return () => clearInterval(id); }, [won, t0]); useEffect(() => { localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userGrid)); if (!won && checkWin(userGrid, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } }, [userGrid, date, puzzle, won, onSolve, t0]); const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; function getCellFromPoint(clientX: number, clientY: number): [number, number] | null { if (!boardRef.current) return null; const rect = boardRef.current.getBoundingClientRect(); const r = Math.floor((clientY - rect.top) / CELL); const c = Math.floor((clientX - rect.left) / CELL); return (r >= 0 && r < size && c >= 0 && c < size) ? [r, c] : null; } /** Paint or erase a single cell for the active region. */ const paintCell = useCallback((r: number, c: number) => { if (activeId === null || won) return; const key = `${r},${c}`; // Don't erase hint cells if (hintCellSet.has(key)) return; setUserGrid(prev => { const next = prev.map(row => [...row]); if (paintMode.current === "erase") { if (next[r][c] === activeId) next[r][c] = -1; } else { // Only paint if empty or belongs to a different non-hint region (replace) if (next[r][c] === -1 || (next[r][c] !== activeId && !hintCellSet.has(key))) { // Remove from old region if any next[r][c] = activeId; } else if (next[r][c] === activeId) { // Toggle off next[r][c] = -1; } } return next; }); }, [activeId, won, hintCellSet]); const stopPainting = useCallback(() => { painting.current = false; }, []); useEffect(() => { window.addEventListener("mouseup", stopPainting); window.addEventListener("touchend", stopPainting); return () => { window.removeEventListener("mouseup", stopPainting); window.removeEventListener("touchend", stopPainting); }; }, [stopPainting]); const undo = useCallback(() => { if (won || history.current.length === 0) return; setUserGrid(history.current.pop()!); }, [won]); useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [undo]); const handleCellMouseDown = useCallback((r: number, c: number, e: React.MouseEvent) => { if (won) return; e.preventDefault(); const key = `${r},${c}`; const isHint = hintCellSet.has(key); if (isHint) { // Click hint cell → activate/deactivate region const reg = hintCellToRegion.get(key); if (reg) setActiveId(prev => prev === reg.id ? null : reg.id); return; } // Non-hint cell: need active region if (activeId === null) { // Try to detect which region the cell belongs to and erase it const cur = userGridSnap.current[r][c]; if (cur !== -1) { history.current.push(userGridSnap.current.map(row => [...row])); setUserGrid(prev => { const next = prev.map(row => [...row]); for (let rr = 0; rr < size; rr++) for (let cc = 0; cc < size; cc++) if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1; return next; }); } return; } // Determine paint mode: if cell already belongs to active region → erase, else → add const cur = userGridSnap.current[r][c]; paintMode.current = cur === activeId ? "erase" : "add"; painting.current = true; history.current.push(userGridSnap.current.map(row => [...row])); paintCell(r, c); }, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!painting.current || won || activeId === null) return; const cell = getCellFromPoint(e.clientX, e.clientY); if (cell) paintCell(cell[0], cell[1]); }, [won, activeId, paintCell]); const handleTouchStart = useCallback((e: React.TouchEvent) => { if (won) return; e.preventDefault(); const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (!cell) return; const [r, c] = cell; const key = `${r},${c}`; const isHint = hintCellSet.has(key); if (isHint) { const reg = hintCellToRegion.get(key); if (reg) setActiveId(prev => prev === reg.id ? null : reg.id); return; } if (activeId === null) { const cur = userGridSnap.current[r][c]; if (cur !== -1) { setUserGrid(prev => { const next = prev.map(row => [...row]); for (let rr = 0; rr < size; rr++) for (let cc = 0; cc < size; cc++) if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1; return next; }); } return; } const cur = userGridSnap.current[r][c]; paintMode.current = cur === activeId ? "erase" : "add"; painting.current = true; paintCell(r, c); }, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (!painting.current || won || activeId === null) return; e.preventDefault(); const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY); if (cell) paintCell(cell[0], cell[1]); }, [won, activeId, paintCell]); const handleHint = useCallback(() => { if (won) return; // Find first region not correctly placed, reveal one correct cell for (const reg of regions) { const correct = reg.cells.every(([r, c]) => userGridSnap.current[r][c] === reg.id); if (!correct) { // Activate this region and fill one missing correct cell setActiveId(reg.id); for (const [r, c] of reg.cells) { if (userGridSnap.current[r][c] !== reg.id) { setUserGrid(prev => { const next = prev.map(row => [...row]); next[r][c] = reg.id; return next; }); return; } } } } }, [won, regions]); const reset = () => { history.current = []; const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1)); for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id; setUserGrid(g); setActiveId(null); setWon(false); }; const filled = userGrid.flat().filter(v => v !== -1).length; // How many cells each region needs (for progress indicator inside hint cell) const regionCorrectCount = useMemo(() => { const m = new Map(); for (const reg of regions) { const correct = reg.cells.filter(([r, c]) => userGrid[r][c] === reg.id).length; m.set(reg.id, correct); } return m; }, [userGrid, regions]); return (
{filled} / {size * size} {fmt(elapsed)}
{won && } {/* Board */}
{ painting.current = false; }} onMouseLeave={() => { painting.current = false; }} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={() => { painting.current = false; }} > {Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => { const key = `${r},${c}`; const isHint = hintCellSet.has(key); const assignedId = userGrid[r][c]; const region = assignedId !== -1 ? regionById.get(assignedId) : undefined; const hintRegion = isHint ? hintCellToRegion.get(key) : undefined; const isActive = hintRegion ? hintRegion.id === activeId : (region?.id === activeId && activeId !== null); // Background let bg: string; if (region) { bg = region.color; } else if (isHint && hintRegion) { bg = hintRegion.color; } else { bg = "#f3f4f6"; } // Opacity: if cell belongs to active region and is not a hint → show at 80% const opacity = !isHint && assignedId === activeId && activeId !== null ? 0.75 : 1; // Seamless borders within same region const sameAs = (dr: number, dc: number) => { const nr = r + dr, nc = c + dc; if (nr < 0 || nr >= size || nc < 0 || nc >= size) return false; return userGrid[nr][nc] === assignedId && assignedId !== -1; }; const seamless = `1px solid ${bg}`; const bTop = sameAs(-1, 0) ? seamless : "1px solid #9ca3af"; const bBottom = sameAs( 1, 0) ? seamless : "1px solid #9ca3af"; const bLeft = sameAs( 0,-1) ? seamless : "1px solid #9ca3af"; const bRight = sameAs( 0, 1) ? seamless : "1px solid #9ca3af"; // Hint cell: active ring const outline = isHint && activeId === hintRegion?.id ? "3px dashed rgba(255,255,255,0.9)" : undefined; // Hint cell preview size: fit shape in ~65% of cell const maxDim = hintRegion ? Math.max(hintRegion.previewRows, hintRegion.previewCols) : 1; const miniCellPx = Math.max(4, Math.floor((CELL * 0.6) / maxDim)); return (
handleCellMouseDown(r, c, e)} style={{ width: CELL, height: CELL, backgroundColor: bg, opacity, borderTop: bTop, borderBottom: bBottom, borderLeft: bLeft, borderRight: bRight, boxSizing: "border-box", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 2, transition: "background-color 0.06s, opacity 0.06s", position: "relative", outline, outlineOffset: "-3px", cursor: isHint ? "pointer" : "crosshair", zIndex: isHint ? 1 : 0, }} > {isHint && hintRegion && ( <> {regionCorrectCount.get(hintRegion.id) ?? 0}/{hintRegion.size} )}
); }) )}
{activeId !== null && !won && (

Région active — peignez les {regionById.get(activeId)?.size} cases

)}

Cliquez sur une icône pour activer sa région, puis peignez les cases. La forme en aperçu indique la disposition à reproduire.

{!won && ( <> )}
); }