Redesign scoreboard

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 01:55:49 -07:00
commit fc270d437f
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
4 changed files with 231 additions and 90 deletions

View file

@ -1,17 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Player } from '../types'; import { Player, PointsBreakdown } from '../types';
import { motion, useSpring, useTransform } from 'framer-motion'; import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts'; import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar'; import { PlayerAvatar } from './PlayerAvatar';
const AnimatedScoreLabel: React.FC<{ const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
x: number; const spring = useSpring(0, { duration });
y: number;
width: number;
value: number;
}> = ({ x, y, width, value }) => {
const spring = useSpring(0, { duration: 500 });
const display = useTransform(spring, (latest) => Math.round(latest)); const display = useTransform(spring, (latest) => Math.round(latest));
const [displayValue, setDisplayValue] = useState(0); const [displayValue, setDisplayValue] = useState(0);
@ -21,18 +15,169 @@ const AnimatedScoreLabel: React.FC<{
return () => unsubscribe(); return () => unsubscribe();
}, [value, spring, display]); }, [value, spring, display]);
return <span>{displayValue}</span>;
};
interface BonusBadgeProps {
points: number;
label: string;
icon: React.ReactNode;
color: string;
delay: number;
}
const BonusBadge: React.FC<BonusBadgeProps> = ({ points, label, icon, color, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-white font-bold text-sm ${color}`}
>
{icon}
<span>+{points}</span>
<span className="text-white/80">{label}</span>
</motion.div>
);
const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay, type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-500 text-white font-bold text-sm"
>
<X size={14} />
<span>-{points}</span>
</motion.div>
);
interface PlayerRowProps {
player: Player & { displayName: string };
index: number;
maxScore: number;
}
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
const [phase, setPhase] = useState(0);
const breakdown = player.pointsBreakdown;
const baseDelay = index * 0.3;
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
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 ( return (
<text <motion.div
x={x + width + 15} initial={{ opacity: 0, x: -20 }}
y={y} animate={{ opacity: 1, x: 0 }}
dy={35} transition={{ delay: baseDelay, duration: 0.4 }}
fill="black" className="flex items-center gap-4 py-3"
fontSize={24}
fontWeight={900}
fontFamily="Fredoka"
> >
{displayValue} <div className="flex items-center gap-3 w-48 shrink-0">
</text> <PlayerAvatar seed={player.avatarSeed} size={32} />
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
</div>
<div className="flex-1 flex items-center gap-4">
<div className="flex-1 h-12 bg-gray-100 rounded-full overflow-hidden relative">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: player.color }}
initial={{ width: 0 }}
animate={{ width: `${Math.max(barWidth, 2)}%` }}
transition={{ duration: 0.6, delay: baseDelay + 0.1 }}
/>
</div>
<div className="w-20 text-right">
<span className="font-black text-2xl font-display">
<AnimatedNumber value={getDisplayScore()} />
</span>
</div>
</div>
<div className="flex items-center gap-2 min-w-[280px] justify-start">
<AnimatePresence>
{breakdown === null && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: baseDelay + 0.3 }}
className="text-gray-400 font-medium text-sm"
>
No answer
</motion.span>
)}
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
<PenaltyBadge points={breakdown.penalty} delay={0} />
)}
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm"
>
<span>+{breakdown.basePoints}</span>
</motion.div>
)}
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
<BonusBadge
points={breakdown.streakBonus}
label="Streak"
icon={<Flame size={14} />}
color="bg-amber-500"
delay={0}
/>
)}
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
<BonusBadge
points={breakdown.comebackBonus}
label="Comeback"
icon={<Rocket size={14} />}
color="bg-blue-500"
delay={0}
/>
)}
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
<BonusBadge
points={breakdown.firstCorrectBonus}
label="First!"
icon={<Zap size={14} />}
color="bg-yellow-500"
delay={0}
/>
)}
</AnimatePresence>
</div>
</motion.div>
); );
}; };
@ -49,59 +194,23 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
})); }));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5); const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5);
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
return ( return (
<div className="flex flex-col h-screen p-8"> <div className="flex flex-col h-screen p-8">
<header className="text-center mb-12"> <header className="text-center mb-8">
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1> <h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header> </header>
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50"> <div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-8 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-8 border-white/50 overflow-hidden">
<div className="flex flex-col justify-around py-4 pr-4"> <div className="space-y-2">
{sortedPlayers.map((player) => ( {sortedPlayers.map((player, index) => (
<div key={player.id} className="flex items-center gap-3 h-[50px]"> <PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
<PlayerAvatar seed={player.avatarSeed} size={24} />
<span className="font-black text-xl font-display whitespace-nowrap">{player.displayName}</span>
</div>
))} ))}
</div> </div>
<div className="flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sortedPlayers}
layout="vertical"
margin={{ top: 20, right: 100, left: 0, bottom: 5 }}
>
<XAxis type="number" hide />
<YAxis type="category" dataKey="displayName" hide />
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
{sortedPlayers.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color}
className="filter drop-shadow-md"
/>
))}
<LabelList
dataKey="score"
position="right"
offset={15}
content={({ x, y, width, value }) => (
<AnimatedScoreLabel
x={x as number}
y={y as number}
width={width as number}
value={value as number}
/>
)}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div> </div>
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto"> <div className="mt-8 flex justify-end max-w-5xl w-full mx-auto">
{isHost ? ( {isHost ? (
<button <button
onClick={onNext} onClick={onNext}
@ -111,11 +220,11 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
</button> </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"> <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" /> <Loader2 className="animate-spin w-8 h-8" />
<span className="text-xl font-bold">Waiting for host...</span> <span className="text-xl font-bold">Waiting for host...</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,5 +1,5 @@
import { Triangle, Diamond, Circle, Square } from 'lucide-react'; import { Triangle, Diamond, Circle, Square } from 'lucide-react';
import type { GameConfig, Player } from './types'; import type { GameConfig, Player, PointsBreakdown } from './types';
export const COLORS = { export const COLORS = {
red: 'bg-red-600', red: 'bg-red-600',
@ -48,33 +48,50 @@ interface PointsCalculationParams {
config: GameConfig; config: GameConfig;
} }
export const calculatePoints = (params: PointsCalculationParams): number => { export const calculatePointsWithBreakdown = (params: PointsCalculationParams): PointsBreakdown => {
const { isCorrect, timeLeftMs, questionTimeMs, streak, playerRank, isFirstCorrect, config } = params; 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 (!isCorrect) {
if (config.penaltyForWrongAnswer) { 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) { if (config.streakBonusEnabled && streak >= config.streakThreshold) {
const streakBonus = streak - config.streakThreshold; const streakCount = streak - config.streakThreshold;
const multiplier = config.streakMultiplier + (streakBonus * (config.streakMultiplier - 1)); const multiplier = config.streakMultiplier + (streakCount * (config.streakMultiplier - 1));
points = Math.round(points * multiplier); pointsAfterStreak = Math.round(breakdown.basePoints * multiplier);
breakdown.streakBonus = pointsAfterStreak - breakdown.basePoints;
} }
if (config.comebackBonusEnabled && playerRank > 3) { if (config.comebackBonusEnabled && playerRank > 3) {
points += config.comebackBonusPoints; breakdown.comebackBonus = config.comebackBonusPoints;
} }
if (config.firstCorrectBonusEnabled && isFirstCorrect) { 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 => { export const getPlayerRank = (playerId: string, players: Player[]): number => {

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; 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 { 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'; import { Peer, DataConnection } from 'peerjs';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
@ -158,8 +158,10 @@ export const useGame = () => {
id: 'host', id: 'host',
name: 'Host', name: 'Host',
score: 0, score: 0,
previousScore: 0,
streak: 0, streak: 0,
lastAnswerCorrect: null, lastAnswerCorrect: null,
pointsBreakdown: null,
isBot: false, isBot: false,
avatarSeed: Math.random(), avatarSeed: Math.random(),
color: PLAYER_COLORS[0] color: PLAYER_COLORS[0]
@ -197,8 +199,10 @@ export const useGame = () => {
id: conn.peer, id: conn.peer,
name: data.payload.name, name: data.payload.name,
score: 0, score: 0,
previousScore: 0,
streak: 0, streak: 0,
lastAnswerCorrect: null, lastAnswerCorrect: null,
pointsBreakdown: null,
isBot: false, isBot: false,
avatarSeed: Math.random(), avatarSeed: Math.random(),
color: PLAYER_COLORS[colorIndex] color: PLAYER_COLORS[colorIndex]
@ -224,7 +228,7 @@ export const useGame = () => {
const newStreak = isCorrect ? currentPlayer.streak + 1 : 0; const newStreak = isCorrect ? currentPlayer.streak + 1 : 0;
const playerRank = getPlayerRank(playerId, playersRef.current); const playerRank = getPlayerRank(playerId, playersRef.current);
const points = calculatePoints({ const breakdown = calculatePointsWithBreakdown({
isCorrect, isCorrect,
timeLeftMs: timeLeftRef.current, timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS, questionTimeMs: QUESTION_TIME_MS,
@ -233,14 +237,14 @@ export const useGame = () => {
isFirstCorrect, isFirstCorrect,
config: gameConfigRef.current, 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 => { setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return 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); setSelectedOption(null);
setTimeLeft(QUESTION_TIME_MS); setTimeLeft(QUESTION_TIME_MS);
setFirstCorrectPlayerId(null); 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 currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current; const currentIndex = currentQuestionIndexRef.current;
@ -469,7 +473,7 @@ export const useGame = () => {
const playerRank = getPlayerRank('host', playersRef.current); const playerRank = getPlayerRank('host', playersRef.current);
const points = calculatePoints({ const breakdown = calculatePointsWithBreakdown({
isCorrect, isCorrect,
timeLeftMs: timeLeftRef.current, timeLeftMs: timeLeftRef.current,
questionTimeMs: QUESTION_TIME_MS, questionTimeMs: QUESTION_TIME_MS,
@ -479,14 +483,14 @@ export const useGame = () => {
config: gameConfigRef.current, config: gameConfigRef.current,
}); });
setLastPointsEarned(points); setLastPointsEarned(breakdown.total);
const newScore = Math.max(0, (hostPlayer?.score || 0) + points); const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
setCurrentPlayerScore(newScore); setCurrentPlayerScore(newScore);
setCurrentStreak(newStreak); setCurrentStreak(newStreak);
setPlayers(prev => prev.map(p => { setPlayers(prev => prev.map(p => {
if (p.id !== 'host') return 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 { } else {
const option = arg as AnswerOption; const option = arg as AnswerOption;

View file

@ -96,12 +96,23 @@ export interface GenerateQuizOptions {
documents?: ProcessedDocument[]; documents?: ProcessedDocument[];
} }
export interface PointsBreakdown {
basePoints: number;
streakBonus: number;
comebackBonus: number;
firstCorrectBonus: number;
penalty: number;
total: number;
}
export interface Player { export interface Player {
id: string; id: string;
name: string; name: string;
score: number; score: number;
previousScore: number;
streak: number; streak: number;
lastAnswerCorrect: boolean | null; lastAnswerCorrect: boolean | null;
pointsBreakdown: PointsBreakdown | null;
isBot: boolean; isBot: boolean;
avatarSeed: number; avatarSeed: number;
color: string; color: string;
@ -126,7 +137,7 @@ export type NetworkMessage =
} }
} }
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } } | { 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_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} } | { type: 'TIME_UP'; payload: {} }
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } } | { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }