2026-04-10 21:41:49 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useCallback, useRef, useEffect } from "react";
|
2026-04-10 22:26:24 +02:00
|
|
|
import { fetchArticle, pickTwoArticles, normalizeTitle } from "./wiki";
|
2026-04-10 21:41:49 +02:00
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
const BLITZ_DURATION = 120; // 2 minutes
|
2026-04-10 21:41:49 +02:00
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
export type BlitzPhase = "setup" | "playing" | "won" | "lost";
|
2026-04-10 21:41:49 +02:00
|
|
|
|
|
|
|
|
export function useBlitzGame() {
|
|
|
|
|
const [phase, setPhase] = useState<BlitzPhase>("setup");
|
|
|
|
|
const [html, setHtml] = useState("");
|
|
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
|
const [timeLeft, setTimeLeft] = useState(BLITZ_DURATION);
|
|
|
|
|
const [clicks, setClicks] = useState(0);
|
2026-04-10 22:26:24 +02:00
|
|
|
const [history, setHistory] = useState<string[]>([]);
|
|
|
|
|
const [puzzle, setPuzzle] = useState<{ start: string; target: string } | null>(null);
|
2026-04-10 21:41:49 +02:00
|
|
|
|
|
|
|
|
const clicksRef = useRef(0);
|
2026-04-10 22:26:24 +02:00
|
|
|
const pathRef = useRef<string[]>([]);
|
2026-04-10 21:41:49 +02:00
|
|
|
const loadingRef = useRef(false);
|
|
|
|
|
const endedRef = useRef(false);
|
|
|
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-04-10 22:26:24 +02:00
|
|
|
const startTimeRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
function stopTimer() {
|
|
|
|
|
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
|
|
|
|
}
|
2026-04-10 21:41:49 +02:00
|
|
|
|
|
|
|
|
function startTimer() {
|
|
|
|
|
startTimeRef.current = Date.now();
|
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
|
|
|
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
|
|
|
|
const left = Math.max(0, BLITZ_DURATION - elapsed);
|
|
|
|
|
setTimeLeft(left);
|
2026-04-10 22:26:24 +02:00
|
|
|
if (left <= 0 && !endedRef.current) {
|
|
|
|
|
endedRef.current = true;
|
|
|
|
|
stopTimer();
|
|
|
|
|
setPhase("lost");
|
|
|
|
|
saveGame(false, pathRef.current, clicksRef.current, BLITZ_DURATION);
|
|
|
|
|
}
|
|
|
|
|
}, 100);
|
2026-04-10 21:41:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
useEffect(() => () => stopTimer(), []);
|
|
|
|
|
|
|
|
|
|
async function saveGame(won: boolean, path: string[], clicks: number, timeSeconds: number) {
|
|
|
|
|
if (!puzzle) return;
|
2026-04-10 21:41:49 +02:00
|
|
|
fetch("/api/games", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
mode: "blitz",
|
2026-04-10 22:26:24 +02:00
|
|
|
startArticle: puzzle.start,
|
|
|
|
|
targetArticle: puzzle.target,
|
|
|
|
|
path,
|
|
|
|
|
clicks,
|
|
|
|
|
timeSeconds: BLITZ_DURATION - timeLeft,
|
|
|
|
|
won,
|
2026-04-10 21:41:49 +02:00
|
|
|
}),
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadArticle(t: string) {
|
|
|
|
|
setLoadError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
loadingRef.current = true;
|
|
|
|
|
const art = await fetchArticle(t);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
loadingRef.current = false;
|
|
|
|
|
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
|
|
|
|
|
setHtml(art.html);
|
|
|
|
|
setTitle(art.title);
|
|
|
|
|
return art.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function start() {
|
|
|
|
|
setLoading(true);
|
2026-04-10 22:26:24 +02:00
|
|
|
stopTimer();
|
2026-04-10 21:41:49 +02:00
|
|
|
clicksRef.current = 0; setClicks(0);
|
2026-04-10 22:26:24 +02:00
|
|
|
pathRef.current = []; setHistory([]);
|
2026-04-10 21:41:49 +02:00
|
|
|
endedRef.current = false;
|
|
|
|
|
setTimeLeft(BLITZ_DURATION);
|
|
|
|
|
setLoadError(null);
|
|
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
const p = await pickTwoArticles();
|
|
|
|
|
setPuzzle(p);
|
|
|
|
|
const canonical = await loadArticle(p.start);
|
2026-04-10 21:41:49 +02:00
|
|
|
if (!canonical) return;
|
|
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
pathRef.current = [canonical];
|
|
|
|
|
setHistory([canonical]);
|
2026-04-10 21:41:49 +02:00
|
|
|
setPhase("playing");
|
|
|
|
|
startTimer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const navigate = useCallback(async (t: string) => {
|
|
|
|
|
if (loadingRef.current || endedRef.current) return;
|
|
|
|
|
clicksRef.current += 1;
|
|
|
|
|
setClicks(clicksRef.current);
|
|
|
|
|
|
|
|
|
|
const canonical = await loadArticle(t);
|
|
|
|
|
if (!canonical) return;
|
|
|
|
|
|
2026-04-10 22:26:24 +02:00
|
|
|
const newPath = [...pathRef.current, canonical];
|
|
|
|
|
pathRef.current = newPath;
|
|
|
|
|
setHistory(newPath);
|
2026-04-10 21:41:49 +02:00
|
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
2026-04-10 22:26:24 +02:00
|
|
|
|
|
|
|
|
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) {
|
|
|
|
|
endedRef.current = true;
|
|
|
|
|
stopTimer();
|
|
|
|
|
setPhase("won");
|
|
|
|
|
saveGame(true, newPath, clicksRef.current, timeLeft);
|
|
|
|
|
}
|
2026-04-10 21:41:49 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-10 22:26:24 +02:00
|
|
|
}, [puzzle, timeLeft]);
|
2026-04-10 21:41:49 +02:00
|
|
|
|
|
|
|
|
function reset() {
|
2026-04-10 22:26:24 +02:00
|
|
|
stopTimer();
|
2026-04-10 21:41:49 +02:00
|
|
|
endedRef.current = false;
|
|
|
|
|
clicksRef.current = 0; setClicks(0);
|
2026-04-10 22:26:24 +02:00
|
|
|
pathRef.current = []; setHistory([]);
|
2026-04-10 21:41:49 +02:00
|
|
|
setHtml(""); setTitle("");
|
|
|
|
|
setTimeLeft(BLITZ_DURATION);
|
2026-04-10 22:26:24 +02:00
|
|
|
setPuzzle(null);
|
2026-04-10 21:41:49 +02:00
|
|
|
setPhase("setup");
|
|
|
|
|
setLoadError(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-10 22:26:24 +02:00
|
|
|
phase, puzzle, html, title, loading, loadError,
|
|
|
|
|
timeLeft, clicks, history,
|
|
|
|
|
canGoBack: false, // pas de retour en blitz
|
2026-04-10 21:41:49 +02:00
|
|
|
start, navigate, reset,
|
|
|
|
|
retryLoad: () => title && loadArticle(title),
|
|
|
|
|
};
|
|
|
|
|
}
|