Compare commits

..

No commits in common. "8b03879ee06f570fa757edd38036798220b6590e" and "604c36818481b6f57ef8550587be7038d0fbbc05" have entirely different histories.

9 changed files with 23 additions and 154 deletions

View file

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

View file

@ -1,10 +0,0 @@
# 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).

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react" import { useState } from "react"
import { Link } from "wouter" import { Link } from "wouter"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Check, ClipboardList, Crown, Music } from "lucide-react" import { Check, ClipboardList, Crown, Music } from "lucide-react"
@ -7,8 +7,6 @@ import type { BlindtestTrackInfo, PlayerScore, RoomSnapshot } from "@nerdware/sh
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 { Avatar } from "@/components/avatar" 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). */ /** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */
function spotifySearch(track: BlindtestTrackInfo): string { function spotifySearch(track: BlindtestTrackInfo): string {
@ -127,16 +125,6 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const returnToLobby = useRoomStore((s) => s.returnToLobby) const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId 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 scores: PlayerScore[] = finalScores ?? snapshot.scores
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) =>

View file

@ -1,9 +1,7 @@
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
@ -29,18 +27,6 @@ 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">

View file

@ -1,48 +0,0 @@
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,
})
}

View file

@ -1,24 +0,0 @@
// 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,5 +1,4 @@
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"
@ -52,14 +51,11 @@ export function RoomPage({ code }: { code: string }) {
) )
} }
// Phase grossière : on n'anime qu'aux frontières lobby ↔ jeu ↔ fin pour // Cards de scores visibles en permanence pendant le jeu (suivi continu).
// préserver le lecteur YouTube entre in_round / reveal / scores. const inGame =
const phase = snapshot.status === "in_round" ||
snapshot.status === "lobby" snapshot.status === "reveal" ||
? "lobby" snapshot.status === "scores"
: 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">
@ -72,34 +68,23 @@ export function RoomPage({ code }: { code: string }) {
/> />
</header> </header>
<AnimatePresence mode="wait"> {inGame && (
<motion.div <PlayerCards
key={phase} snapshot={snapshot}
className="flex flex-col gap-6" playerId={playerId}
initial={{ opacity: 0, y: 12 }} showVoteStatus={snapshot.status === "in_round"}
animate={{ opacity: 1, y: 0 }} votedIds={voteProgress?.voted}
exit={{ opacity: 0, y: -12 }} />
transition={{ duration: 0.22, ease: "easeOut" }} )}
>
{phase === "lobby" && <LobbyView snapshot={snapshot} />} {snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
{phase === "game" && ( {inGame &&
<> (round?.type === "blindtest" ? (
<PlayerCards <BlindtestView snapshot={snapshot} />
snapshot={snapshot} ) : (
playerId={playerId} <QuizView snapshot={snapshot} />
showVoteStatus={snapshot.status === "in_round"} ))}
votedIds={voteProgress?.voted} {snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
/>
{round?.type === "blindtest" ? (
<BlindtestView snapshot={snapshot} />
) : (
<QuizView snapshot={snapshot} />
)}
</>
)}
{phase === "ended" && <GameEndView snapshot={snapshot} />}
</motion.div>
</AnimatePresence>
</div> </div>
{boomKey > 0 && <Boom key={boomKey} />} {boomKey > 0 && <Boom key={boomKey} />}

View file

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