Add disable timer setting and fix per-question time limits
Add a 'Question Timer' toggle to game settings that lets the host disable the countdown timer. When disabled, questions show ∞ instead of a countdown, the host gets an 'End Question' button to manually advance, and all correct answers receive maximum points. Also fix a bug where per-question time limits were ignored — the timer and scoring always used the hardcoded 20-second default instead of each question's individual timeLimit.
This commit is contained in:
parent
bce534486c
commit
d1f82440a1
9 changed files with 94 additions and 31 deletions
5
App.tsx
5
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 ? (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
|
|
|
|||
|
|
@ -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<GameConfigPanelProps> = ({
|
|||
</div>
|
||||
</ValueRow>
|
||||
|
||||
|
||||
<ToggleRow
|
||||
icon={<Timer size={20} />}
|
||||
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."
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<Shuffle size={20} />}
|
||||
iconActive={config.shuffleQuestions}
|
||||
|
|
|
|||
|
|
@ -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<GameScreenProps> = ({
|
||||
|
|
@ -26,13 +29,15 @@ export const GameScreen: React.FC<GameScreenProps> = ({
|
|||
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<GameScreenProps> = ({
|
|||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative flex flex-col items-center gap-2">
|
||||
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
|
||||
{timerEnabled ? (
|
||||
<div className={`bg-white ${timerTextColor} rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-2xl md:text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
|
||||
{timeLeftSeconds}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white text-theme-primary rounded-full w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-3xl md:text-5xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 border-white relative z-10">
|
||||
∞
|
||||
</div>
|
||||
)}
|
||||
{!timerEnabled && role === 'HOST' && onEndQuestion && (
|
||||
<button
|
||||
onClick={onEndQuestion}
|
||||
className="relative z-10 flex items-center gap-1.5 bg-white/90 hover:bg-white text-red-600 px-3 py-1.5 rounded-full text-xs md:text-sm font-bold shadow-lg hover:shadow-xl transition-all active:scale-95"
|
||||
>
|
||||
<StopCircle size={16} />
|
||||
End Question
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/20 backdrop-blur-md px-3 md:px-6 py-1 md:py-2 rounded-xl md:rounded-2xl font-black text-sm md:text-xl shadow-sm border-2 border-white/10">
|
||||
|
|
|
|||
|
|
@ -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,20 +1172,24 @@ 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);
|
||||
|
||||
if (gameConfigRef.current.timerEnabled) {
|
||||
let tickCount = 0;
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
|
|
@ -1195,6 +1202,7 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
|
|||
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,8 +1490,10 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
|
|||
setGameState(serverGameState);
|
||||
if (!playerHasAnswered && serverGameState === 'QUESTION') {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (payload.timerEnabled !== false) {
|
||||
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
|
||||
}
|
||||
}
|
||||
} else if (serverGameState === 'REVEAL') {
|
||||
setGameState('REVEAL');
|
||||
} else if (serverGameState === 'SCOREBOARD') {
|
||||
|
|
@ -1533,8 +1543,10 @@ export const useGame = (defaultGameConfig?: GameConfig) => {
|
|||
});
|
||||
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (data.payload.timerEnabled !== false) {
|
||||
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'RESULT') {
|
||||
setLastPointsEarned(data.payload.scoreAdded);
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -294,6 +294,8 @@ describe('DefaultConfigModal', () => {
|
|||
penaltyPercent: 30,
|
||||
firstCorrectBonusEnabled: true,
|
||||
firstCorrectBonusPoints: 75,
|
||||
timerEnabled: true,
|
||||
maxPlayers: 10,
|
||||
};
|
||||
|
||||
render(<DefaultConfigModal {...defaultProps} config={allEnabledConfig} />);
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ describe('GameConfigPanel', () => {
|
|||
penaltyPercent: 30,
|
||||
firstCorrectBonusEnabled: true,
|
||||
firstCorrectBonusPoints: 75,
|
||||
timerEnabled: true,
|
||||
maxPlayers: 10,
|
||||
};
|
||||
|
||||
render(<GameConfigPanel {...defaultProps} config={allEnabledConfig} />);
|
||||
|
|
@ -439,6 +441,8 @@ describe('GameConfigPanel', () => {
|
|||
penaltyPercent: 25,
|
||||
firstCorrectBonusEnabled: false,
|
||||
firstCorrectBonusPoints: 50,
|
||||
timerEnabled: true,
|
||||
maxPlayers: 10,
|
||||
};
|
||||
|
||||
render(<GameConfigPanel {...defaultProps} config={allDisabledConfig} />);
|
||||
|
|
|
|||
|
|
@ -347,6 +347,8 @@ describe('QuizEditor - Game Config Integration', () => {
|
|||
penaltyPercent: 30,
|
||||
firstCorrectBonusEnabled: true,
|
||||
firstCorrectBonusPoints: 75,
|
||||
timerEnabled: true,
|
||||
maxPlayers: 10,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
5
types.ts
5
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' } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue