From 5cc9113b8c6ba0f2b268d16e1decd1c9e9fa0d20 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:43:17 +0200 Subject: [PATCH 01/10] feat(quiz): quiz game mode end-to-end (server mode + playable client UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - quiz mode (GameRound): in-memory FR question bank (mcq + truefalse), per-room anti-repeat, first-vote lock, score = base + speed bonus ∝ time left; registered via registerRound, loaded at startup - shared: typed quiz payloads (QuizQuestionPayload / reveal truth / result) - tests: quiz scoring, vote lock, bounds, reveal (bun test) Client: - room store handles round:start / voteAck / reveal / game:end - RoomPage dispatches by status: lobby (host start controls) → quiz view (question, countdown, vote, reveal + scoreboard) → game-end view - replaces standalone lobby page Roadmap V1 step 4. Verified end-to-end over the wire (start→vote→reveal→ score→next round→game:end, no answer leaked, anti-repeat works). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/modes/index.ts | 4 + apps/server/src/game/modes/quiz/index.ts | 7 ++ apps/server/src/game/modes/quiz/questions.ts | 110 +++++++++++++++++ .../src/game/modes/quiz/quiz-round.test.ts | 59 +++++++++ apps/server/src/game/modes/quiz/quiz-round.ts | 115 ++++++++++++++++++ apps/server/src/index.ts | 1 + apps/web/src/App.tsx | 4 +- apps/web/src/components/countdown.tsx | 19 +++ apps/web/src/components/game-end-view.tsx | 37 ++++++ apps/web/src/components/lobby-view.tsx | 87 +++++++++++++ apps/web/src/components/quiz-view.tsx | 113 +++++++++++++++++ apps/web/src/components/scoreboard.tsx | 37 ++++++ apps/web/src/pages/lobby.tsx | 71 ----------- apps/web/src/pages/room.tsx | 50 ++++++++ apps/web/src/store/room.ts | 98 ++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/quiz.ts | 30 +++++ 17 files changed, 767 insertions(+), 76 deletions(-) create mode 100644 apps/server/src/game/modes/index.ts create mode 100644 apps/server/src/game/modes/quiz/index.ts create mode 100644 apps/server/src/game/modes/quiz/questions.ts create mode 100644 apps/server/src/game/modes/quiz/quiz-round.test.ts create mode 100644 apps/server/src/game/modes/quiz/quiz-round.ts create mode 100644 apps/web/src/components/countdown.tsx create mode 100644 apps/web/src/components/game-end-view.tsx create mode 100644 apps/web/src/components/lobby-view.tsx create mode 100644 apps/web/src/components/quiz-view.tsx create mode 100644 apps/web/src/components/scoreboard.tsx delete mode 100644 apps/web/src/pages/lobby.tsx create mode 100644 apps/web/src/pages/room.tsx create mode 100644 packages/shared/src/quiz.ts diff --git a/apps/server/src/game/modes/index.ts b/apps/server/src/game/modes/index.ts new file mode 100644 index 0000000..d413056 --- /dev/null +++ b/apps/server/src/game/modes/index.ts @@ -0,0 +1,4 @@ +// Point d'enregistrement de toutes les épreuves. Importé une fois au démarrage +// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici. + +import "./quiz" diff --git a/apps/server/src/game/modes/quiz/index.ts b/apps/server/src/game/modes/quiz/index.ts new file mode 100644 index 0000000..4c45f97 --- /dev/null +++ b/apps/server/src/game/modes/quiz/index.ts @@ -0,0 +1,7 @@ +import { registerRound } from "../../registry" +import { QuizRound } from "./quiz-round" + +// Brancher l'épreuve = un seul registerRound, aucune plomberie à toucher. +registerRound("quiz", () => new QuizRound()) + +export { QuizRound } diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts new file mode 100644 index 0000000..3c4af08 --- /dev/null +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -0,0 +1,110 @@ +// Banque de questions en dur pour la V1 (mode quiz culture geek). +// Le seed Open Trivia DB + le back-office manuel arrivent aux étapes 5 et 7 ; +// d'ici là, ce petit jeu suffit à jouer une partie complète de bout en bout. + +import type { QuizFormat } from "@nerdware/shared" + +export interface QuizQuestion { + id: string + format: QuizFormat + prompt: string + choices: string[] + correctIndex: number + category: string + difficulty: number +} + +const TRUE_FALSE = ["Vrai", "Faux"] + +export const QUIZ_QUESTIONS: QuizQuestion[] = [ + { + id: "q-zelda-princess", + format: "mcq", + prompt: "Dans la saga The Legend of Zelda, quel est le nom du héros ?", + choices: ["Zelda", "Link", "Ganon", "Navi"], + correctIndex: 1, + category: "Jeux vidéo", + difficulty: 1, + }, + { + id: "q-mario-plumber", + format: "truefalse", + prompt: "Mario est plombier de profession.", + choices: TRUE_FALSE, + correctIndex: 0, + category: "Jeux vidéo", + difficulty: 1, + }, + { + id: "q-onepiece-captain", + format: "mcq", + prompt: "Qui est le capitaine de l'équipage du Chapeau de paille dans One Piece ?", + choices: ["Zoro", "Sanji", "Luffy", "Usopp"], + correctIndex: 2, + category: "Manga", + difficulty: 1, + }, + { + id: "q-pokemon-first", + format: "mcq", + prompt: "Quel Pokémon porte le numéro 001 dans le Pokédex national ?", + choices: ["Salamèche", "Carapuce", "Pikachu", "Bulbizarre"], + correctIndex: 3, + category: "Jeux vidéo", + difficulty: 2, + }, + { + id: "q-naruto-village", + format: "mcq", + prompt: "De quel village ninja Naruto est-il originaire ?", + choices: ["Konoha", "Suna", "Kiri", "Iwa"], + correctIndex: 0, + category: "Manga", + difficulty: 2, + }, + { + id: "q-minecraft-creeper", + format: "truefalse", + prompt: "Dans Minecraft, le Creeper explose au contact du joueur.", + choices: TRUE_FALSE, + correctIndex: 0, + category: "Jeux vidéo", + difficulty: 1, + }, + { + id: "q-starwars-vador", + format: "mcq", + prompt: "Dans Star Wars, qui est le père de Luke Skywalker ?", + choices: ["Obi-Wan Kenobi", "Dark Vador", "Yoda", "Palpatine"], + correctIndex: 1, + category: "Pop culture", + difficulty: 1, + }, + { + id: "q-dbz-kamehameha", + format: "mcq", + prompt: "Dans Dragon Ball, quelle attaque emblématique Sangoku utilise-t-il ?", + choices: ["Genkidama", "Kamehameha", "Final Flash", "Makankosappo"], + correctIndex: 1, + category: "Manga", + difficulty: 1, + }, + { + id: "q-tetris-origin", + format: "truefalse", + prompt: "Le jeu Tetris a été créé par un développeur soviétique.", + choices: TRUE_FALSE, + correctIndex: 0, + category: "Jeux vidéo", + difficulty: 2, + }, + { + id: "q-matrix-pill", + format: "mcq", + prompt: "Dans Matrix, quelle pilule Neo doit-il prendre pour découvrir la vérité ?", + choices: ["La bleue", "La rouge", "La verte", "La jaune"], + correctIndex: 1, + category: "Pop culture", + difficulty: 1, + }, +] diff --git a/apps/server/src/game/modes/quiz/quiz-round.test.ts b/apps/server/src/game/modes/quiz/quiz-round.test.ts new file mode 100644 index 0000000..6d5bbe6 --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { RoomManager } from "../../../rooms" +import type { RoundContext } from "../../round" +import { QuizRound } from "./quiz-round" +import { QUIZ_QUESTIONS } from "./questions" + +const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 + +function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { + const rooms = new RoomManager() + const { room, player: a } = rooms.create("Alice", "sa") + const { player: b } = rooms.join(room.code, "Bob", "sb") + const ctx: RoundContext = { + room, + djId: null, + votes: new Map(), + startedAt: 0, + endsAt: 10_000, + data: { question, votedAt: new Map() }, + } + return { ctx, p1: a.id, p2: b.id } +} + +describe("QuizRound", () => { + test("verrouille le premier vote (idempotent)", () => { + const round = new QuizRound(() => 1000) + const { ctx, p1 } = makeCtx() + round.submitAnswer(ctx, p1, { choiceIndex: 1 }) + round.submitAnswer(ctx, p1, { choiceIndex: 0 }) // ignoré + expect(ctx.votes.get(p1)).toEqual({ choiceIndex: 1 }) + }) + + test("ignore un index hors bornes", () => { + const round = new QuizRound(() => 1000) + const { ctx, p1 } = makeCtx() + round.submitAnswer(ctx, p1, { choiceIndex: 99 }) + expect(ctx.votes.has(p1)).toBe(false) + }) + + test("reveal expose la vérité + le résultat par joueur", () => { + const round = new QuizRound(() => 1000) + const { ctx, p1, p2 } = makeCtx() + round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste + round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux + const { truth, perPlayerResult } = round.reveal(ctx) + expect(truth.correctIndex).toBe(question.correctIndex) + expect(perPlayerResult[p1]).toEqual({ choiceIndex: 1, correct: true }) + expect(perPlayerResult[p2]).toEqual({ choiceIndex: 0, correct: false }) + }) + + test("score = base + bonus rapidité pour les bonnes réponses uniquement", () => { + const round = new QuizRound(() => 1000) // vote à t=1000 sur 10000 → reste 90% + const { ctx, p1, p2 } = makeCtx() + round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste, rapide + round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux + const deltas = round.score(ctx) + expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9) + }) +}) diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts new file mode 100644 index 0000000..55116e8 --- /dev/null +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -0,0 +1,115 @@ +// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. +// Implémente le contrat GameRound ; le moteur fait tout le reste. + +import type { + Answer, + QuizPerPlayerResult, + QuizQuestionPayload, + QuizRevealTruth, + ScoreDelta, +} from "@nerdware/shared" +import type { GameRound, RoundContext, RoundStart } from "../../round" +import type { ServerRoom } from "../../../rooms" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" + +const BASE_POINTS = 100 +const SPEED_BONUS_MAX = 100 + +/** Données privées de la manche (jamais diffusées). */ +interface QuizRoundData { + question: QuizQuestion + /** Premier instant de vote par joueur, pour le bonus rapidité. */ + votedAt: Map +} + +// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room. +const playedByRoom = new WeakMap>() + +function pickQuestion(room: ServerRoom): QuizQuestion { + let played = playedByRoom.get(room) + if (!played) { + played = new Set() + playedByRoom.set(room, played) + } + let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id)) + if (pool.length === 0) { + // Banque épuisée sur cette partie : on autorise à nouveau les répétitions. + played.clear() + pool = QUIZ_QUESTIONS + } + const question = pool[Math.floor(Math.random() * pool.length)] + played.add(question.id) + return question +} + +function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { + return ( + typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" + ) +} + +export class QuizRound implements GameRound { + readonly type = "quiz" as const + + constructor(private readonly now: () => number = Date.now) {} + + start(room: ServerRoom): RoundStart { + const question = pickQuestion(room) + const payload: QuizQuestionPayload = { + format: question.format, + prompt: question.prompt, + choices: question.choices, + category: question.category, + difficulty: question.difficulty, + } + const data: QuizRoundData = { question, votedAt: new Map() } + return { djId: null, payload, data } + } + + submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void { + // Premier vote verrouillé (idempotent) : on ignore les votes suivants. + if (ctx.votes.has(playerId)) { + return + } + if (!isQuizAnswer(answer)) { + return + } + const { question, votedAt } = ctx.data as QuizRoundData + if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) { + return + } + ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex }) + votedAt.set(playerId, this.now()) + } + + reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } { + const { question } = ctx.data as QuizRoundData + const perPlayerResult: QuizPerPlayerResult = {} + for (const player of ctx.room.players.values()) { + const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined + const choiceIndex = vote ? vote.choiceIndex : null + perPlayerResult[player.id] = { + choiceIndex, + correct: choiceIndex === question.correctIndex, + } + } + return { truth: { correctIndex: question.correctIndex }, perPlayerResult } + } + + score(ctx: RoundContext): ScoreDelta[] { + const { question, votedAt } = ctx.data as QuizRoundData + const total = ctx.endsAt - ctx.startedAt + const deltas: ScoreDelta[] = [] + for (const [playerId, vote] of ctx.votes) { + if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) { + continue + } + // Bonus rapidité : proportionnel au temps restant au moment du vote. + const at = votedAt.get(playerId) ?? ctx.endsAt + const remaining = Math.max(0, Math.min(1, (ctx.endsAt - at) / total)) + const bonus = Math.round(SPEED_BONUS_MAX * remaining) + deltas.push({ playerId, delta: BASE_POINTS + bonus }) + } + return deltas + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b613840..5990a3f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -2,6 +2,7 @@ import Fastify from "fastify" import cors from "@fastify/cors" import { env, isDev } from "./env" import { createSocketServer } from "./socket" +import "./game/modes" // enregistre les épreuves (registerRound) const app = Fastify({ logger: isDev diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dce07e3..3aca4dd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,13 +1,13 @@ import { Route, Switch } from "wouter" import { HomePage } from "@/pages/home" -import { LobbyPage } from "@/pages/lobby" +import { RoomPage } from "@/pages/room" export function App() { return ( - {(params) => } + {(params) => }
diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx new file mode 100644 index 0000000..fe53b7f --- /dev/null +++ b/apps/web/src/components/countdown.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react" + +/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ +function secondsLeft(endsAt: number): number { + return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) +} + +export function Countdown({ endsAt }: { endsAt: number }) { + const [left, setLeft] = useState(() => secondsLeft(endsAt)) + + useEffect(() => { + const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250) + return () => clearInterval(id) + }, [endsAt]) + + return ( + {left} + ) +} diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx new file mode 100644 index 0000000..0d8c8fe --- /dev/null +++ b/apps/web/src/components/game-end-view.tsx @@ -0,0 +1,37 @@ +import { Link } from "wouter" +import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { Scoreboard } from "@/components/scoreboard" + +export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { + const playerId = useRoomStore((s) => s.playerId) + const finalScores = useRoomStore((s) => s.finalScores) + const reset = useRoomStore((s) => s.reset) + + const scores: PlayerScore[] = finalScores ?? snapshot.scores + const winner = [...scores].sort((a, b) => b.score - a.score)[0] + const winnerName = + snapshot.players.find((p) => p.id === winner?.playerId)?.name ?? "?" + + return ( +
+
+

+ Partie terminée +

+

+ 🏆 {winnerName} l'emporte ! +

+
+ + + + + + +
+ ) +} diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx new file mode 100644 index 0000000..d4c5eab --- /dev/null +++ b/apps/web/src/components/lobby-view.tsx @@ -0,0 +1,87 @@ +import { useState } from "react" +import type { RoomSnapshot } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" + +const QUESTION_OPTIONS = [3, 5, 10] + +export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { + const playerId = useRoomStore((s) => s.playerId) + const startGame = useRoomStore((s) => s.startGame) + const isHost = snapshot.hostId === playerId + + const [count, setCount] = useState(5) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function handleStart() { + setError(null) + setBusy(true) + try { + await startGame(count) + } catch (err) { + setError((err as { message?: string }).message ?? "Erreur") + } finally { + setBusy(false) + } + } + + return ( +
+
+

