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) <noreply@anthropic.com>
This commit is contained in:
AyoubBenziza 2026-06-10 13:41:29 +02:00
parent 98e06b8206
commit e3315d3b35
17 changed files with 529 additions and 283 deletions

View file

@ -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,
}))

View file

@ -179,6 +179,7 @@ async function seedManual(): Promise<number> {
source: "manual",
choices: q.choices,
correctIndex: q.correctIndex,
acceptedAnswers: q.acceptedAnswers,
},
])
}

View file

@ -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 {

View file

@ -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

View file

@ -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,
},
]

View file

@ -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 }])
})
})

View file

@ -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<string, number>
}
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) {
if (question.format === "free") {
const text = asText(answer)
if (text === null || text.trim().length === 0) {
return
}
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
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 })
}
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.

View file

@ -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

View file

@ -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<BlindtestMode, string> = {
title_artist: "Titre & artiste",
who_added: "Qui l'a ajouté ?",
@ -15,6 +25,35 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
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<string | null>(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,25 +116,22 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
</ul>
</section>
{/* Choix du type de partie (hôte). */}
{isHost && (
<div className="flex gap-2">
{(["quiz", "blindtest"] as const).map((type) => (
{GAME_TYPES.map((t) => (
<Button
key={type}
key={t.value}
className="flex-1"
variant={gameType === type ? "default" : "secondary"}
onClick={() => updateSettings({ gameType: type })}
variant={gameType === t.value ? "default" : "secondary"}
onClick={() => updateSettings({ gameType: t.value })}
>
{type === "quiz" ? "Quiz" : "Blindtest"}
{t.label}
</Button>
))}
</div>
)}
{gameType === "quiz" ? (
isHost ? (
<section className="flex flex-col gap-3">
{isHost && showQuiz && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Questions de quiz</span>
<div className="flex gap-1">
@ -104,107 +147,20 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
))}
</div>
</div>
<Button
disabled={busy}
onClick={() =>
start(Array.from({ length: count }, () => ({ type: "quiz" })))
}
>
{busy ? "Lancement…" : "Lancer la partie"}
</Button>
</section>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte
</p>
)
) : (
<BlindtestLobby
snapshot={snapshot}
isHost={isHost}
mode={blindtestMode}
tracksPerPlayer={tracksPerPlayer}
myCount={
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
}
totalTracks={totalTracks}
busy={busy}
onConfig={updateSettings}
onStart={() =>
start(
Array.from({ length: totalTracks }, () => ({ type: "blindtest" }))
)
}
/>
)}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
</div>
)
}
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<string | null>(null)
const [accepted, setAccepted] = useState<string[]>([])
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 (
<section className="flex flex-col gap-4">
{isHost && (
{isHost && showBlindtest && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Mode</span>
<span className="text-sm font-medium">Mode blindtest</span>
<div className="flex gap-1">
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
<Button
key={m}
size="sm"
className="flex-1"
variant={mode === m ? "default" : "secondary"}
onClick={() => onConfig({ blindtestMode: m })}
variant={blindtestMode === m ? "default" : "secondary"}
onClick={() => updateSettings({ blindtestMode: m })}
>
{MODE_LABELS[m]}
</Button>
@ -219,7 +175,7 @@ function BlindtestLobby({
key={n}
size="sm"
variant={tracksPerPlayer === n ? "default" : "secondary"}
onClick={() => onConfig({ tracksPerPlayer: n })}
onClick={() => updateSettings({ tracksPerPlayer: n })}
>
{n}
</Button>
@ -229,6 +185,57 @@ function BlindtestLobby({
</div>
)}
{showBlindtest && (
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
)}
{isHost ? (
<Button disabled={busy || !canStart} onClick={start}>
{busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
</Button>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte
{showBlindtest && ` (${totalTracks} titres soumis)`}
</p>
)}
{error && <p className="text-destructive text-center text-sm">{error}</p>}
</div>
)
}
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<string | null>(null)
const [accepted, setAccepted] = useState<string[]>([])
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 (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Tes titres ({myCount}/{tracksPerPlayer})
@ -261,16 +268,5 @@ function BlindtestLobby({
</ul>
)}
</div>
{isHost ? (
<Button disabled={busy || totalTracks === 0} onClick={onStart}>
{busy ? "Lancement…" : `Lancer (${totalTracks} titres)`}
</Button>
) : (
<p className="text-muted-foreground text-center text-xs">
En attente du lancement par l'hôte ({totalTracks} titres soumis)
</p>
)}
</section>
)
}

View file

@ -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}
</h2>
{isFree ? (
<FreeAnswer
disabled={showReveal || hasVoted}
onSubmit={voteText}
/>
) : (
<div className="flex flex-col gap-2">
{question.choices.map((choice, index) => (
{(question.choices ?? []).map((choice, index) => (
<button
key={index}
className={choiceClass(index)}
disabled={showReveal || myChoiceIndex !== null}
disabled={showReveal || hasVoted}
onClick={() => vote(index)}
>
{choice}
</button>
))}
</div>
)}
{!showReveal &&
(myChoiceIndex !== null ? (
(hasVoted ? (
<p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
))}
{showReveal && (
<div className="flex flex-col items-center gap-1">
{isFree && (
<p className="text-sm">
<span className="text-muted-foreground">Réponse : </span>
<span className="font-medium">{truth?.answer}</span>
</p>
)}
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: myResult?.choiceIndex == null
: !hasVoted
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
</div>
)}
</div>
)
}
function FreeAnswer({
disabled,
onSubmit,
}: {
disabled: boolean
onSubmit: (text: string) => void
}) {
const [text, setText] = useState("")
return (
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Ta réponse"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
/>
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
Valider
</Button>
</div>
)
}

View file

@ -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<string, string>
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 && (
<motion.div
key="round-transition"
className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br from-fuchsia-500 via-purple-600 to-indigo-700"
className={`pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}
initial={{ clipPath: "inset(0 0 100% 0)" }}
animate={{ clipPath: "inset(0 0 0% 0)" }}
exit={{
@ -45,17 +89,6 @@ export function RoundTransition({ index }: { index: number }) {
}}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{media ? (
<motion.img
src={media}
alt=""
className="max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/>
) : (
<>
<motion.div
aria-hidden
className="absolute size-[220vmax] opacity-60"
@ -67,6 +100,16 @@ export function RoundTransition({ index }: { index: number }) {
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
{media ? (
<motion.img
src={media}
alt=""
className="relative max-h-[70vh] max-w-[80vw] object-contain"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
/>
) : (
<motion.div
className="relative flex flex-col items-center"
initial={{ scale: 0, rotate: -12 }}
@ -78,11 +121,22 @@ export function RoundTransition({ index }: { index: number }) {
ease: "easeOut",
}}
>
{modeChanged ? (
<>
<span className="text-[18vmin] leading-none">{theme.emoji}</span>
<span
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
>
{theme.label}
</span>
</>
) : (
<>
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
Question
{theme.kind}
</span>
<motion.span
className="font-heading text-[26vmin] leading-none font-black text-yellow-300 drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]"
className={`font-heading text-[26vmin] leading-none font-black ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
animate={{ scale: [1, 1.07, 1] }}
transition={{
duration: 0.4,
@ -92,6 +146,8 @@ export function RoundTransition({ index }: { index: number }) {
>
{index + 1}
</motion.span>
</>
)}
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@ -101,7 +157,6 @@ export function RoundTransition({ index }: { index: number }) {
PRÊT ?!
</motion.span>
</motion.div>
</>
)}
</motion.div>
)}

View file

@ -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 }) {
</div>
{boomKey > 0 && <Boom key={boomKey} />}
{roundKey > 0 && (
<RoundTransition key={roundKey} index={snapshot.currentRound} />
{roundKey > 0 && round && (
<RoundTransition
key={roundKey}
type={round.type}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>
)}
</div>
)

View file

@ -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<RoomCreatedPayload>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
@ -55,6 +57,7 @@ interface RoomState {
startGame: (rounds: RoundConfig[]) => Promise<void>
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
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<RoomState>((set, get) => ({
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundModeChanged: false,
createRoom: (playerName) =>
new Promise((resolve, reject) => {
@ -141,6 +145,14 @@ export const useRoomStore = create<RoomState>((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<RoomState>((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) =>

View file

@ -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,

View file

@ -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

View file

@ -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. */