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

359 lines
13 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 { 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 (
<svg width={size} height={size} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="14" fill="#f97316" />
</svg>
);
}
function MoonIcon({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40">
<defs>
<mask id="moon-mask">
<rect width="40" height="40" fill="white" />
<circle cx="27" cy="17" r="12" fill="black" />
</mask>
</defs>
<circle cx="20" cy="20" r="14" fill="#3b82f6" mask="url(#moon-mask)" />
</svg>
);
}
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<string> {
const { size, hEdges, vEdges } = puzzle;
const err = new Set<string>();
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<Cell[][]>(() => {
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<Cell[][][]>([]);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [shownErrors, setShownErrors] = useState<Set<string>>(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 (
<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} / {size * size}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="tango" date={date} elapsed={elapsed} />}
{/* Board: positioned cells + constraint symbols on borders */}
<div style={{ position: "relative", width: totalW, height: totalW }}>
{/* Cells */}
<div style={{
display: "grid",
gridTemplateColumns: `repeat(${size}, ${CELL}px)`,
border: "1.5px solid #d1d5db",
borderRadius: 6,
overflow: "hidden",
}}>
{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 (
<div
key={`${r}-${c}`}
onClick={() => 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",
}}
>
<span style={{ pointerEvents: "none" }}>
{val === "sun" && <SunIcon size={iconSize} />}
{val === "moon" && <MoonIcon size={iconSize} />}
</span>
</div>
);
})
)}
</div>
{/* 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 (
<div
key={`h-${r}-${c}`}
style={{
position: "absolute",
left: (c + 1) * CELL - CONSTRAINT_SIZE / 2,
top: r * CELL + (CELL - CONSTRAINT_SIZE) / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
{/* 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 (
<div
key={`v-${r}-${c}`}
style={{
position: "absolute",
left: c * CELL + (CELL - CONSTRAINT_SIZE) / 2,
top: (r + 1) * CELL - CONSTRAINT_SIZE / 2,
width: CONSTRAINT_SIZE,
height: CONSTRAINT_SIZE,
borderRadius: "50%",
background: "#ffffff",
border: "1.5px solid #d1d5db",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#78716c",
zIndex: 10,
pointerEvents: "none",
}}
>
{e === "=" ? "=" : "×"}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={history.current.length === 0}
className="px-5 py-2 rounded-full border border-gray-300 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-5 py-2 rounded-full border border-gray-300 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
Recommencer
</button>
<button onClick={handleHint}
className="px-5 py-2 rounded-full border border-gray-900 text-gray-900 hover:bg-gray-50 text-sm font-medium transition-colors">
Indice
</button>
</>
)}
</div>
<div className="flex flex-col items-center gap-1 text-xs text-gray-400">
<span>1 clic = · 2 clics = · 3 clics = effacer</span>
<span>Autant de soleils que de lunes par ligne et colonne. Pas plus de 2 identiques consécutifs.</span>
</div>
</div>
);
}