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

357 lines
13 KiB
TypeScript

"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 (
<div
style={{
display: "inline-grid",
gridTemplateColumns: `repeat(${previewCols}, ${cellPx}px)`,
gridTemplateRows: `repeat(${previewRows}, ${cellPx}px)`,
gap: 1.5,
opacity: faded ? 0.35 : 1,
}}
>
{Array.from({ length: previewRows }, (_, r) =>
Array.from({ length: previewCols }, (_, c) => (
<div
key={`${r}-${c}`}
style={{
width: cellPx,
height: cellPx,
backgroundColor: filled.has(`${r},${c}`) ? color : "transparent",
borderRadius: 2,
}}
/>
))
)}
</div>
);
}
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<number, Region>(regions.map(r => [r.id, r])), [regions]);
// Canonical shape key per region
const shapeKeys = useMemo(() => {
const m = new Map<number, string>();
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<slotRegionId, pieceRegionId>
const [placement, setPlacement] = useState<Map<number, number>>(() => {
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<number | null>(null);
const [errorSlot, setErrorSlot] = useState<number | null>(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 (
<div className="flex flex-col items-center gap-5">
{/* Header row */}
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{placement.size} / {regions.length} pièces</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="patches" date={date} elapsed={elapsed} />}
{/* Grid */}
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
gridTemplateRows: `repeat(${size}, ${CELL}px)`,
userSelect: "none",
}}
>
{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 (
<div
key={`${r}-${c}`}
onClick={() => 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,
}}
/>
);
})
)}
</div>
{/* Selected piece label */}
{selectedId !== null && !won ? (
<p className="text-sm font-medium" style={{ color: regionById.get(selectedId)?.color }}>
Pièce sélectionnée touche la zone correspondante
</p>
) : !won ? (
<p className="text-xs text-gray-400">
Sélectionne une pièce, puis place-la dans la zone qui lui correspond
</p>
) : null}
{/* Piece tray */}
<div className="w-full overflow-x-auto pb-1">
<div
className="flex gap-3 px-2 py-1"
style={{ width: "max-content", margin: "0 auto" }}
>
{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 (
<button
key={piece.id}
onClick={() => !isPlaced && handleTrayTap(piece.id)}
aria-pressed={isSelected}
style={{
padding: "10px",
borderRadius: 14,
border: isSelected
? `2.5px solid ${piece.color}`
: "2px solid #e5e7eb",
background: isSelected
? `${piece.color}18`
: isPlaced ? "#f5f5f5" : "white",
opacity: isPlaced ? 0.3 : 1,
transform: isSelected ? "scale(1.10) translateY(-4px)" : "scale(1)",
transition: "transform 0.15s, box-shadow 0.15s, opacity 0.2s, border-color 0.15s",
boxShadow: isSelected ? `0 4px 14px ${piece.color}50` : "none",
cursor: isPlaced ? "default" : "pointer",
pointerEvents: isPlaced ? "none" : "auto",
flexShrink: 0,
minWidth: 48,
minHeight: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ShapePreview
relCells={piece.relCells}
previewRows={piece.previewRows}
previewCols={piece.previewCols}
color={piece.color}
cellPx={cellPx}
faded={isPlaced}
/>
</button>
);
})}
</div>
</div>
{/* Controls */}
{!won && (
<div className="flex gap-3">
<button
onClick={undoLast}
disabled={placement.size === 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"
>
Annuler
</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>
);
}