style: apply prettier formatting across codebase
This commit is contained in:
+8
-2
@@ -17,7 +17,9 @@ export type SessionData = {
|
||||
export function saveSession(data: SessionData) {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, JSON.stringify(data));
|
||||
} catch { /* ignore quota */ }
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSession(): SessionData | null {
|
||||
@@ -30,5 +32,9 @@ export function loadSession(): SessionData | null {
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
try { sessionStorage.removeItem(KEY); } catch { /* ignore */ }
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,4 +1,12 @@
|
||||
export type Screen = "home" | "lobby" | "game" | "solo" | "profile" | "leaderboard" | "daily" | "blitz";
|
||||
export type Screen =
|
||||
| "home"
|
||||
| "lobby"
|
||||
| "game"
|
||||
| "solo"
|
||||
| "profile"
|
||||
| "leaderboard"
|
||||
| "daily"
|
||||
| "blitz";
|
||||
|
||||
export type WikiArticle = {
|
||||
title: string;
|
||||
|
||||
+64
-30
@@ -16,7 +16,10 @@ export function useBlitzGame() {
|
||||
const [timeLeft, setTimeLeft] = useState(BLITZ_DURATION);
|
||||
const [clicks, setClicks] = useState(0);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [puzzle, setPuzzle] = useState<{ start: string; target: string } | null>(null);
|
||||
const [puzzle, setPuzzle] = useState<{
|
||||
start: string;
|
||||
target: string;
|
||||
} | null>(null);
|
||||
|
||||
const clicksRef = useRef(0);
|
||||
const pathRef = useRef<string[]>([]);
|
||||
@@ -26,7 +29,10 @@ export function useBlitzGame() {
|
||||
const startTimeRef = useRef(0);
|
||||
|
||||
function stopTimer() {
|
||||
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
@@ -46,7 +52,12 @@ export function useBlitzGame() {
|
||||
|
||||
useEffect(() => () => stopTimer(), []);
|
||||
|
||||
async function saveGame(won: boolean, path: string[], clicks: number, timeSeconds: number) {
|
||||
async function saveGame(
|
||||
won: boolean,
|
||||
path: string[],
|
||||
clicks: number,
|
||||
timeSeconds: number,
|
||||
) {
|
||||
if (!puzzle) return;
|
||||
fetch("/api/games", {
|
||||
method: "POST",
|
||||
@@ -70,7 +81,10 @@ export function useBlitzGame() {
|
||||
const art = await fetchArticle(t);
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
|
||||
if (!art) {
|
||||
setLoadError(`Impossible de charger "${t}".`);
|
||||
return null;
|
||||
}
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
return art.title;
|
||||
@@ -79,8 +93,10 @@ export function useBlitzGame() {
|
||||
async function start() {
|
||||
setLoading(true);
|
||||
stopTimer();
|
||||
clicksRef.current = 0; setClicks(0);
|
||||
pathRef.current = []; setHistory([]);
|
||||
clicksRef.current = 0;
|
||||
setClicks(0);
|
||||
pathRef.current = [];
|
||||
setHistory([]);
|
||||
endedRef.current = false;
|
||||
setTimeLeft(BLITZ_DURATION);
|
||||
setLoadError(null);
|
||||
@@ -96,34 +112,43 @@ export function useBlitzGame() {
|
||||
startTimer();
|
||||
}
|
||||
|
||||
const navigate = useCallback(async (t: string) => {
|
||||
if (loadingRef.current || endedRef.current) return;
|
||||
clicksRef.current += 1;
|
||||
setClicks(clicksRef.current);
|
||||
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;
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) {
|
||||
endedRef.current = true;
|
||||
stopTimer();
|
||||
setPhase("won");
|
||||
saveGame(true, newPath, clicksRef.current, timeLeft);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [puzzle, timeLeft]);
|
||||
if (
|
||||
puzzle &&
|
||||
normalizeTitle(canonical) === normalizeTitle(puzzle.target)
|
||||
) {
|
||||
endedRef.current = true;
|
||||
stopTimer();
|
||||
setPhase("won");
|
||||
saveGame(true, newPath, clicksRef.current, timeLeft);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[puzzle, timeLeft],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
stopTimer();
|
||||
endedRef.current = false;
|
||||
clicksRef.current = 0; setClicks(0);
|
||||
pathRef.current = []; setHistory([]);
|
||||
setHtml(""); setTitle("");
|
||||
clicksRef.current = 0;
|
||||
setClicks(0);
|
||||
pathRef.current = [];
|
||||
setHistory([]);
|
||||
setHtml("");
|
||||
setTitle("");
|
||||
setTimeLeft(BLITZ_DURATION);
|
||||
setPuzzle(null);
|
||||
setPhase("setup");
|
||||
@@ -131,10 +156,19 @@ export function useBlitzGame() {
|
||||
}
|
||||
|
||||
return {
|
||||
phase, puzzle, html, title, loading, loadError,
|
||||
timeLeft, clicks, history,
|
||||
phase,
|
||||
puzzle,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
timeLeft,
|
||||
clicks,
|
||||
history,
|
||||
canGoBack: false, // pas de retour en blitz
|
||||
start, navigate, reset,
|
||||
start,
|
||||
navigate,
|
||||
reset,
|
||||
retryLoad: () => title && loadArticle(title),
|
||||
};
|
||||
}
|
||||
|
||||
+92
-45
@@ -4,7 +4,12 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { fetchArticle, normalizeTitle } from "./wiki";
|
||||
import { useTimer } from "./useTimer";
|
||||
|
||||
export type DailyPhase = "loading" | "playing" | "won" | "gave_up" | "already_played";
|
||||
export type DailyPhase =
|
||||
| "loading"
|
||||
| "playing"
|
||||
| "won"
|
||||
| "gave_up"
|
||||
| "already_played";
|
||||
|
||||
export type DailyPuzzleInfo = {
|
||||
id: string;
|
||||
@@ -42,22 +47,31 @@ export function useDailyGame() {
|
||||
useEffect(() => {
|
||||
fetch("/api/daily")
|
||||
.then((r) => r.json())
|
||||
.then(async (data: { puzzle: DailyPuzzleInfo; alreadyPlayed: boolean; myResult: DailyResult | null }) => {
|
||||
setPuzzle(data.puzzle);
|
||||
if (data.alreadyPlayed && data.myResult) {
|
||||
setMyResult(data.myResult);
|
||||
setPhase("already_played");
|
||||
return;
|
||||
}
|
||||
// Charger l'article de départ
|
||||
const art = await fetchArticle(data.puzzle.startArticle);
|
||||
if (!art) { setLoadError("Impossible de charger l'article de départ."); return; }
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
pathRef.current = [art.title];
|
||||
setHistory([art.title]);
|
||||
setPhase("playing");
|
||||
})
|
||||
.then(
|
||||
async (data: {
|
||||
puzzle: DailyPuzzleInfo;
|
||||
alreadyPlayed: boolean;
|
||||
myResult: DailyResult | null;
|
||||
}) => {
|
||||
setPuzzle(data.puzzle);
|
||||
if (data.alreadyPlayed && data.myResult) {
|
||||
setMyResult(data.myResult);
|
||||
setPhase("already_played");
|
||||
return;
|
||||
}
|
||||
// Charger l'article de départ
|
||||
const art = await fetchArticle(data.puzzle.startArticle);
|
||||
if (!art) {
|
||||
setLoadError("Impossible de charger l'article de départ.");
|
||||
return;
|
||||
}
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
pathRef.current = [art.title];
|
||||
setHistory([art.title]);
|
||||
setPhase("playing");
|
||||
},
|
||||
)
|
||||
.catch(() => setLoadError("Impossible de charger le défi du jour."));
|
||||
}, []);
|
||||
|
||||
@@ -68,7 +82,10 @@ export function useDailyGame() {
|
||||
const art = await fetchArticle(t);
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
|
||||
if (!art) {
|
||||
setLoadError(`Impossible de charger "${t}".`);
|
||||
return null;
|
||||
}
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
return art.title;
|
||||
@@ -86,44 +103,65 @@ export function useDailyGame() {
|
||||
await fetch("/api/daily", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ puzzleId: puzzle.id, ...result, path: result.path }),
|
||||
body: JSON.stringify({
|
||||
puzzleId: puzzle.id,
|
||||
...result,
|
||||
path: result.path,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const navigate = useCallback(async (t: string) => {
|
||||
if (loadingRef.current || gameEndedRef.current) return;
|
||||
clicksRef.current += 1;
|
||||
setClicks(clicksRef.current);
|
||||
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
|
||||
const navigate = useCallback(
|
||||
async (t: string) => {
|
||||
if (loadingRef.current || gameEndedRef.current) return;
|
||||
clicksRef.current += 1;
|
||||
setClicks(clicksRef.current);
|
||||
if (!timerStartedRef.current) {
|
||||
timer.start();
|
||||
timerStartedRef.current = true;
|
||||
}
|
||||
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.targetArticle)) {
|
||||
timer.stop();
|
||||
gameEndedRef.current = true;
|
||||
setPhase("won");
|
||||
await submitResult(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [puzzle]);
|
||||
if (
|
||||
puzzle &&
|
||||
normalizeTitle(canonical) === normalizeTitle(puzzle.targetArticle)
|
||||
) {
|
||||
timer.stop();
|
||||
gameEndedRef.current = true;
|
||||
setPhase("won");
|
||||
await submitResult(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[puzzle],
|
||||
);
|
||||
|
||||
const goBack = useCallback(async () => {
|
||||
if (loadingRef.current || gameEndedRef.current || pathRef.current.length <= 1) return;
|
||||
if (
|
||||
loadingRef.current ||
|
||||
gameEndedRef.current ||
|
||||
pathRef.current.length <= 1
|
||||
)
|
||||
return;
|
||||
clicksRef.current += 1;
|
||||
setClicks(clicksRef.current);
|
||||
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
|
||||
if (!timerStartedRef.current) {
|
||||
timer.start();
|
||||
timerStartedRef.current = true;
|
||||
}
|
||||
const newPath = pathRef.current.slice(0, -1);
|
||||
const canonical = await loadArticle(newPath[newPath.length - 1]);
|
||||
if (!canonical) return;
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function giveUp() {
|
||||
@@ -134,11 +172,20 @@ export function useDailyGame() {
|
||||
}
|
||||
|
||||
return {
|
||||
phase, puzzle, myResult,
|
||||
html, title, loading, loadError,
|
||||
history, clicks, elapsed: timer.elapsed,
|
||||
phase,
|
||||
puzzle,
|
||||
myResult,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
history,
|
||||
clicks,
|
||||
elapsed: timer.elapsed,
|
||||
canGoBack: pathRef.current.length > 1,
|
||||
navigate, goBack, giveUp,
|
||||
navigate,
|
||||
goBack,
|
||||
giveUp,
|
||||
retryLoad: () => title && loadArticle(title),
|
||||
};
|
||||
}
|
||||
|
||||
+20
-6
@@ -37,21 +37,35 @@ export function useGameEffects({
|
||||
const saved = loadSession();
|
||||
if (!saved) return;
|
||||
|
||||
if (saved.screen === "solo" && saved.soloPuzzle && saved.soloHistory?.length) {
|
||||
if (
|
||||
saved.screen === "solo" &&
|
||||
saved.soloPuzzle &&
|
||||
saved.soloHistory?.length
|
||||
) {
|
||||
setScreen("solo");
|
||||
solo.restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0)
|
||||
.then((ok) => { if (!ok) { clearSession(); setScreen("home"); } });
|
||||
solo
|
||||
.restore(saved.soloPuzzle, saved.soloHistory, saved.soloClicks ?? 0)
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
clearSession();
|
||||
setScreen("home");
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
(saved.screen === "lobby" || saved.screen === "game") &&
|
||||
saved.multiRoomCode && saved.multiPlayerId
|
||||
saved.multiRoomCode &&
|
||||
saved.multiPlayerId
|
||||
) {
|
||||
if (saved.playerName) setPlayerName(saved.playerName);
|
||||
multi.restore(saved.multiRoomCode, saved.multiPlayerId).then((ok) => {
|
||||
if (ok) setScreen("lobby");
|
||||
else { clearSession(); setScreen("home"); }
|
||||
else {
|
||||
clearSession();
|
||||
setScreen("home");
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Bloquer le bouton retour navigateur
|
||||
|
||||
+48
-13
@@ -20,29 +20,64 @@ type Handlers = {
|
||||
};
|
||||
|
||||
export function useGameHandlers({
|
||||
solo, multi, playerName, joinCode, maxPlayers, totalRounds, gameMode, setScreen, setError, setLoading,
|
||||
solo,
|
||||
multi,
|
||||
playerName,
|
||||
joinCode,
|
||||
maxPlayers,
|
||||
totalRounds,
|
||||
gameMode,
|
||||
setScreen,
|
||||
setError,
|
||||
setLoading,
|
||||
}: Handlers) {
|
||||
async function handleCreateRoom() {
|
||||
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
|
||||
setLoading(true); setError(null);
|
||||
const { error: err } = await multi.createRoom(playerName.trim(), maxPlayers, totalRounds, gameMode);
|
||||
if (!playerName.trim()) {
|
||||
setError("Entre ton pseudo !");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error: err } = await multi.createRoom(
|
||||
playerName.trim(),
|
||||
maxPlayers,
|
||||
totalRounds,
|
||||
gameMode,
|
||||
);
|
||||
setLoading(false);
|
||||
if (err) { setError(err); return; }
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
async function handleJoinRoom() {
|
||||
if (!playerName.trim()) { setError("Entre ton pseudo !"); return; }
|
||||
if (joinCode.trim().length !== 4) { setError("Le code doit faire 4 lettres"); return; }
|
||||
setLoading(true); setError(null);
|
||||
const { error: err } = await multi.joinRoom(playerName.trim(), joinCode.trim().toUpperCase());
|
||||
if (!playerName.trim()) {
|
||||
setError("Entre ton pseudo !");
|
||||
return;
|
||||
}
|
||||
if (joinCode.trim().length !== 4) {
|
||||
setError("Le code doit faire 4 lettres");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error: err } = await multi.joinRoom(
|
||||
playerName.trim(),
|
||||
joinCode.trim().toUpperCase(),
|
||||
);
|
||||
setLoading(false);
|
||||
if (err) { setError(err); return; }
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setScreen("lobby");
|
||||
}
|
||||
|
||||
async function handleStartGame() {
|
||||
setLoading(true); setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error: err } = await multi.startGame();
|
||||
setLoading(false);
|
||||
if (err) setError(err);
|
||||
@@ -50,12 +85,12 @@ export function useGameHandlers({
|
||||
|
||||
async function handleNextRound() {
|
||||
await multi.nextRound();
|
||||
// On reste sur "game" — le GameScreen affiche le lobby quand phase === "waiting"
|
||||
// On reste sur "game" - le GameScreen affiche le lobby quand phase === "waiting"
|
||||
}
|
||||
|
||||
async function handleResetGame() {
|
||||
await multi.resetGame();
|
||||
// On reste sur "game" — le GameScreen affiche le lobby quand phase === "waiting"
|
||||
// On reste sur "game" - le GameScreen affiche le lobby quand phase === "waiting"
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
|
||||
+167
-67
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { fetchArticle, pickTwoArticles, prefetchArticle, POLL_INTERVAL, COUNTDOWN_DURATION } from "./wiki";
|
||||
import {
|
||||
fetchArticle,
|
||||
pickTwoArticles,
|
||||
prefetchArticle,
|
||||
POLL_INTERVAL,
|
||||
COUNTDOWN_DURATION,
|
||||
} from "./wiki";
|
||||
import { useTimer } from "./useTimer";
|
||||
import { saveSession, clearSession } from "./session";
|
||||
import type { Room } from "../app/api/rooms/route";
|
||||
@@ -32,11 +38,18 @@ export function useMultiGame() {
|
||||
// Article loading
|
||||
|
||||
async function loadArticle(t: string): Promise<string | null> {
|
||||
setLoading(true); loadingRef.current = true; setLoadError(null);
|
||||
setLoading(true);
|
||||
loadingRef.current = true;
|
||||
setLoadError(null);
|
||||
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);
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
if (!art) {
|
||||
setLoadError(`Impossible de charger "${t}".`);
|
||||
return null;
|
||||
}
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
return art.title;
|
||||
}
|
||||
|
||||
@@ -53,14 +66,20 @@ export function useMultiGame() {
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownRef.current) { clearInterval(countdownRef.current); countdownRef.current = null; }
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
setCountdown(null);
|
||||
}
|
||||
|
||||
// Polling
|
||||
|
||||
function stopPolling() {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(code: string, pid: string) {
|
||||
@@ -70,8 +89,10 @@ export function useMultiGame() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
} catch { /* ignore */ }
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const pollCodeRef = useRef<string | null>(null);
|
||||
@@ -87,14 +108,18 @@ export function useMultiGame() {
|
||||
// Relance le polling quand le tab redevient visible (les setInterval sont throttlés en arrière-plan)
|
||||
useEffect(() => {
|
||||
function onVisible() {
|
||||
if (document.visibilityState === "visible" && pollCodeRef.current && pollPidRef.current) {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
pollCodeRef.current &&
|
||||
pollPidRef.current
|
||||
) {
|
||||
poll(pollCodeRef.current, pollPidRef.current);
|
||||
startPolling(pollCodeRef.current, pollPidRef.current);
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Phase sync
|
||||
@@ -107,8 +132,10 @@ export function useMultiGame() {
|
||||
prevRoundRef.current = room.round;
|
||||
|
||||
if (room.phase === "countdown" && prevPhase !== "countdown") {
|
||||
setHtml(""); setLoadError(null);
|
||||
clicksRef.current = 0; setClicksDisplay(0);
|
||||
setHtml("");
|
||||
setLoadError(null);
|
||||
clicksRef.current = 0;
|
||||
setClicksDisplay(0);
|
||||
timerStartedRef.current = false;
|
||||
timer.reset();
|
||||
startCountdown(room.countdownStart ?? Date.now());
|
||||
@@ -125,7 +152,7 @@ export function useMultiGame() {
|
||||
if (room.round !== prevRound && room.phase === "playing") {
|
||||
loadArticle(room.startArticle);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room]);
|
||||
|
||||
// Countdown -> playing transition
|
||||
@@ -136,72 +163,117 @@ export function useMultiGame() {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "play", playerId }),
|
||||
}).then((r) => r.json()).then((d) => {
|
||||
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [countdown, room, playerId]);
|
||||
|
||||
// Navigation
|
||||
|
||||
const navigate = useCallback(async (t: string) => {
|
||||
if (!room || !playerId || loadingRef.current || room.phase !== "playing") return;
|
||||
clicksRef.current += 1; setClicksDisplay(clicksRef.current);
|
||||
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
|
||||
const navigate = useCallback(
|
||||
async (t: string) => {
|
||||
if (!room || !playerId || loadingRef.current || room.phase !== "playing")
|
||||
return;
|
||||
clicksRef.current += 1;
|
||||
setClicksDisplay(clicksRef.current);
|
||||
if (!timerStartedRef.current) {
|
||||
timer.start();
|
||||
timerStartedRef.current = true;
|
||||
}
|
||||
|
||||
// Heartbeat optimiste pour éviter le kick pendant le chargement de l'article
|
||||
fetch(`/api/rooms/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "heartbeat", playerId }),
|
||||
}).catch(() => {});
|
||||
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
|
||||
const newHistory = [...historyRef.current, canonical];
|
||||
historyRef.current = newHistory;
|
||||
setHistory(newHistory);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${room.code}`, {
|
||||
// Heartbeat optimiste pour éviter le kick pendant le chargement de l'article
|
||||
fetch(`/api/rooms/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "navigate", playerId, article: canonical }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
} catch { /* on continue localement */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, playerId]);
|
||||
body: JSON.stringify({ action: "heartbeat", playerId }),
|
||||
}).catch(() => {});
|
||||
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
|
||||
const newHistory = [...historyRef.current, canonical];
|
||||
historyRef.current = newHistory;
|
||||
setHistory(newHistory);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/rooms/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "navigate",
|
||||
playerId,
|
||||
article: canonical,
|
||||
}),
|
||||
});
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
} catch {
|
||||
/* on continue localement */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[room, playerId],
|
||||
);
|
||||
|
||||
// Room actions
|
||||
|
||||
async function createRoom(playerName: string, maxPlayers = 16, totalRounds = 3, gameMode: "race" | "all_finish" = "race"): Promise<{ error?: string }> {
|
||||
async function createRoom(
|
||||
playerName: string,
|
||||
maxPlayers = 16,
|
||||
totalRounds = 3,
|
||||
gameMode: "race" | "all_finish" = "race",
|
||||
): Promise<{ error?: string }> {
|
||||
const res = await fetch("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ playerName, maxPlayers, totalRounds, gameMode }),
|
||||
});
|
||||
const data = await res.json() as { room?: Room; playerId?: string; error?: string };
|
||||
const data = (await res.json()) as {
|
||||
room?: Room;
|
||||
playerId?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) return { error: data.error ?? "Erreur" };
|
||||
setRoom(data.room!); setPlayerId(data.playerId!);
|
||||
setRoom(data.room!);
|
||||
setPlayerId(data.playerId!);
|
||||
startPolling(data.room!.code, data.playerId!);
|
||||
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName });
|
||||
saveSession({
|
||||
screen: "lobby",
|
||||
multiRoomCode: data.room!.code,
|
||||
multiPlayerId: data.playerId!,
|
||||
playerName,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
async function joinRoom(playerName: string, code: string): Promise<{ error?: string }> {
|
||||
async function joinRoom(
|
||||
playerName: string,
|
||||
code: string,
|
||||
): Promise<{ error?: string }> {
|
||||
const res = await fetch(`/api/rooms/${code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "join", playerName }),
|
||||
});
|
||||
const data = await res.json() as { room?: Room; playerId?: string; error?: string };
|
||||
const data = (await res.json()) as {
|
||||
room?: Room;
|
||||
playerId?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" };
|
||||
setRoom(data.room!); setPlayerId(data.playerId!);
|
||||
setRoom(data.room!);
|
||||
setPlayerId(data.playerId!);
|
||||
startPolling(data.room!.code, data.playerId!);
|
||||
saveSession({ screen: "lobby", multiRoomCode: data.room!.code, multiPlayerId: data.playerId!, playerName });
|
||||
saveSession({
|
||||
screen: "lobby",
|
||||
multiRoomCode: data.room!.code,
|
||||
multiPlayerId: data.playerId!,
|
||||
playerName,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -211,9 +283,14 @@ export function useMultiGame() {
|
||||
const res = await fetch(`/api/rooms/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "start", playerId, startArticle: puzzle.start, targetArticle: puzzle.target }),
|
||||
body: JSON.stringify({
|
||||
action: "start",
|
||||
playerId,
|
||||
startArticle: puzzle.start,
|
||||
targetArticle: puzzle.target,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { room?: Room; error?: string };
|
||||
const data = (await res.json()) as { room?: Room; error?: string };
|
||||
if (!res.ok) return { error: data.error ?? "Erreur" };
|
||||
prefetchArticle(puzzle.start);
|
||||
setRoom(data.room!);
|
||||
@@ -227,7 +304,7 @@ export function useMultiGame() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "setSearchAllowed", playerId, value }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
}
|
||||
|
||||
async function surrender() {
|
||||
@@ -237,7 +314,7 @@ export function useMultiGame() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "surrender", playerId }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
}
|
||||
|
||||
async function nextRound() {
|
||||
@@ -247,7 +324,7 @@ export function useMultiGame() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "nextRound", playerId }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
}
|
||||
|
||||
async function resetGame() {
|
||||
@@ -257,15 +334,21 @@ export function useMultiGame() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "resetGame", playerId }),
|
||||
});
|
||||
if (res.ok) setRoom((await res.json() as { room: Room }).room);
|
||||
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
||||
}
|
||||
|
||||
function leave() {
|
||||
stopPolling(); stopCountdown(); timer.stop();
|
||||
setRoom(null); setPlayerId(null);
|
||||
setHtml(""); setTitle("");
|
||||
setHistory([]); historyRef.current = [];
|
||||
clicksRef.current = 0; setClicksDisplay(0);
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
timer.stop();
|
||||
setRoom(null);
|
||||
setPlayerId(null);
|
||||
setHtml("");
|
||||
setTitle("");
|
||||
setHistory([]);
|
||||
historyRef.current = [];
|
||||
clicksRef.current = 0;
|
||||
setClicksDisplay(0);
|
||||
timerStartedRef.current = false;
|
||||
clearSession();
|
||||
}
|
||||
@@ -279,7 +362,7 @@ export function useMultiGame() {
|
||||
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json() as { room: Room };
|
||||
const data = (await res.json()) as { room: Room };
|
||||
setRoom(data.room);
|
||||
setPlayerId(pid);
|
||||
startPolling(code, pid);
|
||||
@@ -290,9 +373,26 @@ export function useMultiGame() {
|
||||
}
|
||||
|
||||
return {
|
||||
room, playerId, html, title, loading, loadError,
|
||||
history, clicks: clicksDisplay, elapsed: timer.elapsed, countdown,
|
||||
createRoom, joinRoom, startGame, nextRound, resetGame, leave, navigate, surrender, setSearchAllowed, restore,
|
||||
room,
|
||||
playerId,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
history,
|
||||
clicks: clicksDisplay,
|
||||
elapsed: timer.elapsed,
|
||||
countdown,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
startGame,
|
||||
nextRound,
|
||||
resetGame,
|
||||
leave,
|
||||
navigate,
|
||||
surrender,
|
||||
setSearchAllowed,
|
||||
restore,
|
||||
retryLoad: () => title && loadArticle(title),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useCtrlFBlock(allowed: boolean) {
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () =>
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [allowed]);
|
||||
}
|
||||
|
||||
+88
-40
@@ -33,7 +33,10 @@ export function useSoloGame() {
|
||||
const art = await fetchArticle(t);
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
if (!art) { setLoadError(`Impossible de charger "${t}".`); return null; }
|
||||
if (!art) {
|
||||
setLoadError(`Impossible de charger "${t}".`);
|
||||
return null;
|
||||
}
|
||||
setHtml(art.html);
|
||||
setTitle(art.title);
|
||||
return art.title;
|
||||
@@ -41,8 +44,10 @@ export function useSoloGame() {
|
||||
|
||||
async function start() {
|
||||
setLoading(true);
|
||||
clicksRef.current = 0; setClicksDisplay(0);
|
||||
pathRef.current = []; setHistory([]);
|
||||
clicksRef.current = 0;
|
||||
setClicksDisplay(0);
|
||||
pathRef.current = [];
|
||||
setHistory([]);
|
||||
timerStartedRef.current = false;
|
||||
gameEndedRef.current = false;
|
||||
timer.reset();
|
||||
@@ -55,38 +60,65 @@ export function useSoloGame() {
|
||||
pathRef.current = [canonical];
|
||||
setHistory([canonical]);
|
||||
setPhase("playing");
|
||||
saveSession({ screen: "solo", soloPuzzle: p, soloHistory: [canonical], soloClicks: 0 });
|
||||
saveSession({
|
||||
screen: "solo",
|
||||
soloPuzzle: p,
|
||||
soloHistory: [canonical],
|
||||
soloClicks: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const navigate = useCallback(async (t: string) => {
|
||||
if (loadingRef.current || gameEndedRef.current) return;
|
||||
clicksRef.current += 1;
|
||||
setClicksDisplay(clicksRef.current);
|
||||
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
|
||||
const navigate = useCallback(
|
||||
async (t: string) => {
|
||||
if (loadingRef.current || gameEndedRef.current) return;
|
||||
clicksRef.current += 1;
|
||||
setClicksDisplay(clicksRef.current);
|
||||
if (!timerStartedRef.current) {
|
||||
timer.start();
|
||||
timerStartedRef.current = true;
|
||||
}
|
||||
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
const canonical = await loadArticle(t);
|
||||
if (!canonical) return;
|
||||
const newPath = [...pathRef.current, canonical];
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
if (puzzle && normalizeTitle(canonical) === normalizeTitle(puzzle.target)) {
|
||||
timer.stop();
|
||||
gameEndedRef.current = true;
|
||||
setPhase("won");
|
||||
clearSession();
|
||||
} else {
|
||||
saveSession({ screen: "solo", soloPuzzle: puzzle ?? undefined, soloHistory: newPath, soloClicks: clicksRef.current });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [puzzle]);
|
||||
if (
|
||||
puzzle &&
|
||||
normalizeTitle(canonical) === normalizeTitle(puzzle.target)
|
||||
) {
|
||||
timer.stop();
|
||||
gameEndedRef.current = true;
|
||||
setPhase("won");
|
||||
clearSession();
|
||||
} else {
|
||||
saveSession({
|
||||
screen: "solo",
|
||||
soloPuzzle: puzzle ?? undefined,
|
||||
soloHistory: newPath,
|
||||
soloClicks: clicksRef.current,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[puzzle],
|
||||
);
|
||||
|
||||
const goBack = useCallback(async () => {
|
||||
if (loadingRef.current || gameEndedRef.current || pathRef.current.length <= 1) return;
|
||||
if (
|
||||
loadingRef.current ||
|
||||
gameEndedRef.current ||
|
||||
pathRef.current.length <= 1
|
||||
)
|
||||
return;
|
||||
clicksRef.current += 1;
|
||||
setClicksDisplay(clicksRef.current);
|
||||
if (!timerStartedRef.current) { timer.start(); timerStartedRef.current = true; }
|
||||
if (!timerStartedRef.current) {
|
||||
timer.start();
|
||||
timerStartedRef.current = true;
|
||||
}
|
||||
|
||||
const newPath = pathRef.current.slice(0, -1);
|
||||
const canonical = await loadArticle(newPath[newPath.length - 1]);
|
||||
@@ -94,16 +126,19 @@ export function useSoloGame() {
|
||||
pathRef.current = newPath;
|
||||
setHistory(newPath);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function reset() {
|
||||
timer.reset();
|
||||
clicksRef.current = 0; setClicksDisplay(0);
|
||||
pathRef.current = []; setHistory([]);
|
||||
clicksRef.current = 0;
|
||||
setClicksDisplay(0);
|
||||
pathRef.current = [];
|
||||
setHistory([]);
|
||||
timerStartedRef.current = false;
|
||||
gameEndedRef.current = false;
|
||||
setHtml(""); setTitle("");
|
||||
setHtml("");
|
||||
setTitle("");
|
||||
setPuzzle(null);
|
||||
setPhase("setup");
|
||||
setLoadError(null);
|
||||
@@ -111,9 +146,14 @@ export function useSoloGame() {
|
||||
}
|
||||
|
||||
// Expose une fonction pour restaurer une session sauvegardée
|
||||
async function restore(savedPuzzle: Puzzle, savedHistory: string[], savedClicks: number) {
|
||||
async function restore(
|
||||
savedPuzzle: Puzzle,
|
||||
savedHistory: string[],
|
||||
savedClicks: number,
|
||||
) {
|
||||
setPuzzle(savedPuzzle);
|
||||
clicksRef.current = savedClicks; setClicksDisplay(savedClicks);
|
||||
clicksRef.current = savedClicks;
|
||||
setClicksDisplay(savedClicks);
|
||||
const lastTitle = savedHistory[savedHistory.length - 1];
|
||||
const canonical = await loadArticle(lastTitle);
|
||||
if (!canonical) return false;
|
||||
@@ -126,18 +166,26 @@ export function useSoloGame() {
|
||||
}
|
||||
|
||||
return {
|
||||
phase, puzzle, html, title, loading, loadError, history,
|
||||
clicks: clicksDisplay, elapsed: timer.elapsed,
|
||||
phase,
|
||||
puzzle,
|
||||
html,
|
||||
title,
|
||||
loading,
|
||||
loadError,
|
||||
history,
|
||||
clicks: clicksDisplay,
|
||||
elapsed: timer.elapsed,
|
||||
canGoBack: pathRef.current.length > 1,
|
||||
start, navigate, goBack, reset, restore,
|
||||
start,
|
||||
navigate,
|
||||
goBack,
|
||||
reset,
|
||||
restore,
|
||||
retryLoad: () => title && loadArticle(title),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSoloKeyboard(
|
||||
active: boolean,
|
||||
goBack: () => void,
|
||||
) {
|
||||
export function useSoloKeyboard(active: boolean, goBack: () => void) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
||||
+6
-1
@@ -39,7 +39,12 @@ export function useTimer() {
|
||||
const stop = useCallback(() => stopRef.current(), []);
|
||||
const reset = useCallback(() => resetRef.current(), []);
|
||||
|
||||
useEffect(() => () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); }, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { elapsed, start, stop, reset };
|
||||
}
|
||||
|
||||
+3
-1
@@ -19,5 +19,7 @@ export async function saveGame(data: {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch { /* silencieux - pas de compte ou hors ligne */ }
|
||||
} catch {
|
||||
/* silencieux - pas de compte ou hors ligne */
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -83,7 +83,9 @@ async function fetchRandomCandidates(): Promise<string[]> {
|
||||
});
|
||||
const res = await fetch(`${WIKI_API_BASE}?${params}`);
|
||||
if (!res.ok) throw new Error("Erreur reseau");
|
||||
const data = await res.json() as { query: { pages: Record<string, WikiPageInfo> } };
|
||||
const data = (await res.json()) as {
|
||||
query: { pages: Record<string, WikiPageInfo> };
|
||||
};
|
||||
return Object.values(data.query.pages)
|
||||
.filter(isGoodArticle)
|
||||
.map((p) => p.title);
|
||||
@@ -96,7 +98,8 @@ export async function pickTwoArticles(): Promise<Puzzle> {
|
||||
const batch = await fetchRandomCandidates();
|
||||
for (const title of batch) {
|
||||
if (!collected.includes(title)) collected.push(title);
|
||||
if (collected.length >= 2) return { start: collected[0], target: collected[1] };
|
||||
if (collected.length >= 2)
|
||||
return { start: collected[0], target: collected[1] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user