"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { TangoPuzzle, Cell } from "@/lib/generators/tango"; import WinBanner from "./WinBanner"; interface Props { puzzle: TangoPuzzle; date: string; onSolve?: (elapsed: number) => void; } const MAX_CELL = 68; const CONSTRAINT_SIZE = 22; // badge diameter function SunIcon({ size }: { size: number }) { return ( ); } function MoonIcon({ size }: { size: number }) { return ( ); } 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: Cell[][], puzzle: TangoPuzzle): boolean { const { size, hEdges, vEdges } = puzzle; for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!grid[r][c]) return false; for (let i = 0; i < size; i++) { let rs = 0, rm = 0, cs = 0, cm = 0; for (let j = 0; j < size; j++) { if (grid[i][j] === "sun") rs++; else rm++; if (grid[j][i] === "sun") cs++; else cm++; } if (rs !== size / 2 || rm !== size / 2 || cs !== size / 2 || cm !== size / 2) return false; } for (let i = 0; i < size; i++) for (let j = 0; j <= size - 3; j++) { if (grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) return false; if (grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) return false; } for (let r = 0; r < size; r++) for (let c = 0; c < size - 1; c++) { const e = hEdges[r][c]; if (!e) continue; const same = grid[r][c] === grid[r][c + 1]; if ((e === "=" && !same) || (e === "x" && same)) return false; } for (let r = 0; r < size - 1; r++) for (let c = 0; c < size; c++) { const e = vEdges[r][c]; if (!e) continue; const same = grid[r][c] === grid[r + 1][c]; if ((e === "=" && !same) || (e === "x" && same)) return false; } return true; } function getErrors(grid: Cell[][], puzzle: TangoPuzzle): Set { const { size, hEdges, vEdges } = puzzle; const err = new Set(); for (let i = 0; i < size; i++) { let rs = 0, rm = 0, cs = 0, cm = 0; for (let j = 0; j < size; j++) { if (grid[i][j] === "sun") rs++; if (grid[i][j] === "moon") rm++; if (grid[j][i] === "sun") cs++; if (grid[j][i] === "moon") cm++; } if (rs > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "sun") err.add(`${i},${j}`); if (rm > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "moon") err.add(`${i},${j}`); if (cs > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "sun") err.add(`${j},${i}`); if (cm > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "moon") err.add(`${j},${i}`); } for (let i = 0; i < size; i++) for (let j = 0; j <= size - 3; j++) { if (grid[i][j] && grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) { err.add(`${i},${j}`); err.add(`${i},${j + 1}`); err.add(`${i},${j + 2}`); } if (grid[j][i] && grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) { err.add(`${j},${i}`); err.add(`${j + 1},${i}`); err.add(`${j + 2},${i}`); } } // Edge constraint violations for (let r = 0; r < size; r++) for (let c = 0; c < size - 1; c++) { const e = hEdges[r][c]; if (!e || !grid[r][c] || !grid[r][c + 1]) continue; const same = grid[r][c] === grid[r][c + 1]; if ((e === "=" && !same) || (e === "x" && same)) { err.add(`${r},${c}`); err.add(`${r},${c + 1}`); } } for (let r = 0; r < size - 1; r++) for (let c = 0; c < size; c++) { const e = vEdges[r][c]; if (!e || !grid[r][c] || !grid[r + 1][c]) continue; const same = grid[r][c] === grid[r + 1][c]; if ((e === "=" && !same) || (e === "x" && same)) { err.add(`${r},${c}`); err.add(`${r + 1},${c}`); } } return err; } const STORAGE_KEY = (d: string) => `tango-${d}`; export default function TangoBoard({ puzzle, date, onSolve }: Props) { const { size, given, hEdges, vEdges } = puzzle; const [CELL, setCELL] = useState(() => typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) ); const [grid, setGrid] = useState(() => { if (typeof window !== "undefined") { const s = localStorage.getItem(STORAGE_KEY(date)); if (s) return JSON.parse(s); } return given.map(r => [...r]); }); const history = useRef([]); const errorTimerRef = useRef | null>(null); const [shownErrors, setShownErrors] = useState>(new Set()); const [won, setWon] = useState(false); const [elapsed, setElapsed] = useState(0); const [t0] = useState(() => Date.now()); 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, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } }, [grid, date, puzzle, won, onSolve, t0]); 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 undo = useCallback(() => { if (won || history.current.length === 0) return; setGrid(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 cycleCell = useCallback((r: number, c: number) => { if (won || given[r][c] !== null) return; setGrid(prev => { history.current.push(prev.map(row => [...row])); const next = prev.map(row => [...row]); next[r][c] = next[r][c] === null ? "sun" : next[r][c] === "sun" ? "moon" : null; return next; }); }, [won, given]); const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setWon(false); }; const handleHint = () => { if (won) return; for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!grid[r][c] && !given[r][c]) { setGrid(prev => { const next = prev.map(row => [...row]); next[r][c] = puzzle.solution[r][c]; return next; }); return; } }; const totalW = size * CELL; const iconSize = Math.round(CELL * 0.55); return (
{filled} / {size * size} {fmt(elapsed)}
{won && } {/* Board: positioned cells + constraint symbols on borders */}
{/* Cells */}
{Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => { const val = grid[r][c]; const isGiven = given[r][c] !== null; const err = shownErrors.has(`${r},${c}`); let bg = "#ffffff"; if (isGiven) bg = "#e5e7eb"; // gray for given cells const bRight = c < size - 1 ? "1px solid #d1d5db" : "none"; const bBottom = r < size - 1 ? "1px solid #d1d5db" : "none"; return (
cycleCell(r, c)} style={{ width: CELL, height: CELL, backgroundColor: bg, backgroundImage: err ? DIAGONAL_ERROR : undefined, borderRight: bRight, borderBottom: bBottom, cursor: (isGiven || won) ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", userSelect: "none", transition: "background-color 0.1s", }} > {val === "sun" && } {val === "moon" && }
); }) )}
{/* Horizontal constraint symbols: between (r,c) and (r,c+1) — on the vertical border */} {Array.from({ length: size }, (_, r) => Array.from({ length: size - 1 }, (_, c) => { const e = hEdges[r][c]; if (!e) return null; return (
{e === "=" ? "=" : "×"}
); }) )} {/* Vertical constraint symbols: between (r,c) and (r+1,c) — on the horizontal border */} {Array.from({ length: size - 1 }, (_, r) => Array.from({ length: size }, (_, c) => { const e = vEdges[r][c]; if (!e) return null; return (
{e === "=" ? "=" : "×"}
); }) )}
{!won && ( <> )}
1 clic = ☀ · 2 clics = ☽ · 3 clics = effacer Autant de soleils que de lunes par ligne et colonne. Pas plus de 2 identiques consécutifs.
); }