227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
import React, { useState, useEffect } from '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 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);
|
|
|
|
useEffect(() => {
|
|
spring.set(value);
|
|
const unsubscribe = display.on('change', (v) => setDisplayValue(v));
|
|
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 (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: baseDelay, duration: 0.4 }}
|
|
className="flex flex-col gap-2 py-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3 w-full">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<PlayerAvatar seed={player.avatarSeed} size={32} />
|
|
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
|
|
</div>
|
|
<span className="font-black text-2xl font-display">
|
|
<AnimatedNumber value={getDisplayScore()} />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-full h-10 md: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: phase === 0 ? baseDelay + 0.1 : 0 }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 w-full 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>
|
|
);
|
|
};
|
|
|
|
interface ScoreboardProps {
|
|
players: Player[];
|
|
onNext: () => void;
|
|
isHost: boolean;
|
|
currentPlayerId: string | null;
|
|
}
|
|
|
|
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => {
|
|
const playersWithDisplayName = players.map(p => ({
|
|
...p,
|
|
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
|
}));
|
|
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
|
|
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1);
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
|
|
<header className="text-center mb-4 md:mb-8 shrink-0">
|
|
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
|
</header>
|
|
|
|
<div className="flex-1 min-h-0 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto">
|
|
<div className="space-y-2">
|
|
{sortedPlayers.map((player, index) => (
|
|
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto shrink-0">
|
|
{isHost ? (
|
|
<button
|
|
onClick={onNext}
|
|
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all"
|
|
>
|
|
Next
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20">
|
|
<Loader2 className="animate-spin w-6 h-6 md:w-8 md:h-8" />
|
|
<span className="text-base md:text-xl font-bold">Waiting for host...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|