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

555 lines
22 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { QueensPuzzle } from "@/lib/generators/queens";
import WinBanner from "./WinBanner";
interface Props {
puzzle: QueensPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const REGION_COLORS = [
{ bg: "#c8b8e8", border: "#9870c8" },
{ bg: "#f5c898", border: "#d4986a" },
{ bg: "#a8c8f0", border: "#6098d8" },
{ bg: "#b8d8b0", border: "#78b870" },
{ bg: "#f5a898", border: "#d86858" },
{ bg: "#d8d8d8", border: "#a8a8a8" },
{ bg: "#d8e888", border: "#b0c840" },
{ bg: "#c8b8a8", border: "#a89878" },
{ bg: "#f0b8c8", border: "#d880a0" },
{ bg: "#a8e0d8", border: "#60b8b0" },
];
type CellState = "empty" | "queen" | "mark";
interface HintInfo {
explanation: string;
// cells highlighted in blue (the "why" context)
focusCells: Set<string>;
// cells highlighted in green (queen to place) or red (cells to eliminate)
actionCells: Set<string>;
action:
| { kind: "queen"; r: number; c: number }
| { kind: "marks"; cells: [number, number][] };
}
function CrownIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 18h18v2H3v-2zm1.5-12L8 10.5 12 4l4 6.5 3.5-4.5L22 16H2L5.5 6z" />
</svg>
);
}
function XIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
);
}
function checkWin(board: CellState[][], puzzle: QueensPuzzle): boolean {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
if (queens.length !== size) return false;
const rows = new Set<number>(), cols = new Set<number>(), regs = new Set<number>();
for (const [r, c] of queens) {
if (rows.has(r) || cols.has(c) || regs.has(regions[r][c])) return false;
rows.add(r); cols.add(c); regs.add(regions[r][c]);
}
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) return false;
return true;
}
function getErrors(board: CellState[][], puzzle: QueensPuzzle): Set<string> {
const { size, regions } = puzzle;
const errors = new Set<string>();
const queens: [number, number][] = [];
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") queens.push([r, c]);
const byRow = new Map<number, number[]>(), byCol = new Map<number, number[]>(), byReg = new Map<number, number[]>();
for (const [r, c] of queens) {
const reg = regions[r][c];
byRow.set(r, [...(byRow.get(r) || []), c]);
byCol.set(c, [...(byCol.get(c) || []), r]);
byReg.set(reg, [...(byReg.get(reg) || []), r * size + c]);
}
for (const [r, cs] of byRow) if (cs.length > 1) cs.forEach(c => errors.add(`${r},${c}`));
for (const [c, rs] of byCol) if (rs.length > 1) rs.forEach(r => errors.add(`${r},${c}`));
for (const [, cells] of byReg) if (cells.length > 1) cells.forEach(idx => errors.add(`${Math.floor(idx / size)},${idx % size}`));
for (let i = 0; i < queens.length; i++)
for (let j = i + 1; j < queens.length; j++)
if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) {
errors.add(`${queens[i][0]},${queens[i][1]}`);
errors.add(`${queens[j][0]},${queens[j][1]}`);
}
return errors;
}
// Logical hint finder: detects which rule applies and explains the reasoning
function findLogicalHint(board: CellState[][], puzzle: QueensPuzzle): HintInfo | null {
const { size, regions } = puzzle;
const queens: [number, number][] = [];
const queenRows = new Set<number>();
const queenCols = new Set<number>();
const queenRegs = new Set<number>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (board[r][c] === "queen") {
queens.push([r, c]);
queenRows.add(r);
queenCols.add(c);
queenRegs.add(regions[r][c]);
}
// A cell is "possible" if no placed queen or user mark excludes it
function isPossible(r: number, c: number): boolean {
if (board[r][c] === "queen" || board[r][c] === "mark") return false;
if (queenRows.has(r) || queenCols.has(c) || queenRegs.has(regions[r][c])) return false;
for (const [qr, qc] of queens)
if (Math.abs(r - qr) <= 1 && Math.abs(c - qc) <= 1) return false;
return true;
}
const possSet = new Set<string>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (isPossible(r, c)) possSet.add(`${r},${c}`);
// Group all cells by region
const byRegion = new Map<number, [number, number][]>();
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++) {
const reg = regions[r][c];
if (!byRegion.has(reg)) byRegion.set(reg, []);
byRegion.get(reg)!.push([r, c]);
}
// Rule 1: A region has exactly one possible cell → forced queen
for (const [reg, cells] of byRegion) {
if (queenRegs.has(reg)) continue;
const poss = cells.filter(([r, c]) => possSet.has(`${r},${c}`));
if (poss.length === 1) {
const [r, c] = poss[0];
return {
explanation: "Cette zone colorée (en bleu) n'a plus qu'une seule case disponible (en vert). Toutes les autres ont été éliminées par les couronnes déjà posées. La couronne de cette zone doit obligatoirement s'y trouver.",
focusCells: new Set(cells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set([`${r},${c}`]),
action: { kind: "queen", r, c },
};
}
}
// Rule 2: A row has exactly one possible cell → forced queen
for (let r = 0; r < size; r++) {
if (queenRows.has(r)) continue;
const poss: [number, number][] = [];
for (let c = 0; c < size; c++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La ligne ${r + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette ligne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, c) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rule 3: A column has exactly one possible cell → forced queen
for (let c = 0; c < size; c++) {
if (queenCols.has(c)) continue;
const poss: [number, number][] = [];
for (let r = 0; r < size; r++) if (possSet.has(`${r},${c}`)) poss.push([r, c]);
if (poss.length === 1) {
const [pr, pc] = poss[0];
return {
explanation: `La colonne ${c + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette colonne n'a pas d'autre choix.`,
focusCells: new Set(Array.from({ length: size }, (_, r) => `${r},${c}`)),
actionCells: new Set([`${pr},${pc}`]),
action: { kind: "queen", r: pr, c: pc },
};
}
}
// Rules 4+: Generalized naked subset — N regions confined to same N rows or cols
// N=1: locked region, N=2: naked pair, N=3: naked triple, N=4: naked quad
function* genCombinations<T>(arr: T[], n: number, start = 0): Generator<T[]> {
if (n === 0) { yield []; return; }
for (let i = start; i <= arr.length - n; i++)
for (const rest of genCombinations(arr, n - 1, i + 1))
yield [arr[i], ...rest];
}
const unsolved = [...byRegion.entries()].filter(([reg, cells]) =>
!queenRegs.has(reg) && cells.some(([r, c]) => possSet.has(`${r},${c}`))
);
for (let n = 1; n <= unsolved.length - 1; n++) {
for (const combo of genCombinations(unsolved, n)) {
const regSet = new Set(combo.map(([reg]) => reg));
const possCells = combo.flatMap(([, cells]) => cells.filter(([r, c]) => possSet.has(`${r},${c}`)));
if (possCells.length === 0) continue;
// Row subset
const rowSet = new Set(possCells.map(([r]) => r));
if (rowSet.size === n) {
const elimCells: [number, number][] = [];
for (const lr of rowSet)
for (let c = 0; c < size; c++)
if (possSet.has(`${lr},${c}`) && !regSet.has(regions[lr][c]))
elimCells.push([lr, c]);
if (elimCells.length > 0) {
const rowNames = [...rowSet].sort((a, b) => a - b).map(r => `ligne ${r + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent sur la ${rowNames}. La couronne de cette zone occupera forcément cette ligne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que sur les ${rowNames}. Ces lignes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
// Col subset
const colSet = new Set(possCells.map(([, c]) => c));
if (colSet.size === n) {
const elimCells: [number, number][] = [];
for (const lc of colSet)
for (let r = 0; r < size; r++)
if (possSet.has(`${r},${lc}`) && !regSet.has(regions[r][lc]))
elimCells.push([r, lc]);
if (elimCells.length > 0) {
const colNames = [...colSet].sort((a, b) => a - b).map(c => `colonne ${c + 1}`).join(" et ");
return {
explanation: n === 1
? `Toutes les cases disponibles de la zone bleue se trouvent dans la ${colNames}. La couronne de cette zone occupera forcément cette colonne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.`
: `Ces ${n} zones (en bleu) ne peuvent se placer que dans les ${colNames}. Ces colonnes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`,
focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)),
actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)),
action: { kind: "marks", cells: elimCells },
};
}
}
}
}
return null;
}
const STORAGE_KEY = (date: string) => `queens-v2-${date}`;
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)";
const MAX_CELL = 52;
export default function QueensBoard({ puzzle, date, onSolve }: Props) {
const { size, regions } = puzzle;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const [board, setBoard] = useState<CellState[][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return Array.from({ length: size }, () => Array(size).fill("empty"));
});
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const [hintInfo, setHintInfo] = useState<HintInfo | null>(null);
const history = useRef<CellState[][][]>([]);
const drag = useRef<{
action: "mark" | "clear";
origin: string;
originApplied: boolean;
lastCell: string;
moved: boolean;
} | null>(null);
const boardSnap = useRef(board);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => { boardSnap.current = board; }, [board]);
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(board));
if (!won && checkWin(board, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [board, date, puzzle, won, onSolve, t0]);
useEffect(() => {
const stop = () => { drag.current = null; };
window.addEventListener("pointerup", stop);
return () => window.removeEventListener("pointerup", stop);
}, []);
const errors = won ? new Set<string>() : getErrors(board, puzzle);
const queensPlaced = board.flat().filter(c => c === "queen").length;
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const applyDragToCell = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
const { action } = drag.current;
setBoard(prev => {
const cur = prev[r][c];
if (action === "mark" && cur === "empty") {
const next = prev.map(row => [...row]); next[r][c] = "mark"; return next;
}
if (action === "clear" && cur === "mark") {
const next = prev.map(row => [...row]); next[r][c] = "empty"; return next;
}
return prev;
});
}, [won]);
const undo = useCallback(() => {
if (won || history.current.length === 0) return;
setBoard(history.current.pop()!);
setHintInfo(null);
}, [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 handlePointerDown = useCallback((r: number, c: number, e: React.PointerEvent) => {
if (won) return;
e.preventDefault();
history.current.push(boardSnap.current.map(row => [...row]));
setHintInfo(null);
const cur = boardSnap.current[r][c];
const action: "mark" | "clear" = cur === "mark" ? "clear" : "mark";
drag.current = { action, origin: `${r},${c}`, originApplied: false, lastCell: `${r},${c}`, moved: false };
}, [won]);
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!drag.current || won) return;
if (!gridRef.current) return;
const rect = gridRef.current.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (r < 0 || r >= size || c < 0 || c >= size) return;
const key = `${r},${c}`;
if (key === drag.current.lastCell) return;
// On first move to a different cell, also apply action to origin
if (!drag.current.originApplied) {
const [or, oc] = drag.current.origin.split(",").map(Number);
applyDragToCell(or, oc);
drag.current.originApplied = true;
}
drag.current.moved = true;
drag.current.lastCell = key;
applyDragToCell(r, c);
}, [won, applyDragToCell, size, CELL]);
const handlePointerUp = useCallback((r: number, c: number) => {
if (!drag.current || won) return;
if (!drag.current.moved) {
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = next[r][c] === "empty" ? "mark" : next[r][c] === "mark" ? "queen" : "empty";
return next;
});
}
drag.current = null;
}, [won]);
const reset = () => { history.current = []; setBoard(Array.from({ length: size }, () => Array(size).fill("empty"))); setWon(false); setHintInfo(null); };
const handleHint = () => {
if (won) return;
if (hintInfo) { setHintInfo(null); return; } // toggle off
const hint = findLogicalHint(boardSnap.current, puzzle);
if (hint) {
setHintInfo(hint);
}
};
const applyHint = () => {
if (!hintInfo) return;
if (hintInfo.action.kind === "queen") {
const { r, c } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
next[r][c] = "queen";
return next;
});
} else {
const { cells } = hintInfo.action;
setBoard(prev => {
const next = prev.map(row => [...row]);
for (const [r, c] of cells) if (next[r][c] === "empty") next[r][c] = "mark";
return next;
});
}
setHintInfo(null);
};
return (
<div className="flex flex-col items-center gap-4 w-full">
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
<span>{queensPlaced} / {size} reines</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="queens" date={date} elapsed={elapsed} />}
{/* Hint explanation panel */}
{hintInfo && (
<div className="w-full max-w-xs bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-900 shadow-sm">
<p className="mb-3 leading-relaxed">{hintInfo.explanation}</p>
<div className="flex gap-2">
{hintInfo.actionCells.size > 0 && (
<button
onClick={applyHint}
className="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 transition-colors"
>
Appliquer
</button>
)}
<button
onClick={() => setHintInfo(null)}
className={`flex-1 py-1.5 rounded-lg border border-blue-200 text-blue-600 text-xs font-semibold hover:bg-blue-100 transition-colors`}
>
Fermer
</button>
</div>
</div>
)}
{/* Board */}
<div
ref={gridRef}
onPointerMove={handlePointerMove}
style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "2px solid #1a1a1a",
borderRadius: 4,
overflow: "hidden",
touchAction: "none",
}}
>
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const reg = regions[r][c];
const state = board[r][c];
const err = errors.has(`${r},${c}`);
const color = REGION_COLORS[reg % REGION_COLORS.length];
const key = `${r},${c}`;
const bTop = r > 0 && regions[r - 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bLeft = c > 0 && regions[r][c - 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bBottom = r < size - 1 && regions[r + 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
const bRight = c < size - 1 && regions[r][c + 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a";
// Hint highlighting overrides normal bg
let bg = color.bg;
const bgImage = err ? DIAGONAL_ERROR : undefined;
if (!err && hintInfo) {
if (hintInfo.action.kind === "queen" && hintInfo.actionCells.has(key)) {
bg = "#bbf7d0"; // green: forced queen
} else if (hintInfo.action.kind === "marks" && hintInfo.actionCells.has(key)) {
bg = "#fecaca"; // red: cells to eliminate
} else if (hintInfo.focusCells.has(key)) {
bg = "#bfdbfe"; // blue: context cells
}
}
return (
<div
key={key}
onPointerDown={(e) => handlePointerDown(r, c, e)}
onPointerUp={() => handlePointerUp(r, c)}
style={{
width: CELL, height: CELL,
backgroundColor: bg,
backgroundImage: bgImage,
borderTop: bTop, borderLeft: bLeft, borderBottom: bBottom, borderRight: bRight,
cursor: won ? "default" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
userSelect: "none",
transition: "background-color 0.12s",
}}
>
{state === "queen" && (
<span style={{ color: err ? "#dc2626" : "#1a1a1a" }}>
<CrownIcon size={CELL * 0.48} />
</span>
)}
{state === "mark" && (
<span style={{ color: "#6b7280", opacity: 0.6 }}>
<XIcon size={CELL * 0.4} />
</span>
)}
</div>
);
})
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = couronne · glisser = en série</span>
</div>
<div className="flex gap-3">
{!won && (
<button
onClick={handleHint}
className={`px-4 py-2 rounded-xl border text-sm transition-colors ${hintInfo ? "border-blue-300 bg-blue-50 text-blue-700" : "border-amber-200 text-amber-600 hover:bg-amber-50"}`}
>
{hintInfo ? "Masquer" : "Indice"}
</button>
)}
<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={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>
<p className="text-xs text-gray-400 text-center max-w-xs">
Une couronne par ligne, par colonne et par zone colorée. Les couronnes ne peuvent pas se toucher.
</p>
</div>
);
}