476 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|