feat(web): podium on the end screen + larger avatars

- GameEndView: top-3 podium — 2nd left, champion center (taller), 3rd right,
  with gold/silver/bronze pedestals, staggered rise-in (climax on the champion),
  crown + pulsing gold glow kept on #1; ranks 4+ listed below
- bigger avatars where they read too small: lobby (size-7→11) and in-game
  player cards (size-9→14, slightly wider cards)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-09 11:05:54 +02:00
parent c3e00ab9d9
commit 0c2534f3a4
3 changed files with 140 additions and 54 deletions

View file

@ -6,6 +6,109 @@ import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room" import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar" import { Avatar } from "@/components/avatar"
type Place = 1 | 2 | 3
// Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place.
const PODIUM: Record<Place, {
bar: string
pedestal: string
text: string
ring: string
avatar: string
}> = {
1: {
bar: "h-24",
pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5",
text: "text-amber-400",
ring: "ring-amber-400",
avatar: "size-20",
},
2: {
bar: "h-16",
pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5",
text: "text-zinc-300",
ring: "ring-zinc-300",
avatar: "size-16",
},
3: {
bar: "h-12",
pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5",
text: "text-amber-600",
ring: "ring-amber-700",
avatar: "size-16",
},
}
function PodiumColumn({
entry,
place,
name,
isMe,
delay,
}: {
entry: PlayerScore
place: Place
name: string
isMe: boolean
delay: number
}) {
const cfg = PODIUM[place]
return (
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 22, delay }}
className="flex w-24 flex-col items-center gap-1"
>
<div className="relative">
{place === 1 && (
<motion.span
initial={{ y: -26, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: delay + 0.25 }}
className="absolute -top-5 left-1/2 z-10 -translate-x-1/2"
>
<Crown className="size-8 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span>
)}
<motion.div
animate={
place === 1
? {
boxShadow: [
"0 0 0px 0px rgba(251,191,36,0)",
"0 0 24px 4px rgba(251,191,36,0.5)",
"0 0 12px 2px rgba(251,191,36,0.25)",
],
}
: undefined
}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse" }}
className="rounded-full"
>
<Avatar seed={name} className={`${cfg.avatar} ring-2 ${cfg.ring}`} />
</motion.div>
</div>
<span className="max-w-24 truncate text-sm font-medium">
{name}
{isMe && <span className="text-muted-foreground"> (toi)</span>}
</span>
<span className={`font-heading text-lg font-black tabular-nums ${cfg.text}`}>
{entry.score}
</span>
<div
className={`mt-1 flex w-full items-start justify-center rounded-t-lg border-x border-t-2 pt-1 ${cfg.bar} ${cfg.pedestal}`}
>
<span className={`font-heading text-2xl font-black ${cfg.text}`}>
{place}
</span>
</div>
</motion.div>
)
}
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const playerId = useRoomStore((s) => s.playerId) const playerId = useRoomStore((s) => s.playerId)
const finalScores = useRoomStore((s) => s.finalScores) const finalScores = useRoomStore((s) => s.finalScores)
@ -15,11 +118,11 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const ranked = [...scores].sort((a, b) => b.score - a.score) const ranked = [...scores].sort((a, b) => b.score - a.score)
const nameOf = (id: string) => const nameOf = (id: string) =>
snapshot.players.find((p) => p.id === id)?.name ?? "?" snapshot.players.find((p) => p.id === id)?.name ?? "?"
const winner = ranked[0]
const rest = ranked.slice(1)
const isMe = (id: string) => id === playerId const isMe = (id: string) => id === playerId
const [first, second, third] = ranked
const rest = ranked.slice(3)
return ( return (
<div className="flex w-full max-w-md flex-col gap-6 text-center"> <div className="flex w-full max-w-md flex-col gap-6 text-center">
<header> <header>
@ -27,57 +130,40 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
Partie terminée Partie terminée
</p> </p>
<h2 className="font-heading text-2xl font-bold"> <h2 className="font-heading text-2xl font-bold">
🏆 {winner ? nameOf(winner.playerId) : "?"} l'emporte ! 🏆 {first ? nameOf(first.playerId) : "?"} l'emporte !
</h2> </h2>
</header> </header>
{winner && ( {/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
<motion.div <div className="mt-4 flex items-end justify-center gap-2">
initial={{ scale: 0.7, opacity: 0, y: 10 }} {second && (
animate={{ <PodiumColumn
scale: 1, entry={second}
opacity: 1, place={2}
y: 0, name={nameOf(second.playerId)}
boxShadow: [ isMe={isMe(second.playerId)}
"0 0 0px 0px rgba(251,191,36,0.0)", delay={0.1}
"0 0 28px 4px rgba(251,191,36,0.45)",
"0 0 14px 2px rgba(251,191,36,0.25)",
],
}}
transition={{
scale: { type: "spring", stiffness: 300, damping: 18 },
boxShadow: { duration: 2, repeat: Infinity, repeatType: "reverse" },
}}
className="relative mt-3 flex flex-col items-center gap-1 rounded-2xl border-2 border-amber-400 bg-gradient-to-b from-amber-400/20 to-amber-400/5 px-6 py-5"
>
<motion.span
initial={{ y: -28, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: 0.2 }}
className="absolute -top-5 z-10"
>
<Crown className="size-9 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span>
<Avatar
seed={nameOf(winner.playerId)}
className="size-20 ring-2 ring-amber-400"
/> />
)}
<span className="font-heading mt-1 text-xl font-bold text-balance"> {first && (
{nameOf(winner.playerId)} <PodiumColumn
{isMe(winner.playerId) && ( entry={first}
<span className="text-muted-foreground text-sm"> (toi)</span> place={1}
)} name={nameOf(first.playerId)}
</span> isMe={isMe(first.playerId)}
<span className="font-heading text-3xl font-black tabular-nums text-amber-400"> delay={0.3}
{winner.score} />
</span> )}
<span className="text-muted-foreground text-[10px] tracking-widest uppercase"> {third && (
Champion·ne <PodiumColumn
</span> entry={third}
</motion.div> place={3}
)} name={nameOf(third.playerId)}
isMe={isMe(third.playerId)}
delay={0}
/>
)}
</div>
{rest.length > 0 && ( {rest.length > 0 && (
<ol className="flex flex-col gap-1"> <ol className="flex flex-col gap-1">
@ -90,7 +176,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="text-muted-foreground w-5 text-right tabular-nums"> <span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 2} {i + 4}
</span> </span>
<Avatar seed={nameOf(s.playerId)} className="size-7" /> <Avatar seed={nameOf(s.playerId)} className="size-7" />
<span> <span>

View file

@ -42,7 +42,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
<span <span
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`} className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
> >
<Avatar seed={p.name} className="size-7" /> <Avatar seed={p.name} className="size-11" />
{p.name} {p.name}
{p.id === playerId && ( {p.id === playerId && (
<span className="text-muted-foreground"> (toi)</span> <span className="text-muted-foreground"> (toi)</span>

View file

@ -41,7 +41,7 @@ export function PlayerCards({
key={p.id} key={p.id}
layout layout
transition={{ type: "spring", stiffness: 500, damping: 40 }} transition={{ type: "spring", stiffness: 500, damping: 40 }}
className={`relative flex min-w-20 flex-col items-center gap-0.5 rounded-xl border px-3 py-2 ${ className={`relative flex min-w-24 flex-col items-center gap-1 rounded-xl border px-3 py-2.5 ${
isLeader isLeader
? "border-amber-400/60 bg-amber-400/10" ? "border-amber-400/60 bg-amber-400/10"
: "border-input bg-muted/40" : "border-input bg-muted/40"
@ -61,7 +61,7 @@ export function PlayerCards({
)} )}
<Avatar <Avatar
seed={p.name} seed={p.name}
className={`size-9 ${isLeader ? "ring-2 ring-amber-400" : ""}`} className={`size-14 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
/> />
<span className="max-w-24 truncate text-xs font-medium"> <span className="max-w-24 truncate text-xs font-medium">
{p.name} {p.name}