feature/quiz-mode #4
3 changed files with 140 additions and 54 deletions
|
|
@ -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">
|
|
||||||
{nameOf(winner.playerId)}
|
|
||||||
{isMe(winner.playerId) && (
|
|
||||||
<span className="text-muted-foreground text-sm"> (toi)</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
{first && (
|
||||||
<span className="font-heading text-3xl font-black tabular-nums text-amber-400">
|
<PodiumColumn
|
||||||
{winner.score}
|
entry={first}
|
||||||
</span>
|
place={1}
|
||||||
<span className="text-muted-foreground text-[10px] tracking-widest uppercase">
|
name={nameOf(first.playerId)}
|
||||||
Champion·ne
|
isMe={isMe(first.playerId)}
|
||||||
</span>
|
delay={0.3}
|
||||||
</motion.div>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{third && (
|
||||||
|
<PodiumColumn
|
||||||
|
entry={third}
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue