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 { 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 <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 (
<text
x={x + width + 15}
y={y}
dy={35}
fill="black"
fontSize={24}
fontWeight={900}
fontFamily="Fredoka"
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: baseDelay, duration: 0.4 }}
className="flex items-center gap-4 py-3"
>
{displayValue}
</text>
<div className="flex items-center gap-3 w-48 shrink-0">
<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
}));
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score).slice(0, 5);
const maxScore = Math.max(...sortedPlayers.map(p => p.score), 1);
return (
<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>
</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 flex-col justify-around py-4 pr-4">
{sortedPlayers.map((player) => (
<div key={player.id} className="flex items-center gap-3 h-[50px]">
<PlayerAvatar seed={player.avatarSeed} size={24} />
<span className="font-black text-xl font-display whitespace-nowrap">{player.displayName}</span>
</div>
<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="space-y-2">
{sortedPlayers.map((player, index) => (
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
))}
</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 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 ? (
<button
onClick={onNext}
@ -111,8 +220,8 @@ export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost,
</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>
<Loader2 className="animate-spin w-8 h-8" />
<span className="text-xl font-bold">Waiting for host...</span>
</div>
)}
</div>

View file

@ -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 => {

View file

@ -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;

View file

@ -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[] } }