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