diff --git a/App.tsx b/App.tsx index 7834e11..8fddd04 100644 --- a/App.tsx +++ b/App.tsx @@ -106,7 +106,8 @@ function App() { kickPlayer, leaveGame, connectedPlayerIds, - setHostName + setHostName, + endQuestion, } = useGame(defaultConfig); const handleSaveQuiz = async () => { @@ -275,6 +276,8 @@ function App() { hasAnswered={hasAnswered} lastPointsEarned={lastPointsEarned} hostPlays={gameConfig.hostParticipates} + timerEnabled={gameConfig.timerEnabled} + onEndQuestion={role === 'HOST' ? endQuestion : undefined} /> ) : role === 'CLIENT' && hasAnswered ? (
diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx index 7c1338e..4a298af 100644 --- a/components/GameConfigPanel.tsx +++ b/components/GameConfigPanel.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users } from 'lucide-react'; +import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp, Dices, Users, Timer } from 'lucide-react'; import type { GameConfig } from '../types'; interface GameConfigPanelProps { @@ -263,6 +263,17 @@ export const GameConfigPanel: React.FC = ({
+ + } + iconActive={config.timerEnabled} + label="Question Timer" + description="Countdown timer for each question" + checked={config.timerEnabled} + onChange={(v) => update({ timerEnabled: v })} + tooltip="When disabled, questions have no time limit. The host must manually end each question. All correct answers receive maximum points." + /> + } iconActive={config.shuffleQuestions} diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index 244da2b..f817e88 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Question, AnswerOption, GameState, GameRole } from '../types'; import { COLORS, SHAPES } from '../constants'; import { motion, AnimatePresence } from 'framer-motion'; +import { StopCircle } from 'lucide-react'; interface GameScreenProps { question?: Question; @@ -14,6 +15,8 @@ interface GameScreenProps { hasAnswered: boolean; lastPointsEarned: number | null; hostPlays?: boolean; + timerEnabled?: boolean; + onEndQuestion?: () => void; } export const GameScreen: React.FC = ({ @@ -26,13 +29,15 @@ export const GameScreen: React.FC = ({ onAnswer, hasAnswered, hostPlays = true, + timerEnabled = true, + onEndQuestion, }) => { const isClient = role === 'CLIENT'; const isSpectator = role === 'HOST' && !hostPlays; const displayOptions = question?.options || []; const timeLeftSeconds = Math.ceil(timeLeft / 1000); - const isUrgent = timeLeftSeconds <= 5 && timeLeftSeconds > 0; + const isUrgent = timerEnabled && timeLeftSeconds <= 5 && timeLeftSeconds > 0; const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white'; const timerTextColor = isUrgent ? 'text-red-500' : 'text-theme-primary'; const timerAnimation = isUrgent ? 'animate-ping' : ''; @@ -45,11 +50,26 @@ export const GameScreen: React.FC = ({ {currentQuestionIndex + 1} / {totalQuestions} -
+
-
- {timeLeftSeconds} -
+ {timerEnabled ? ( +
+ {timeLeftSeconds} +
+ ) : ( +
+ ∞ +
+ )} + {!timerEnabled && role === 'HOST' && onEndQuestion && ( + + )}
@@ -163,4 +183,4 @@ export const GameScreen: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 0d3f64d..74488fe 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -4,7 +4,7 @@ import { useAuth } from 'react-oidc-context'; import { useAuthenticatedFetch } from './useAuthenticatedFetch'; import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { generateQuiz } from '../services/geminiService'; -import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; +import { PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { Peer, DataConnection, PeerOptions } from 'peerjs'; import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator'; @@ -975,6 +975,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { selectedShape: null, assignedName, presenterId: presenterIdRef.current, + timerEnabled: gameConfigRef.current.timerEnabled, }; if (currentQuestion) { @@ -985,6 +986,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { ...o, isCorrect: false })); + welcomePayload.questionTimeLimit = currentQuestion.timeLimit; } if (reconnectedPlayer) { @@ -1030,7 +1032,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, - questionTimeMs: QUESTION_TIME_MS, + questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000, streak: newStreak, playerRank, isFirstCorrect, @@ -1133,7 +1135,6 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const startQuestion = (isResume: boolean = false) => { setGameState('QUESTION'); - setTimeLeft(QUESTION_TIME_MS); setFirstCorrectPlayerId(null); if (isResume) { @@ -1162,6 +1163,8 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const options = currentQ.options || []; const correctOpt = options.find(o => o.isCorrect); const correctShape = correctOpt?.shape || 'triangle'; + const questionTimeMs = currentQ.timeLimit * 1000; + setTimeLeft(questionTimeMs); setCurrentCorrectShape(correctShape); const optionsForClient = options.map(o => ({ @@ -1169,32 +1172,37 @@ export const useGame = (defaultGameConfig?: GameConfig) => { isCorrect: false })); + const timerOn = gameConfigRef.current.timerEnabled; broadcast({ type: 'QUESTION_START', payload: { totalQuestions: currentQuiz.questions.length, currentQuestionIndex: currentIndex, - timeLimit: QUESTION_TIME, + timeLimit: currentQ.timeLimit, correctShape, questionText: currentQ.text, - options: optionsForClient + options: optionsForClient, + timerEnabled: timerOn, } }); } if (timerRef.current) clearInterval(timerRef.current); - let tickCount = 0; - timerRef.current = setInterval(() => { - setTimeLeft(prev => { - if (prev <= 100) { endQuestion(); return 0; } - const newTime = prev - 100; - tickCount++; - if (tickCount % 10 === 0) { - broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } }); - } - return newTime; - }); - }, 100); + + if (gameConfigRef.current.timerEnabled) { + let tickCount = 0; + timerRef.current = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 100) { endQuestion(); return 0; } + const newTime = prev - 100; + tickCount++; + if (tickCount % 10 === 0) { + broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } }); + } + return newTime; + }); + }, 100); + } }; const endQuestion = () => { @@ -1209,7 +1217,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const breakdown = calculatePointsWithBreakdown({ isCorrect: false, timeLeftMs: 0, - questionTimeMs: QUESTION_TIME_MS, + questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000, streak: 0, playerRank, isFirstCorrect: false, @@ -1463,7 +1471,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { id: `q-${i}`, text: payload.questionText, options: payload.options, - timeLimit: QUESTION_TIME + timeLimit: payload.questionTimeLimit || 20 }); } else { questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 }); @@ -1482,7 +1490,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => { setGameState(serverGameState); if (!playerHasAnswered && serverGameState === 'QUESTION') { if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); + if (payload.timerEnabled !== false) { + timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); + } } } else if (serverGameState === 'REVEAL') { setGameState('REVEAL'); @@ -1533,7 +1543,9 @@ export const useGame = (defaultGameConfig?: GameConfig) => { }); if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); + if (data.payload.timerEnabled !== false) { + timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100); + } } if (data.type === 'RESULT') { @@ -1629,7 +1641,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, - questionTimeMs: QUESTION_TIME_MS, + questionTimeMs: (quizRef.current?.questions[currentQuestionIndexRef.current]?.timeLimit || 20) * 1000, streak: newStreak, playerRank, isFirstCorrect, @@ -1759,7 +1771,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig, pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName, presenterId, connectedPlayerIds, - startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, + startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard, endQuestion, updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame, setPresenterPlayer, sendAdvance, kickPlayer, leaveGame, setHostName }; }; diff --git a/tests/components/DefaultConfigModal.test.tsx b/tests/components/DefaultConfigModal.test.tsx index de57cba..34dea40 100644 --- a/tests/components/DefaultConfigModal.test.tsx +++ b/tests/components/DefaultConfigModal.test.tsx @@ -294,6 +294,8 @@ describe('DefaultConfigModal', () => { penaltyPercent: 30, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 75, + timerEnabled: true, + maxPlayers: 10, }; render(); diff --git a/tests/components/GameConfigPanel.test.tsx b/tests/components/GameConfigPanel.test.tsx index 079d1de..b417aa2 100644 --- a/tests/components/GameConfigPanel.test.tsx +++ b/tests/components/GameConfigPanel.test.tsx @@ -415,6 +415,8 @@ describe('GameConfigPanel', () => { penaltyPercent: 30, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 75, + timerEnabled: true, + maxPlayers: 10, }; render(); @@ -439,6 +441,8 @@ describe('GameConfigPanel', () => { penaltyPercent: 25, firstCorrectBonusEnabled: false, firstCorrectBonusPoints: 50, + timerEnabled: true, + maxPlayers: 10, }; render(); diff --git a/tests/components/QuizEditorConfig.test.tsx b/tests/components/QuizEditorConfig.test.tsx index 11b7434..8a4ba31 100644 --- a/tests/components/QuizEditorConfig.test.tsx +++ b/tests/components/QuizEditorConfig.test.tsx @@ -347,6 +347,8 @@ describe('QuizEditor - Game Config Integration', () => { penaltyPercent: 30, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 75, + timerEnabled: true, + maxPlayers: 10, }, }); diff --git a/tests/hooks/useQuizLibrary.test.tsx b/tests/hooks/useQuizLibrary.test.tsx index 0679f72..c0dfe0d 100644 --- a/tests/hooks/useQuizLibrary.test.tsx +++ b/tests/hooks/useQuizLibrary.test.tsx @@ -294,6 +294,8 @@ describe('useQuizLibrary', () => { penaltyPercent: 25, firstCorrectBonusEnabled: false, firstCorrectBonusPoints: 50, + timerEnabled: true, + maxPlayers: 10, }, }); @@ -1135,6 +1137,8 @@ it('creates blob with correct mime type', async () => { penaltyPercent: 10, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 25, + timerEnabled: true, + maxPlayers: 10, }; await act(async () => { diff --git a/types.ts b/types.ts index 3e9d399..b69677f 100644 --- a/types.ts +++ b/types.ts @@ -67,6 +67,7 @@ export interface Question { } export interface GameConfig { + timerEnabled: boolean; shuffleQuestions: boolean; shuffleAnswers: boolean; hostParticipates: boolean; @@ -84,6 +85,7 @@ export interface GameConfig { } export const DEFAULT_GAME_CONFIG: GameConfig = { + timerEnabled: true, shuffleQuestions: false, shuffleAnswers: false, hostParticipates: true, @@ -205,8 +207,10 @@ export type NetworkMessage = options?: AnswerOption[]; correctShape?: string; timeLeft?: number; + questionTimeLimit?: number; assignedName?: string; presenterId?: string | null; + timerEnabled?: boolean; } } | { type: 'PLAYER_JOINED'; payload: { player: Player } } | { type: 'GAME_START'; payload: {} } @@ -220,6 +224,7 @@ export type NetworkMessage = correctShape: string; questionText: string; options: AnswerOption[]; + timerEnabled?: boolean; } } | { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean; selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' } }