363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Player, PointsBreakdown } from '../types';
|
|
import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion';
|
|
import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react';
|
|
import { PlayerAvatar, getAvatarColors } 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 };
|
|
maxScore: number;
|
|
rank: number;
|
|
currentScore: number;
|
|
phase: number;
|
|
baseDelay: number;
|
|
}
|
|
|
|
const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
|
|
const breakdown = player.pointsBreakdown;
|
|
|
|
const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
|
|
|
|
const isFirst = rank === 1;
|
|
const isSecond = rank === 2;
|
|
const isThird = rank === 3;
|
|
const isTop3 = rank <= 3;
|
|
|
|
let rankStyles = "bg-white border-gray-100";
|
|
let rankBadgeStyles = "bg-gray-100 text-gray-500";
|
|
|
|
if (isFirst) {
|
|
rankStyles = "bg-gradient-to-r from-yellow-50 to-amber-100 border-amber-300 shadow-lg scale-[1.02] z-10";
|
|
rankBadgeStyles = "bg-gradient-to-br from-yellow-400 to-amber-600 text-white shadow-sm";
|
|
} else if (isSecond) {
|
|
rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0";
|
|
rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm";
|
|
} else if (isThird) {
|
|
rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0";
|
|
rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm";
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
layout
|
|
layoutId={player.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{
|
|
layout: { type: "spring", stiffness: 300, damping: 30 },
|
|
opacity: { duration: 0.3 }
|
|
}}
|
|
className={`flex items-center gap-3 md:gap-4 p-3 md:p-4 rounded-xl md:rounded-2xl border-2 ${rankStyles}`}
|
|
>
|
|
<div className={`flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-full font-black text-lg md:text-xl shrink-0 ${rankBadgeStyles}`}>
|
|
{rank}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
|
<div className="flex items-center justify-between gap-3 w-full">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="relative">
|
|
<PlayerAvatar seed={player.avatarSeed} size={isTop3 ? 40 : 32} />
|
|
{isFirst && (
|
|
<motion.div
|
|
initial={{ scale: 0, rotate: -45 }}
|
|
animate={{ scale: 1, rotate: -15 }}
|
|
transition={{ delay: 0.5, type: 'spring' }}
|
|
className="absolute -top-3 -left-2 drop-shadow-md"
|
|
>
|
|
<Crown size={24} className="fill-yellow-400 text-yellow-600" />
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
<span className={`font-black font-display truncate ${isTop3 ? 'text-xl' : 'text-lg'}`}>
|
|
{player.displayName}
|
|
</span>
|
|
</div>
|
|
<span className={`font-black font-display ${isTop3 ? 'text-3xl text-theme-primary' : 'text-2xl text-gray-700'}`}>
|
|
<AnimatedNumber value={currentScore} />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-full h-8 md:h-10 bg-black/5 rounded-full overflow-hidden relative">
|
|
<motion.div
|
|
className="h-full rounded-full"
|
|
style={{ background: `linear-gradient(90deg, ${getAvatarColors(player.avatarSeed)[0]}, ${getAvatarColors(player.avatarSeed)[1]})` }}
|
|
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 h-6">
|
|
<AnimatePresence mode='popLayout'>
|
|
{breakdown === null && (
|
|
<motion.span
|
|
key="no-answer"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ delay: baseDelay + 0.3 }}
|
|
className="text-gray-400 font-medium text-sm"
|
|
>
|
|
No answer
|
|
</motion.span>
|
|
)}
|
|
|
|
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
|
|
<PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
|
|
)}
|
|
|
|
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
|
|
<motion.div
|
|
key="base-points"
|
|
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-0.5 rounded-full bg-green-500 text-white font-bold text-xs md:text-sm"
|
|
>
|
|
<span>+{breakdown.basePoints}</span>
|
|
</motion.div>
|
|
)}
|
|
|
|
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
|
|
<BonusBadge
|
|
key="streak"
|
|
points={breakdown.streakBonus}
|
|
label="Streak"
|
|
icon={<Flame size={12} />}
|
|
color="bg-amber-500"
|
|
delay={0}
|
|
/>
|
|
)}
|
|
|
|
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
|
|
<BonusBadge
|
|
key="comeback"
|
|
points={breakdown.comebackBonus}
|
|
label="Comeback"
|
|
icon={<Rocket size={12} />}
|
|
color="bg-blue-500"
|
|
delay={0}
|
|
/>
|
|
)}
|
|
|
|
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
|
|
<BonusBadge
|
|
key="first-correct"
|
|
points={breakdown.firstCorrectBonus}
|
|
label="First!"
|
|
icon={<Zap size={12} />}
|
|
color="bg-yellow-500"
|
|
delay={0}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
interface ScoreboardProps {
|
|
players: Player[];
|
|
onNext: () => void;
|
|
isHost: boolean;
|
|
currentPlayerId: string | null;
|
|
isPresenter?: boolean;
|
|
onPresenterAdvance?: () => void;
|
|
}
|
|
|
|
interface AnimatedPlayerState extends Player {
|
|
displayName: string;
|
|
currentScore: number;
|
|
phase: number;
|
|
initialIndex: number;
|
|
}
|
|
|
|
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId, isPresenter = false, onPresenterAdvance }) => {
|
|
const canAdvance = isHost || isPresenter;
|
|
// Initialize players sorted by previousScore to start
|
|
const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
|
|
const playersWithMeta = players.map(p => ({
|
|
...p,
|
|
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name,
|
|
currentScore: p.previousScore,
|
|
phase: 0,
|
|
}));
|
|
|
|
// Sort by previous score initially (descending)
|
|
// Add a secondary sort by ID to ensure stable sorting
|
|
return playersWithMeta
|
|
.sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id))
|
|
.map((p, index) => ({
|
|
...p,
|
|
initialIndex: index // Store the initial rank for staggered animation timing
|
|
}));
|
|
});
|
|
|
|
useEffect(() => {
|
|
const timers: NodeJS.Timeout[] = [];
|
|
|
|
// We use the initial state to set up the timers
|
|
// We can't use animatedPlayers in the dependency array or it will loop
|
|
// But we need a reference to the initial setup to schedule things.
|
|
// The state initializer runs once, so we need to reconstruct that list or trust the state is fresh on mount.
|
|
// However, inside useEffect, animatedPlayers might change if we include it in deps.
|
|
// We want to schedule based on the INITIAL state.
|
|
|
|
// Let's grab the initial list again to be safe and consistent with the initializer logic
|
|
const initialPlayers = players.map(p => ({
|
|
...p,
|
|
previousScore: p.previousScore,
|
|
pointsBreakdown: p.pointsBreakdown
|
|
})).sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id));
|
|
|
|
initialPlayers.forEach((player, initialIndex) => {
|
|
const breakdown = player.pointsBreakdown;
|
|
const baseDelay = initialIndex * 0.1;
|
|
|
|
// Helper to update state
|
|
const updatePlayerState = (phase: number, scoreToAdd: number) => {
|
|
setAnimatedPlayers(prev => {
|
|
const updated = prev.map(p => {
|
|
if (p.id !== player.id) return p;
|
|
return {
|
|
...p,
|
|
phase,
|
|
currentScore: p.currentScore + scoreToAdd
|
|
};
|
|
});
|
|
// Re-sort on every update
|
|
return updated.sort((a, b) => (b.currentScore - a.currentScore) || a.id.localeCompare(b.id));
|
|
});
|
|
};
|
|
|
|
if (!breakdown) return;
|
|
|
|
// Phase 1: Base Points + Penalty
|
|
// (baseDelay + 0.2)s
|
|
timers.push(setTimeout(() => {
|
|
const points = breakdown.basePoints - breakdown.penalty;
|
|
updatePlayerState(1, points);
|
|
}, (baseDelay + 0.2) * 1000));
|
|
|
|
// Phase 2: Streak
|
|
// (baseDelay + 0.8)s
|
|
if (breakdown.streakBonus > 0) {
|
|
timers.push(setTimeout(() => {
|
|
updatePlayerState(2, breakdown.streakBonus);
|
|
}, (baseDelay + 0.8) * 1000));
|
|
}
|
|
|
|
// Phase 3: Comeback
|
|
// (baseDelay + 1.2)s
|
|
if (breakdown.comebackBonus > 0) {
|
|
timers.push(setTimeout(() => {
|
|
updatePlayerState(3, breakdown.comebackBonus);
|
|
}, (baseDelay + 1.2) * 1000));
|
|
}
|
|
|
|
// Phase 4: First Correct
|
|
// (baseDelay + 1.6)s
|
|
if (breakdown.firstCorrectBonus > 0) {
|
|
timers.push(setTimeout(() => {
|
|
updatePlayerState(4, breakdown.firstCorrectBonus);
|
|
}, (baseDelay + 1.6) * 1000));
|
|
}
|
|
});
|
|
|
|
return () => timers.forEach(clearTimeout);
|
|
}, []); // Run once on mount
|
|
|
|
// Calculate max score based on FINAL scores (so the bar scale is consistent/correct relative to the winner)
|
|
const maxScore = Math.max(...players.map(p => p.score), 1);
|
|
|
|
return (
|
|
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden bg-theme-bg">
|
|
<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/95 backdrop-blur-sm rounded-2xl md:rounded-[3rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] p-4 md:p-8 text-gray-900 max-w-4xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto custom-scrollbar">
|
|
<LayoutGroup>
|
|
<div className="space-y-3">
|
|
{animatedPlayers.map((player, index) => (
|
|
<PlayerRow
|
|
key={player.id}
|
|
player={player}
|
|
maxScore={maxScore}
|
|
rank={index + 1}
|
|
currentScore={player.currentScore}
|
|
phase={player.phase}
|
|
baseDelay={player.initialIndex * 0.1}
|
|
/>
|
|
))}
|
|
</div>
|
|
</LayoutGroup>
|
|
</div>
|
|
|
|
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20">
|
|
{canAdvance ? (
|
|
<button
|
|
onClick={isHost ? onNext : onPresenterAdvance}
|
|
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 flex items-center gap-2"
|
|
>
|
|
Next <Trophy size={24} />
|
|
</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 shadow-lg text-white">
|
|
<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>
|
|
);
|
|
};
|