diff --git a/components/Scoreboard.tsx b/components/Scoreboard.tsx index 61a00d5..86fb60a 100644 --- a/components/Scoreboard.tsx +++ b/components/Scoreboard.tsx @@ -1,17 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { Player } from '../types'; -import { motion, useSpring, useTransform } from 'framer-motion'; -import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts'; -import { Loader2 } from 'lucide-react'; +import { Player, PointsBreakdown } from '../types'; +import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion'; +import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react'; import { PlayerAvatar } from './PlayerAvatar'; -const AnimatedScoreLabel: React.FC<{ - x: number; - y: number; - width: number; - value: number; -}> = ({ x, y, width, value }) => { - const spring = useSpring(0, { duration: 500 }); +const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => { + const spring = useSpring(0, { duration }); const display = useTransform(spring, (latest) => Math.round(latest)); const [displayValue, setDisplayValue] = useState(0); @@ -21,18 +15,169 @@ const AnimatedScoreLabel: React.FC<{ return () => unsubscribe(); }, [value, spring, display]); + return {displayValue}; +}; + +interface BonusBadgeProps { + points: number; + label: string; + icon: React.ReactNode; + color: string; + delay: number; +} + +const BonusBadge: React.FC = ({ points, label, icon, color, delay }) => ( + + {icon} + +{points} + {label} + +); + +const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => ( + + + -{points} + +); + +interface PlayerRowProps { + player: Player & { displayName: string }; + index: number; + maxScore: number; +} + +const PlayerRow: React.FC = ({ player, index, maxScore }) => { + const [phase, setPhase] = useState(0); + const breakdown = player.pointsBreakdown; + const baseDelay = index * 0.3; + + useEffect(() => { + const timers: ReturnType[] = []; + + timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000)); + if (breakdown) { + if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000)); + if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000)); + if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000)); + } + + return () => timers.forEach(clearTimeout); + }, [baseDelay, breakdown]); + + const getDisplayScore = () => { + if (!breakdown) return player.previousScore; + + let score = player.previousScore; + if (phase >= 1) score += breakdown.basePoints - breakdown.penalty; + if (phase >= 2) score += breakdown.streakBonus; + if (phase >= 3) score += breakdown.comebackBonus; + if (phase >= 4) score += breakdown.firstCorrectBonus; + return Math.max(0, score); + }; + + const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0; + return ( - - {displayValue} - +
+ + {player.displayName} +
+ +
+
+ +
+ +
+ + + +
+
+ +
+ + {breakdown === null && ( + + No answer + + )} + + {breakdown && breakdown.penalty > 0 && phase >= 1 && ( + + )} + + {breakdown && breakdown.basePoints > 0 && phase >= 1 && ( + + +{breakdown.basePoints} + + )} + + {breakdown && breakdown.streakBonus > 0 && phase >= 2 && ( + } + color="bg-amber-500" + delay={0} + /> + )} + + {breakdown && breakdown.comebackBonus > 0 && phase >= 3 && ( + } + color="bg-blue-500" + delay={0} + /> + )} + + {breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && ( + } + color="bg-yellow-500" + delay={0} + /> + )} + +
+ ); }; @@ -49,59 +194,23 @@ export const Scoreboard: React.FC = ({ players, onNext, isHost, displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name })); const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5); + const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1); return (
-
+

Scoreboard

-
-
- {sortedPlayers.map((player) => ( -
- - {player.displayName} -
+
+
+ {sortedPlayers.map((player, index) => ( + ))}
-
- - - - - - {sortedPlayers.map((entry, index) => ( - - ))} - ( - - )} - /> - - - -
-
+
{isHost ? ( ) : (
- - Waiting for host... + + Waiting for host...
)}
); -}; \ No newline at end of file +}; diff --git a/constants.ts b/constants.ts index ecc3c83..476eff1 100644 --- a/constants.ts +++ b/constants.ts @@ -1,5 +1,5 @@ import { Triangle, Diamond, Circle, Square } from 'lucide-react'; -import type { GameConfig, Player } from './types'; +import type { GameConfig, Player, PointsBreakdown } from './types'; export const COLORS = { red: 'bg-red-600', @@ -48,33 +48,50 @@ interface PointsCalculationParams { config: GameConfig; } -export const calculatePoints = (params: PointsCalculationParams): number => { +export const calculatePointsWithBreakdown = (params: PointsCalculationParams): PointsBreakdown => { const { isCorrect, timeLeftMs, questionTimeMs, streak, playerRank, isFirstCorrect, config } = params; + const breakdown: PointsBreakdown = { + basePoints: 0, + streakBonus: 0, + comebackBonus: 0, + firstCorrectBonus: 0, + penalty: 0, + total: 0, + }; + if (!isCorrect) { if (config.penaltyForWrongAnswer) { - return -Math.round(POINTS_PER_QUESTION * (config.penaltyPercent / 100)); + breakdown.penalty = Math.round(POINTS_PER_QUESTION * (config.penaltyPercent / 100)); + breakdown.total = -breakdown.penalty; } - return 0; + return breakdown; } - let points = calculateBasePoints(timeLeftMs, questionTimeMs); + breakdown.basePoints = calculateBasePoints(timeLeftMs, questionTimeMs); + let pointsAfterStreak = breakdown.basePoints; if (config.streakBonusEnabled && streak >= config.streakThreshold) { - const streakBonus = streak - config.streakThreshold; - const multiplier = config.streakMultiplier + (streakBonus * (config.streakMultiplier - 1)); - points = Math.round(points * multiplier); + const streakCount = streak - config.streakThreshold; + const multiplier = config.streakMultiplier + (streakCount * (config.streakMultiplier - 1)); + pointsAfterStreak = Math.round(breakdown.basePoints * multiplier); + breakdown.streakBonus = pointsAfterStreak - breakdown.basePoints; } if (config.comebackBonusEnabled && playerRank > 3) { - points += config.comebackBonusPoints; + breakdown.comebackBonus = config.comebackBonusPoints; } if (config.firstCorrectBonusEnabled && isFirstCorrect) { - points += config.firstCorrectBonusPoints; + breakdown.firstCorrectBonus = config.firstCorrectBonusPoints; } - return points; + breakdown.total = pointsAfterStreak + breakdown.comebackBonus + breakdown.firstCorrectBonus; + return breakdown; +}; + +export const calculatePoints = (params: PointsCalculationParams): number => { + return calculatePointsWithBreakdown(params).total; }; export const getPlayerRank = (playerId: string, players: Player[]): number => { diff --git a/hooks/useGame.ts b/hooks/useGame.ts index e21d00b..97b6faa 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG } from '../types'; +import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types'; import { generateQuiz } from '../services/geminiService'; -import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePoints, getPlayerRank } from '../constants'; +import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants'; import { Peer, DataConnection } from 'peerjs'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; @@ -158,8 +158,10 @@ export const useGame = () => { id: 'host', name: 'Host', score: 0, + previousScore: 0, streak: 0, lastAnswerCorrect: null, + pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: PLAYER_COLORS[0] @@ -197,8 +199,10 @@ export const useGame = () => { id: conn.peer, name: data.payload.name, score: 0, + previousScore: 0, streak: 0, lastAnswerCorrect: null, + pointsBreakdown: null, isBot: false, avatarSeed: Math.random(), color: PLAYER_COLORS[colorIndex] @@ -224,7 +228,7 @@ export const useGame = () => { const newStreak = isCorrect ? currentPlayer.streak + 1 : 0; const playerRank = getPlayerRank(playerId, playersRef.current); - const points = calculatePoints({ + const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, questionTimeMs: QUESTION_TIME_MS, @@ -233,14 +237,14 @@ export const useGame = () => { isFirstCorrect, config: gameConfigRef.current, }); - const newScore = Math.max(0, currentPlayer.score + points); + const newScore = Math.max(0, currentPlayer.score + breakdown.total); setPlayers(prev => prev.map(p => { if (p.id !== playerId) return p; - return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect }; + return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; })); - conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } }); + conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } }); } }; @@ -284,7 +288,7 @@ export const useGame = () => { setSelectedOption(null); setTimeLeft(QUESTION_TIME_MS); setFirstCorrectPlayerId(null); - setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null }))); + setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }))); const currentQuiz = quizRef.current; const currentIndex = currentQuestionIndexRef.current; @@ -469,7 +473,7 @@ export const useGame = () => { const playerRank = getPlayerRank('host', playersRef.current); - const points = calculatePoints({ + const breakdown = calculatePointsWithBreakdown({ isCorrect, timeLeftMs: timeLeftRef.current, questionTimeMs: QUESTION_TIME_MS, @@ -479,14 +483,14 @@ export const useGame = () => { config: gameConfigRef.current, }); - setLastPointsEarned(points); - const newScore = Math.max(0, (hostPlayer?.score || 0) + points); + setLastPointsEarned(breakdown.total); + const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total); setCurrentPlayerScore(newScore); setCurrentStreak(newStreak); setPlayers(prev => prev.map(p => { if (p.id !== 'host') return p; - return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect }; + return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown }; })); } else { const option = arg as AnswerOption; diff --git a/types.ts b/types.ts index 0689dbc..0733de7 100644 --- a/types.ts +++ b/types.ts @@ -96,12 +96,23 @@ export interface GenerateQuizOptions { documents?: ProcessedDocument[]; } +export interface PointsBreakdown { + basePoints: number; + streakBonus: number; + comebackBonus: number; + firstCorrectBonus: number; + penalty: number; + total: number; +} + export interface Player { id: string; name: string; score: number; + previousScore: number; streak: number; lastAnswerCorrect: boolean | null; + pointsBreakdown: PointsBreakdown | null; isBot: boolean; avatarSeed: number; color: string; @@ -126,7 +137,7 @@ export type NetworkMessage = } } | { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } } - | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } } + | { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } } | { type: 'TIME_SYNC'; payload: { timeLeft: number } } | { type: 'TIME_UP'; payload: {} } | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }