357 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|