Compare commits

..

4 commits

Author SHA1 Message Date
8b03879ee0 Merge pull request 'feature/polish' (#9) from feature/polish into dev
Reviewed-on: #9
2026-06-10 19:22:05 +00:00
AyoubBenziza
db0100e272 feat(web): victory sound on the end screen (drop-in)
- lib/sound: plays src/assets/sounds/victory.{mp3,ogg,wav} if present,
  auto-detected via import.meta.glob; no-op when absent
- played alongside the confetti when the end screen appears
- README documenting where to drop the file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:17:25 +02:00
AyoubBenziza
e490f23dec 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>
2026-06-10 18:57:49 +02:00
AyoubBenziza
7093ce8cd9 feat(web): victory confetti on the end screen
canvas-confetti burst (center pop + side jets) fired once when the game-end
screen appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:54:23 +02:00
9 changed files with 154 additions and 23 deletions

View file

@ -18,6 +18,7 @@
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"canvas-confetti": "^1.9.4",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
@ -29,6 +30,7 @@
"devDependencies": {
"@eslint/js": "^10",
"@tailwindcss/vite": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",

View file

@ -0,0 +1,10 @@
# Sons du jeu
Dépose ici les effets sonores (détectés automatiquement au build).
- **`victory.mp3`** → jingle joué sur l'écran de fin de partie.
(formats acceptés : `.mp3`, `.ogg`, `.wav` — nomme le fichier `victory.*`)
Si aucun fichier n'est présent, le son est simplement ignoré (pas d'erreur).
> ⚠️ N'utilise que des sons dont tu as les droits (libres / CC0).

Binary file not shown.

View file

@ -1,4 +1,4 @@
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import { Link } from "wouter"
import { motion } from "framer-motion"
import { Check, ClipboardList, Crown, Music } from "lucide-react"
@ -7,6 +7,8 @@ import type { BlindtestTrackInfo, PlayerScore, RoomSnapshot } from "@nerdware/sh
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Avatar } from "@/components/avatar"
import { celebrate } from "@/lib/confetti"
import { playVictory } from "@/lib/sound"
/** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */
function spotifySearch(track: BlindtestTrackInfo): string {
@ -125,6 +127,16 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
// Confettis de victoire (une fois à l'arrivée sur l'écran de fin).
const celebrated = useRef(false)
useEffect(() => {
if (!celebrated.current) {
celebrated.current = true
celebrate()
playVictory()
}
}, [])
const scores: PlayerScore[] = finalScores ?? snapshot.scores
const ranked = [...scores].sort((a, b) => b.score - a.score)
const nameOf = (id: string) =>

View file

@ -1,7 +1,9 @@
import { useEffect, useRef } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared"
import { Avatar } from "@/components/avatar"
import { celebrateLead } from "@/lib/confetti"
interface PlayerCardsProps {
snapshot: RoomSnapshot
@ -27,6 +29,18 @@ export function PlayerCards({
(a, b) => scoreOf(b.id) - scoreOf(a.id)
)
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 (
<div className="flex flex-wrap justify-center gap-2">

View file

@ -0,0 +1,48 @@
import confetti from "canvas-confetti"
const COLORS = ["#a855f7", "#ec4899", "#22d3ee", "#fbbf24", "#f97316"]
/** Salve de confettis de victoire : gros pop central + jets latéraux ~1s. */
export function celebrate(): void {
confetti({
particleCount: 140,
spread: 100,
startVelocity: 45,
origin: { y: 0.6 },
colors: COLORS,
})
const end = Date.now() + 900
const frame = () => {
confetti({
particleCount: 5,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: COLORS,
})
confetti({
particleCount: 5,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: COLORS,
})
if (Date.now() < end) {
requestAnimationFrame(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,
})
}

24
apps/web/src/lib/sound.ts Normal file
View file

@ -0,0 +1,24 @@
// Sons du jeu. Dépose les fichiers dans src/assets/sounds/ (voir le README).
// Tout est optionnel : si le fichier est absent, la lecture est ignorée.
const victorySources = Object.values(
import.meta.glob("../assets/sounds/victory.{mp3,ogg,wav}", {
eager: true,
import: "default",
})
) as string[]
const victoryUrl: string | null = victorySources[0] ?? null
let victoryAudio: HTMLAudioElement | null = null
/** Joue le jingle de victoire (si présent). Nécessite une interaction préalable. */
export function playVictory(volume = 0.6): void {
if (!victoryUrl) {
return
}
victoryAudio ??= new Audio(victoryUrl)
victoryAudio.volume = volume
victoryAudio.currentTime = 0
// L'autoplay peut être bloqué tant qu'aucun geste utilisateur n'a eu lieu.
void victoryAudio.play().catch(() => {})
}

View file

@ -1,4 +1,5 @@
import { Link } from "wouter"
import { AnimatePresence, motion } from "framer-motion"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
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).
const inGame =
snapshot.status === "in_round" ||
snapshot.status === "reveal" ||
snapshot.status === "scores"
// Phase grossière : on n'anime qu'aux frontières lobby ↔ jeu ↔ fin pour
// préserver le lecteur YouTube entre in_round / reveal / scores.
const phase =
snapshot.status === "lobby"
? "lobby"
: snapshot.status === "ended"
? "ended"
: "game"
return (
<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>
{inGame && (
<PlayerCards
snapshot={snapshot}
playerId={playerId}
showVoteStatus={snapshot.status === "in_round"}
votedIds={voteProgress?.voted}
/>
)}
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{inGame &&
(round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
))}
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
<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
snapshot={snapshot}
playerId={playerId}
showVoteStatus={snapshot.status === "in_round"}
votedIds={voteProgress?.voted}
/>
{round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
)}
</>
)}
{phase === "ended" && <GameEndView snapshot={snapshot} />}
</motion.div>
</AnimatePresence>
</div>
{boomKey > 0 && <Boom key={boomKey} />}

View file

@ -44,6 +44,7 @@
"@nerdware/shared": "workspace:*",
"@tanstack/react-query": "^5.101.0",
"@workspace/ui": "workspace:*",
"canvas-confetti": "^1.9.4",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
@ -55,6 +56,7 @@
"devDependencies": {
"@eslint/js": "^10",
"@tailwindcss/vite": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -584,6 +586,8 @@
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@ -690,6 +694,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],