feat: Integrate join URL and QR code for player access
Adds a dynamic join URL and QR code displayed on the spectator view and host dashboard. This allows players to easily join the quiz session by scanning the QR code or navigating to the provided URL. The `joinUrl` is now managed within the `GameContext` and exposed to relevant components. The spectator view uses this URL to generate the QR code, while the host view displays it for easy access. This enhances the onboarding experience for new players and simplifies session management.
This commit is contained in:
@@ -1,28 +1,39 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import { GameProvider } from './context/GameContext';
|
||||
import { Monitor, Smartphone, LayoutDashboard, Loader2 } from 'lucide-react';
|
||||
import { GameProvider, useGame } from './context/GameContext';
|
||||
import { Monitor, Smartphone, LayoutDashboard, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
// Lazy load components to reduce initial bundle size
|
||||
const HostView = React.lazy(() => import('./components/HostView').then(module => ({ default: module.HostView })));
|
||||
const PlayerView = React.lazy(() => import('./components/PlayerView').then(module => ({ default: module.PlayerView })));
|
||||
const SpectatorView = React.lazy(() => import('./components/SpectatorView').then(module => ({ default: module.SpectatorView })));
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Inner Component to access Context
|
||||
const AppContent: React.FC = () => {
|
||||
const { setIsHost, isSyncing, isHost } = useGame();
|
||||
const [view, setView] = useState<'HOST' | 'PLAYER' | 'SPECTATOR'>('SPECTATOR');
|
||||
|
||||
// Simple hash routing for demo purposes
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash === '#host') setView('HOST');
|
||||
else if (hash === '#player') setView('PLAYER');
|
||||
else setView('SPECTATOR');
|
||||
if (hash === '#host') {
|
||||
setView('HOST');
|
||||
setIsHost(true); // Promote this session to Host Authority
|
||||
}
|
||||
else if (hash === '#player') {
|
||||
setView('PLAYER');
|
||||
setIsHost(false);
|
||||
}
|
||||
else {
|
||||
setView('SPECTATOR');
|
||||
setIsHost(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleHashChange();
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
}, [setIsHost]);
|
||||
|
||||
const navigate = (newView: 'HOST' | 'PLAYER' | 'SPECTATOR') => {
|
||||
setView(newView);
|
||||
@@ -39,13 +50,15 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<GameProvider>
|
||||
<div className="flex flex-col h-screen bg-slate-950 overflow-hidden">
|
||||
{/* Top Navigation Bar */}
|
||||
<nav className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shadow-md shrink-0 z-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center font-black italic text-white shadow-lg border border-white/10">Q</div>
|
||||
<span className="font-bold text-lg tracking-wide text-slate-100 hidden sm:block">QuizMaster Live</span>
|
||||
{isSyncing && (
|
||||
<RefreshCw size={14} className="text-slate-500 animate-spin ml-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-800 p-1 rounded-lg border border-slate-700">
|
||||
@@ -82,6 +95,13 @@ const App: React.FC = () => {
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<GameProvider>
|
||||
<AppContent />
|
||||
</GameProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||
header("Access-Control-Allow-Headers: Content-Type");
|
||||
header("Content-Type: application/json");
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
$host = 'localhost';
|
||||
$db = 'quiz_db'; // CHANGE THIS
|
||||
$user = 'root'; // CHANGE THIS
|
||||
$pass = ''; // CHANGE THIS
|
||||
$charset = 'utf8mb4';
|
||||
|
||||
// --- DB CONNECTION ---
|
||||
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user, $pass, $options);
|
||||
} catch (\PDOException $e) {
|
||||
// Fallback for when DB isn't configured yet so app doesn't crash completely
|
||||
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Handle Preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
try {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
if ($action === 'getState') {
|
||||
$stmt = $pdo->query("SELECT game_data FROM game_state WHERE id = 1");
|
||||
$row = $stmt->fetch();
|
||||
echo $row['game_data'] ?: '{}';
|
||||
}
|
||||
elseif ($action === 'getIntents') {
|
||||
// Transaction to read and delete to ensure processed once
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->query("SELECT * FROM player_intents ORDER BY created_at ASC");
|
||||
$intents = $stmt->fetchAll();
|
||||
if ($intents) {
|
||||
$pdo->exec("DELETE FROM player_intents"); // Clear queue after reading
|
||||
}
|
||||
$pdo->commit();
|
||||
|
||||
// Parse payloads
|
||||
foreach ($intents as &$intent) {
|
||||
$intent['payload'] = json_decode($intent['payload'], true);
|
||||
}
|
||||
echo json_encode($intents);
|
||||
}
|
||||
}
|
||||
elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if ($action === 'pushState') {
|
||||
$data = json_encode($input);
|
||||
$stmt = $pdo->prepare("UPDATE game_state SET game_data = ? WHERE id = 1");
|
||||
$stmt->execute([$data]);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
elseif ($action === 'pushIntent') {
|
||||
$type = $input['type'];
|
||||
$payload = json_encode($input['payload']);
|
||||
$stmt = $pdo->prepare("INSERT INTO player_intents (type, payload) VALUES (?, ?)");
|
||||
$stmt->execute([$type, $payload]);
|
||||
echo json_encode(['success' => true]);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
+20
-3
@@ -3,13 +3,13 @@ import { useGame } from '../context/GameContext';
|
||||
import { GamePhase } from '../types';
|
||||
import type { Question } from '../types';
|
||||
import { Soundboard } from './Soundboard';
|
||||
import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw } from 'lucide-react';
|
||||
import { Play, SkipForward, CheckCircle, XCircle, Users, Library, Sparkles, Plus, Trash2, Edit, ArrowLeft, Upload, RefreshCw, Image as ImageIcon, List, Trophy, RotateCcw, QrCode } from 'lucide-react';
|
||||
import { generateQuestions } from '../services/geminiService';
|
||||
|
||||
export const HostView: React.FC = () => {
|
||||
const {
|
||||
gameState, activeGameName, players, teams, buzzQueue, questions, games,
|
||||
approvePlayer, startGame, startCountdown, openBuzzers,
|
||||
gameState, activeGameName, players, teams, buzzQueue, questions, games, joinUrl,
|
||||
setJoinUrl, approvePlayer, startGame, startCountdown, openBuzzers,
|
||||
resolveBuzz, rectifyBuzz, skipQuestion, nextPhase, resetGame,
|
||||
createGame, updateGame, deleteGame, loadGameToLive
|
||||
} = useGame();
|
||||
@@ -189,6 +189,23 @@ export const HostView: React.FC = () => {
|
||||
{/* GAME CONTROL TAB */}
|
||||
{activeTab === 'GAME' && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Session Settings */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 mb-6 flex items-center gap-4">
|
||||
<div className="bg-indigo-100 p-2 rounded-lg text-indigo-600">
|
||||
<QrCode size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase">Player Join URL (for QR Code)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={joinUrl}
|
||||
onChange={(e) => setJoinUrl(e.target.value)}
|
||||
className="w-full font-mono text-sm border-b border-slate-300 focus:border-indigo-600 outline-none bg-transparent py-1 text-slate-800"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LAST QUESTION WARNING */}
|
||||
{isLastQuestionPhase && !isGameOver && (
|
||||
<div className="bg-amber-100 border-l-4 border-amber-500 text-amber-700 p-4 mb-6 font-bold shadow-sm">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from
|
||||
import { Trophy, Zap, Users } from 'lucide-react';
|
||||
|
||||
export const SpectatorView: React.FC = () => {
|
||||
const { gameState, questions, teams, players, buzzQueue } = useGame();
|
||||
const { gameState, questions, teams, players, buzzQueue, joinUrl } = useGame();
|
||||
const currentQ = questions[gameState.currentQuestionIndex];
|
||||
|
||||
// Helper to get formatted leaderboard data
|
||||
@@ -29,10 +29,11 @@ export const SpectatorView: React.FC = () => {
|
||||
<h1 className="text-6xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500 mb-8">
|
||||
JOIN THE QUIZ
|
||||
</h1>
|
||||
<div className="bg-white p-4 inline-block rounded-xl mb-8">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https://quiz-app-demo.com" alt="QR Code" className="w-48 h-48" />
|
||||
<div className="bg-white p-4 inline-block rounded-xl mb-4">
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(joinUrl)}`} alt="QR Code" className="w-48 h-48" />
|
||||
</div>
|
||||
<p className="text-2xl text-slate-300 mb-8">Scan to Join</p>
|
||||
<p className="text-2xl text-slate-300 mb-2">Scan to Join</p>
|
||||
<p className="text-lg text-slate-500 font-mono mb-8">{joinUrl}</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4 max-w-4xl mx-auto">
|
||||
{players.filter(p => p.isApproved).map(p => (
|
||||
|
||||
+238
-85
@@ -1,22 +1,29 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { GamePhase } from '../types';
|
||||
import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
|
||||
import type { Player, Team, Question, BuzzerLog, GameState, Game, PlayerIntent } from '../types';
|
||||
|
||||
interface GameContextType {
|
||||
// State
|
||||
gameState: GameState;
|
||||
activeGameName: string; // New: Track which game is loaded
|
||||
activeGameName: string;
|
||||
joinUrl: string;
|
||||
players: Player[];
|
||||
teams: Team[];
|
||||
questions: Question[]; // The ACTIVE questions currently being played
|
||||
games: Game[]; // The LIBRARY of saved games
|
||||
questions: Question[];
|
||||
games: Game[];
|
||||
buzzQueue: BuzzerLog[];
|
||||
currentPlayerId: string | null;
|
||||
|
||||
// Sync Status
|
||||
isHost: boolean;
|
||||
setIsHost: (isHost: boolean) => void;
|
||||
isSyncing: boolean;
|
||||
|
||||
// Actions
|
||||
setJoinUrl: (url: string) => void;
|
||||
addPlayer: (name: string, teamName: string) => void;
|
||||
approvePlayer: (playerId: string) => void;
|
||||
removePlayer: (playerId: string) => void; // New action
|
||||
removePlayer: (playerId: string) => void;
|
||||
startGame: () => void;
|
||||
startCountdown: () => void;
|
||||
openBuzzers: () => void;
|
||||
@@ -39,6 +46,11 @@ interface GameContextType {
|
||||
|
||||
const GameContext = createContext<GameContextType | undefined>(undefined);
|
||||
|
||||
// --- API CONFIG ---
|
||||
// Assuming api.php is at the root. Change if in a subfolder.
|
||||
// If using Vite development server, you might need to point this to your actual PHP server URL.
|
||||
const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php';
|
||||
|
||||
// Initial Mock Data
|
||||
const INITIAL_QUESTIONS: Question[] = [
|
||||
{ id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" },
|
||||
@@ -68,6 +80,10 @@ const INITIAL_GAMES: Game[] = [
|
||||
];
|
||||
|
||||
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isHost, setIsHost] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
// --- GAME STATE ---
|
||||
const [gameState, setGameState] = useState<GameState>({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
@@ -76,6 +92,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
});
|
||||
|
||||
const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo");
|
||||
const [joinUrl, setJoinUrl] = useState<string>('');
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [questions, setQuestionsQuestions] = useState<Question[]>(INITIAL_QUESTIONS);
|
||||
@@ -83,7 +100,143 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]);
|
||||
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
||||
|
||||
// Helper: Find or create team
|
||||
// Refs for accessing state inside intervals without dependencies
|
||||
const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl });
|
||||
useEffect(() => {
|
||||
stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl };
|
||||
}, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl]);
|
||||
|
||||
// Set default Join URL on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = `${window.location.origin}${window.location.pathname}#player`;
|
||||
setJoinUrl(url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- SYNC ENGINE ---
|
||||
useEffect(() => {
|
||||
const syncInterval = setInterval(async () => {
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
if (isHost) {
|
||||
// --- HOST LOGIC: PULL INTENTS -> PROCESS -> PUSH STATE ---
|
||||
|
||||
// 1. Fetch Intents
|
||||
const response = await fetch(`${API_URL}?action=getIntents`);
|
||||
const intents = await response.json();
|
||||
|
||||
let stateChanged = false;
|
||||
|
||||
// 2. Process Intents
|
||||
if (Array.isArray(intents) && intents.length > 0) {
|
||||
console.log("Processing Intents:", intents);
|
||||
intents.forEach((item: any) => {
|
||||
const { type, payload } = item;
|
||||
|
||||
if (type === 'JOIN') {
|
||||
// Logic extracted from addPlayer
|
||||
const { name, teamName, tempId } = payload;
|
||||
const currentTeams = stateRef.current.teams;
|
||||
const existingTeam = currentTeams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
|
||||
|
||||
let teamId = existingTeam?.id;
|
||||
if (!existingTeam) {
|
||||
const newTeam: Team = { id: crypto.randomUUID(), name: teamName, score: 0 };
|
||||
setTeams(prev => [...prev, newTeam]);
|
||||
teamId = newTeam.id;
|
||||
}
|
||||
|
||||
// Check if player already exists to prevent dupes
|
||||
const exists = stateRef.current.players.some(p => p.name === name && p.teamId === teamId);
|
||||
if (!exists && teamId) {
|
||||
const newPlayer: Player = {
|
||||
id: tempId || crypto.randomUUID(),
|
||||
name,
|
||||
teamId,
|
||||
score: 0,
|
||||
isApproved: true, // Auto approve for now for smoother UX
|
||||
stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
|
||||
};
|
||||
setPlayers(prev => [...prev, newPlayer]);
|
||||
}
|
||||
stateChanged = true;
|
||||
}
|
||||
else if (type === 'BUZZ') {
|
||||
const { playerId } = payload;
|
||||
const currentState = stateRef.current.gameState;
|
||||
const currentQueue = stateRef.current.buzzQueue;
|
||||
|
||||
if (currentState.phase === GamePhase.BUZZER_OPEN && !currentQueue.find(b => b.playerId === playerId)) {
|
||||
const newBuzz: BuzzerLog = {
|
||||
playerId,
|
||||
timestamp: Date.now(),
|
||||
order: currentQueue.length + 1,
|
||||
status: 'PENDING'
|
||||
};
|
||||
setBuzzQueue(prev => {
|
||||
const updated = [...prev, newBuzz];
|
||||
if (updated.length === 1) { // Only force update phase if it was the first buzz
|
||||
setGameState(gs => ({ ...gs, phase: GamePhase.ADJUDICATION }));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
stateChanged = true;
|
||||
}
|
||||
}
|
||||
else if (type === 'LEAVE') {
|
||||
const { playerId } = payload;
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
stateChanged = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Push State (Always push to keep server alive with latest data, or at least periodically)
|
||||
// For now, we push every cycle to ensure consistency.
|
||||
const fullState = {
|
||||
gameState: stateRef.current.gameState,
|
||||
players: stateRef.current.players,
|
||||
teams: stateRef.current.teams,
|
||||
questions: stateRef.current.questions,
|
||||
activeGameName: stateRef.current.activeGameName,
|
||||
buzzQueue: stateRef.current.buzzQueue,
|
||||
joinUrl: stateRef.current.joinUrl
|
||||
};
|
||||
|
||||
await fetch(`${API_URL}?action=pushState`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fullState)
|
||||
});
|
||||
|
||||
} else {
|
||||
// --- CLIENT LOGIC: PULL STATE -> UPDATE LOCAL ---
|
||||
const response = await fetch(`${API_URL}?action=getState`);
|
||||
const remoteState = await response.json();
|
||||
|
||||
if (remoteState && remoteState.gameState) {
|
||||
setGameState(remoteState.gameState);
|
||||
setPlayers(remoteState.players || []);
|
||||
setTeams(remoteState.teams || []);
|
||||
setQuestionsQuestions(remoteState.questions || []);
|
||||
setActiveGameName(remoteState.activeGameName || "");
|
||||
setBuzzQueue(remoteState.buzzQueue || []);
|
||||
setJoinUrl(remoteState.joinUrl || "");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sync Error:", e);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, 500); // 500ms polling rate
|
||||
|
||||
return () => clearInterval(syncInterval);
|
||||
}, [isHost]); // Re-run effect if role changes
|
||||
|
||||
|
||||
// --- HOST HELPERS (Local) ---
|
||||
const getOrCreateTeam = (name: string) => {
|
||||
const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase());
|
||||
if (existing) return existing;
|
||||
@@ -92,37 +245,72 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
return newTeam;
|
||||
};
|
||||
|
||||
const addPlayer = (name: string, teamName: string) => {
|
||||
const team = getOrCreateTeam(teamName);
|
||||
const newPlayer: Player = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
teamId: team.id,
|
||||
score: 0,
|
||||
isApproved: false, // Requires admin approval
|
||||
stats: {
|
||||
correctAnswers: 0,
|
||||
totalBuzzes: 0,
|
||||
bestReactionTime: null
|
||||
// --- ACTIONS ---
|
||||
|
||||
const sendIntent = async (type: string, payload: any) => {
|
||||
try {
|
||||
await fetch(`${API_URL}?action=pushIntent`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, payload })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send intent", e);
|
||||
}
|
||||
};
|
||||
setPlayers(prev => [...prev, newPlayer]);
|
||||
if (!currentPlayerId) setCurrentPlayerId(newPlayer.id);
|
||||
|
||||
const addPlayer = (name: string, teamName: string) => {
|
||||
// If Host, do it immediately (Old Logic) - Although now logic is in Sync Loop
|
||||
// To make it unified, Host also sends intent OR we just trust the sync loop.
|
||||
// BUT, for "Host-Added Players", we can just do it locally.
|
||||
// For "Client Joining", they send intent.
|
||||
|
||||
// PLAYER-SIDE LOGIC:
|
||||
const tempId = crypto.randomUUID();
|
||||
setCurrentPlayerId(tempId); // Set ID immediately so UI shows "Waiting..."
|
||||
sendIntent('JOIN', { name, teamName, tempId });
|
||||
};
|
||||
|
||||
const removePlayer = (playerId: string) => {
|
||||
if (isHost) {
|
||||
setPlayers(prev => prev.filter(p => p.id !== playerId));
|
||||
if (currentPlayerId === playerId) {
|
||||
setCurrentPlayerId(null);
|
||||
} else {
|
||||
// Player leaving
|
||||
sendIntent('LEAVE', { playerId });
|
||||
if (currentPlayerId === playerId) setCurrentPlayerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const approvePlayer = (playerId: string) => {
|
||||
if (!isHost) return;
|
||||
setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p));
|
||||
};
|
||||
|
||||
const handleBuzz = (playerId: string) => {
|
||||
if (isHost) {
|
||||
// Host manual buzz? Rare, but allowed.
|
||||
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
|
||||
if (buzzQueue.find(b => b.playerId === playerId)) return;
|
||||
const newBuzz: BuzzerLog = {
|
||||
playerId,
|
||||
timestamp: Date.now(),
|
||||
order: buzzQueue.length + 1,
|
||||
status: 'PENDING'
|
||||
};
|
||||
setBuzzQueue(prev => [...prev, newBuzz]);
|
||||
if (buzzQueue.length === 0) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
|
||||
}
|
||||
} else {
|
||||
// Client buzz
|
||||
sendIntent('BUZZ', { playerId });
|
||||
}
|
||||
};
|
||||
|
||||
// --- HOST ONLY ACTIONS (No change needed, just guard them) ---
|
||||
|
||||
const startGame = () => {
|
||||
// Directly start Q1 Countdown, skipping initial Leaderboard
|
||||
if (!isHost) return;
|
||||
setGameState({
|
||||
phase: GamePhase.COUNTDOWN,
|
||||
currentQuestionIndex: 0,
|
||||
@@ -133,16 +321,15 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const startCountdown = () => {
|
||||
if (!isHost) return;
|
||||
let nextIndex = gameState.currentQuestionIndex;
|
||||
if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) {
|
||||
nextIndex = gameState.currentQuestionIndex + 1;
|
||||
}
|
||||
|
||||
if (nextIndex >= questions.length) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS }));
|
||||
return;
|
||||
}
|
||||
|
||||
setGameState({
|
||||
phase: GamePhase.COUNTDOWN,
|
||||
currentQuestionIndex: nextIndex,
|
||||
@@ -152,7 +339,9 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
// Countdown Timer Effect (Runs on Host Only to drive state)
|
||||
useEffect(() => {
|
||||
if (!isHost) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
if (gameState.phase === GamePhase.COUNTDOWN) {
|
||||
if (gameState.countdownValue > 0) {
|
||||
@@ -164,60 +353,32 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [gameState.phase, gameState.countdownValue]);
|
||||
}, [gameState.phase, gameState.countdownValue, isHost]);
|
||||
|
||||
const openBuzzers = () => {
|
||||
if (!isHost) return;
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN, buzzerOpenTimestamp: Date.now() }));
|
||||
};
|
||||
|
||||
const handleBuzz = (playerId: string) => {
|
||||
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
|
||||
if (buzzQueue.find(b => b.playerId === playerId)) return;
|
||||
|
||||
const newBuzz: BuzzerLog = {
|
||||
playerId,
|
||||
timestamp: Date.now(),
|
||||
order: buzzQueue.length + 1,
|
||||
status: 'PENDING'
|
||||
};
|
||||
|
||||
setBuzzQueue(prev => [...prev, newBuzz]);
|
||||
|
||||
if (buzzQueue.length === 0) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
|
||||
}
|
||||
};
|
||||
|
||||
const resolveBuzz = (playerId: string, correct: boolean) => {
|
||||
if (!isHost) return;
|
||||
const currentQ = questions[gameState.currentQuestionIndex];
|
||||
const player = players.find(p => p.id === playerId);
|
||||
|
||||
// Update Stats logic
|
||||
setPlayers(prev => prev.map(p => {
|
||||
if (p.id !== playerId) return p;
|
||||
|
||||
let newStats = { ...p.stats };
|
||||
// Increment attempts (total buzzes)
|
||||
newStats.totalBuzzes += 1;
|
||||
|
||||
// Calculate reaction time if this buzz was the first one processed
|
||||
// We only really care about updating reaction time if it was a successful/valid buzz attempt
|
||||
if (gameState.buzzerOpenTimestamp) {
|
||||
const reactionTime = Date.now() - gameState.buzzerOpenTimestamp;
|
||||
if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) {
|
||||
newStats.bestReactionTime = reactionTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (correct) {
|
||||
newStats.correctAnswers += 1;
|
||||
return {
|
||||
...p,
|
||||
score: p.score + currentQ.points,
|
||||
stats: newStats
|
||||
};
|
||||
return { ...p, score: p.score + currentQ.points, stats: newStats };
|
||||
}
|
||||
|
||||
return { ...p, stats: newStats };
|
||||
}));
|
||||
|
||||
@@ -237,6 +398,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => {
|
||||
if (!isHost) return;
|
||||
const currentQ = questions[gameState.currentQuestionIndex];
|
||||
const player = players.find(p => p.id === playerId);
|
||||
if (!player || !player.teamId) return;
|
||||
@@ -250,43 +412,38 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') {
|
||||
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t));
|
||||
// Also correct stat if rectifying
|
||||
setPlayers(prev => prev.map(p => p.id === playerId ? {
|
||||
...p,
|
||||
score: p.score + points,
|
||||
stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
|
||||
...p, score: p.score + points, stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
|
||||
} : p));
|
||||
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||
}
|
||||
else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') {
|
||||
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t));
|
||||
setPlayers(prev => prev.map(p => p.id === playerId ? {
|
||||
...p,
|
||||
score: p.score - points,
|
||||
stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
|
||||
...p, score: p.score - points, stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
|
||||
} : p));
|
||||
|
||||
const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING');
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
|
||||
...prev, phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const skipQuestion = () => {
|
||||
if (!isHost) return;
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
const nextPhase = () => {
|
||||
if (!isHost) return;
|
||||
if (gameState.phase === GamePhase.ANSWER_REVEAL) {
|
||||
setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD }));
|
||||
}
|
||||
};
|
||||
|
||||
const playAudio = (url: string, start: number = 0, end?: number) => {
|
||||
// Audio is mostly local for now
|
||||
const audio = new Audio(url);
|
||||
audio.currentTime = start;
|
||||
audio.play();
|
||||
@@ -298,34 +455,24 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const resetGame = () => {
|
||||
// "Soft Reset" - Keep players and teams, but reset their scores and stats
|
||||
if (!isHost) return;
|
||||
setGameState({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
countdownValue: 3,
|
||||
buzzerOpenTimestamp: null
|
||||
});
|
||||
|
||||
// Reset Scores and Stats for Players
|
||||
setPlayers(prev => prev.map(p => ({
|
||||
...p,
|
||||
score: 0,
|
||||
stats: {
|
||||
correctAnswers: 0,
|
||||
totalBuzzes: 0,
|
||||
bestReactionTime: null
|
||||
}
|
||||
...p, score: 0, stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
|
||||
})));
|
||||
|
||||
// Reset Team Scores
|
||||
setTeams(prev => prev.map(t => ({ ...t, score: 0 })));
|
||||
|
||||
setBuzzQueue([]);
|
||||
};
|
||||
|
||||
// --- LIBRARY ACTIONS ---
|
||||
// --- LIBRARY ACTIONS (Local to Host) ---
|
||||
|
||||
const createGame = (name: string) => {
|
||||
if (!isHost) return;
|
||||
const newGame: Game = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
@@ -336,19 +483,21 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
};
|
||||
|
||||
const updateGame = (gameId: string, updates: Partial<Game>) => {
|
||||
if (!isHost) return;
|
||||
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g));
|
||||
};
|
||||
|
||||
const deleteGame = (gameId: string) => {
|
||||
if (!isHost) return;
|
||||
setGames(prev => prev.filter(g => g.id !== gameId));
|
||||
};
|
||||
|
||||
const loadGameToLive = (gameId: string) => {
|
||||
if (!isHost) return;
|
||||
const game = games.find(g => g.id === gameId);
|
||||
if (game) {
|
||||
setQuestionsQuestions([...game.questions]);
|
||||
setActiveGameName(game.name);
|
||||
|
||||
setGameState({
|
||||
phase: GamePhase.LOBBY,
|
||||
currentQuestionIndex: -1,
|
||||
@@ -356,7 +505,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
buzzerOpenTimestamp: null
|
||||
});
|
||||
setBuzzQueue([]);
|
||||
// NOTE: We do not clear players here anymore, allowing roster to persist between games
|
||||
}
|
||||
};
|
||||
|
||||
@@ -364,12 +512,17 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
<GameContext.Provider value={{
|
||||
gameState,
|
||||
activeGameName,
|
||||
joinUrl,
|
||||
players,
|
||||
teams,
|
||||
questions,
|
||||
games,
|
||||
buzzQueue,
|
||||
currentPlayerId,
|
||||
isHost,
|
||||
setIsHost,
|
||||
isSyncing,
|
||||
setJoinUrl,
|
||||
addPlayer,
|
||||
approvePlayer,
|
||||
removePlayer,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `game_state` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`game_data` longtext NOT NULL,
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `player_intents` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`type` varchar(50) NOT NULL,
|
||||
`payload` longtext NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Initialize with one row
|
||||
INSERT INTO `game_state` (`id`, `game_data`) VALUES (1, '{}') ON DUPLICATE KEY UPDATE `id`=1;
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
export const GamePhase = {
|
||||
LOBBY: 'LOBBY',
|
||||
COUNTDOWN: 'COUNTDOWN',
|
||||
@@ -67,3 +68,10 @@ export interface GameState {
|
||||
countdownValue: number;
|
||||
buzzerOpenTimestamp: number | null; // To calculate reaction time
|
||||
}
|
||||
|
||||
// Synchronization Types
|
||||
export interface PlayerIntent {
|
||||
type: 'JOIN' | 'BUZZ' | 'LEAVE';
|
||||
payload: any;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user