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

253 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { SudokuPuzzle } from "@/lib/generators/sudoku";
import WinBanner from "./WinBanner";
interface Props {
puzzle: SudokuPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const MAX_CELL = 64;
const STORAGE_KEY = (d: string) => `sudoku-${d}`;
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
function checkWin(grid: number[][], solution: number[][]): boolean {
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (grid[r][c] !== solution[r][c]) return false;
return true;
}
function getErrors(grid: number[][], given: number[][]): Set<string> {
const errors = new Set<string>();
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 6; c++) {
const v = grid[r][c];
if (!v || given[r][c]) continue;
// Row
for (let cc = 0; cc < 6; cc++) if (cc !== c && grid[r][cc] === v) { errors.add(`${r},${c}`); errors.add(`${r},${cc}`); }
// Col
for (let rr = 0; rr < 6; rr++) if (rr !== r && grid[rr][c] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${c}`); }
// Box
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
for (let dr = 0; dr < 2; dr++)
for (let dc = 0; dc < 3; dc++) {
const rr = br + dr, cc = bc + dc;
if ((rr !== r || cc !== c) && grid[rr][cc] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${cc}`); }
}
}
}
return errors;
}
export default function SudokuBoard({ puzzle, date, onSolve }: Props) {
const { given, solution } = puzzle;
const size = 6;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [grid, setGrid] = useState<number[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return given.map(r => [...r]);
});
const [selected, setSelected] = useState<[number, number] | null>(null);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const history = useRef<number[][][]>([]);
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(grid));
if (!won && checkWin(grid, solution)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [grid, date, solution, won, onSolve]);
const errors = won ? new Set<string>() : getErrors(grid, given);
const filled = grid.flat().filter(Boolean).length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const input = useCallback((val: number) => {
if (!selected || won) return;
const [r, c] = selected;
if (given[r][c]) return;
setGrid(prev => {
history.current.push(prev.map(row => [...row]));
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === val ? 0 : val;
return next;
});
}, [selected, won, given]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
const prev = history.current.pop()!;
setGrid(prev);
}, [won]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const n = parseInt(e.key);
if (n >= 1 && n <= 6) input(n);
if (e.key === "Backspace" || e.key === "Delete" || e.key === "0") input(0);
if ((e.ctrlKey || e.metaKey) && e.key === "z") undo();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [input, undo]);
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setSelected(null); setWon(false); };
const handleHint = useCallback(() => {
if (won) return;
// Find first empty non-given cell
for (let r = 0; r < 6; r++)
for (let c = 0; c < 6; c++)
if (!given[r][c] && grid[r][c] === 0) {
history.current.push(grid.map(row => [...row]));
setGrid(prev => {
const next = prev.map(row => [...row]);
next[r][c] = solution[r][c];
return next;
});
setSelected([r, c]);
return;
}
}, [won, given, grid, solution]);
const [selR, selC] = selected ?? [-1, -1];
// Box borders: thick between 2x3 boxes
const boxBorderB = (r: number) => (r === 1 || r === 3) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
const boxBorderR = (c: number) => (c === 2) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
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} / 36</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="sudoku" date={date} elapsed={elapsed} />}
{/* Grid */}
<div style={{ border: "3px solid #1a1a1a", display: "inline-block", borderRadius: 4 }}>
{Array.from({ length: 6 }, (_, r) => (
<div key={r} style={{ display: "flex" }}>
{Array.from({ length: 6 }, (_, c) => {
const val = grid[r][c];
const isGiven = !!given[r][c];
const isSelected = r === selR && c === selC;
const isSameVal = selected && val && grid[selR]?.[selC] === val && !isSelected;
const isHighlighted = selected && !isSelected && (r === selR || c === selC || (Math.floor(r / 2) === Math.floor(selR / 2) && Math.floor(c / 3) === Math.floor(selC / 3)));
const err = errors.has(`${r},${c}`);
let bg = "#ffffff";
const bgImage: string | undefined = err ? DIAGONAL_ERROR : undefined;
if (isSelected) bg = "#ccfbf1";
else if (isSameVal) bg = "#99f6e4";
else if (isHighlighted) bg = "#f0fdfa";
const selectedBorder = isSelected ? "2px solid #0d9488" : undefined;
return (
<div
key={c}
onClick={() => !won && setSelected([r, c])}
style={{
width: CELL,
height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderBottom: selectedBorder ?? boxBorderB(r),
borderRight: selectedBorder ?? boxBorderR(c),
borderTop: isSelected ? "2px solid #10b981" : undefined,
borderLeft: isSelected ? "2px solid #10b981" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: isGiven || won ? "default" : "pointer",
fontSize: CELL * 0.42,
fontWeight: isGiven ? 700 : 500,
color: err ? "#dc2626" : isGiven ? "#1a1a1a" : "#1d4ed8",
transition: "background-color 0.1s",
userSelect: "none",
position: "relative",
zIndex: isSelected ? 1 : 0,
boxSizing: "border-box",
}}
>
{val || ""}
</div>
);
})}
</div>
))}
</div>
{/* Number pad */}
{!won && (
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
onClick={() => input(n)}
className="w-10 h-10 rounded-lg border border-gray-300 text-gray-700 font-semibold text-base hover:bg-blue-50 hover:border-blue-300 transition-colors"
>
{n}
</button>
))}
<button
onClick={() => input(0)}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-xs hover:bg-gray-50 transition-colors"
title="Effacer"
>
</button>
<button
onClick={undo}
disabled={history.current.length === 0}
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-base hover:bg-gray-50 transition-colors disabled:opacity-30"
title="Annuler (Ctrl+Z)"
>
</button>
</div>
)}
<p className="text-xs text-gray-400 text-center max-w-[300px]">
Chiffres 16 : un seul par ligne, colonne et bloc 2×3. Clic + clavier ou pavé numérique.
</p>
<div className="flex gap-3">
{!won && (
<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>
);
}