2026-04-10 15:43:07 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
2026-04-11 22:25:34 +02:00
|
|
|
import {
|
|
|
|
|
fetchArticle,
|
|
|
|
|
pickTwoArticles,
|
|
|
|
|
prefetchArticle,
|
|
|
|
|
COUNTDOWN_DURATION,
|
|
|
|
|
} from "./wiki";
|
2026-04-10 15:43:07 +02:00
|
|
|
import { useTimer } from "./useTimer";
|
|
|
|
|
import { saveSession, clearSession } from "./session";
|
|
|
|
|
import type { Room } from "../app/api/rooms/route";
|
|
|
|
|
|
|
|
|
|
export function useMultiGame() {
|
|
|
|
|
const timer = useTimer();
|
|
|
|
|
|
|
|
|
|
const [room, setRoom] = useState<Room | null>(null);
|
|
|
|
|
const [playerId, setPlayerId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const clicksRef = useRef(0);
|
|
|
|
|
const [clicksDisplay, setClicksDisplay] = useState(0);
|
|
|
|
|
const [history, setHistory] = useState<string[]>([]);
|
|
|
|
|
const historyRef = useRef<string[]>([]);
|
|
|
|
|
const [html, setHtml] = useState("");
|
|
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
|
const loadingRef = useRef(false);
|
|
|
|
|
const timerStartedRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
const [countdown, setCountdown] = useState<number | null>(null);
|
2026-04-11 22:41:40 +02:00
|
|
|
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
|
|
|
|
const timeLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-04-10 15:43:07 +02:00
|
|
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-04-14 16:00:09 +02:00
|
|
|
const sseRef = useRef<EventSource | null>(null);
|
2026-04-10 15:43:07 +02:00
|
|
|
const prevPhaseRef = useRef<string | null>(null);
|
|
|
|
|
const prevRoundRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
// Article loading
|
|
|
|
|
|
|
|
|
|
async function loadArticle(t: string): Promise<string | null> {
|
2026-04-11 22:25:34 +02:00
|
|
|
setLoading(true);
|
|
|
|
|
loadingRef.current = true;
|
|
|
|
|
setLoadError(null);
|
2026-04-10 15:43:07 +02:00
|
|
|
const art = await fetchArticle(t);
|
2026-04-11 22:25:34 +02:00
|
|
|
setLoading(false);
|
|
|
|
|
loadingRef.current = false;
|
|
|
|
|
if (!art) {
|
|
|
|
|
setLoadError(`Impossible de charger "${t}".`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
setHtml(art.html);
|
|
|
|
|
setTitle(art.title);
|
2026-04-10 15:43:07 +02:00
|
|
|
return art.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Countdown
|
|
|
|
|
|
|
|
|
|
function startCountdown(start: number) {
|
|
|
|
|
if (countdownRef.current) clearInterval(countdownRef.current);
|
|
|
|
|
const tick = () => {
|
|
|
|
|
const rem = Math.ceil((COUNTDOWN_DURATION - (Date.now() - start)) / 1000);
|
|
|
|
|
setCountdown(rem <= 0 ? 0 : rem);
|
|
|
|
|
};
|
|
|
|
|
tick();
|
|
|
|
|
countdownRef.current = setInterval(tick, 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopCountdown() {
|
2026-04-11 22:25:34 +02:00
|
|
|
if (countdownRef.current) {
|
|
|
|
|
clearInterval(countdownRef.current);
|
|
|
|
|
countdownRef.current = null;
|
|
|
|
|
}
|
2026-04-10 15:43:07 +02:00
|
|
|
setCountdown(null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
// SSE
|
2026-04-10 15:43:07 +02:00
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
const sseCodeRef = useRef<string | null>(null);
|
|
|
|
|
const ssePidRef = useRef<string | null>(null);
|
2026-04-10 15:43:07 +02:00
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
function stopSSE() {
|
|
|
|
|
if (sseRef.current) {
|
|
|
|
|
sseRef.current.close();
|
|
|
|
|
sseRef.current = null;
|
2026-04-11 22:25:34 +02:00
|
|
|
}
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
function startSSE(code: string, pid: string) {
|
|
|
|
|
stopSSE();
|
|
|
|
|
sseCodeRef.current = code;
|
|
|
|
|
ssePidRef.current = pid;
|
|
|
|
|
|
|
|
|
|
const es = new EventSource(`/api/rooms/${code}/stream`);
|
|
|
|
|
sseRef.current = es;
|
|
|
|
|
|
|
|
|
|
es.onmessage = (e) => {
|
|
|
|
|
try {
|
|
|
|
|
setRoom(JSON.parse(e.data) as Room);
|
2026-04-14 16:32:32 +02:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-04-14 16:00:09 +02:00
|
|
|
};
|
2026-04-11 22:12:39 +02:00
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
es.onerror = () => {
|
2026-04-14 16:32:32 +02:00
|
|
|
// EventSource reconnecte automatiquement - pas besoin de gérer manuellement
|
2026-04-14 16:00:09 +02:00
|
|
|
};
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 16:00:09 +02:00
|
|
|
// Quand le tab redevient visible : heartbeat immédiat pour rafraîchir l'état
|
2026-04-11 22:12:39 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
function onVisible() {
|
2026-04-14 16:32:32 +02:00
|
|
|
if (
|
|
|
|
|
document.visibilityState === "visible" &&
|
|
|
|
|
sseCodeRef.current &&
|
|
|
|
|
ssePidRef.current
|
|
|
|
|
) {
|
2026-04-14 16:00:09 +02:00
|
|
|
fetch(`/api/rooms/${sseCodeRef.current}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-04-14 16:32:32 +02:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "heartbeat",
|
|
|
|
|
playerId: ssePidRef.current,
|
|
|
|
|
}),
|
2026-04-14 16:00:09 +02:00
|
|
|
})
|
|
|
|
|
.then((r) => r.json())
|
2026-04-14 16:32:32 +02:00
|
|
|
.then((d) => {
|
|
|
|
|
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
|
|
|
|
|
})
|
2026-04-14 16:00:09 +02:00
|
|
|
.catch(() => {});
|
2026-04-11 22:12:39 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener("visibilitychange", onVisible);
|
|
|
|
|
return () => document.removeEventListener("visibilitychange", onVisible);
|
2026-04-11 22:25:34 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-11 22:12:39 +02:00
|
|
|
}, []);
|
|
|
|
|
|
2026-04-10 15:43:07 +02:00
|
|
|
// Phase sync
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!room) return;
|
|
|
|
|
const prevPhase = prevPhaseRef.current;
|
|
|
|
|
const prevRound = prevRoundRef.current;
|
|
|
|
|
prevPhaseRef.current = room.phase;
|
|
|
|
|
prevRoundRef.current = room.round;
|
|
|
|
|
|
|
|
|
|
if (room.phase === "countdown" && prevPhase !== "countdown") {
|
2026-04-11 22:25:34 +02:00
|
|
|
setHtml("");
|
|
|
|
|
setLoadError(null);
|
|
|
|
|
clicksRef.current = 0;
|
|
|
|
|
setClicksDisplay(0);
|
2026-04-10 15:43:07 +02:00
|
|
|
timerStartedRef.current = false;
|
|
|
|
|
timer.reset();
|
|
|
|
|
startCountdown(room.countdownStart ?? Date.now());
|
|
|
|
|
}
|
|
|
|
|
if (room.phase === "playing" && prevPhase !== "playing") {
|
|
|
|
|
stopCountdown();
|
|
|
|
|
historyRef.current = [room.startArticle];
|
|
|
|
|
setHistory([room.startArticle]);
|
|
|
|
|
loadArticle(room.startArticle);
|
2026-04-11 22:41:40 +02:00
|
|
|
// Démarrer le timer de temps limité si configuré
|
|
|
|
|
if (timeLimitTimerRef.current) clearInterval(timeLimitTimerRef.current);
|
|
|
|
|
if (room.timeLimit > 0) {
|
|
|
|
|
const tick = () => {
|
2026-04-13 15:42:00 +02:00
|
|
|
const left = Math.ceil(
|
|
|
|
|
((room.roundStart ?? Date.now()) +
|
|
|
|
|
room.timeLimit * 1000 -
|
|
|
|
|
Date.now()) /
|
|
|
|
|
1000,
|
|
|
|
|
);
|
2026-04-11 22:41:40 +02:00
|
|
|
setTimeLeft(left <= 0 ? 0 : left);
|
|
|
|
|
};
|
|
|
|
|
tick();
|
|
|
|
|
timeLimitTimerRef.current = setInterval(tick, 200);
|
|
|
|
|
} else {
|
|
|
|
|
setTimeLeft(null);
|
|
|
|
|
}
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
if (room.phase === "results" && prevPhase !== "results") {
|
|
|
|
|
timer.stop();
|
2026-04-13 15:42:00 +02:00
|
|
|
if (timeLimitTimerRef.current) {
|
|
|
|
|
clearInterval(timeLimitTimerRef.current);
|
|
|
|
|
timeLimitTimerRef.current = null;
|
|
|
|
|
}
|
2026-04-11 22:41:40 +02:00
|
|
|
setTimeLeft(null);
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
if (room.round !== prevRound && room.phase === "playing") {
|
|
|
|
|
loadArticle(room.startArticle);
|
|
|
|
|
}
|
2026-04-11 22:25:34 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-10 15:43:07 +02:00
|
|
|
}, [room]);
|
|
|
|
|
|
2026-04-11 22:41:40 +02:00
|
|
|
// Temps limité : envoie timeUp quand le compteur atteint 0
|
|
|
|
|
useEffect(() => {
|
2026-04-13 15:42:00 +02:00
|
|
|
if (
|
|
|
|
|
timeLeft !== 0 ||
|
|
|
|
|
!room ||
|
|
|
|
|
room.phase !== "playing" ||
|
|
|
|
|
!playerId ||
|
|
|
|
|
!room.timeLimit
|
|
|
|
|
)
|
|
|
|
|
return;
|
2026-04-11 22:41:40 +02:00
|
|
|
fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "timeUp", playerId }),
|
2026-04-13 15:42:00 +02:00
|
|
|
})
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((d) => {
|
|
|
|
|
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-11 22:41:40 +02:00
|
|
|
}, [timeLeft]);
|
|
|
|
|
|
2026-04-10 15:43:07 +02:00
|
|
|
// Countdown -> playing transition
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!room || room.phase !== "countdown" || !playerId) return;
|
|
|
|
|
if (Date.now() - (room.countdownStart ?? 0) >= COUNTDOWN_DURATION) {
|
|
|
|
|
fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "play", playerId }),
|
2026-04-11 22:25:34 +02:00
|
|
|
})
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((d) => {
|
|
|
|
|
if ((d as { room: Room }).room) setRoom((d as { room: Room }).room);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
}, [countdown, room, playerId]);
|
|
|
|
|
|
|
|
|
|
// Navigation
|
|
|
|
|
|
2026-04-11 22:25:34 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-10 15:43:07 +02:00
|
|
|
|
2026-04-11 22:25:34 +02:00
|
|
|
// Heartbeat optimiste pour éviter le kick pendant le chargement de l'article
|
|
|
|
|
fetch(`/api/rooms/${room.code}`, {
|
2026-04-10 15:43:07 +02:00
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-04-11 22:25:34 +02:00
|
|
|
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],
|
|
|
|
|
);
|
2026-04-10 15:43:07 +02:00
|
|
|
|
|
|
|
|
// Room actions
|
|
|
|
|
|
2026-04-11 22:25:34 +02:00
|
|
|
async function createRoom(
|
|
|
|
|
playerName: string,
|
|
|
|
|
maxPlayers = 16,
|
|
|
|
|
totalRounds = 3,
|
|
|
|
|
gameMode: "race" | "all_finish" = "race",
|
|
|
|
|
): Promise<{ error?: string }> {
|
2026-04-10 15:43:07 +02:00
|
|
|
const res = await fetch("/api/rooms", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-04-11 21:58:35 +02:00
|
|
|
body: JSON.stringify({ playerName, maxPlayers, totalRounds, gameMode }),
|
2026-04-10 15:43:07 +02:00
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
const data = (await res.json()) as {
|
|
|
|
|
room?: Room;
|
|
|
|
|
playerId?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
2026-04-10 15:43:07 +02:00
|
|
|
if (!res.ok) return { error: data.error ?? "Erreur" };
|
2026-04-11 22:25:34 +02:00
|
|
|
setRoom(data.room!);
|
|
|
|
|
setPlayerId(data.playerId!);
|
2026-04-14 16:00:09 +02:00
|
|
|
startSSE(data.room!.code, data.playerId!);
|
2026-04-11 22:25:34 +02:00
|
|
|
saveSession({
|
|
|
|
|
screen: "lobby",
|
|
|
|
|
multiRoomCode: data.room!.code,
|
|
|
|
|
multiPlayerId: data.playerId!,
|
|
|
|
|
playerName,
|
|
|
|
|
});
|
2026-04-10 15:43:07 +02:00
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:25:34 +02:00
|
|
|
async function joinRoom(
|
|
|
|
|
playerName: string,
|
|
|
|
|
code: string,
|
|
|
|
|
): Promise<{ error?: string }> {
|
2026-04-10 15:43:07 +02:00
|
|
|
const res = await fetch(`/api/rooms/${code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "join", playerName }),
|
|
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
const data = (await res.json()) as {
|
|
|
|
|
room?: Room;
|
|
|
|
|
playerId?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
2026-04-10 15:43:07 +02:00
|
|
|
if (!res.ok) return { error: data.error ?? "Impossible de rejoindre" };
|
2026-04-11 22:25:34 +02:00
|
|
|
setRoom(data.room!);
|
|
|
|
|
setPlayerId(data.playerId!);
|
2026-04-14 16:00:09 +02:00
|
|
|
startSSE(data.room!.code, data.playerId!);
|
2026-04-11 22:25:34 +02:00
|
|
|
saveSession({
|
|
|
|
|
screen: "lobby",
|
|
|
|
|
multiRoomCode: data.room!.code,
|
|
|
|
|
multiPlayerId: data.playerId!,
|
|
|
|
|
playerName,
|
|
|
|
|
});
|
2026-04-10 15:43:07 +02:00
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startGame(): Promise<{ error?: string }> {
|
|
|
|
|
if (!room || !playerId) return {};
|
|
|
|
|
const puzzle = await pickTwoArticles();
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-04-11 22:25:34 +02:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "start",
|
|
|
|
|
playerId,
|
|
|
|
|
startArticle: puzzle.start,
|
|
|
|
|
targetArticle: puzzle.target,
|
|
|
|
|
}),
|
2026-04-10 15:43:07 +02:00
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
const data = (await res.json()) as { room?: Room; error?: string };
|
2026-04-10 15:43:07 +02:00
|
|
|
if (!res.ok) return { error: data.error ?? "Erreur" };
|
|
|
|
|
prefetchArticle(puzzle.start);
|
|
|
|
|
setRoom(data.room!);
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:30:47 +02:00
|
|
|
const goBack = useCallback(async () => {
|
2026-04-13 15:42:00 +02:00
|
|
|
if (!room || !playerId || loadingRef.current || room.phase !== "playing")
|
|
|
|
|
return;
|
2026-04-11 22:30:47 +02:00
|
|
|
if (historyRef.current.length <= 1) return;
|
|
|
|
|
|
|
|
|
|
clicksRef.current += 1;
|
|
|
|
|
setClicksDisplay(clicksRef.current);
|
2026-04-13 15:42:00 +02:00
|
|
|
if (!timerStartedRef.current) {
|
|
|
|
|
timer.start();
|
|
|
|
|
timerStartedRef.current = true;
|
|
|
|
|
}
|
2026-04-11 22:30:47 +02:00
|
|
|
|
|
|
|
|
const newHistory = historyRef.current.slice(0, -1);
|
|
|
|
|
const target = newHistory[newHistory.length - 1];
|
|
|
|
|
|
|
|
|
|
fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "heartbeat", playerId }),
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
|
|
|
|
|
const canonical = await loadArticle(target);
|
|
|
|
|
if (!canonical) return;
|
|
|
|
|
|
|
|
|
|
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" },
|
2026-04-13 15:42:00 +02:00
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "navigate",
|
|
|
|
|
playerId,
|
|
|
|
|
article: canonical,
|
|
|
|
|
}),
|
2026-04-11 22:30:47 +02:00
|
|
|
});
|
2026-04-13 15:42:00 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
|
|
|
|
} catch {
|
|
|
|
|
/* on continue localement */
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-11 22:30:47 +02:00
|
|
|
}, [room, playerId]);
|
|
|
|
|
|
2026-04-12 23:59:40 +02:00
|
|
|
async function setGameMode(value: "race" | "all_finish") {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "setGameMode", playerId, value }),
|
|
|
|
|
});
|
2026-04-13 15:42:00 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-12 23:59:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:41:40 +02:00
|
|
|
async function setTimeLimit(value: number) {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "setTimeLimit", playerId, value }),
|
|
|
|
|
});
|
2026-04-13 15:42:00 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setTotalRounds(value: number) {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "setTotalRounds", playerId, value }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-11 22:41:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 19:26:50 +02:00
|
|
|
async function setMaxPlayers(value: number) {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "setMaxPlayers", playerId, value }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:02:06 +02:00
|
|
|
async function setSearchAllowed(value: boolean) {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "setSearchAllowed", playerId, value }),
|
|
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-11 22:02:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 21:58:35 +02:00
|
|
|
async function surrender() {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "surrender", playerId }),
|
|
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-11 21:58:35 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:07 +02:00
|
|
|
async function nextRound() {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "nextRound", playerId }),
|
|
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resetGame() {
|
|
|
|
|
if (!room || !playerId) return;
|
|
|
|
|
const res = await fetch(`/api/rooms/${room.code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "resetGame", playerId }),
|
|
|
|
|
});
|
2026-04-11 22:25:34 +02:00
|
|
|
if (res.ok) setRoom(((await res.json()) as { room: Room }).room);
|
2026-04-10 15:43:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function leave() {
|
2026-04-14 16:00:09 +02:00
|
|
|
stopSSE();
|
2026-04-11 22:25:34 +02:00
|
|
|
stopCountdown();
|
|
|
|
|
timer.stop();
|
|
|
|
|
setRoom(null);
|
|
|
|
|
setPlayerId(null);
|
|
|
|
|
setHtml("");
|
|
|
|
|
setTitle("");
|
|
|
|
|
setHistory([]);
|
|
|
|
|
historyRef.current = [];
|
|
|
|
|
clicksRef.current = 0;
|
|
|
|
|
setClicksDisplay(0);
|
2026-04-10 15:43:07 +02:00
|
|
|
timerStartedRef.current = false;
|
|
|
|
|
clearSession();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore session depuis sessionStorage (F5)
|
|
|
|
|
async function restore(code: string, pid: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/rooms/${code}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ action: "heartbeat", playerId: pid }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) return false;
|
2026-04-11 22:25:34 +02:00
|
|
|
const data = (await res.json()) as { room: Room };
|
2026-04-10 15:43:07 +02:00
|
|
|
setRoom(data.room);
|
|
|
|
|
setPlayerId(pid);
|
2026-04-14 16:00:09 +02:00
|
|
|
startSSE(code, pid);
|
2026-04-10 15:43:07 +02:00
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-11 22:25:34 +02:00
|
|
|
room,
|
|
|
|
|
playerId,
|
|
|
|
|
html,
|
|
|
|
|
title,
|
|
|
|
|
loading,
|
|
|
|
|
loadError,
|
|
|
|
|
history,
|
|
|
|
|
clicks: clicksDisplay,
|
|
|
|
|
elapsed: timer.elapsed,
|
|
|
|
|
countdown,
|
|
|
|
|
createRoom,
|
|
|
|
|
joinRoom,
|
|
|
|
|
startGame,
|
|
|
|
|
nextRound,
|
|
|
|
|
resetGame,
|
|
|
|
|
leave,
|
|
|
|
|
navigate,
|
2026-04-11 22:30:47 +02:00
|
|
|
goBack,
|
|
|
|
|
canGoBack: historyRef.current.length > 1,
|
2026-04-11 22:25:34 +02:00
|
|
|
surrender,
|
2026-04-11 22:41:40 +02:00
|
|
|
timeLeft,
|
2026-04-12 23:59:40 +02:00
|
|
|
setGameMode,
|
2026-04-13 15:42:00 +02:00
|
|
|
setTotalRounds,
|
2026-04-15 19:26:50 +02:00
|
|
|
setMaxPlayers,
|
2026-04-11 22:41:40 +02:00
|
|
|
setTimeLimit,
|
2026-04-11 22:25:34 +02:00
|
|
|
setSearchAllowed,
|
|
|
|
|
restore,
|
2026-04-10 15:43:07 +02:00
|
|
|
retryLoad: () => title && loadArticle(title),
|
|
|
|
|
};
|
|
|
|
|
}
|