From e3315d3b3507030e76c02ad756daadd1677d2945 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Wed, 10 Jun 2026 13:41:29 +0200 Subject: [PATCH] feat: mixed games by default, free-text quiz, mode-aware transitions Mixed mode (default): - gameType is now mixed | quiz | blindtest, default "mixed"; the lobby interleaves quiz + blindtest rounds so modes alternate - lobby reworked: 3-way game-type switch, quiz count and/or blindtest config + track submission shown per selected type Free-text quiz questions: - quiz supports the `free` format (e.g. maths) alongside mcq/truefalse: payload without choices, {text} vote, tolerant matching on acceptedAnswers - shared QuizQuestionPayload.choices optional; reveal truth carries answer - match util moved to game/match.ts (shared by quiz + blindtest) - repo/seed include free + acceptedAnswers; in-code bank gains free questions - client quiz view renders a text input for free questions Mode-aware transitions: - RoundTransition themed per mode (colors/label/emoji); announces the mode on change (quiz <-> blindtest), lighter teaser within the same mode - custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared - store tracks roundModeChanged Verified e2e: mixed game alternates quiz/blindtest to completion; free-text scoring covered by tests (22 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/db/quiz-repo.ts | 17 +- apps/server/src/db/seed.ts | 1 + .../src/game/{modes/blindtest => }/match.ts | 0 .../modes/blindtest/blindtest-round.test.ts | 2 +- .../game/modes/blindtest/blindtest-round.ts | 2 +- apps/server/src/game/modes/quiz/questions.ts | 49 ++- .../src/game/modes/quiz/quiz-round.test.ts | 30 +- apps/server/src/game/modes/quiz/quiz-round.ts | 73 +++-- apps/web/src/assets/transitions/README.md | 16 +- apps/web/src/components/lobby-view.tsx | 304 +++++++++--------- apps/web/src/components/quiz-view.tsx | 92 ++++-- apps/web/src/components/round-transition.tsx | 183 +++++++---- apps/web/src/pages/room.tsx | 10 +- apps/web/src/store/room.ts | 14 + packages/shared/src/domain.ts | 7 +- packages/shared/src/events.ts | 3 +- packages/shared/src/quiz.ts | 9 +- 17 files changed, 529 insertions(+), 283 deletions(-) rename apps/server/src/game/{modes/blindtest => }/match.ts (100%) diff --git a/apps/server/src/db/quiz-repo.ts b/apps/server/src/db/quiz-repo.ts index 92eec3d..b9512f2 100644 --- a/apps/server/src/db/quiz-repo.ts +++ b/apps/server/src/db/quiz-repo.ts @@ -1,6 +1,6 @@ // Accès aux questions de quiz en base. No-op si pas de DB (db === null). -import { and, eq, inArray, isNotNull, notInArray, sql } from "drizzle-orm" +import { and, eq, inArray, notInArray, sql } from "drizzle-orm" import { db } from "./index" import { quizCategory, quizPlayed, quizQuestion } from "./schema" import type { QuizQuestion } from "../game/modes/quiz/questions" @@ -28,6 +28,7 @@ export async function loadQuizPool( prompt: quizQuestion.prompt, choices: quizQuestion.choices, correctIndex: quizQuestion.correctIndex, + acceptedAnswers: quizQuestion.acceptedAnswers, difficulty: quizQuestion.difficulty, category: quizCategory.name, }) @@ -35,8 +36,7 @@ export async function loadQuizPool( .leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id)) .where( and( - inArray(quizQuestion.format, ["mcq", "truefalse"]), - isNotNull(quizQuestion.correctIndex), + inArray(quizQuestion.format, ["mcq", "truefalse", "free"]), notInArray(quizQuestion.id, alreadyPlayed) ) ) @@ -44,13 +44,18 @@ export async function loadQuizPool( .limit(limit) return rows - .filter((r) => r.choices && r.correctIndex !== null) + .filter((r) => + r.format === "free" + ? (r.acceptedAnswers?.length ?? 0) > 0 + : r.choices && r.correctIndex !== null + ) .map((r) => ({ id: r.id, format: r.format as QuizQuestion["format"], prompt: r.prompt, - choices: r.choices as string[], - correctIndex: r.correctIndex as number, + choices: r.choices ?? undefined, + correctIndex: r.correctIndex ?? undefined, + acceptedAnswers: r.acceptedAnswers ?? undefined, category: r.category ?? "Quiz", difficulty: r.difficulty, })) diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts index 019a499..e1f9579 100644 --- a/apps/server/src/db/seed.ts +++ b/apps/server/src/db/seed.ts @@ -179,6 +179,7 @@ async function seedManual(): Promise { source: "manual", choices: q.choices, correctIndex: q.correctIndex, + acceptedAnswers: q.acceptedAnswers, }, ]) } diff --git a/apps/server/src/game/modes/blindtest/match.ts b/apps/server/src/game/match.ts similarity index 100% rename from apps/server/src/game/modes/blindtest/match.ts rename to apps/server/src/game/match.ts diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts index bcfb726..30a8eb1 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.test.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.test.ts @@ -3,7 +3,7 @@ import { RoomManager, type BlindtestTrack } from "../../../rooms" import type { RoundContext } from "../../round" import { BlindtestRound } from "./blindtest-round" import { prepareBlindtestForRoom } from "./pool" -import { fuzzyMatch, normalize } from "./match" +import { fuzzyMatch, normalize } from "../../match" function track(submittedBy: string): BlindtestTrack { return { diff --git a/apps/server/src/game/modes/blindtest/blindtest-round.ts b/apps/server/src/game/modes/blindtest/blindtest-round.ts index d7dbbe1..59199cf 100644 --- a/apps/server/src/game/modes/blindtest/blindtest-round.ts +++ b/apps/server/src/game/modes/blindtest/blindtest-round.ts @@ -12,7 +12,7 @@ import type { } from "@nerdware/shared" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { BlindtestTrack, ServerRoom } from "../../../rooms" -import { fuzzyMatch } from "./match" +import { fuzzyMatch } from "../../match" import { takeTrack } from "./pool" const TITLE_POINTS = 60 diff --git a/apps/server/src/game/modes/quiz/questions.ts b/apps/server/src/game/modes/quiz/questions.ts index 3c4af08..271157e 100644 --- a/apps/server/src/game/modes/quiz/questions.ts +++ b/apps/server/src/game/modes/quiz/questions.ts @@ -8,8 +8,12 @@ export interface QuizQuestion { id: string format: QuizFormat prompt: string - choices: string[] - correctIndex: number + /** mcq/truefalse uniquement. */ + choices?: string[] + /** mcq/truefalse uniquement. */ + correctIndex?: number + /** free : réponses acceptées (matching tolérant). */ + acceptedAnswers?: string[] category: string difficulty: number } @@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [ category: "Pop culture", difficulty: 1, }, + // Saisie libre (matching tolérant sur acceptedAnswers). + { + id: "q-free-math-1", + format: "free", + prompt: "Combien font 7 × 8 ?", + acceptedAnswers: ["56"], + category: "Maths", + difficulty: 1, + }, + { + id: "q-free-math-2", + format: "free", + prompt: "Résous : 12² − 44 = ?", + acceptedAnswers: ["100", "cent"], + category: "Maths", + difficulty: 2, + }, + { + id: "q-free-zelda-princess", + format: "free", + prompt: "Comment s'appelle la princesse dans The Legend of Zelda ?", + acceptedAnswers: ["Zelda"], + category: "Jeux vidéo", + difficulty: 1, + }, + { + id: "q-free-pi", + format: "free", + prompt: "Donne les 3 premières décimales de Pi (après la virgule).", + acceptedAnswers: ["141"], + category: "Maths", + difficulty: 2, + }, + { + id: "q-free-onepiece", + format: "free", + prompt: "Quel est le nom du bateau de l'équipage de Luffy (2e navire) ?", + acceptedAnswers: ["Thousand Sunny", "Sunny", "le Thousand Sunny"], + category: "Manga", + difficulty: 3, + }, ] diff --git a/apps/server/src/game/modes/quiz/quiz-round.test.ts b/apps/server/src/game/modes/quiz/quiz-round.test.ts index 6d5bbe6..399269a 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.test.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.test.ts @@ -2,11 +2,15 @@ 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" +import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions" const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1 -function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { +function makeCtx(q: QuizQuestion = question): { + 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") @@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } { votes: new Map(), startedAt: 0, endsAt: 10_000, - data: { question, votedAt: new Map() }, + data: { question: q, votedAt: new Map() }, } return { ctx, p1: a.id, p2: b.id } } @@ -56,4 +60,24 @@ describe("QuizRound", () => { const deltas = round.score(ctx) expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9) }) + + test("free : matching tolérant sur acceptedAnswers", () => { + const free: QuizQuestion = { + id: "f1", + format: "free", + prompt: "7 × 8 ?", + acceptedAnswers: ["56"], + category: "Maths", + difficulty: 1, + } + const round = new QuizRound(() => 1000) + const { ctx, p1, p2 } = makeCtx(free) + round.submitAnswer(ctx, p1, { text: " 56 " }) // juste (espaces tolérés) + round.submitAnswer(ctx, p2, { text: "57" }) // faux + const { truth, perPlayerResult } = round.reveal(ctx) + expect(truth).toEqual({ answer: "56" }) + expect(perPlayerResult[p1].correct).toBe(true) + expect(perPlayerResult[p2].correct).toBe(false) + expect(round.score(ctx)).toEqual([{ playerId: p1, delta: 190 }]) + }) }) diff --git a/apps/server/src/game/modes/quiz/quiz-round.ts b/apps/server/src/game/modes/quiz/quiz-round.ts index e8f121e..310b046 100644 --- a/apps/server/src/game/modes/quiz/quiz-round.ts +++ b/apps/server/src/game/modes/quiz/quiz-round.ts @@ -1,5 +1,5 @@ // Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio. -// Implémente le contrat GameRound ; le moteur fait tout le reste. +// Formats : mcq, truefalse (choix), free (saisie libre, matching tolérant). import type { Answer, @@ -10,6 +10,7 @@ import type { } from "@nerdware/shared" import { hasDb } from "../../../db" import { markQuestionPlayed } from "../../../db/quiz-repo" +import { fuzzyMatch } from "../../match" import type { GameRound, RoundContext, RoundStart } from "../../round" import type { ServerRoom } from "../../../rooms" import type { QuizQuestion } from "./questions" @@ -25,10 +26,29 @@ interface QuizRoundData { votedAt: Map } -function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } { - return ( - typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number" - ) +function asChoice(answer: Answer): number | null { + const v = (answer as { choiceIndex?: unknown }).choiceIndex + return typeof v === "number" ? v : null +} + +function asText(answer: Answer): string | null { + const v = (answer as { text?: unknown }).text + return typeof v === "string" ? v : null +} + +/** Réponse correcte ? Selon le format de la question. */ +function isCorrect(question: QuizQuestion, answer: Answer | undefined): boolean { + if (!answer) { + return false + } + if (question.format === "free") { + const text = asText(answer) + if (!text) { + return false + } + return (question.acceptedAnswers ?? []).some((a) => fuzzyMatch(text, a)) + } + return asChoice(answer) === question.correctIndex } export class QuizRound implements GameRound { @@ -45,10 +65,12 @@ export class QuizRound implements GameRound { const payload: QuizQuestionPayload = { format: question.format, prompt: question.prompt, - choices: question.choices, category: question.category, difficulty: question.difficulty, } + if (question.format !== "free") { + payload.choices = question.choices + } const data: QuizRoundData = { question, votedAt: new Map() } return { djId: null, payload, data } } @@ -58,29 +80,42 @@ export class QuizRound implements GameRound { 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 + if (question.format === "free") { + const text = asText(answer) + if (text === null || text.trim().length === 0) { + return + } + ctx.votes.set(playerId, { text }) + } else { + const choiceIndex = asChoice(answer) + const count = question.choices?.length ?? 0 + if (choiceIndex === null || choiceIndex < 0 || choiceIndex >= count) { + return + } + ctx.votes.set(playerId, { choiceIndex }) } - ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex }) votedAt.set(playerId, this.now()) } - reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } { + 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 + const vote = ctx.votes.get(player.id) perPlayerResult[player.id] = { - choiceIndex, - correct: choiceIndex === question.correctIndex, + choiceIndex: vote ? asChoice(vote) : null, + correct: isCorrect(question, vote), } } - return { truth: { correctIndex: question.correctIndex }, perPlayerResult } + const truth: QuizRevealTruth = + question.format === "free" + ? { answer: question.acceptedAnswers?.[0] ?? "" } + : { correctIndex: question.correctIndex } + return { truth, perPlayerResult } } score(ctx: RoundContext): ScoreDelta[] { @@ -88,7 +123,7 @@ export class QuizRound implements GameRound { const total = ctx.endsAt - ctx.startedAt const deltas: ScoreDelta[] = [] for (const [playerId, vote] of ctx.votes) { - if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) { + if (!isCorrect(question, vote)) { continue } // Bonus rapidité : proportionnel au temps restant au moment du vote. diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md index eb8792a..a1675d9 100644 --- a/apps/web/src/assets/transitions/README.md +++ b/apps/web/src/assets/transitions/README.md @@ -3,9 +3,19 @@ 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. +Rangement par mode (recommandé pour des transitions personnalisées) : + +- `quiz/` → jouées avant une manche de **quiz** +- `blindtest/` → jouées avant une manche de **blindtest** +- racine (`./`) → **communs**, fallback pour les deux modes + +Règles : + +- Détectés automatiquement au build (`import.meta.glob`). +- À chaque manche, un fichier est tiré **au hasard** dans le pool du mode + (dossier du mode + communs). +- S'il n'y en a aucun, l'animation Framer par défaut est jouée : annonce du + mode (« QUIZ » / « BLINDTEST ») quand on change de mode, sinon « Question/Titre N ». 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 diff --git a/apps/web/src/components/lobby-view.tsx b/apps/web/src/components/lobby-view.tsx index 4713f3d..8eb8375 100644 --- a/apps/web/src/components/lobby-view.tsx +++ b/apps/web/src/components/lobby-view.tsx @@ -1,11 +1,21 @@ import { useState } from "react" -import type { BlindtestMode, RoomSnapshot, RoundConfig } from "@nerdware/shared" +import type { + BlindtestMode, + GameType, + RoomSnapshot, + RoundConfig, +} from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" const QUESTION_OPTIONS = [3, 5, 10] const TRACKS_OPTIONS = [1, 2, 3] +const GAME_TYPES: { value: GameType; label: string }[] = [ + { value: "mixed", label: "Mixte" }, + { value: "quiz", label: "Quiz" }, + { value: "blindtest", label: "Blindtest" }, +] const MODE_LABELS: Record = { title_artist: "Titre & artiste", who_added: "Qui l'a ajouté ?", @@ -15,6 +25,35 @@ const MODE_LABELS: Record = { const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" +/** Construit la séquence de manches selon le type de partie. */ +function buildRounds( + gameType: GameType, + quizCount: number, + totalTracks: number +): RoundConfig[] { + if (gameType === "quiz") { + return Array.from({ length: quizCount }, () => ({ type: "quiz" })) + } + if (gameType === "blindtest") { + return Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) + } + // Mixte : on alterne quiz / blindtest pour multiplier les changements de mode. + const rounds: RoundConfig[] = [] + let q = quizCount + let b = totalTracks + while (q > 0 || b > 0) { + if (q > 0) { + rounds.push({ type: "quiz" }) + q-- + } + if (b > 0) { + rounds.push({ type: "blindtest" }) + b-- + } + } + return rounds +} + export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const updateSettings = useRoomStore((s) => s.updateSettings) @@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const [error, setError] = useState(null) const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0) + const myCount = + snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0 + const showQuiz = gameType === "quiz" || gameType === "mixed" + const showBlindtest = gameType === "blindtest" || gameType === "mixed" - async function start(rounds: RoundConfig[]) { + const rounds = buildRounds(gameType, showQuiz ? count : 0, totalTracks) + const canStart = rounds.length > 0 + + async function start() { setError(null) setBusy(true) try { @@ -70,141 +116,51 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { - {/* Choix du type de partie (hôte). */} {isHost && (
- {(["quiz", "blindtest"] as const).map((type) => ( + {GAME_TYPES.map((t) => ( ))}
)} - {gameType === "quiz" ? ( - isHost ? ( -
-
- Questions de quiz -
- {QUESTION_OPTIONS.map((n) => ( - - ))} -
-
- -
- ) : ( -

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

- ) - ) : ( - s.playerId === playerId)?.count ?? 0 - } - totalTracks={totalTracks} - busy={busy} - onConfig={updateSettings} - onStart={() => - start( - Array.from({ length: totalTracks }, () => ({ type: "blindtest" })) - ) - } - /> + {isHost && showQuiz && ( +
+ Questions de quiz +
+ {QUESTION_OPTIONS.map((n) => ( + + ))} +
+
)} - {error &&

{error}

} - - ) -} - -function BlindtestLobby({ - isHost, - mode, - tracksPerPlayer, - myCount, - totalTracks, - busy, - onConfig, - onStart, -}: { - snapshot: RoomSnapshot - isHost: boolean - mode: BlindtestMode - tracksPerPlayer: number - myCount: number - totalTracks: number - busy: boolean - onConfig: (partial: { - blindtestMode?: BlindtestMode - tracksPerPlayer?: number - }) => void - onStart: () => void -}) { - const submitTrack = useRoomStore((s) => s.submitTrack) - const [url, setUrl] = useState("") - const [submitting, setSubmitting] = useState(false) - const [feedback, setFeedback] = useState(null) - const [accepted, setAccepted] = useState([]) - - const quotaReached = myCount >= tracksPerPlayer - - async function submit() { - if (!url.trim()) { - return - } - setSubmitting(true) - setFeedback(null) - const res = await submitTrack(url.trim()) - if (res.accepted) { - setAccepted((a) => [...a, res.title ?? url.trim()]) - setUrl("") - setFeedback(null) - } else { - setFeedback(res.reason ?? "Refusé") - } - setSubmitting(false) - } - - return ( -
- {isHost && ( + {isHost && showBlindtest && (
- Mode + Mode blindtest
{(["title_artist", "who_added", "mixed"] as const).map((m) => ( @@ -219,7 +175,7 @@ function BlindtestLobby({ key={n} size="sm" variant={tracksPerPlayer === n ? "default" : "secondary"} - onClick={() => onConfig({ tracksPerPlayer: n })} + onClick={() => updateSettings({ tracksPerPlayer: n })} > {n} @@ -229,48 +185,88 @@ function BlindtestLobby({
)} -
- - Tes titres ({myCount}/{tracksPerPlayer}) - -
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
- {feedback &&

{feedback}

} - {accepted.length > 0 && ( -
    - {accepted.map((t, i) => ( -
  • - ✓ {t} -
  • - ))} -
- )} -
+ {showBlindtest && ( + + )} {isHost ? ( - ) : (

- En attente du lancement par l'hôte… ({totalTracks} titres soumis) + En attente du lancement par l'hôte… + {showBlindtest && ` (${totalTracks} titres soumis)`}

)} -
+ + {error &&

{error}

} + + ) +} + +function TrackSubmission({ + tracksPerPlayer, + myCount, +}: { + tracksPerPlayer: number + myCount: number +}) { + const submitTrack = useRoomStore((s) => s.submitTrack) + const [url, setUrl] = useState("") + const [submitting, setSubmitting] = useState(false) + const [feedback, setFeedback] = useState(null) + const [accepted, setAccepted] = useState([]) + const quotaReached = myCount >= tracksPerPlayer + + async function submit() { + if (!url.trim()) { + return + } + setSubmitting(true) + setFeedback(null) + const res = await submitTrack(url.trim()) + if (res.accepted) { + setAccepted((a) => [...a, res.title ?? url.trim()]) + setUrl("") + } else { + setFeedback(res.reason ?? "Refusé") + } + setSubmitting(false) + } + + return ( +
+ + Tes titres ({myCount}/{tracksPerPlayer}) + +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + +
+ {feedback &&

{feedback}

} + {accepted.length > 0 && ( +
    + {accepted.map((t, i) => ( +
  • + ✓ {t} +
  • + ))} +
+ )} +
) } diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index c4035da..ddc401a 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -1,19 +1,26 @@ +import { useState } from "react" import type { QuizPerPlayerResult, QuizQuestionPayload, QuizRevealTruth, RoomSnapshot, } from "@nerdware/shared" +import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Countdown } from "@/components/countdown" +const inputClass = + "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" + 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 hasVoted = useRoomStore((s) => s.hasVoted) const playerId = useRoomStore((s) => s.playerId) const vote = useRoomStore((s) => s.vote) + const voteText = useRoomStore((s) => s.voteText) if (!round) { return ( @@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const question = round.payload as QuizQuestionPayload const truth = reveal ? (reveal.truth as QuizRevealTruth) : null const showReveal = truth !== null + const isFree = question.format === "free" const myResult = reveal ? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""] : undefined @@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.prompt} -
- {question.choices.map((choice, index) => ( - - ))} -
+ {isFree ? ( + + ) : ( +
+ {(question.choices ?? []).map((choice, index) => ( + + ))} +
+ )} {!showReveal && - (myChoiceIndex !== null ? ( + (hasVoted ? (

Réponse envoyée — en attente des autres {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""} @@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { ))} {showReveal && ( -

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

+
+ {isFree && ( +

+ Réponse : + {truth?.answer} +

+ )} +

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

+
)} ) } + +function FreeAnswer({ + disabled, + onSubmit, +}: { + disabled: boolean + onSubmit: (text: string) => void +}) { + const [text, setText] = useState("") + return ( +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)} + /> + +
+ ) +} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 7d33241..937123c 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -1,30 +1,74 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" +import type { RoundType } from "@nerdware/shared" 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[] +// Médias custom déposés par l'utilisateur, rangés par mode : +// src/assets/transitions/quiz/* → transitions de quiz +// src/assets/transitions/blindtest/* → transitions de blindtest +// src/assets/transitions/* → communs (fallback) +// Détectés au build ; sinon, animation Framer par défaut. +const ALL_MEDIA = import.meta.glob( + "../assets/transitions/**/*.{gif,webp,apng,png,avif}", + { eager: true, import: "default" } +) as Record + +const QUIZ_MEDIA: string[] = [] +const BLINDTEST_MEDIA: string[] = [] +const SHARED_MEDIA: string[] = [] +for (const [path, url] of Object.entries(ALL_MEDIA)) { + if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url) + else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url) + else SHARED_MEDIA.push(url) +} + +const THEME: Record< + RoundType, + { gradient: string; accent: string; label: string; kind: string; emoji: string } +> = { + quiz: { + gradient: "from-indigo-500 via-blue-600 to-cyan-600", + accent: "text-cyan-300", + label: "QUIZ", + kind: "Question", + emoji: "🧠", + }, + blindtest: { + gradient: "from-fuchsia-500 via-purple-600 to-indigo-700", + accent: "text-yellow-300", + label: "BLINDTEST", + kind: "Titre", + emoji: "🎧", + }, +} + +function mediaFor(type: RoundType): string[] { + const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA + return [...own, ...SHARED_MEDIA] +} /** - * Transition "WarioWare" jouée entre les manches : wipe coloré plein écran, - * média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie. + * Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand + * il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche. * Montée avec une `key` qui change → rejoue à chaque manche. */ -export function RoundTransition({ index }: { index: number }) { +export function RoundTransition({ + type, + index, + modeChanged, +}: { + type: RoundType + index: number + modeChanged: boolean +}) { 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 - ) + const theme = THEME[type] + const [media] = useState(() => { + const pool = mediaFor(type) + return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null + }) useEffect(() => { const t = setTimeout(() => setShow(false), VISIBLE_MS) @@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) { {show && ( + + {media ? ( ) : ( - <> - - - + {modeChanged ? ( + <> + {theme.emoji} + + {theme.label} + + + ) : ( + <> + + {theme.kind} + + + {index + 1} + + + )} + - - Question - - - {index + 1} - - - PRÊT ?! - - - + PRÊT ?! + + )} )} diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 52e7957..ee937fc 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) { const roundKey = useRoomStore((s) => s.roundKey) const voteProgress = useRoomStore((s) => s.voteProgress) const round = useRoomStore((s) => s.round) + const roundModeChanged = useRoomStore((s) => s.roundModeChanged) // Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil. if (!snapshot || snapshot.code !== code) { @@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) { {boomKey > 0 && } - {roundKey > 0 && ( - + {roundKey > 0 && round && ( + )} ) diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 7a30cea..55024e8 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -48,6 +48,8 @@ interface RoomState { mediaSync: MediaSyncPayload | null boomKey: number roundKey: number + /** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ + roundModeChanged: boolean createRoom: (playerName: string) => Promise joinRoom: (roomCode: string, playerName: string) => Promise @@ -55,6 +57,7 @@ interface RoomState { startGame: (rounds: RoundConfig[]) => Promise submitTrack: (youtubeUrl: string) => Promise vote: (choiceIndex: number) => void + voteText: (text: string) => void voteBlindtest: (answer: BlindtestAnswer) => void mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void boom: () => void @@ -77,6 +80,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, createRoom: (playerName) => new Promise((resolve, reject) => { @@ -141,6 +145,14 @@ export const useRoomStore = create((set, get) => ({ socket.emit("round:vote", { answer: { choiceIndex } }) }, + voteText: (text) => { + if (get().hasVoted || text.trim().length === 0) { + return + } + set({ hasVoted: true }) + socket.emit("round:vote", { answer: { text: text.trim() } }) + }, + voteBlindtest: (answer) => { if (get().hasVoted) { return @@ -170,6 +182,7 @@ export const useRoomStore = create((set, get) => ({ mediaSync: null, boomKey: 0, roundKey: 0, + roundModeChanged: false, }), })) @@ -194,6 +207,7 @@ socket.on("round:start", (payload) => hasVoted: false, mediaSync: null, roundKey: s.roundKey + 1, + roundModeChanged: (s.round?.type ?? null) !== payload.type, })) ) socket.on("round:voteAck", (voteProgress) => diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index a56a6f6..d7ee165 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended" /** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */ export type RoundType = "blindtest" | "quiz" +/** Type de partie choisi au lobby : mélange par défaut, ou un seul mode. */ +export type GameType = "mixed" | "quiz" | "blindtest" + /** Modes de blindtest, déterminent la forme du vote et le barème. */ export type BlindtestMode = "title_artist" | "who_added" | "mixed" @@ -28,7 +31,7 @@ export interface RoundConfig { /** Réglages de la room, modifiables dans le lobby par l'hôte. */ export interface RoomSettings { /** Type de partie choisi dans le lobby (pilote l'UI partagée). */ - gameType: RoundType + gameType: GameType blindtestMode: BlindtestMode /** Durée d'une manche en secondes (def. 60). */ roundDuration: number @@ -40,7 +43,7 @@ export interface RoomSettings { /** Réglages par défaut d'une nouvelle room. */ export const DEFAULT_ROOM_SETTINGS: RoomSettings = { - gameType: "quiz", + gameType: "mixed", blindtestMode: "title_artist", roundDuration: 60, tracksPerPlayer: 2, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index e03de62..8b9a7b1 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -4,6 +4,7 @@ import type { Answer, BlindtestMode, + GameType, PlayerScore, RoomSnapshot, RoundConfig, @@ -27,7 +28,7 @@ export interface RoomJoinPayload { } export interface UpdateSettingsPayload { - gameType: RoundType + gameType: GameType blindtestMode: BlindtestMode roundDuration: number tracksPerPlayer: number diff --git a/packages/shared/src/quiz.ts b/packages/shared/src/quiz.ts index a2156a6..d61bd3c 100644 --- a/packages/shared/src/quiz.ts +++ b/packages/shared/src/quiz.ts @@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain" export interface QuizQuestionPayload { format: QuizFormat prompt: string - /** Choix proposés (truefalse = ["Vrai", "Faux"]). */ - choices: string[] + /** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */ + choices?: string[] category?: string /** 1 (facile) .. 3 (difficile). */ difficulty?: number @@ -17,7 +17,10 @@ export interface QuizQuestionPayload { /** Vérité révélée à tous (round:reveal → truth). */ export interface QuizRevealTruth { - correctIndex: number + /** Index de la bonne réponse (mcq/truefalse). */ + correctIndex?: number + /** Réponse canonique affichée (free). */ + answer?: string } /** Résultat d'un joueur sur la manche. */