puzzle-trainer/PatchesBoard.tsx
2026-05-23 01:05:21 +00:00

476 lines
17 KiB
TypeScript

"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 (
<div style={{
display: "grid",
gridTemplateColumns: `repeat(${previewCols}, ${cellPx}px)`,
gridTemplateRows: `repeat(${previewRows}, ${cellPx}px)`,
gap,
pointerEvents: "none",
}}>
{Array.from({ length: previewRows }, (_, r) =>
Array.from({ length: previewCols }, (_, c) => (
<div
key={`${r}-${c}`}
style={{
width: cellPx,
height: cellPx,
backgroundColor: filled.has(`${r},${c}`) ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,0.2)",
borderRadius: 1,
}}
/>
))
)}
</div>
);
}
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<number, Region>(regions.map(reg => [reg.id, reg])),
[regions]
);
const hintCellToRegion = useMemo(() => {
const m = new Map<string, Region>();
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<number[][]>(initGrid);
const [activeId, setActiveId] = useState<number | null>(null); // active region for painting
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const history = useRef<number[][][]>([]);
// Painting state
const painting = useRef(false);
const paintMode = useRef<"add" | "erase">("add");
const boardRef = useRef<HTMLDivElement>(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<number, number>();
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 (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{filled} / {size * size}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="patches" date={date} elapsed={elapsed} />}
{/* Board */}
<div
ref={boardRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "2px solid #374151",
borderRadius: 6,
overflow: "hidden",
touchAction: "none",
userSelect: "none",
cursor: won ? "default" : "crosshair",
}}
onMouseMove={handleMouseMove}
onMouseUp={() => { 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 (
<div
key={key}
onMouseDown={(e) => 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 && (
<>
<ShapePreview
relCells={hintRegion.relCells}
previewRows={hintRegion.previewRows}
previewCols={hintRegion.previewCols}
color={hintRegion.color}
cellPx={miniCellPx}
/>
<span style={{
fontSize: Math.max(9, CELL * 0.18),
fontWeight: 800,
color: "rgba(255,255,255,0.95)",
textShadow: "0 1px 3px rgba(0,0,0,0.4)",
lineHeight: 1,
pointerEvents: "none",
}}>
{regionCorrectCount.get(hintRegion.id) ?? 0}/{hintRegion.size}
</span>
</>
)}
</div>
);
})
)}
</div>
{activeId !== null && !won && (
<p className="text-sm font-medium" style={{ color: regionById.get(activeId)?.color ?? "#6b7280" }}>
Région active peignez les {regionById.get(activeId)?.size} cases
</p>
)}
<p className="text-xs text-gray-400 text-center max-w-[320px]">
Cliquez sur une icône pour activer sa région, puis peignez les cases. La forme en aperçu indique la disposition à reproduire.
</p>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
Annuler
</button>
<button onClick={handleHint}
className="px-4 py-2 rounded-xl border border-amber-200 text-amber-600 hover:bg-amber-50 text-sm transition-colors">
Indice
</button>
</>
)}
<button
onClick={reset}
className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors"
>
Recommencer
</button>
</div>
</div>
);
}