Initial commit
This commit is contained in:
commit
c87ebf0a74
22 changed files with 4973 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
147
App.tsx
Normal file
147
App.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import React from 'react';
|
||||
import { useGame } from './hooks/useGame';
|
||||
import { Landing } from './components/Landing';
|
||||
import { Lobby } from './components/Lobby';
|
||||
import { GameScreen } from './components/GameScreen';
|
||||
import { Scoreboard } from './components/Scoreboard';
|
||||
import { Podium } from './components/Podium';
|
||||
import { QuizCreator } from './components/QuizCreator';
|
||||
import { RevealScreen } from './components/RevealScreen';
|
||||
|
||||
const FloatingShapes = () => {
|
||||
// Deterministic "random" for SSR safety if needed, but client-side is fine here
|
||||
const shapes = [...Array(15)].map((_, i) => ({
|
||||
left: `${Math.random() * 100}%`,
|
||||
width: `${Math.random() * 100 + 40}px`,
|
||||
height: `${Math.random() * 100 + 40}px`,
|
||||
animationDuration: `${Math.random() * 20 + 15}s`,
|
||||
animationDelay: `-${Math.random() * 20}s`,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '20%', // Mix of circles and rounded squares
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{shapes.map((style, i) => (
|
||||
<div key={i} className="floating-shape" style={style} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
const {
|
||||
role,
|
||||
gameState,
|
||||
quiz,
|
||||
players,
|
||||
currentQuestionIndex,
|
||||
timeLeft,
|
||||
error,
|
||||
gamePin,
|
||||
startQuizGen,
|
||||
startManualCreation,
|
||||
finalizeManualQuiz,
|
||||
joinGame,
|
||||
startGame,
|
||||
handleAnswer,
|
||||
hasAnswered,
|
||||
lastPointsEarned,
|
||||
nextQuestion,
|
||||
currentCorrectShape
|
||||
} = useGame();
|
||||
|
||||
const currentQ = quiz?.questions[currentQuestionIndex];
|
||||
|
||||
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
|
||||
const correctOpt = currentQ?.options.find(o => {
|
||||
if (role === 'HOST') return o.isCorrect;
|
||||
return o.shape === currentCorrectShape;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white relative">
|
||||
<FloatingShapes />
|
||||
|
||||
<div className="relative z-10">
|
||||
{gameState === 'LANDING' || gameState === 'GENERATING' ? (
|
||||
<Landing
|
||||
onGenerate={startQuizGen}
|
||||
onCreateManual={startManualCreation}
|
||||
onJoin={joinGame}
|
||||
isLoading={gameState === 'GENERATING'}
|
||||
error={error}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gameState === 'CREATING' ? (
|
||||
<QuizCreator
|
||||
onFinalize={finalizeManualQuiz}
|
||||
onCancel={() => window.location.reload()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gameState === 'LOBBY' ? (
|
||||
<Lobby
|
||||
quizTitle={quiz?.title || 'OpenHoot'}
|
||||
players={players}
|
||||
gamePin={gamePin}
|
||||
role={role}
|
||||
onStart={startGame}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
|
||||
gameState === 'COUNTDOWN' ? (
|
||||
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
|
||||
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
|
||||
<div className="text-[12rem] font-black leading-none drop-shadow-[0_10px_0_rgba(0,0,0,0.3)]">
|
||||
{timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GameScreen
|
||||
question={quiz.questions[currentQuestionIndex]}
|
||||
timeLeft={timeLeft}
|
||||
totalQuestions={quiz.questions.length}
|
||||
currentQuestionIndex={currentQuestionIndex}
|
||||
gameState={gameState}
|
||||
role={role}
|
||||
onAnswer={handleAnswer}
|
||||
hasAnswered={hasAnswered}
|
||||
lastPointsEarned={lastPointsEarned}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{gameState === 'REVEAL' && correctOpt ? (
|
||||
<RevealScreen
|
||||
isCorrect={lastPointsEarned !== null && lastPointsEarned > 0}
|
||||
pointsEarned={lastPointsEarned || 0}
|
||||
newScore={0}
|
||||
streak={0}
|
||||
correctOption={correctOpt}
|
||||
role={role}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gameState === 'SCOREBOARD' ? (
|
||||
<Scoreboard
|
||||
players={players}
|
||||
onNext={nextQuestion}
|
||||
isHost={role === 'HOST'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{gameState === 'PODIUM' ? (
|
||||
<Podium
|
||||
players={players}
|
||||
onRestart={() => window.location.reload()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
142
components/GameScreen.tsx
Normal file
142
components/GameScreen.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React from 'react';
|
||||
import { Question, AnswerOption, GameState, GameRole } from '../types';
|
||||
import { COLORS, SHAPES } from '../constants';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface GameScreenProps {
|
||||
question?: Question;
|
||||
timeLeft: number;
|
||||
totalQuestions: number;
|
||||
currentQuestionIndex: number;
|
||||
gameState: GameState;
|
||||
role: GameRole;
|
||||
onAnswer: (isCorrect: boolean) => void;
|
||||
hasAnswered: boolean;
|
||||
lastPointsEarned: number | null;
|
||||
}
|
||||
|
||||
export const GameScreen: React.FC<GameScreenProps> = ({
|
||||
question,
|
||||
timeLeft,
|
||||
totalQuestions,
|
||||
currentQuestionIndex,
|
||||
gameState,
|
||||
role,
|
||||
onAnswer,
|
||||
hasAnswered,
|
||||
}) => {
|
||||
const isClient = role === 'CLIENT';
|
||||
const displayOptions = question?.options || [];
|
||||
|
||||
// Timer styling logic
|
||||
const isUrgent = timeLeft < 5 && timeLeft > 0;
|
||||
const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white';
|
||||
const timerTextColor = isUrgent ? 'text-red-500' : 'text-[#46178f]';
|
||||
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-4 md:p-6">
|
||||
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
|
||||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</div>
|
||||
|
||||
{/* Whimsical Timer */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center 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`}>
|
||||
{timeLeft}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
|
||||
{isClient ? 'Controller' : 'Host'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Area */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4 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-8 md:p-12 rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-8 border-gray-200"
|
||||
>
|
||||
<h2 className="text-2xl 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-3 md:gap-6 p-3 md:p-6 h-1/2 md:h-[45vh] pb-8 md:pb-12">
|
||||
{displayOptions.map((option, idx) => {
|
||||
const ShapeIcon = SHAPES[option.shape];
|
||||
const colorClass = COLORS[option.color];
|
||||
|
||||
let opacityClass = "opacity-100";
|
||||
let scaleClass = "scale-100";
|
||||
|
||||
// If answering phase and user answered, dim everything
|
||||
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}
|
||||
onClick={() => onAnswer(option as any)}
|
||||
className={`
|
||||
${colorClass} ${opacityClass} ${scaleClass}
|
||||
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
|
||||
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-[#46178f]/95 flex flex-col items-center justify-center z-50 p-8 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="text-6xl mb-6"
|
||||
>
|
||||
🚀
|
||||
</motion.div>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
|
||||
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
components/Landing.tsx
Normal file
127
components/Landing.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
|
||||
|
||||
interface LandingProps {
|
||||
onGenerate: (topic: string) => void;
|
||||
onCreateManual: () => void;
|
||||
onJoin: (pin: string, name: string) => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => {
|
||||
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const handleHostSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (topic.trim()) onGenerate(topic);
|
||||
};
|
||||
|
||||
const handleJoinSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pin.trim() && name.trim()) onJoin(pin, name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
|
||||
animate={{ scale: 1, opacity: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.5 }}
|
||||
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
|
||||
>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-[#46178f] p-4 rounded-3xl rotate-3 shadow-lg">
|
||||
<BrainCircuit size={48} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-5xl font-black mb-2 text-[#46178f] tracking-tight">OpenHoot</h1>
|
||||
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
|
||||
|
||||
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8">
|
||||
<button
|
||||
onClick={() => setMode('HOST')}
|
||||
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
Host
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('JOIN')}
|
||||
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'JOIN' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'HOST' ? (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleHostSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Topic (e.g. 'Space')"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none transition-all placeholder:font-medium text-center"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !topic.trim()}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" /> : <><BrainCircuit size={24} /> Generate Quiz</>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative flex py-2 items-center opacity-50">
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
|
||||
<div className="flex-grow border-t-2 border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCreateManual}
|
||||
className="w-full bg-white border-2 border-[#46178f] text-[#46178f] py-3 rounded-2xl text-lg font-black hover:bg-purple-50 shadow-[0_4px_0_#46178f] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PenTool size={20} /> Create Manually
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleJoinSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Game PIN"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nickname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!pin.trim() || !name.trim()}
|
||||
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
|
||||
>
|
||||
<Play fill="currentColor" /> Join Game
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="overflow-hidden mt-4">
|
||||
<p className="text-red-500 font-bold bg-red-50 p-3 rounded-xl border-2 border-red-100">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
components/Lobby.tsx
Normal file
103
components/Lobby.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { User, Sparkles } from 'lucide-react';
|
||||
|
||||
interface LobbyProps {
|
||||
quizTitle: string;
|
||||
players: Player[];
|
||||
gamePin: string | null;
|
||||
role: 'HOST' | 'CLIENT';
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart }) => {
|
||||
const isHost = role === 'HOST';
|
||||
const realPlayers = players.filter(p => p.id !== 'host');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen p-6">
|
||||
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-6 rounded-[2rem] backdrop-blur-md mb-8 gap-6 border-4 border-white/20 shadow-xl">
|
||||
<div className="flex flex-col items-center md:items-start">
|
||||
<span className="text-white/80 font-bold uppercase tracking-widest text-sm mb-1">Game PIN</span>
|
||||
<div className="text-5xl md:text-6xl font-black bg-white text-[#46178f] px-8 py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
|
||||
{gamePin}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black font-display mb-2">{quizTitle}</div>
|
||||
<div className="inline-flex items-center gap-2 bg-[#00000040] px-4 py-1 rounded-full">
|
||||
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="font-bold">Live Lobby</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/20 px-6 py-3 rounded-2xl font-black text-2xl flex flex-col items-center min-w-[120px]">
|
||||
<span>{realPlayers.length}</span>
|
||||
<span className="text-sm font-bold opacity-80 uppercase">Players</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center">
|
||||
{isHost ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4 justify-center w-full max-w-6xl mb-12 min-h-[200px] content-start">
|
||||
<AnimatePresence>
|
||||
{realPlayers.length === 0 && (
|
||||
<div className="flex flex-col items-center opacity-60 mt-12">
|
||||
<div className="bg-white/10 p-6 rounded-full mb-4 animate-bounce">
|
||||
<Sparkles size={48} />
|
||||
</div>
|
||||
<div className="text-3xl font-bold font-display">Waiting for players to join...</div>
|
||||
</div>
|
||||
)}
|
||||
{realPlayers.map((player) => (
|
||||
<motion.div
|
||||
key={player.id}
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="bg-white text-black px-6 py-3 rounded-full font-black text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-3 border-b-4 border-gray-200"
|
||||
>
|
||||
<div className={`p-2 rounded-full bg-gradient-to-br from-purple-400 to-blue-500 text-white`}>
|
||||
<User size={20} />
|
||||
</div>
|
||||
{player.name}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="fixed bottom-8"
|
||||
>
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={realPlayers.length === 0}
|
||||
className="bg-white text-[#46178f] px-16 py-5 rounded-full text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2"
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.6 }}
|
||||
className="bg-white text-[#46178f] p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-8"
|
||||
>
|
||||
<User size={80} strokeWidth={2.5} />
|
||||
</motion.div>
|
||||
<h2 className="text-5xl font-black mb-4 font-display">You're in!</h2>
|
||||
<p className="text-2xl font-bold opacity-80">See your name on the big screen?</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
components/Podium.tsx
Normal file
97
components/Podium.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Trophy, Medal, RotateCcw } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface PodiumProps {
|
||||
players: Player[];
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
|
||||
const sorted = [...players].sort((a, b) => b.score - a.score);
|
||||
const winner = sorted[0];
|
||||
const second = sorted[1];
|
||||
const third = sorted[2];
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 3 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const interval: any = setInterval(function() {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
if (timeLeft <= 0) return clearInterval(interval);
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
||||
}, 250);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<h1 className="text-6xl font-black text-white mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1>
|
||||
|
||||
<div className="flex items-end justify-center gap-4 md:gap-8 mb-12 w-full max-w-4xl h-96">
|
||||
{/* Second Place */}
|
||||
{second && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "60%" }}
|
||||
transition={{ type: 'spring', bounce: 0.5, delay: 0.5 }}
|
||||
className="w-1/3 bg-gray-200 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
|
||||
>
|
||||
<div className="absolute -top-16 flex flex-col items-center">
|
||||
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{second.name}</span>
|
||||
<div className="bg-gray-300 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
|
||||
</div>
|
||||
<span className="text-3xl font-black text-gray-500 mb-4">{second.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* First Place */}
|
||||
{winner && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "80%" }}
|
||||
transition={{ type: 'spring', bounce: 0.6, delay: 1 }}
|
||||
className="w-1/3 bg-yellow-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative z-10 shadow-2xl border-x-4 border-t-4 border-white/50"
|
||||
>
|
||||
<div className="absolute -top-24 flex flex-col items-center">
|
||||
<span className="text-3xl font-bold mb-2 text-yellow-100 drop-shadow-md">{winner.name}</span>
|
||||
<div className="bg-yellow-500 p-6 rounded-full text-white shadow-lg scale-110"><Trophy size={48} /></div>
|
||||
</div>
|
||||
<span className="text-5xl font-black text-yellow-900 mb-6">{winner.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Third Place */}
|
||||
{third && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "40%" }}
|
||||
transition={{ type: 'spring', bounce: 0.5, delay: 0 }}
|
||||
className="w-1/3 bg-orange-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
|
||||
>
|
||||
<div className="absolute -top-16 flex flex-col items-center">
|
||||
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{third.name}</span>
|
||||
<div className="bg-orange-500 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
|
||||
</div>
|
||||
<span className="text-3xl font-black text-orange-900 mb-4">{third.score}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center gap-3 bg-white text-[#46178f] px-10 py-4 rounded-2xl text-2xl font-black hover:scale-105 transition shadow-[0_8px_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[8px]"
|
||||
>
|
||||
<RotateCcw size={28} /> Play Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
172
components/QuizCreator.tsx
Normal file
172
components/QuizCreator.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Quiz, Question, AnswerOption } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
|
||||
import { COLORS, SHAPES } from '../constants';
|
||||
|
||||
interface QuizCreatorProps {
|
||||
onFinalize: (quiz: Quiz) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
const [qText, setQText] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '', '', '']);
|
||||
const [correctIdx, setCorrectIdx] = useState<number>(0);
|
||||
|
||||
const handleAddQuestion = () => {
|
||||
if (!qText.trim() || options.some(o => !o.trim())) {
|
||||
alert("Please fill in the question and all 4 options.");
|
||||
return;
|
||||
}
|
||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
||||
|
||||
const newOptions: AnswerOption[] = options.map((text, idx) => ({
|
||||
text,
|
||||
isCorrect: idx === correctIdx,
|
||||
shape: shapes[idx],
|
||||
color: colors[idx]
|
||||
}));
|
||||
|
||||
const newQuestion: Question = {
|
||||
id: uuidv4(),
|
||||
text: qText,
|
||||
options: newOptions,
|
||||
timeLimit: 20
|
||||
};
|
||||
|
||||
setQuestions([...questions, newQuestion]);
|
||||
setQText('');
|
||||
setOptions(['', '', '', '']);
|
||||
setCorrectIdx(0);
|
||||
};
|
||||
|
||||
const handleRemoveQuestion = (id: string) => {
|
||||
setQuestions(questions.filter(q => q.id !== id));
|
||||
};
|
||||
|
||||
const handleFinalize = () => {
|
||||
if (!title.trim() || questions.length === 0) return;
|
||||
onFinalize({ title, questions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
|
||||
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
|
||||
<div className="bg-[#46178f] p-8 text-white flex justify-between items-center relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl font-black font-display">Create Quiz</h2>
|
||||
<p className="opacity-80 font-bold">Build your masterpiece</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-white/20 p-3 rounded-full hover:bg-white/30 transition relative z-10"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
{/* Decorative Circles */}
|
||||
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>
|
||||
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full p-4 border-4 border-gray-200 rounded-2xl text-2xl font-bold focus:border-[#46178f] outline-none transition-colors"
|
||||
placeholder="e.g., The Ultimate Trivia"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="border-4 border-gray-100 p-4 rounded-2xl bg-gray-50 flex justify-between items-center group hover:border-gray-200 transition">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="bg-[#46178f] text-white w-10 h-10 flex items-center justify-center rounded-full font-black">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="font-bold text-lg">{q.text}</span>
|
||||
</div>
|
||||
<button onClick={() => handleRemoveQuestion(q.id)} className="text-gray-400 hover:text-red-500 p-2 rounded-xl hover:bg-red-50 transition">
|
||||
<Trash2 size={24} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-6 md:p-8 rounded-[2rem] border-4 border-blue-100">
|
||||
<h3 className="text-xl font-black mb-6 flex items-center gap-2 text-blue-900">
|
||||
<Plus size={24} /> New Question
|
||||
</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={qText}
|
||||
onChange={(e) => setQText(e.target.value)}
|
||||
className="w-full p-4 border-4 border-white shadow-sm rounded-2xl font-bold text-lg focus:border-blue-400 outline-none"
|
||||
placeholder="What is the question?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = correctIdx === idx;
|
||||
const borderColor = isSelected ? 'border-green-500' : 'border-white';
|
||||
const bgClass = COLORS[['red','blue','yellow','green'][idx] as any];
|
||||
|
||||
return (
|
||||
<div key={idx} className={`flex items-center gap-3 p-3 rounded-2xl border-4 ${borderColor} bg-white shadow-sm transition-all`}>
|
||||
<button
|
||||
onClick={() => setCorrectIdx(idx)}
|
||||
className={`p-1 rounded-full ${isSelected ? 'text-green-500' : 'text-gray-200 hover:text-gray-400'}`}
|
||||
>
|
||||
{isSelected ? <CheckCircle size={32} fill="currentColor" className="text-white" /> : <Circle size={32} />}
|
||||
</button>
|
||||
|
||||
<div className={`w-4 h-full rounded-full ${bgClass}`}></div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...options];
|
||||
newOpts[idx] = e.target.value;
|
||||
setOptions(newOpts);
|
||||
}}
|
||||
className="w-full p-2 outline-none font-bold text-gray-700 bg-transparent placeholder:font-normal"
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddQuestion}
|
||||
className="mt-8 w-full bg-blue-600 text-white py-4 rounded-2xl font-black text-lg hover:bg-blue-700 shadow-[0_6px_0_#1e40af] active:shadow-none active:translate-y-[6px] transition-all"
|
||||
>
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"
|
||||
>
|
||||
<Save size={24} /> Finish & Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
components/RevealScreen.tsx
Normal file
156
components/RevealScreen.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, X, Flame, Trophy } from 'lucide-react';
|
||||
import { AnswerOption, Player, GameRole } from '../types';
|
||||
import { SHAPES, COLORS } from '../constants';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface RevealScreenProps {
|
||||
isCorrect: boolean;
|
||||
pointsEarned: number;
|
||||
newScore: number;
|
||||
streak: number;
|
||||
correctOption: AnswerOption;
|
||||
role: GameRole;
|
||||
}
|
||||
|
||||
export const RevealScreen: React.FC<RevealScreenProps> = ({
|
||||
isCorrect,
|
||||
pointsEarned,
|
||||
newScore,
|
||||
streak,
|
||||
correctOption,
|
||||
role
|
||||
}) => {
|
||||
const isHost = role === 'HOST';
|
||||
|
||||
// Trigger confetti for correct answers
|
||||
useEffect(() => {
|
||||
if (isCorrect && !isHost) {
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
colors: ['#22c55e', '#ffffff', '#fbbf24']
|
||||
});
|
||||
}
|
||||
}, [isCorrect, isHost]);
|
||||
|
||||
// -- HOST VIEW --
|
||||
if (isHost) {
|
||||
const ShapeIcon = SHAPES[correctOption.shape];
|
||||
const colorClass = COLORS[correctOption.color];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-900 text-white p-8 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold uppercase tracking-widest mb-12 opacity-80"
|
||||
>
|
||||
The correct answer is
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.5 }}
|
||||
className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20`}
|
||||
>
|
||||
<div className="bg-black/20 p-6 rounded-full mb-6">
|
||||
<ShapeIcon size={80} fill="currentColor" />
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-black font-display text-center drop-shadow-md leading-tight">
|
||||
{correctOption.text}
|
||||
</h1>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- CLIENT VIEW --
|
||||
const bgColor = isCorrect ? 'bg-[#22c55e]' : 'bg-[#ef4444]';
|
||||
const darkerColor = isCorrect ? 'bg-[#15803d]' : 'bg-[#b91c1c]';
|
||||
const ShapeIcon = SHAPES[correctOption.shape];
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center h-screen ${bgColor} text-white p-6 relative overflow-hidden transition-colors duration-500`}>
|
||||
|
||||
{/* Dynamic Background Circles */}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{ repeat: Infinity, duration: 4 }}
|
||||
className="absolute w-[800px] h-[800px] bg-white rounded-full blur-3xl opacity-10 pointer-events-none"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", bounce: 0.6 }}
|
||||
className="flex flex-col items-center z-10"
|
||||
>
|
||||
<div className="bg-white p-6 rounded-full shadow-[0_10px_0_rgba(0,0,0,0.2)] mb-8">
|
||||
{isCorrect ? (
|
||||
<Check size={80} className="text-[#22c55e]" strokeWidth={4} />
|
||||
) : (
|
||||
<X size={80} className="text-[#ef4444]" strokeWidth={4} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-black font-display mb-4 drop-shadow-lg text-center">
|
||||
{isCorrect ? "Correct!" : "Incorrect"}
|
||||
</h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
{isCorrect ? (
|
||||
<div className="bg-black/20 px-8 py-4 rounded-3xl backdrop-blur-sm border-4 border-white/30 flex items-center gap-4 mb-8">
|
||||
<span className="text-4xl font-black">+{pointsEarned}</span>
|
||||
<span className="font-bold uppercase opacity-80">Points</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl font-bold opacity-90 mb-8 max-w-md text-center">
|
||||
Don't worry, you can catch up in the next round!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streak Indicator */}
|
||||
{streak > 1 && isCorrect && (
|
||||
<div className="flex items-center gap-2 text-yellow-200 font-black text-2xl animate-pulse">
|
||||
<Flame fill="currentColor" />
|
||||
<span>Answer Streak: {streak}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-black/20 px-6 py-2 rounded-xl text-xl font-bold">
|
||||
Total Score: {newScore}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{!isCorrect && (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute bottom-0 left-0 right-0 bg-black/30 backdrop-blur-md p-6 pb-12"
|
||||
>
|
||||
<p className="text-center text-sm font-bold uppercase tracking-widest mb-4 opacity-70">The correct answer was</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className={`${COLORS[correctOption.color]} p-3 rounded-xl shadow-lg`}>
|
||||
<ShapeIcon size={24} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-2xl font-black">{correctOption.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
components/Scoreboard.tsx
Normal file
74
components/Scoreboard.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import { Player } from '../types';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ScoreboardProps {
|
||||
players: Player[];
|
||||
onNext: () => void;
|
||||
isHost: boolean;
|
||||
}
|
||||
|
||||
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost }) => {
|
||||
const sortedPlayers = [...players].sort((a, b) => b.score - a.score).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-8">
|
||||
<header className="text-center mb-12">
|
||||
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex flex-col text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sortedPlayers}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 120, left: 20, bottom: 5 }}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 24, fontWeight: 800, fill: '#333', fontFamily: 'Fredoka' }}
|
||||
width={200}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
|
||||
{sortedPlayers.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.id.startsWith('host') ? '#46178f' : '#8884d8'}
|
||||
className="filter drop-shadow-md"
|
||||
/>
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="score"
|
||||
position="right"
|
||||
offset={15}
|
||||
style={{ fontSize: '24px', fontWeight: '900', fill: '#46178f', fontFamily: 'Fredoka' }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto">
|
||||
{isHost ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="bg-white text-[#46178f] px-12 py-4 rounded-2xl text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 bg-white/10 px-8 py-4 rounded-2xl backdrop-blur-md border-2 border-white/20">
|
||||
<Loader2 className="animate-spin w-8 h-8" />
|
||||
<span className="text-xl font-bold">Waiting for host...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
constants.ts
Normal file
25
constants.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
|
||||
|
||||
export const COLORS = {
|
||||
red: 'bg-red-600',
|
||||
blue: 'bg-blue-600',
|
||||
yellow: 'bg-yellow-600',
|
||||
green: 'bg-green-600',
|
||||
purple: 'bg-[#46178f]',
|
||||
purpleLight: 'bg-[#864cbf]',
|
||||
};
|
||||
|
||||
export const SHAPES = {
|
||||
triangle: Triangle,
|
||||
diamond: Diamond,
|
||||
circle: Circle,
|
||||
square: Square,
|
||||
};
|
||||
|
||||
export const BOT_NAMES = [
|
||||
"QuizWiz", "FastFinger", "Brainiac", "KnowItAll", "Guesser",
|
||||
"LuckyStrike", "OwlFan", "Kahootie", "ZigZag", "Pixel"
|
||||
];
|
||||
|
||||
export const QUESTION_TIME = 20; // seconds
|
||||
export const POINTS_PER_QUESTION = 1000;
|
||||
356
hooks/useGame.ts
Normal file
356
hooks/useGame.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
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 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);
|
||||
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);
|
||||
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 isCorrect = arg as boolean;
|
||||
// Use Ref for time to be consistent with handleHostData logic
|
||||
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;
|
||||
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,
|
||||
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
|
||||
};
|
||||
};
|
||||
62
index.html
Normal file
62
index.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenHoot</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300;400;600;700&family=Montserrat:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
/* Richer purple gradient */
|
||||
background: linear-gradient(180deg, #46178f 0%, #25094f 100%);
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, .font-display {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
}
|
||||
|
||||
/* Whimsical Background Animation */
|
||||
@keyframes floatUp {
|
||||
0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 0.3; }
|
||||
90% { opacity: 0.3; }
|
||||
100% { transform: translateY(-10vh) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.floating-shape {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
animation-name: floatUp;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.35.0",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||
"uuid": "https://esm.sh/uuid@^13.0.0",
|
||||
"recharts": "https://esm.sh/recharts@^3.6.0",
|
||||
"framer-motion": "https://esm.sh/framer-motion@^12.26.1",
|
||||
"canvas-confetti": "https://esm.sh/canvas-confetti@^1.9.4",
|
||||
"peerjs": "https://esm.sh/peerjs@^1.5.2"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
Normal file
15
index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "Kaboot",
|
||||
"description": "An open-source, AI-powered Kahoot clone. Generate quizzes on any topic instantly using Gemini and play against AI bots in a single-player arcade mode.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
3183
package-lock.json
generated
Normal file
3183
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "kaboot",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.3",
|
||||
"@google/genai": "^1.35.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"uuid": "^13.0.0",
|
||||
"recharts": "^3.6.0",
|
||||
"framer-motion": "^12.26.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"peerjs": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
86
services/geminiService.ts
Normal file
86
services/geminiService.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { Quiz, Question, AnswerOption } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const getClient = () => {
|
||||
const apiKey = process.env.API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("API_KEY environment variable is missing");
|
||||
}
|
||||
return new GoogleGenAI({ apiKey });
|
||||
};
|
||||
|
||||
export const generateQuiz = async (topic: string): Promise<Quiz> => {
|
||||
const ai = getClient();
|
||||
|
||||
const prompt = `Generate a trivia quiz about "${topic}". Create 10 engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING, description: "A catchy title for the quiz" },
|
||||
questions: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
text: { type: Type.STRING, description: "The question text" },
|
||||
options: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
text: { type: Type.STRING },
|
||||
isCorrect: { type: Type.BOOLEAN }
|
||||
},
|
||||
required: ["text", "isCorrect"]
|
||||
},
|
||||
}
|
||||
},
|
||||
required: ["text", "options"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["title", "questions"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.text) {
|
||||
throw new Error("Failed to generate quiz content");
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.text);
|
||||
|
||||
// Transform to our internal type with shapes/colors pre-assigned
|
||||
const questions: Question[] = data.questions.map((q: any) => {
|
||||
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
|
||||
const colors = ['red', 'blue', 'yellow', 'green'] as const;
|
||||
|
||||
// Shuffle options so the correct one isn't always first (though Gemini usually randomizes, safety first)
|
||||
// Actually, to map shapes consistently, let's keep array order but assign props
|
||||
const options: AnswerOption[] = q.options.map((opt: any, index: number) => ({
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect,
|
||||
shape: shapes[index % 4],
|
||||
color: colors[index % 4]
|
||||
}));
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
text: q.text,
|
||||
options: options,
|
||||
timeLimit: 20
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
questions
|
||||
};
|
||||
};
|
||||
34
tasks.md
Normal file
34
tasks.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# OpenHoot Development Task List
|
||||
|
||||
## Core Setup
|
||||
- [x] Initialize project structure (React, TypeScript, Vite-like setup)
|
||||
- [x] Configure Tailwind CSS via CDN
|
||||
- [x] Define global types (Quiz, Question, Player, GameState)
|
||||
- [x] Create constant definitions for Kahoot-style colors and shapes
|
||||
|
||||
## Backend Services (Gemini AI)
|
||||
- [x] Setup Google GenAI client
|
||||
- [x] Implement `generateQuiz` function with strictly typed JSON schema
|
||||
- [x] Error handling for API limits or failures
|
||||
|
||||
## Game Logic (Hooks)
|
||||
- [x] Create `useGame` hook to manage state machine (Landing -> Lobby -> Game -> Podium)
|
||||
- [x] Implement "Bot" logic (simulated players joining and answering)
|
||||
- [x] Implement Timer logic for questions
|
||||
- [x] Implement Scoring system (speed + accuracy)
|
||||
|
||||
## UI Components
|
||||
- [x] **Landing Page**: Topic input and "Generate" button
|
||||
- [x] **Lobby**: Display Game PIN and simulated player list
|
||||
- [x] **Game Interface**:
|
||||
- [x] Question display with countdown
|
||||
- [x] Answer buttons (Red/Triangle, Blue/Diamond, etc.)
|
||||
- [x] "Host View" vs "Player View" hybrid layout
|
||||
- [x] **Scoreboard**: Bar charts showing round results
|
||||
- [x] **Podium**: Final winners display with confetti feel
|
||||
|
||||
## Polish & UX
|
||||
- [x] Add animations using Framer Motion (transitions, entrances)
|
||||
- [x] Ensure responsive design (Mobile-first)
|
||||
- [x] Add sound effects/music toggle (Visual only for this demo)
|
||||
- [x] Final code cleanup and type safety checks
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
65
types.ts
Normal file
65
types.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
export type GameState =
|
||||
| 'LANDING'
|
||||
| 'CREATING'
|
||||
| 'GENERATING'
|
||||
| 'LOBBY'
|
||||
| 'COUNTDOWN'
|
||||
| 'QUESTION'
|
||||
| 'REVEAL'
|
||||
| 'SCOREBOARD'
|
||||
| 'PODIUM';
|
||||
|
||||
export type GameRole = 'HOST' | 'CLIENT';
|
||||
|
||||
export interface AnswerOption {
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
shape: 'triangle' | 'diamond' | 'circle' | 'square';
|
||||
color: 'red' | 'blue' | 'yellow' | 'green';
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
options: AnswerOption[];
|
||||
timeLimit: number; // in seconds
|
||||
}
|
||||
|
||||
export interface Quiz {
|
||||
title: string;
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
streak: number;
|
||||
lastAnswerCorrect: boolean | null;
|
||||
isBot: boolean;
|
||||
avatarSeed: number;
|
||||
}
|
||||
|
||||
// Network Types
|
||||
export type NetworkMessage =
|
||||
| { type: 'JOIN'; payload: { name: string } }
|
||||
| { type: 'WELCOME'; payload: { playerId: string; quizTitle: string; players: Player[] } }
|
||||
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
|
||||
| { type: 'GAME_START'; payload: {} }
|
||||
| { type: 'START_COUNTDOWN'; payload: { duration: number } }
|
||||
| {
|
||||
type: 'QUESTION_START';
|
||||
payload: {
|
||||
totalQuestions: number;
|
||||
currentQuestionIndex: number;
|
||||
timeLimit: number;
|
||||
correctShape: string;
|
||||
questionText: string;
|
||||
options: AnswerOption[];
|
||||
}
|
||||
}
|
||||
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
|
||||
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } }
|
||||
| { type: 'TIME_UP'; payload: {} }
|
||||
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
|
||||
| { type: 'GAME_OVER'; payload: { players: Player[] } };
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue