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

302 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { ZipPuzzle, canMove } from "@/lib/generators/zip";
import WinBanner from "./WinBanner";
interface Props {
puzzle: ZipPuzzle;
date: string;
onSolve?: (elapsed: number) => void;
}
const WALL_W = 5;
const PATH_COLOR = "#f97316"; // orange — matches LinkedIn
const PATH_DONE = "#10b981"; // green on win
const PATH_BG = "#fde8d0"; // light salmon for path cells
const CELL_1_BG = "#fddcbb"; // slightly deeper salmon for waypoint 1 cell
const STORAGE_KEY = (d: string) => `zip-${d}`;
function cellKey(r: number, c: number) { return `${r},${c}`; }
const MAX_CELL = 52;
export default function ZipBoard({ puzzle, date, onSolve }: Props) {
const { size, path: solution, numberedCells, walls } = puzzle;
const totalCells = size * size;
const [CELL, setCELL] = useState(() =>
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
);
const STEP = CELL;
const [userPath, setUserPath] = useState<[number, number][]>(() => {
if (typeof window !== "undefined") {
const s = localStorage.getItem(STORAGE_KEY(date));
if (s) return JSON.parse(s);
}
return [];
});
const pathHistory = useRef<[number, number][][]>([]);
const [dragging, setDragging] = useState(false);
const [won, setWon] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [t0] = useState(() => Date.now());
const boardRef = useRef<HTMLDivElement>(null);
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]);
const checkWin = useCallback((p: [number, number][]) => {
if (p.length !== totalCells) return false;
const waypointCells = Object.entries(numberedCells).sort((a, b) => a[1] - b[1]).map(([k]) => k);
let wi = 0;
for (const [r, c] of p) {
if (wi < waypointCells.length && cellKey(r, c) === waypointCells[wi]) wi++;
}
return wi === waypointCells.length;
}, [totalCells, numberedCells]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userPath));
if (!won && checkWin(userPath)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
}, [userPath, date, won, checkWin, onSolve]);
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
const pathSet = new Set(userPath.map(([r, c]) => cellKey(r, c)));
function getCellFromPoint(clientX: number, clientY: number): [number, number] | null {
const board = boardRef.current;
if (!board) return null;
const rect = board.getBoundingClientRect();
const c = Math.floor((clientX - rect.left) / STEP);
const r = Math.floor((clientY - rect.top) / STEP);
if (r < 0 || r >= size || c < 0 || c >= size) return null;
return [r, c];
}
const undo = useCallback(() => {
if (won || pathHistory.current.length === 0) return;
setUserPath(pathHistory.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 startPath = useCallback((r: number, c: number) => {
if (won) return;
setDragging(true);
const key = numberedCells[cellKey(r, c)];
if (key === 1) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
return;
}
const last = userPath[userPath.length - 1];
if (last && last[0] === r && last[1] === c) return;
if (key) {
pathHistory.current.push([...userPath]);
setUserPath([[r, c]]);
}
}, [won, numberedCells, userPath]);
const extendPath = useCallback((r: number, c: number) => {
if (!dragging || won) return;
setUserPath(prev => {
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const key = cellKey(r, c);
const dr = Math.abs(last[0] - r), dc = Math.abs(last[1] - c);
if (dr + dc !== 1) return prev;
if (!canMove(walls, last[0], last[1], r, c)) return prev;
if (prev.length >= 2 && prev[prev.length - 2][0] === r && prev[prev.length - 2][1] === c)
return prev.slice(0, -1);
if (pathSet.has(key)) return prev;
return [...prev, [r, c]];
});
}, [dragging, won, pathSet, walls]);
const handleMouseDown = (e: React.MouseEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging) return;
const cell = getCellFromPoint(e.clientX, e.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
const handleTouchStart = (e: React.TouchEvent, r: number, c: number) => {
e.preventDefault(); startPath(r, c);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!dragging) return;
const t = e.touches[0];
const cell = getCellFromPoint(t.clientX, t.clientY);
if (cell) extendPath(cell[0], cell[1]);
};
// Thick orange polyline with rounded joins — matches LinkedIn visual
function renderPath() {
if (userPath.length < 1) return null;
const color = won ? PATH_DONE : PATH_COLOR;
const pts = userPath.map(([r, c]) => `${c * STEP + CELL / 2},${r * STEP + CELL / 2}`).join(" ");
return (
<svg
style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 5 }}
width={size * STEP} height={size * STEP}
>
<polyline
points={pts}
stroke={color}
strokeWidth={CELL * 0.46}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
opacity={0.85}
/>
</svg>
);
}
const handleHint = () => {
if (won) return;
let matchLen = 0;
for (let i = 0; i < Math.min(userPath.length, solution.length); i++) {
if (userPath[i][0] === solution[i][0] && userPath[i][1] === solution[i][1]) matchLen = i + 1;
else break;
}
if (matchLen < solution.length)
setUserPath(solution.slice(0, matchLen + 1) as [number, number][]);
};
const reset = () => { pathHistory.current = []; setUserPath([]); setWon(false); };
const boardSize = size * STEP;
const waypointR = Math.round(CELL * 0.35); // radius of black circle
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>{userPath.length} / {totalCells}</span>
<span className="tabular-nums">{fmt(elapsed)}</span>
</div>
{won && <WinBanner game="zip" date={date} elapsed={elapsed} />}
<div
ref={boardRef}
style={{
position: "relative", width: boardSize, height: boardSize,
userSelect: "none", touchAction: "none",
borderRadius: 8, overflow: "hidden",
border: "1.5px solid #d1d5db",
background: "#ffffff",
}}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragging(false)}
onMouseLeave={() => setDragging(false)}
onTouchMove={handleTouchMove}
onTouchEnd={() => setDragging(false)}
>
{renderPath()}
{Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => {
const key = cellKey(r, c);
const inPath = pathSet.has(key);
const num = numberedCells[key];
const w = walls[r][c];
const isStart = num === 1;
const bTop = (w & 8) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bBottom = (w & 2) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bLeft = (w & 4) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
const bRight = (w & 1) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb";
let bg = "#ffffff";
if (inPath) bg = won ? "#d1fae5" : PATH_BG;
else if (isStart) bg = CELL_1_BG;
return (
<div
key={key}
onMouseDown={e => handleMouseDown(e, r, c)}
onTouchStart={e => handleTouchStart(e, r, c)}
style={{
position: "absolute",
left: c * STEP, top: r * STEP,
width: CELL, height: CELL,
backgroundColor: bg,
borderTop: bTop, borderBottom: bBottom,
borderLeft: bLeft, borderRight: bRight,
boxSizing: "border-box",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "crosshair",
zIndex: num ? 10 : 2,
transition: "background-color 0.08s",
}}
>
{num && (
<div style={{
width: waypointR * 2,
height: waypointR * 2,
borderRadius: "50%",
background: "#1a1a1a",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#ffffff",
fontSize: CELL * 0.28,
fontWeight: 700,
zIndex: 20,
position: "relative",
flexShrink: 0,
}}>{num}</div>
)}
</div>
);
})
)}
</div>
<div className="flex gap-3">
{!won && (
<>
<button
onClick={undo}
disabled={pathHistory.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>
<p className="text-xs text-gray-400 text-center max-w-xs">
Glissez du <b>1</b> en passant par les chiffres dans l&apos;ordre, en couvrant toutes les cases.
</p>
</div>
);
}