+ Joueurs ({snapshot.players.length}) +

+
    + {snapshot.players.map((p) => ( +
  • + + {p.name} + {p.id === playerId && ( + (toi) + )} + + + {p.id === snapshot.hostId ? "Hôte" : ""} + {!p.connected && " · hors ligne"} + +
  • + ))} +
+
+ + {isHost ? ( +
+
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
+ + {error && ( +

{error}

+ )} +
+ ) : ( +

+ En attente du lancement de la partie par l'hôte… +

+ )} +
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx new file mode 100644 index 0000000..d255a3b --- /dev/null +++ b/apps/web/src/components/quiz-view.tsx @@ -0,0 +1,113 @@ +import type { + QuizPerPlayerResult, + QuizQuestionPayload, + QuizRevealTruth, + RoomSnapshot, +} from "@nerdware/shared" +import { useRoomStore } from "@/store/room" +import { Countdown } from "@/components/countdown" +import { Scoreboard } from "@/components/scoreboard" + +export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { + const round = useRoomStore((s) => s.round) + const reveal = useRoomStore((s) => s.reveal) + const voteProgress = useRoomStore((s) => s.voteProgress) + const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex) + const playerId = useRoomStore((s) => s.playerId) + const vote = useRoomStore((s) => s.vote) + + if (!round) { + return ( +

Préparation de la manche…

+ ) + } + + const question = round.payload as QuizQuestionPayload + const truth = reveal ? (reveal.truth as QuizRevealTruth) : null + const showReveal = truth !== null + const myResult = reveal + ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] + : undefined + + function choiceClass(index: number): string { + const base = + "w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors" + if (showReveal) { + if (index === truth?.correctIndex) { + return `${base} border-green-500 bg-green-500/15 font-medium` + } + if (index === myChoiceIndex) { + return `${base} border-red-500 bg-red-500/10` + } + return `${base} border-input opacity-60` + } + if (index === myChoiceIndex) { + return `${base} border-primary bg-primary/10 font-medium` + } + return `${base} border-input hover:bg-muted/60` + } + + return ( +
+
+ + Question {snapshot.currentRound + 1} / {snapshot.totalRounds} + {question.category ? ` · ${question.category}` : ""} + + {!showReveal && ( + + )} +
+ +

+ {question.prompt} +

+ +
+ {question.choices.map((choice, index) => ( + + ))} +
+ + {!showReveal && + (myChoiceIndex !== null ? ( +

+ Réponse envoyée — en attente des autres + {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} +

+ ) : ( + voteProgress && ( +

+ {voteProgress.count}/{voteProgress.total} ont répondu +

+ ) + ))} + + {showReveal && ( +
+

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : myResult?.choiceIndex == null + ? "Pas de réponse 😴" + : "Raté 💥"} +

+ +
+ )} +
+ ) +} diff --git a/apps/web/src/components/scoreboard.tsx b/apps/web/src/components/scoreboard.tsx new file mode 100644 index 0000000..7758bb0 --- /dev/null +++ b/apps/web/src/components/scoreboard.tsx @@ -0,0 +1,37 @@ +import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" + +interface ScoreboardProps { + scores: PlayerScore[] + snapshot: RoomSnapshot + playerId: string | null +} + +export function Scoreboard({ scores, snapshot, playerId }: ScoreboardProps) { + const nameOf = (id: string) => + snapshot.players.find((p) => p.id === id)?.name ?? "?" + const ranked = [...scores].sort((a, b) => b.score - a.score) + + return ( +
    + {ranked.map((s, i) => ( +
  1. + + + {i + 1} + + + {nameOf(s.playerId)} + {s.playerId === playerId && ( + (toi) + )} + + + {s.score} +
  2. + ))} +
