feat(web): screen transitions + confetti when the lead changes
- RoomPage animates between coarse phases (lobby ↔ game ↔ end) with a fade/ slide (AnimatePresence, mode wait). Keyed by phase, not status, so the YouTube player survives in_round → reveal → scores. - PlayerCards fires a small confetti burst when a player overtakes to take the lead during the game. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7093ce8cd9
commit
e490f23dec
3 changed files with 63 additions and 22 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { Check, Crown } from "lucide-react"
|
import { Check, Crown } from "lucide-react"
|
||||||
import type { RoomSnapshot } from "@nerdware/shared"
|
import type { RoomSnapshot } from "@nerdware/shared"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
|
import { celebrateLead } from "@/lib/confetti"
|
||||||
|
|
||||||
interface PlayerCardsProps {
|
interface PlayerCardsProps {
|
||||||
snapshot: RoomSnapshot
|
snapshot: RoomSnapshot
|
||||||
|
|
@ -27,6 +29,18 @@ export function PlayerCards({
|
||||||
(a, b) => scoreOf(b.id) - scoreOf(a.id)
|
(a, b) => scoreOf(b.id) - scoreOf(a.id)
|
||||||
)
|
)
|
||||||
const voted = new Set(votedIds)
|
const voted = new Set(votedIds)
|
||||||
|
const leaderId = max > 0 ? ranked[0].id : null
|
||||||
|
|
||||||
|
// Confettis quand un joueur DÉPASSE pour prendre la tête (pas à la 1re prise).
|
||||||
|
const prevLeader = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (leaderId && leaderId !== prevLeader.current) {
|
||||||
|
if (prevLeader.current !== null) {
|
||||||
|
celebrateLead()
|
||||||
|
}
|
||||||
|
prevLeader.current = leaderId
|
||||||
|
}
|
||||||
|
}, [leaderId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,15 @@ export function celebrate(): void {
|
||||||
}
|
}
|
||||||
frame()
|
frame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Petite salve quand un joueur prend la tête en cours de partie. */
|
||||||
|
export function celebrateLead(): void {
|
||||||
|
confetti({
|
||||||
|
particleCount: 45,
|
||||||
|
spread: 70,
|
||||||
|
startVelocity: 30,
|
||||||
|
scalar: 0.8,
|
||||||
|
origin: { y: 0.25 },
|
||||||
|
colors: COLORS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from "wouter"
|
import { Link } from "wouter"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { LobbyView } from "@/components/lobby-view"
|
import { LobbyView } from "@/components/lobby-view"
|
||||||
|
|
@ -51,11 +52,14 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards de scores visibles en permanence pendant le jeu (suivi continu).
|
// Phase grossière : on n'anime qu'aux frontières lobby ↔ jeu ↔ fin pour
|
||||||
const inGame =
|
// préserver le lecteur YouTube entre in_round / reveal / scores.
|
||||||
snapshot.status === "in_round" ||
|
const phase =
|
||||||
snapshot.status === "reveal" ||
|
snapshot.status === "lobby"
|
||||||
snapshot.status === "scores"
|
? "lobby"
|
||||||
|
: snapshot.status === "ended"
|
||||||
|
? "ended"
|
||||||
|
: "game"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
||||||
|
|
@ -68,23 +72,34 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{inGame && (
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={phase}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{phase === "lobby" && <LobbyView snapshot={snapshot} />}
|
||||||
|
{phase === "game" && (
|
||||||
|
<>
|
||||||
<PlayerCards
|
<PlayerCards
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
playerId={playerId}
|
playerId={playerId}
|
||||||
showVoteStatus={snapshot.status === "in_round"}
|
showVoteStatus={snapshot.status === "in_round"}
|
||||||
votedIds={voteProgress?.voted}
|
votedIds={voteProgress?.voted}
|
||||||
/>
|
/>
|
||||||
)}
|
{round?.type === "blindtest" ? (
|
||||||
|
|
||||||
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
|
||||||
{inGame &&
|
|
||||||
(round?.type === "blindtest" ? (
|
|
||||||
<BlindtestView snapshot={snapshot} />
|
<BlindtestView snapshot={snapshot} />
|
||||||
) : (
|
) : (
|
||||||
<QuizView snapshot={snapshot} />
|
<QuizView snapshot={snapshot} />
|
||||||
))}
|
)}
|
||||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
</>
|
||||||
|
)}
|
||||||
|
{phase === "ended" && <GameEndView snapshot={snapshot} />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{boomKey > 0 && <Boom key={boomKey} />}
|
{boomKey > 0 && <Boom key={boomKey} />}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue