diff --git a/apps/web/package.json b/apps/web/package.json index b9a9752..bbb95dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/assets/sounds/README.md b/apps/web/src/assets/sounds/README.md new file mode 100644 index 0000000..771c890 --- /dev/null +++ b/apps/web/src/assets/sounds/README.md @@ -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). diff --git a/apps/web/src/assets/sounds/victory.mp3 b/apps/web/src/assets/sounds/victory.mp3 new file mode 100644 index 0000000..a96779f Binary files /dev/null and b/apps/web/src/assets/sounds/victory.mp3 differ diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 5708d87..67a67be 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -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) => diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx index e079e68..a77c388 100644 --- a/apps/web/src/components/player-cards.tsx +++ b/apps/web/src/components/player-cards.tsx @@ -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(null) + useEffect(() => { + if (leaderId && leaderId !== prevLeader.current) { + if (prevLeader.current !== null) { + celebrateLead() + } + prevLeader.current = leaderId + } + }, [leaderId]) return (
diff --git a/apps/web/src/lib/confetti.ts b/apps/web/src/lib/confetti.ts new file mode 100644 index 0000000..7f20fcb --- /dev/null +++ b/apps/web/src/lib/confetti.ts @@ -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, + }) +} diff --git a/apps/web/src/lib/sound.ts b/apps/web/src/lib/sound.ts new file mode 100644 index 0000000..f0b4763 --- /dev/null +++ b/apps/web/src/lib/sound.ts @@ -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(() => {}) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 5c00307..05e0db4 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -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 (
@@ -68,23 +72,34 @@ export function RoomPage({ code }: { code: string }) { /> - {inGame && ( - - )} - - {snapshot.status === "lobby" && } - {inGame && - (round?.type === "blindtest" ? ( - - ) : ( - - ))} - {snapshot.status === "ended" && } + + + {phase === "lobby" && } + {phase === "game" && ( + <> + + {round?.type === "blindtest" ? ( + + ) : ( + + )} + + )} + {phase === "ended" && } + +
{boomKey > 0 && } diff --git a/bun.lock b/bun.lock index 687265d..21cacd6 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="],