+ ) +} diff --git a/apps/web/src/pages/lobby.tsx b/apps/web/src/pages/lobby.tsx deleted file mode 100644 index 4e763ba..0000000 --- a/apps/web/src/pages/lobby.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Link } from "wouter" -import { Button } from "@workspace/ui/components/button" -import { useRoomStore } from "@/store/room" - -export function LobbyPage({ code }: { code: string }) { - const snapshot = useRoomStore((s) => s.snapshot) - const playerId = useRoomStore((s) => s.playerId) - const connected = useRoomStore((s) => s.connected) - - // Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil. - if (!snapshot || snapshot.code !== code) { - return ( -
-

- Room introuvable ou session perdue. -

- - - -
- ) - } - - return ( -
-
-
-
-

Code room

-

- {snapshot.code} -

-
- -
- -
-

- Joueurs ({snapshot.players.length}) -

-
    - {snapshot.players.map((p) => ( -
  • - - {p.name} - {p.id === playerId && ( - (toi) - )} - - - {p.id === snapshot.hostId ? "Hôte" : ""} - {!p.connected && " · hors ligne"} - -
  • - ))} -
-
- -

- En attente du lancement de la partie par l'hôte… -

-
-
- ) -} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx new file mode 100644 index 0000000..949bd77 --- /dev/null +++ b/apps/web/src/pages/room.tsx @@ -0,0 +1,50 @@ +import { Link } from "wouter" +import { Button } from "@workspace/ui/components/button" +import { useRoomStore } from "@/store/room" +import { LobbyView } from "@/components/lobby-view" +import { QuizView } from "@/components/quiz-view" +import { GameEndView } from "@/components/game-end-view" + +export function RoomPage({ code }: { code: string }) { + const snapshot = useRoomStore((s) => s.snapshot) + const connected = useRoomStore((s) => s.connected) + + // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. + if (!snapshot || snapshot.code !== code) { + return ( +
+

+ Room introuvable ou session perdue. +

+ + + +
+ ) + } + + return ( +
+
+
+
+

Code room

+

+ {snapshot.code} +

+
+ +
+ + {snapshot.status === "lobby" && } + {(snapshot.status === "in_round" || + snapshot.status === "reveal" || + snapshot.status === "scores") && } + {snapshot.status === "ended" && } +
+
+ ) +} diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 09ba400..ca91a02 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -1,30 +1,62 @@ import { create } from "zustand" import type { ErrorPayload, + PlayerScore, RoomCreatedPayload, RoomSnapshot, + RoundStartPayload, + VoteAckPayload, } from "@nerdware/shared" import { socket } from "@/lib/socket" +/** Manche en cours côté client (depuis round:start). */ +export interface ActiveRound { + type: RoundStartPayload["type"] + djId?: string + endsAt: number + payload: unknown +} + +/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */ +export interface RoundReveal { + truth: unknown + perPlayerResult: unknown +} + interface RoomState { connected: boolean - /** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */ + /** Identité locale, pour reconnaître "moi" dans le snapshot. */ playerId: string | null roomCode: string | null snapshot: RoomSnapshot | null lastError: ErrorPayload | null + + // État de jeu (poussé par le moteur serveur). + round: ActiveRound | null + voteProgress: VoteAckPayload | null + reveal: RoundReveal | null + myChoiceIndex: number | null + finalScores: PlayerScore[] | null + createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise + startGame: (questionCount: number) => Promise + vote: (choiceIndex: number) => void clearError: () => void reset: () => void } -export const useRoomStore = create((set) => ({ +export const useRoomStore = create((set, get) => ({ connected: socket.connected, playerId: null, roomCode: null, snapshot: null, lastError: null, + round: null, + voteProgress: null, + reveal: null, + myChoiceIndex: null, + finalScores: null, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -52,8 +84,47 @@ export const useRoomStore = create((set) => ({ }) }), + startGame: (questionCount) => + new Promise((resolve, reject) => { + const rounds = Array.from({ length: questionCount }, () => ({ + type: "quiz" as const, + })) + // On pousse la séquence de manches, puis on lance. + socket.emit("lobby:updateSettings", { + blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist", + roundDuration: 20, + rounds, + }) + socket.emit("game:start", (res) => { + if (res.ok) { + resolve() + } else { + set({ lastError: res.error }) + reject(res.error) + } + }) + }), + + vote: (choiceIndex) => { + if (get().myChoiceIndex !== null) { + return // vote déjà émis pour cette manche + } + set({ myChoiceIndex: choiceIndex }) + socket.emit("round:vote", { answer: { choiceIndex } }) + }, + clearError: () => set({ lastError: null }), - reset: () => set({ roomCode: null, snapshot: null, lastError: null }), + reset: () => + set({ + roomCode: null, + snapshot: null, + lastError: null, + round: null, + voteProgress: null, + reveal: null, + myChoiceIndex: null, + finalScores: null, + }), })) // Listeners socket → on pousse l'état serveur dans le store (source de vérité). @@ -61,3 +132,24 @@ socket.on("connect", () => useRoomStore.setState({ connected: true })) socket.on("disconnect", () => useRoomStore.setState({ connected: false })) socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) + +socket.on("round:start", (payload) => + useRoomStore.setState({ + round: { + type: payload.type, + djId: payload.djId, + endsAt: payload.endsAt, + payload: payload.payload, + }, + voteProgress: null, + reveal: null, + myChoiceIndex: null, + }) +) +socket.on("round:voteAck", (voteProgress) => + useRoomStore.setState({ voteProgress }) +) +socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal })) +socket.on("game:end", ({ finalScores }) => + useRoomStore.setState({ finalScores }) +) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 313fded..dece734 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from "./domain" export * from "./events" +export * from "./quiz" diff --git a/packages/shared/src/quiz.ts b/packages/shared/src/quiz.ts new file mode 100644 index 0000000..a2156a6 --- /dev/null +++ b/packages/shared/src/quiz.ts @@ -0,0 +1,30 @@ +// Types concrets de l'épreuve Quiz, partagés client/serveur. +// Le payload générique du moteur est `unknown` ; le client le narrow via type === "quiz". +// V1 : formats mcq + truefalse (free / image_reveal arrivent avec la DB). + +import type { QuizFormat } from "./domain" + +/** Payload diffusé au lancement d'une manche quiz (round:start), SANS la réponse. */ +export interface QuizQuestionPayload { + format: QuizFormat + prompt: string + /** Choix proposés (truefalse = ["Vrai", "Faux"]). */ + choices: string[] + category?: string + /** 1 (facile) .. 3 (difficile). */ + difficulty?: number +} + +/** Vérité révélée à tous (round:reveal → truth). */ +export interface QuizRevealTruth { + correctIndex: number +} + +/** Résultat d'un joueur sur la manche. */ +export interface QuizPlayerResult { + choiceIndex: number | null + correct: boolean +} + +/** round:reveal → perPlayerResult : playerId → résultat. */ +export type QuizPerPlayerResult = Record From f82c7d97003da5cbd3476d38a75b9f8ba787ff31 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 15:56:57 +0200 Subject: [PATCH 02/10] feat(web): bomb countdown, copyable room code, persistent score cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Countdown: under 10s it ramps into a "bomb" — white→red color and an escalating scale/wiggle pulse (faster & wider as it nears 0) via Framer Motion - RoomCode: click-to-copy the room code with visual feedback - PlayerCards: always-on score HUD during the game (cards per player, animated reorder, crown on the leader) so scores are tracked continuously instead of only appearing at reveal - quiz view: drop the reveal-only scoreboard (now covered by PlayerCards) - add framer-motion dependency Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 1 + apps/web/src/components/countdown.tsx | 45 +++++++++++++++- apps/web/src/components/player-cards.tsx | 69 ++++++++++++++++++++++++ apps/web/src/components/quiz-view.tsx | 26 ++++----- apps/web/src/components/room-code.tsx | 39 ++++++++++++++ apps/web/src/pages/room.tsx | 22 ++++---- bun.lock | 7 +++ 7 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/player-cards.tsx create mode 100644 apps/web/src/components/room-code.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 2c7c96e..5d5458d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index fe53b7f..9f2012a 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,10 +1,25 @@ import { useEffect, useState } from "react" +import { motion } from "framer-motion" + +const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" /** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ function secondsLeft(endsAt: number): number { return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) } +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** Blanc → rouge sur les DANGER_FROM dernières secondes. */ +function dangerColor(t: number): string { + const r = Math.round(lerp(255, 239, t)) + const g = Math.round(lerp(255, 68, t)) + const b = Math.round(lerp(255, 68, t)) + return `rgb(${r}, ${g}, ${b})` +} + export function Countdown({ endsAt }: { endsAt: number }) { const [left, setLeft] = useState(() => secondsLeft(endsAt)) @@ -13,7 +28,35 @@ export function Countdown({ endsAt }: { endsAt: number }) { return () => clearInterval(id) }, [endsAt]) + const danger = left <= DANGER_FROM && left > 0 + // 0 (début du danger) → 1 (explosion imminente) + const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0 + + // Plus on approche de 0, plus le "battement" est ample et rapide. + const peak = 1.15 + intensity * 0.5 + const wiggle = 4 + intensity * 12 + const duration = 0.7 - intensity * 0.46 + return ( - {left} + + {danger && 💣} + {left} + ) } diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx new file mode 100644 index 0000000..353116f --- /dev/null +++ b/apps/web/src/components/player-cards.tsx @@ -0,0 +1,69 @@ +import { AnimatePresence, motion } from "framer-motion" +import { Crown } from "lucide-react" +import type { RoomSnapshot } from "@nerdware/shared" + +interface PlayerCardsProps { + snapshot: RoomSnapshot + playerId: string | null +} + +/** HUD persistant : une card par joueur avec son score, couronne au leader. */ +export function PlayerCards({ snapshot, playerId }: PlayerCardsProps) { + const scoreOf = (id: string) => + snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 + + const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id))) + const ranked = [...snapshot.players].sort( + (a, b) => scoreOf(b.id) - scoreOf(a.id) + ) + + return ( +
+ + {ranked.map((p) => { + const score = scoreOf(p.id) + const isLeader = max > 0 && score === max + const isMe = p.id === playerId + return ( + + {isLeader && ( + + + + )} + + {p.name} + {isMe && (toi)} + + + {score} + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index d255a3b..cbc231a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -6,7 +6,6 @@ import type { } from "@nerdware/shared" import { useRoomStore } from "@/store/room" import { Countdown } from "@/components/countdown" -import { Scoreboard } from "@/components/scoreboard" export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const round = useRoomStore((s) => s.round) @@ -91,22 +90,15 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -
-

- {myResult?.correct - ? "Bonne réponse ! 🎉" - : myResult?.choiceIndex == null - ? "Pas de réponse 😴" - : "Raté 💥"} -

- -
+

+ {myResult?.correct + ? "Bonne réponse ! 🎉" + : myResult?.choiceIndex == null + ? "Pas de réponse 😴" + : "Raté 💥"} +

)}
) diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx new file mode 100644 index 0000000..58142df --- /dev/null +++ b/apps/web/src/components/room-code.tsx @@ -0,0 +1,39 @@ +import { useState } from "react" +import { Check, Copy } from "lucide-react" + +/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */ +export function RoomCode({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + + async function copy() { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // presse-papier indisponible (http non sécurisé) : on ignore silencieusement + } + } + + return ( + + ) +} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 949bd77..bb975dd 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -4,9 +4,12 @@ import { useRoomStore } from "@/store/room" import { LobbyView } from "@/components/lobby-view" import { QuizView } from "@/components/quiz-view" import { GameEndView } from "@/components/game-end-view" +import { PlayerCards } from "@/components/player-cards" +import { RoomCode } from "@/components/room-code" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) + const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. @@ -23,26 +26,27 @@ 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" + return (
-
-

Code room

-

- {snapshot.code} -

-
+
+ {inGame && } + {snapshot.status === "lobby" && } - {(snapshot.status === "in_round" || - snapshot.status === "reveal" || - snapshot.status === "scores") && } + {inGame && } {snapshot.status === "ended" && }
diff --git a/bun.lock b/bun.lock index 515f883..8ff9b27 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "dependencies": { "@nerdware/shared": "workspace:*", "@workspace/ui": "workspace:*", + "framer-motion": "^11", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -872,6 +873,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -1076,6 +1079,10 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], From c9f319730012a48ebc7a91dde5c27d481d0be0ee Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Mon, 8 Jun 2026 16:05:42 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(web):=20real=20explosion=20at=200=20?= =?UTF-8?q?=E2=80=94=20fullscreen=20flash=20+=20giant=20=F0=9F=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store: boomKey counter + boom() action - Countdown fires boom() once when it reaches 0 (timer expiry only) - Boom component (portal to body) plays a fullscreen white flash + giant 💥 - rendered at RoomPage level, keyed by boomKey so it plays fully (independent of the countdown unmounting on reveal) and replays each round Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/boom.tsx | 35 +++++++++++++++++++++++++++ apps/web/src/components/countdown.tsx | 16 ++++++++++-- apps/web/src/pages/room.tsx | 4 +++ apps/web/src/store/room.ts | 7 ++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/boom.tsx diff --git a/apps/web/src/components/boom.tsx b/apps/web/src/components/boom.tsx new file mode 100644 index 0000000..6670893 --- /dev/null +++ b/apps/web/src/components/boom.tsx @@ -0,0 +1,35 @@ +import { createPortal } from "react-dom" +import { motion } from "framer-motion" + +/** + * Explosion plein écran : flash blanc + 💥 géant. + * Rendue dans document.body via portal pour échapper aux transforms parents, + * et montée avec une `key` qui change → l'animation rejoue à chaque boom. + */ +export function Boom() { + return createPortal( +
+ + + 💥 + +
, + document.body + ) +} diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index 9f2012a..7847867 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { motion } from "framer-motion" +import { useRoomStore } from "@/store/room" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" @@ -22,12 +23,23 @@ function dangerColor(t: number): string { export function Countdown({ endsAt }: { endsAt: number }) { const [left, setLeft] = useState(() => secondsLeft(endsAt)) + const boom = useRoomStore((s) => s.boom) + const boomed = useRef(false) useEffect(() => { const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250) return () => clearInterval(id) }, [endsAt]) + // Déclenche l'explosion une seule fois au passage à 0 (fin au timer). + useEffect(() => { + if (left === 0 && !boomed.current) { + boomed.current = true + boom() + } + }, [left, boom]) + + const exploded = left === 0 const danger = left <= DANGER_FROM && left > 0 // 0 (début du danger) → 1 (explosion imminente) const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0 @@ -56,7 +68,7 @@ export function Countdown({ endsAt }: { endsAt: number }) { } > {danger && 💣} - {left} + {exploded ? "💥" : left} ) } diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index bb975dd..f0dc0b5 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -6,11 +6,13 @@ import { QuizView } from "@/components/quiz-view" import { GameEndView } from "@/components/game-end-view" import { PlayerCards } from "@/components/player-cards" import { RoomCode } from "@/components/room-code" +import { Boom } from "@/components/boom" export function RoomPage({ code }: { code: string }) { const snapshot = useRoomStore((s) => s.snapshot) const playerId = useRoomStore((s) => s.playerId) const connected = useRoomStore((s) => s.connected) + const boomKey = useRoomStore((s) => s.boomKey) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -49,6 +51,8 @@ export function RoomPage({ code }: { code: string }) { {inGame && } {snapshot.status === "ended" && } + + {boomKey > 0 && } ) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index ca91a02..b1ca21e 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -37,11 +37,14 @@ interface RoomState { reveal: RoundReveal | null myChoiceIndex: number | null finalScores: PlayerScore[] | null + /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ + boomKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise startGame: (questionCount: number) => Promise vote: (choiceIndex: number) => void + boom: () => void clearError: () => void reset: () => void } @@ -57,6 +60,7 @@ export const useRoomStore = create((set, get) => ({ reveal: null, myChoiceIndex: null, finalScores: null, + boomKey: 0, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -113,6 +117,8 @@ export const useRoomStore = create((set, get) => ({ socket.emit("round:vote", { answer: { choiceIndex } }) }, + boom: () => set((s) => ({ boomKey: s.boomKey + 1 })), + clearError: () => set({ lastError: null }), reset: () => set({ @@ -124,6 +130,7 @@ export const useRoomStore = create((set, get) => ({ reveal: null, myChoiceIndex: null, finalScores: null, + boomKey: 0, }), })) From 0c9eaca31295e3ada3a085d1170194d9275b4d13 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Tue, 9 Jun 2026 10:25:28 +0200 Subject: [PATCH 04/10] feat(web): glorify the winner on the final leaderboard - dedicated winner card: gold border, gold gradient, pulsing gold glow, spring scale-in, and a Crown that drops & bounces in (Framer Motion) - champion label + big gold score; runners-up listed below from rank 2 - drop the generic Scoreboard component (now unused) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/game-end-view.tsx | 86 +++++++++++++++++++++-- apps/web/src/components/scoreboard.tsx | 37 ---------- 2 files changed, 80 insertions(+), 43 deletions(-) delete mode 100644 apps/web/src/components/scoreboard.tsx diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index 0d8c8fe..fb85092 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -1,8 +1,9 @@ import { Link } from "wouter" +import { motion } from "framer-motion" +import { Crown } from "lucide-react" import type { PlayerScore, RoomSnapshot } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" -import { Scoreboard } from "@/components/scoreboard" export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) @@ -10,9 +11,13 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const reset = useRoomStore((s) => s.reset) const scores: PlayerScore[] = finalScores ?? snapshot.scores - const winner = [...scores].sort((a, b) => b.score - a.score)[0] - const winnerName = - snapshot.players.find((p) => p.id === winner?.playerId)?.name ?? "?" + const ranked = [...scores].sort((a, b) => b.score - a.score) + const nameOf = (id: string) => + snapshot.players.find((p) => p.id === id)?.name ?? "?" + + const winner = ranked[0] + const rest = ranked.slice(1) + const isMe = (id: string) => id === playerId return (
@@ -21,11 +26,80 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { Partie terminée

- 🏆 {winnerName} l'emporte ! + 🏆 {winner ? nameOf(winner.playerId) : "?"} l'emporte !

- + {winner && ( + + + + + + + {nameOf(winner.playerId)} + {isMe(winner.playerId) && ( + (toi) + )} + + + {winner.score} + + + Champion·ne + + + )} + + {rest.length > 0 && ( +
    + {rest.map((s, i) => ( +
  1. + + + {i + 2} + + + {nameOf(s.playerId)} + {isMe(s.playerId) && ( + (toi) + )} + + + + {s.score} + +
  2. + ))} +
+ )}
) } diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index b1ca21e..70c7cb1 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -39,6 +39,8 @@ interface RoomState { finalScores: PlayerScore[] | null /** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */ boomKey: number + /** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */ + roundKey: number createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise @@ -61,6 +63,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, finalScores: null, boomKey: 0, + roundKey: 0, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -131,6 +134,7 @@ export const useRoomStore = create((set, get) => ({ myChoiceIndex: null, finalScores: null, boomKey: 0, + roundKey: 0, }), })) @@ -141,7 +145,7 @@ socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot })) socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("round:start", (payload) => - useRoomStore.setState({ + useRoomStore.setState((s) => ({ round: { type: payload.type, djId: payload.djId, @@ -151,7 +155,8 @@ socket.on("round:start", (payload) => voteProgress: null, reveal: null, myChoiceIndex: null, - }) + roundKey: s.roundKey + 1, + })) ) socket.on("round:voteAck", (voteProgress) => useRoomStore.setState({ voteProgress }) From 7dda00c65c39bd558c3c982777842ea03de09938 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Tue, 9 Jun 2026 11:25:17 +0200 Subject: [PATCH 10/10] feat: lead-in timer compensation + GIF support for round transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (option 2 — don't eat answer time): - engine: leadMs prep delay (default 1600ms) before the clock starts; the round timer now fires at lead + duration, and scoring uses the post-lead start so the speed bonus spans the full answer window - shared: round:start carries startsAt (answers begin) alongside endsAt - engine test passes leadMs:0 Client: - Countdown shows the full duration (no ticking) until startsAt, then counts down — so the WarioWare transition plays in full without stealing time - store/ActiveRound carry startsAt Custom transition media: - RoundTransition auto-loads any gif/webp/apng/png/avif dropped in src/assets/transitions/ (import.meta.glob), plays one at random per round, falls back to the Framer animation when none are present - README documenting how to add animations Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/game/engine.test.ts | 1 + apps/server/src/game/engine.ts | 13 ++- apps/web/src/assets/transitions/README.md | 15 +++ apps/web/src/components/countdown.tsx | 27 +++-- apps/web/src/components/quiz-view.tsx | 6 +- apps/web/src/components/round-transition.tsx | 115 ++++++++++++------- apps/web/src/store/room.ts | 2 + packages/shared/src/events.ts | 4 +- 8 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/assets/transitions/README.md diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 7cbf67c..73f8fa7 100644 --- a/apps/server/src/game/engine.test.ts +++ b/apps/server/src/game/engine.test.ts @@ -66,6 +66,7 @@ function setup(roundDurationSec: number): { const options: GameEngineOptions = { createRound: () => dummyRound, revealPauseMs: 0, + leadMs: 0, } return { io, emits, rooms, room, p1: a.id, p2: b.id, options } } diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 6c56f59..3bef317 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -22,11 +22,15 @@ export interface GameEngineOptions { createRound?: (type: RoundConfig["type"]) => GameRound /** Pause d'affichage du reveal/scores entre deux manches (ms). */ revealPauseMs?: number + /** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */ + leadMs?: number /** Permet de patcher l'horloge en test ; défaut = Date.now. */ now?: () => number } const DEFAULT_REVEAL_PAUSE_MS = 5000 +// Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche. +const DEFAULT_LEAD_MS = 1600 export class GameEngine implements RoomGameController { private runtime: RoundRuntime | null = null @@ -34,6 +38,7 @@ export class GameEngine implements RoomGameController { private resolveRoundEnd: (() => void) | null = null private readonly createRound: (type: RoundConfig["type"]) => GameRound private readonly revealPauseMs: number + private readonly leadMs: number private readonly now: () => number constructor( @@ -44,6 +49,7 @@ export class GameEngine implements RoomGameController { ) { this.createRound = options.createRound ?? defaultCreateRound this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS + this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS this.now = options.now ?? Date.now } @@ -85,7 +91,9 @@ export class GameEngine implements RoomGameController { const round = this.createRound(config.type) const start = round.start(this.room) const durationSec = start.durationSec ?? this.room.settings.roundDuration - const startedAt = this.now() + const now = this.now() + // Les réponses ne comptent qu'après le délai de préparation (transition). + const startedAt = now + this.leadMs const endsAt = startedAt + durationSec * 1000 this.runtime = { @@ -101,6 +109,7 @@ export class GameEngine implements RoomGameController { this.io.to(this.room.code).emit("round:start", { type: round.type, djId: this.runtime.djId ?? undefined, + startsAt: startedAt, endsAt, payload: start.payload, }) @@ -108,7 +117,7 @@ export class GameEngine implements RoomGameController { // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { this.resolveRoundEnd = resolve - this.timer = setTimeout(() => this.endRound(), durationSec * 1000) + this.timer = setTimeout(() => this.endRound(), endsAt - now) }) const ctx = this.context(this.runtime) diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md new file mode 100644 index 0000000..eb8792a --- /dev/null +++ b/apps/web/src/assets/transitions/README.md @@ -0,0 +1,15 @@ +# Animations de transition (style WarioWare) + +Dépose ici des fichiers d'animation pour les transitions entre manches : +formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`. + +- Ils sont détectés automatiquement au build (`import.meta.glob`). +- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents. +- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée. + +Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré +plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans +`src/components/round-transition.tsx`), calée sur le délai de préparation +serveur (`leadMs`) pour ne pas rogner le temps de réponse. + +> ⚠️ N'utilise que des médias dont tu as les droits (pas d'assets Nintendo). diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index 7847867..e50e4b1 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -4,9 +4,16 @@ import { useRoomStore } from "@/store/room" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" -/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ -function secondsLeft(endsAt: number): number { - return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) +/** + * Secondes restantes. Avant `startsAt` (phase de préparation / transition), + * on affiche la durée pleine sans décompter. + */ +function secondsLeft(startsAt: number, endsAt: number): number { + const now = Date.now() + if (now < startsAt) { + return Math.ceil((endsAt - startsAt) / 1000) + } + return Math.max(0, Math.ceil((endsAt - now) / 1000)) } function lerp(a: number, b: number, t: number): number { @@ -21,15 +28,21 @@ function dangerColor(t: number): string { return `rgb(${r}, ${g}, ${b})` } -export function Countdown({ endsAt }: { endsAt: number }) { - const [left, setLeft] = useState(() => secondsLeft(endsAt)) +export function Countdown({ + startsAt, + endsAt, +}: { + startsAt: number + endsAt: number +}) { + const [left, setLeft] = useState(() => secondsLeft(startsAt, endsAt)) const boom = useRoomStore((s) => s.boom) const boomed = useRef(false) useEffect(() => { - const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250) + const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250) return () => clearInterval(id) - }, [endsAt]) + }, [startsAt, endsAt]) // Déclenche l'explosion une seule fois au passage à 0 (fin au timer). useEffect(() => { diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index cbc231a..c4035da 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -54,7 +54,11 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.category ? ` · ${question.category}` : ""} {!showReveal && ( - + )} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 3e4bef9..7d33241 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -2,15 +2,29 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" -const VISIBLE_MS = 1100 // durée d'affichage avant le wipe de sortie +const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) + +// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...). +// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont +// détectés au build et joués aléatoirement. Sinon, fallback animation Framer. +const CUSTOM = Object.values( + import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", { + eager: true, + import: "default", + }) +) as string[] /** * Transition "WarioWare" jouée entre les manches : wipe coloré plein écran, - * sunburst qui tourne, gros numéro de question qui rebondit, puis sortie. + * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. * Montée avec une `key` qui change → rejoue à chaque manche. */ export function RoundTransition({ index }: { index: number }) { const [show, setShow] = useState(true) + // Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre). + const [media] = useState(() => + CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null + ) useEffect(() => { const t = setTimeout(() => setShow(false), VISIBLE_MS) @@ -31,47 +45,64 @@ export function RoundTransition({ index }: { index: number }) { }} transition={{ duration: 0.25, ease: "easeOut" }} > - + {media ? ( + + ) : ( + <> + - - - Question - - - {index + 1} - - - PRÊT ?! - - + + + Question + + + {index + 1} + + + PRÊT ?! + + + + )} )} , diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 70c7cb1..bf8d558 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -13,6 +13,7 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + startsAt: number endsAt: number payload: unknown } @@ -149,6 +150,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, }, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index d8d2ee4..d18b5b2 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -48,7 +48,9 @@ export interface SubmitOkPayload { export interface RoundStartPayload { type: RoundType djId?: string - /** Timestamp serveur de fin de manche (startedAt + roundDuration). */ + /** Timestamp serveur de début des réponses (après le délai de préparation / transition). */ + startsAt: number + /** Timestamp serveur de fin de manche (startsAt + roundDuration). */ endsAt: number /** Payload spécifique au type d'épreuve, SANS la réponse. */ payload: unknown