361 lines
No EOL
12 KiB
TypeScript
361 lines
No EOL
12 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question } from '../types';
|
|
import { generateQuiz } from '../services/geminiService';
|
|
import { POINTS_PER_QUESTION, QUESTION_TIME } from '../constants';
|
|
import { Peer, DataConnection } from 'peerjs';
|
|
|
|
export const useGame = () => {
|
|
const [role, setRole] = useState<GameRole>('HOST');
|
|
const [gameState, setGameState] = useState<GameState>('LANDING');
|
|
const [quiz, setQuiz] = useState<Quiz | null>(null);
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [players, setPlayers] = useState<Player[]>([]);
|
|
const [timeLeft, setTimeLeft] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasAnswered, setHasAnswered] = useState(false);
|
|
const [gamePin, setGamePin] = useState<string | null>(null);
|
|
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
|
|
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
|
|
const [selectedOption, setSelectedOption] = useState<AnswerOption | null>(null);
|
|
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const peerRef = useRef<Peer | null>(null);
|
|
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
|
|
const hostConnectionRef = useRef<DataConnection | null>(null);
|
|
|
|
// Refs for callbacks/async functions to access latest state
|
|
const timeLeftRef = useRef(0);
|
|
const playersRef = useRef<Player[]>([]);
|
|
const currentQuestionIndexRef = useRef(0);
|
|
const quizRef = useRef<Quiz | null>(null);
|
|
|
|
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
|
useEffect(() => { playersRef.current = players; }, [players]);
|
|
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
|
|
useEffect(() => { quizRef.current = quiz; }, [quiz]);
|
|
|
|
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
|
|
|
// -- HOST LOGIC --
|
|
|
|
const startQuizGen = async (topic: string) => {
|
|
try {
|
|
setGameState('GENERATING');
|
|
setError(null);
|
|
setRole('HOST');
|
|
const generatedQuiz = await generateQuiz(topic);
|
|
initializeHostGame(generatedQuiz);
|
|
} catch (e) {
|
|
setError("Failed to generate quiz.");
|
|
setGameState('LANDING');
|
|
}
|
|
};
|
|
|
|
const startManualCreation = () => {
|
|
setRole('HOST');
|
|
setGameState('CREATING');
|
|
};
|
|
|
|
const finalizeManualQuiz = (manualQuiz: Quiz) => {
|
|
initializeHostGame(manualQuiz);
|
|
};
|
|
|
|
// We use a ref to hold the current handleHostData function
|
|
// This prevents stale closures in the PeerJS event listeners
|
|
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
|
|
|
const initializeHostGame = (newQuiz: Quiz) => {
|
|
setQuiz(newQuiz);
|
|
const pin = generateGamePin();
|
|
setGamePin(pin);
|
|
|
|
const peer = new Peer(`openhoot-${pin}`);
|
|
peerRef.current = peer;
|
|
|
|
peer.on('open', (id) => {
|
|
const hostPlayer: Player = {
|
|
id: 'host',
|
|
name: 'Host (You)',
|
|
score: 0,
|
|
streak: 0,
|
|
lastAnswerCorrect: null,
|
|
isBot: false,
|
|
avatarSeed: Math.random()
|
|
};
|
|
setPlayers([hostPlayer]);
|
|
setGameState('LOBBY');
|
|
});
|
|
|
|
peer.on('connection', (conn) => {
|
|
conn.on('data', (data: any) => {
|
|
// Delegate to the ref to ensure we always use the latest function closure
|
|
handleHostDataRef.current(conn, data);
|
|
});
|
|
});
|
|
|
|
peer.on('error', () => {
|
|
setError("Network error. Try a different topic or reload.");
|
|
setGameState('LANDING');
|
|
});
|
|
};
|
|
|
|
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
|
|
if (data.type === 'JOIN') {
|
|
const newPlayer: Player = {
|
|
id: conn.peer,
|
|
name: data.payload.name,
|
|
score: 0,
|
|
streak: 0,
|
|
lastAnswerCorrect: null,
|
|
isBot: false,
|
|
avatarSeed: Math.random()
|
|
};
|
|
setPlayers(prev => {
|
|
if (prev.find(p => p.id === newPlayer.id)) return prev;
|
|
return [...prev, newPlayer];
|
|
});
|
|
connectionsRef.current.set(conn.peer, conn);
|
|
conn.send({ type: 'WELCOME', payload: { playerId: conn.peer, quizTitle: 'OpenHoot', players: [] } });
|
|
}
|
|
|
|
if (data.type === 'ANSWER') {
|
|
const { playerId, isCorrect } = data.payload;
|
|
|
|
const currentPlayer = playersRef.current.find(p => p.id === playerId);
|
|
|
|
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
|
|
|
|
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
|
|
const newScore = currentPlayer.score + points;
|
|
|
|
setPlayers(prev => prev.map(p => {
|
|
if (p.id !== playerId) return p;
|
|
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
|
|
}));
|
|
|
|
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
|
|
}
|
|
};
|
|
|
|
// Update the ref whenever handleHostData changes (which happens on render)
|
|
useEffect(() => {
|
|
handleHostDataRef.current = handleHostData;
|
|
});
|
|
|
|
const broadcast = (msg: NetworkMessage) => {
|
|
connectionsRef.current.forEach(conn => { if (conn.open) conn.send(msg); });
|
|
};
|
|
|
|
const startHostGame = () => {
|
|
setCurrentQuestionIndex(0);
|
|
broadcast({ type: 'GAME_START', payload: {} });
|
|
startCountdown();
|
|
};
|
|
|
|
const startCountdown = () => {
|
|
setGameState('COUNTDOWN');
|
|
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
|
|
|
|
let count = 3;
|
|
setTimeLeft(count);
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => {
|
|
count--;
|
|
setTimeLeft(count);
|
|
if (count <= 0) {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
startQuestion();
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
const startQuestion = () => {
|
|
setGameState('QUESTION');
|
|
setHasAnswered(false);
|
|
setLastPointsEarned(null);
|
|
setSelectedOption(null);
|
|
setTimeLeft(QUESTION_TIME);
|
|
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
|
|
|
|
// Use refs to get the latest state inside this async callback
|
|
const currentQuiz = quizRef.current;
|
|
const currentIndex = currentQuestionIndexRef.current;
|
|
|
|
if (currentQuiz) {
|
|
const currentQ = currentQuiz.questions[currentIndex];
|
|
// Ensure options exist
|
|
const options = currentQ.options || [];
|
|
const correctOpt = options.find(o => o.isCorrect);
|
|
const correctShape = correctOpt?.shape || 'triangle';
|
|
setCurrentCorrectShape(correctShape);
|
|
|
|
const optionsForClient = options.map(o => ({
|
|
...o,
|
|
isCorrect: false // Masked
|
|
}));
|
|
|
|
broadcast({
|
|
type: 'QUESTION_START',
|
|
payload: {
|
|
totalQuestions: currentQuiz.questions.length,
|
|
currentQuestionIndex: currentIndex,
|
|
timeLimit: QUESTION_TIME,
|
|
correctShape,
|
|
questionText: currentQ.text,
|
|
options: optionsForClient
|
|
}
|
|
});
|
|
}
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => {
|
|
if (prev <= 1) { endQuestion(); return 0; }
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
};
|
|
|
|
const endQuestion = () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
setGameState('REVEAL');
|
|
broadcast({ type: 'TIME_UP', payload: {} });
|
|
setTimeout(() => setGameState('SCOREBOARD'), 4000);
|
|
};
|
|
|
|
const nextQuestion = () => {
|
|
const currentQuiz = quizRef.current;
|
|
const currentIndex = currentQuestionIndexRef.current;
|
|
|
|
if (!currentQuiz) return;
|
|
|
|
if (currentIndex < currentQuiz.questions.length - 1) {
|
|
setCurrentQuestionIndex(prev => prev + 1);
|
|
setTimeout(() => startCountdown(), 0);
|
|
} else {
|
|
setGameState('PODIUM');
|
|
broadcast({ type: 'GAME_OVER', payload: { players } });
|
|
}
|
|
};
|
|
|
|
// -- CLIENT LOGIC --
|
|
|
|
const joinGame = (pin: string, name: string) => {
|
|
setRole('CLIENT');
|
|
setError(null);
|
|
setGamePin(pin);
|
|
const peer = new Peer();
|
|
peerRef.current = peer;
|
|
|
|
peer.on('open', () => {
|
|
const conn = peer.connect(`openhoot-${pin}`);
|
|
hostConnectionRef.current = conn;
|
|
conn.on('open', () => {
|
|
conn.send({ type: 'JOIN', payload: { name } });
|
|
setGameState('LOBBY');
|
|
});
|
|
conn.on('data', (data: any) => handleClientData(data));
|
|
conn.on('close', () => { setError("Disconnected"); setGameState('LANDING'); });
|
|
setTimeout(() => { if (!conn.open) setError("Check PIN"); }, 5000);
|
|
});
|
|
};
|
|
|
|
const handleClientData = (data: NetworkMessage) => {
|
|
if (data.type === 'WELCOME') setQuiz({ title: data.payload.quizTitle, questions: [] });
|
|
|
|
if (data.type === 'START_COUNTDOWN') {
|
|
setGameState('COUNTDOWN');
|
|
setTimeLeft(data.payload.duration);
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => Math.max(0, prev - 1));
|
|
}, 1000);
|
|
}
|
|
|
|
if (data.type === 'QUESTION_START') {
|
|
setGameState('QUESTION');
|
|
setHasAnswered(false);
|
|
setLastPointsEarned(null);
|
|
setSelectedOption(null);
|
|
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
|
|
setTimeLeft(data.payload.timeLimit);
|
|
setCurrentCorrectShape(data.payload.correctShape);
|
|
|
|
setQuiz(prev => {
|
|
const tempQuestions = prev ? [...prev.questions] : [];
|
|
while (tempQuestions.length < data.payload.totalQuestions) {
|
|
tempQuestions.push({ id: `loading-${tempQuestions.length}`, text: '', options: [], timeLimit: 0 });
|
|
}
|
|
tempQuestions[data.payload.currentQuestionIndex] = {
|
|
id: `q-${data.payload.currentQuestionIndex}`,
|
|
text: data.payload.questionText,
|
|
options: data.payload.options,
|
|
timeLimit: data.payload.timeLimit
|
|
};
|
|
return { title: prev?.title || 'Quiz', questions: tempQuestions };
|
|
});
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 1)), 1000);
|
|
}
|
|
|
|
if (data.type === 'RESULT') {
|
|
setLastPointsEarned(data.payload.scoreAdded);
|
|
}
|
|
|
|
if (data.type === 'TIME_UP') {
|
|
setGameState('REVEAL');
|
|
}
|
|
|
|
if (data.type === 'SHOW_SCOREBOARD') {
|
|
setGameState('SCOREBOARD');
|
|
setPlayers(data.payload.players);
|
|
}
|
|
if (data.type === 'GAME_OVER') {
|
|
setGameState('PODIUM');
|
|
setPlayers(data.payload.players);
|
|
}
|
|
};
|
|
|
|
const handleAnswer = (arg: boolean | AnswerOption) => {
|
|
if (hasAnswered || gameState !== 'QUESTION') return;
|
|
setHasAnswered(true);
|
|
|
|
if (role === 'HOST') {
|
|
const option = arg as AnswerOption;
|
|
const isCorrect = option.isCorrect;
|
|
setSelectedOption(option);
|
|
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
|
|
setLastPointsEarned(points);
|
|
|
|
setPlayers(prev => prev.map(p => {
|
|
if (p.id !== 'host') return p;
|
|
return { ...p, score: p.score + points, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
|
|
}));
|
|
} else {
|
|
const option = arg as AnswerOption;
|
|
setSelectedOption(option);
|
|
const isCorrect = option.shape === currentCorrectShape;
|
|
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect } });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
|
|
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
|
|
}
|
|
}, [gameState, players, role]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
if (peerRef.current) peerRef.current.destroy();
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption,
|
|
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
|
|
};
|
|
}; |