Redesign scoreboard
This commit is contained in:
parent
af21f2bcdc
commit
fc270d437f
4 changed files with 231 additions and 90 deletions
|
|
@ -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>
|
||||
|
|
|
|||
39
constants.ts
39
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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
13
types.ts
13
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[] } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue