kaboot/components/GameScreen.tsx
Joey Yakimowich-Payne d1f82440a1
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.
2026-02-23 13:44:12 -07:00

186 lines
8.2 KiB
TypeScript

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;
timeLeft: number;
totalQuestions: number;
currentQuestionIndex: number;
gameState: GameState;
role: GameRole;
onAnswer: (isCorrect: boolean) => void;
hasAnswered: boolean;
lastPointsEarned: number | null;
hostPlays?: boolean;
timerEnabled?: boolean;
onEndQuestion?: () => void;
}
export const GameScreen: React.FC<GameScreenProps> = ({
question,
timeLeft,
totalQuestions,
currentQuestionIndex,
gameState,
role,
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 = 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' : '';
return (
<div className="flex flex-col h-screen max-h-screen overflow-hidden relative">
{/* Header */}
<div className="flex justify-between items-center p-2 md:p-6 shrink-0">
<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">
{currentQuestionIndex + 1} / {totalQuestions}
</div>
<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">
{isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
</div>
</div>
{/* Question Area */}
<div className="shrink-0 flex flex-col items-center justify-center p-2 md:p-8 text-center relative z-10">
{question && (
<motion.div
key={question.id}
initial={{ y: 20, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="bg-white text-black p-4 md:p-12 rounded-2xl md:rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-4 md:border-b-8 border-gray-200"
>
<h2 className="text-lg md:text-5xl font-black text-[#333] font-display leading-tight">
{question.text}
</h2>
</motion.div>
)}
</div>
{/* Answer Grid */}
<div className="grid grid-cols-2 gap-2 md:gap-6 p-2 md:p-6 flex-1 min-h-0">
{displayOptions.map((option, idx) => {
const ShapeIcon = SHAPES[option.shape];
const colorClass = COLORS[option.color];
let opacityClass = "opacity-100";
let scaleClass = "scale-100";
let cursorClass = "";
if (isSpectator) {
cursorClass = "cursor-default";
} else if (hasAnswered) {
opacityClass = "opacity-50 cursor-not-allowed grayscale";
scaleClass = "scale-95";
}
return (
<motion.button
key={idx}
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
disabled={hasAnswered || isSpectator}
onClick={() => !isSpectator && onAnswer(option as any)}
className={`
${colorClass} ${opacityClass} ${scaleClass} ${cursorClass}
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
flex flex-col md:flex-row items-center justify-center md:justify-start
p-4 md:p-8
${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''}
transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10
`}
>
<ShapeIcon className="absolute -right-6 -bottom-6 w-32 h-32 text-black/10 rotate-12 group-hover:rotate-45 transition-transform duration-500" />
<div className="flex-shrink-0 mb-2 md:mb-0 md:mr-6 bg-black/20 p-3 md:p-4 rounded-2xl shadow-inner">
<ShapeIcon className="w-8 h-8 md:w-12 md:h-12 text-white" fill="currentColor" />
</div>
<span className="text-lg md:text-3xl font-black text-white text-center md:text-left drop-shadow-md leading-tight relative z-10">
{option.text}
</span>
</motion.button>
);
})}
</div>
{/* "Answer Sent" Overlay */}
<AnimatePresence>
{isClient && hasAnswered && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/20 backdrop-blur-[2px] flex flex-col items-center justify-center z-50 p-4"
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-theme-primary/40 backdrop-blur-xl border border-white/20 p-8 md:p-12 rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] text-center max-w-md w-full relative overflow-hidden"
>
<div className="absolute -inset-full bg-gradient-to-tr from-transparent via-white/10 to-transparent rotate-45 animate-pulse pointer-events-none" />
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-white/10 to-transparent pointer-events-none" />
<motion.div
animate={{
y: [0, -15, 0],
rotate: [0, 5, -5, 0],
scale: [1, 1.1, 1]
}}
transition={{ repeat: Infinity, duration: 3, ease: "easeInOut" }}
className="text-7xl mb-6 drop-shadow-2xl relative z-10 inline-block filter"
>
🚀
</motion.div>
<h2 className="text-4xl font-black text-white font-display mb-3 drop-shadow-lg relative z-10 tracking-tight">
Answer Sent!
</h2>
<p className="text-lg font-bold text-white/80 relative z-10">
Cross your fingers...
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};