302 lines
11 KiB
TypeScript
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'ordre, en couvrant toutes les cases.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|