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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
39
constants.ts
39
constants.ts
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
13
types.ts
13
types.ts
|
|
@ -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[] } }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue