release/0.1.0 #18
17 changed files with 529 additions and 283 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
// Accès aux questions de quiz en base. No-op si pas de DB (db === null).
|
// 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 { db } from "./index"
|
||||||
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
|
import { quizCategory, quizPlayed, quizQuestion } from "./schema"
|
||||||
import type { QuizQuestion } from "../game/modes/quiz/questions"
|
import type { QuizQuestion } from "../game/modes/quiz/questions"
|
||||||
|
|
@ -28,6 +28,7 @@ export async function loadQuizPool(
|
||||||
prompt: quizQuestion.prompt,
|
prompt: quizQuestion.prompt,
|
||||||
choices: quizQuestion.choices,
|
choices: quizQuestion.choices,
|
||||||
correctIndex: quizQuestion.correctIndex,
|
correctIndex: quizQuestion.correctIndex,
|
||||||
|
acceptedAnswers: quizQuestion.acceptedAnswers,
|
||||||
difficulty: quizQuestion.difficulty,
|
difficulty: quizQuestion.difficulty,
|
||||||
category: quizCategory.name,
|
category: quizCategory.name,
|
||||||
})
|
})
|
||||||
|
|
@ -35,8 +36,7 @@ export async function loadQuizPool(
|
||||||
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
.leftJoin(quizCategory, eq(quizQuestion.categoryId, quizCategory.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(quizQuestion.format, ["mcq", "truefalse"]),
|
inArray(quizQuestion.format, ["mcq", "truefalse", "free"]),
|
||||||
isNotNull(quizQuestion.correctIndex),
|
|
||||||
notInArray(quizQuestion.id, alreadyPlayed)
|
notInArray(quizQuestion.id, alreadyPlayed)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -44,13 +44,18 @@ export async function loadQuizPool(
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
return rows
|
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) => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
format: r.format as QuizQuestion["format"],
|
format: r.format as QuizQuestion["format"],
|
||||||
prompt: r.prompt,
|
prompt: r.prompt,
|
||||||
choices: r.choices as string[],
|
choices: r.choices ?? undefined,
|
||||||
correctIndex: r.correctIndex as number,
|
correctIndex: r.correctIndex ?? undefined,
|
||||||
|
acceptedAnswers: r.acceptedAnswers ?? undefined,
|
||||||
category: r.category ?? "Quiz",
|
category: r.category ?? "Quiz",
|
||||||
difficulty: r.difficulty,
|
difficulty: r.difficulty,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ async function seedManual(): Promise<number> {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
choices: q.choices,
|
choices: q.choices,
|
||||||
correctIndex: q.correctIndex,
|
correctIndex: q.correctIndex,
|
||||||
|
acceptedAnswers: q.acceptedAnswers,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { RoomManager, type BlindtestTrack } from "../../../rooms"
|
||||||
import type { RoundContext } from "../../round"
|
import type { RoundContext } from "../../round"
|
||||||
import { BlindtestRound } from "./blindtest-round"
|
import { BlindtestRound } from "./blindtest-round"
|
||||||
import { prepareBlindtestForRoom } from "./pool"
|
import { prepareBlindtestForRoom } from "./pool"
|
||||||
import { fuzzyMatch, normalize } from "./match"
|
import { fuzzyMatch, normalize } from "../../match"
|
||||||
|
|
||||||
function track(submittedBy: string): BlindtestTrack {
|
function track(submittedBy: string): BlindtestTrack {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import type {
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||||
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
||||||
import { fuzzyMatch } from "./match"
|
import { fuzzyMatch } from "../../match"
|
||||||
import { takeTrack } from "./pool"
|
import { takeTrack } from "./pool"
|
||||||
|
|
||||||
const TITLE_POINTS = 60
|
const TITLE_POINTS = 60
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ export interface QuizQuestion {
|
||||||
id: string
|
id: string
|
||||||
format: QuizFormat
|
format: QuizFormat
|
||||||
prompt: string
|
prompt: string
|
||||||
choices: string[]
|
/** mcq/truefalse uniquement. */
|
||||||
correctIndex: number
|
choices?: string[]
|
||||||
|
/** mcq/truefalse uniquement. */
|
||||||
|
correctIndex?: number
|
||||||
|
/** free : réponses acceptées (matching tolérant). */
|
||||||
|
acceptedAnswers?: string[]
|
||||||
category: string
|
category: string
|
||||||
difficulty: number
|
difficulty: number
|
||||||
}
|
}
|
||||||
|
|
@ -107,4 +111,45 @@ export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||||
category: "Pop culture",
|
category: "Pop culture",
|
||||||
difficulty: 1,
|
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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ import { describe, expect, test } from "bun:test"
|
||||||
import { RoomManager } from "../../../rooms"
|
import { RoomManager } from "../../../rooms"
|
||||||
import type { RoundContext } from "../../round"
|
import type { RoundContext } from "../../round"
|
||||||
import { QuizRound } from "./quiz-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
|
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 rooms = new RoomManager()
|
||||||
const { room, player: a } = rooms.create("Alice", "sa")
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
|
@ -16,7 +20,7 @@ function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
|
||||||
votes: new Map(),
|
votes: new Map(),
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
endsAt: 10_000,
|
endsAt: 10_000,
|
||||||
data: { question, votedAt: new Map() },
|
data: { question: q, votedAt: new Map() },
|
||||||
}
|
}
|
||||||
return { ctx, p1: a.id, p2: b.id }
|
return { ctx, p1: a.id, p2: b.id }
|
||||||
}
|
}
|
||||||
|
|
@ -56,4 +60,24 @@ describe("QuizRound", () => {
|
||||||
const deltas = round.score(ctx)
|
const deltas = round.score(ctx)
|
||||||
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
|
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 }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
|
// É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 {
|
import type {
|
||||||
Answer,
|
Answer,
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { hasDb } from "../../../db"
|
import { hasDb } from "../../../db"
|
||||||
import { markQuestionPlayed } from "../../../db/quiz-repo"
|
import { markQuestionPlayed } from "../../../db/quiz-repo"
|
||||||
|
import { fuzzyMatch } from "../../match"
|
||||||
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||||
import type { ServerRoom } from "../../../rooms"
|
import type { ServerRoom } from "../../../rooms"
|
||||||
import type { QuizQuestion } from "./questions"
|
import type { QuizQuestion } from "./questions"
|
||||||
|
|
@ -25,10 +26,29 @@ interface QuizRoundData {
|
||||||
votedAt: Map<string, number>
|
votedAt: Map<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
|
function asChoice(answer: Answer): number | null {
|
||||||
return (
|
const v = (answer as { choiceIndex?: unknown }).choiceIndex
|
||||||
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
|
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 {
|
export class QuizRound implements GameRound {
|
||||||
|
|
@ -45,10 +65,12 @@ export class QuizRound implements GameRound {
|
||||||
const payload: QuizQuestionPayload = {
|
const payload: QuizQuestionPayload = {
|
||||||
format: question.format,
|
format: question.format,
|
||||||
prompt: question.prompt,
|
prompt: question.prompt,
|
||||||
choices: question.choices,
|
|
||||||
category: question.category,
|
category: question.category,
|
||||||
difficulty: question.difficulty,
|
difficulty: question.difficulty,
|
||||||
}
|
}
|
||||||
|
if (question.format !== "free") {
|
||||||
|
payload.choices = question.choices
|
||||||
|
}
|
||||||
const data: QuizRoundData = { question, votedAt: new Map() }
|
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||||
return { djId: null, payload, data }
|
return { djId: null, payload, data }
|
||||||
}
|
}
|
||||||
|
|
@ -58,29 +80,42 @@ export class QuizRound implements GameRound {
|
||||||
if (ctx.votes.has(playerId)) {
|
if (ctx.votes.has(playerId)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isQuizAnswer(answer)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { question, votedAt } = ctx.data as QuizRoundData
|
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
|
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())
|
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 { question } = ctx.data as QuizRoundData
|
||||||
const perPlayerResult: QuizPerPlayerResult = {}
|
const perPlayerResult: QuizPerPlayerResult = {}
|
||||||
for (const player of ctx.room.players.values()) {
|
for (const player of ctx.room.players.values()) {
|
||||||
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
|
const vote = ctx.votes.get(player.id)
|
||||||
const choiceIndex = vote ? vote.choiceIndex : null
|
|
||||||
perPlayerResult[player.id] = {
|
perPlayerResult[player.id] = {
|
||||||
choiceIndex,
|
choiceIndex: vote ? asChoice(vote) : null,
|
||||||
correct: choiceIndex === question.correctIndex,
|
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[] {
|
score(ctx: RoundContext): ScoreDelta[] {
|
||||||
|
|
@ -88,7 +123,7 @@ export class QuizRound implements GameRound {
|
||||||
const total = ctx.endsAt - ctx.startedAt
|
const total = ctx.endsAt - ctx.startedAt
|
||||||
const deltas: ScoreDelta[] = []
|
const deltas: ScoreDelta[] = []
|
||||||
for (const [playerId, vote] of ctx.votes) {
|
for (const [playerId, vote] of ctx.votes) {
|
||||||
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
|
if (!isCorrect(question, vote)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Bonus rapidité : proportionnel au temps restant au moment du vote.
|
// Bonus rapidité : proportionnel au temps restant au moment du vote.
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,19 @@
|
||||||
Dépose ici des fichiers d'animation pour les transitions entre manches :
|
Dépose ici des fichiers d'animation pour les transitions entre manches :
|
||||||
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
|
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
|
||||||
|
|
||||||
- Ils sont détectés automatiquement au build (`import.meta.glob`).
|
Rangement par mode (recommandé pour des transitions personnalisées) :
|
||||||
- À 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.
|
- `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é
|
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
|
plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
import { useState } from "react"
|
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 { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { Avatar } from "@/components/avatar"
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
const QUESTION_OPTIONS = [3, 5, 10]
|
const QUESTION_OPTIONS = [3, 5, 10]
|
||||||
const TRACKS_OPTIONS = [1, 2, 3]
|
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> = {
|
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
title_artist: "Titre & artiste",
|
title_artist: "Titre & artiste",
|
||||||
who_added: "Qui l'a ajouté ?",
|
who_added: "Qui l'a ajouté ?",
|
||||||
|
|
@ -15,6 +25,35 @@ const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
const inputClass =
|
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"
|
"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 }) {
|
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const updateSettings = useRoomStore((s) => s.updateSettings)
|
const updateSettings = useRoomStore((s) => s.updateSettings)
|
||||||
|
|
@ -27,8 +66,15 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
|
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)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,25 +116,22 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Choix du type de partie (hôte). */}
|
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(["quiz", "blindtest"] as const).map((type) => (
|
{GAME_TYPES.map((t) => (
|
||||||
<Button
|
<Button
|
||||||
key={type}
|
key={t.value}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant={gameType === type ? "default" : "secondary"}
|
variant={gameType === t.value ? "default" : "secondary"}
|
||||||
onClick={() => updateSettings({ gameType: type })}
|
onClick={() => updateSettings({ gameType: t.value })}
|
||||||
>
|
>
|
||||||
{type === "quiz" ? "Quiz" : "Blindtest"}
|
{t.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameType === "quiz" ? (
|
{isHost && showQuiz && (
|
||||||
isHost ? (
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Questions de quiz</span>
|
<span className="text-sm font-medium">Questions de quiz</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
@ -104,107 +147,20 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{isHost && showBlindtest && (
|
||||||
</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 && (
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<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">
|
<div className="flex gap-1">
|
||||||
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
{(["title_artist", "who_added", "mixed"] as const).map((m) => (
|
||||||
<Button
|
<Button
|
||||||
key={m}
|
key={m}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
variant={mode === m ? "default" : "secondary"}
|
variant={blindtestMode === m ? "default" : "secondary"}
|
||||||
onClick={() => onConfig({ blindtestMode: m })}
|
onClick={() => updateSettings({ blindtestMode: m })}
|
||||||
>
|
>
|
||||||
{MODE_LABELS[m]}
|
{MODE_LABELS[m]}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -219,7 +175,7 @@ function BlindtestLobby({
|
||||||
key={n}
|
key={n}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={tracksPerPlayer === n ? "default" : "secondary"}
|
variant={tracksPerPlayer === n ? "default" : "secondary"}
|
||||||
onClick={() => onConfig({ tracksPerPlayer: n })}
|
onClick={() => updateSettings({ tracksPerPlayer: n })}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -229,6 +185,57 @@ function BlindtestLobby({
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Tes titres ({myCount}/{tracksPerPlayer})
|
Tes titres ({myCount}/{tracksPerPlayer})
|
||||||
|
|
@ -261,16 +268,5 @@ function BlindtestLobby({
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
|
import { useState } from "react"
|
||||||
import type {
|
import type {
|
||||||
QuizPerPlayerResult,
|
QuizPerPlayerResult,
|
||||||
QuizQuestionPayload,
|
QuizQuestionPayload,
|
||||||
QuizRevealTruth,
|
QuizRevealTruth,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { Countdown } from "@/components/countdown"
|
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 }) {
|
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const round = useRoomStore((s) => s.round)
|
const round = useRoomStore((s) => s.round)
|
||||||
const reveal = useRoomStore((s) => s.reveal)
|
const reveal = useRoomStore((s) => s.reveal)
|
||||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
||||||
|
const hasVoted = useRoomStore((s) => s.hasVoted)
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const vote = useRoomStore((s) => s.vote)
|
const vote = useRoomStore((s) => s.vote)
|
||||||
|
const voteText = useRoomStore((s) => s.voteText)
|
||||||
|
|
||||||
if (!round) {
|
if (!round) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,6 +31,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const question = round.payload as QuizQuestionPayload
|
const question = round.payload as QuizQuestionPayload
|
||||||
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||||
const showReveal = truth !== null
|
const showReveal = truth !== null
|
||||||
|
const isFree = question.format === "free"
|
||||||
const myResult = reveal
|
const myResult = reveal
|
||||||
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -66,21 +74,28 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{isFree ? (
|
||||||
|
<FreeAnswer
|
||||||
|
disabled={showReveal || hasVoted}
|
||||||
|
onSubmit={voteText}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{question.choices.map((choice, index) => (
|
{(question.choices ?? []).map((choice, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
className={choiceClass(index)}
|
className={choiceClass(index)}
|
||||||
disabled={showReveal || myChoiceIndex !== null}
|
disabled={showReveal || hasVoted}
|
||||||
onClick={() => vote(index)}
|
onClick={() => vote(index)}
|
||||||
>
|
>
|
||||||
{choice}
|
{choice}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!showReveal &&
|
{!showReveal &&
|
||||||
(myChoiceIndex !== null ? (
|
(hasVoted ? (
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
Réponse envoyée — en attente des autres
|
Réponse envoyée — en attente des autres
|
||||||
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
||||||
|
|
@ -94,16 +109,49 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{showReveal && (
|
{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
|
<p
|
||||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||||
>
|
>
|
||||||
{myResult?.correct
|
{myResult?.correct
|
||||||
? "Bonne réponse ! 🎉"
|
? "Bonne réponse ! 🎉"
|
||||||
: myResult?.choiceIndex == null
|
: !hasVoted
|
||||||
? "Pas de réponse 😴"
|
? "Pas de réponse 😴"
|
||||||
: "Raté 💥"}
|
: "Raté 💥"}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,74 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
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)
|
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...).
|
// Médias custom déposés par l'utilisateur, rangés par mode :
|
||||||
// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont
|
// src/assets/transitions/quiz/* → transitions de quiz
|
||||||
// détectés au build et joués aléatoirement. Sinon, fallback animation Framer.
|
// src/assets/transitions/blindtest/* → transitions de blindtest
|
||||||
const CUSTOM = Object.values(
|
// src/assets/transitions/* → communs (fallback)
|
||||||
import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", {
|
// Détectés au build ; sinon, animation Framer par défaut.
|
||||||
eager: true,
|
const ALL_MEDIA = import.meta.glob(
|
||||||
import: "default",
|
"../assets/transitions/**/*.{gif,webp,apng,png,avif}",
|
||||||
})
|
{ eager: true, import: "default" }
|
||||||
) as string[]
|
) 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,
|
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
|
||||||
* média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie.
|
* il change (quiz ↔ blindtest), sinon affiche un teaser léger de la manche.
|
||||||
* Montée avec une `key` qui change → rejoue à chaque 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)
|
const [show, setShow] = useState(true)
|
||||||
// Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre).
|
const theme = THEME[type]
|
||||||
const [media] = useState(() =>
|
const [media] = useState(() => {
|
||||||
CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null
|
const pool = mediaFor(type)
|
||||||
)
|
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setShow(false), VISIBLE_MS)
|
const t = setTimeout(() => setShow(false), VISIBLE_MS)
|
||||||
|
|
@ -36,7 +80,7 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
{show && (
|
{show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="round-transition"
|
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)" }}
|
initial={{ clipPath: "inset(0 0 100% 0)" }}
|
||||||
animate={{ clipPath: "inset(0 0 0% 0)" }}
|
animate={{ clipPath: "inset(0 0 0% 0)" }}
|
||||||
exit={{
|
exit={{
|
||||||
|
|
@ -45,17 +89,6 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
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
|
<motion.div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute size-[220vmax] opacity-60"
|
className="absolute size-[220vmax] opacity-60"
|
||||||
|
|
@ -67,6 +100,16 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
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
|
<motion.div
|
||||||
className="relative flex flex-col items-center"
|
className="relative flex flex-col items-center"
|
||||||
initial={{ scale: 0, rotate: -12 }}
|
initial={{ scale: 0, rotate: -12 }}
|
||||||
|
|
@ -78,11 +121,22 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
ease: "easeOut",
|
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">
|
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
|
||||||
Question
|
{theme.kind}
|
||||||
</span>
|
</span>
|
||||||
<motion.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] }}
|
animate={{ scale: [1, 1.07, 1] }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.4,
|
duration: 0.4,
|
||||||
|
|
@ -92,6 +146,8 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<motion.span
|
<motion.span
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -101,7 +157,6 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
PRÊT ?!
|
PRÊT ?!
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
const roundKey = useRoomStore((s) => s.roundKey)
|
const roundKey = useRoomStore((s) => s.roundKey)
|
||||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
const round = useRoomStore((s) => s.round)
|
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.
|
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
||||||
if (!snapshot || snapshot.code !== code) {
|
if (!snapshot || snapshot.code !== code) {
|
||||||
|
|
@ -70,8 +71,13 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{boomKey > 0 && <Boom key={boomKey} />}
|
{boomKey > 0 && <Boom key={boomKey} />}
|
||||||
{roundKey > 0 && (
|
{roundKey > 0 && round && (
|
||||||
<RoundTransition key={roundKey} index={snapshot.currentRound} />
|
<RoundTransition
|
||||||
|
key={roundKey}
|
||||||
|
type={round.type}
|
||||||
|
index={snapshot.currentRound}
|
||||||
|
modeChanged={roundModeChanged}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ interface RoomState {
|
||||||
mediaSync: MediaSyncPayload | null
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
roundKey: 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>
|
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||||
|
|
@ -55,6 +57,7 @@ interface RoomState {
|
||||||
startGame: (rounds: RoundConfig[]) => Promise<void>
|
startGame: (rounds: RoundConfig[]) => Promise<void>
|
||||||
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
|
voteText: (text: string) => void
|
||||||
voteBlindtest: (answer: BlindtestAnswer) => void
|
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||||
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
|
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
|
||||||
boom: () => void
|
boom: () => void
|
||||||
|
|
@ -77,6 +80,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundModeChanged: false,
|
||||||
|
|
||||||
createRoom: (playerName) =>
|
createRoom: (playerName) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|
@ -141,6 +145,14 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
socket.emit("round:vote", { answer: { choiceIndex } })
|
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) => {
|
voteBlindtest: (answer) => {
|
||||||
if (get().hasVoted) {
|
if (get().hasVoted) {
|
||||||
return
|
return
|
||||||
|
|
@ -170,6 +182,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundModeChanged: false,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -194,6 +207,7 @@ socket.on("round:start", (payload) =>
|
||||||
hasVoted: false,
|
hasVoted: false,
|
||||||
mediaSync: null,
|
mediaSync: null,
|
||||||
roundKey: s.roundKey + 1,
|
roundKey: s.roundKey + 1,
|
||||||
|
roundModeChanged: (s.round?.type ?? null) !== payload.type,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
socket.on("round:voteAck", (voteProgress) =>
|
socket.on("round:voteAck", (voteProgress) =>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ export type RoomStatus = "lobby" | "in_round" | "reveal" | "scores" | "ended"
|
||||||
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
|
/** Types d'épreuves disponibles. Étendre = ajouter une valeur + un module GameRound. */
|
||||||
export type RoundType = "blindtest" | "quiz"
|
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. */
|
/** Modes de blindtest, déterminent la forme du vote et le barème. */
|
||||||
export type BlindtestMode = "title_artist" | "who_added" | "mixed"
|
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. */
|
/** Réglages de la room, modifiables dans le lobby par l'hôte. */
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
/** Type de partie choisi dans le lobby (pilote l'UI partagée). */
|
||||||
gameType: RoundType
|
gameType: GameType
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
/** Durée d'une manche en secondes (def. 60). */
|
/** Durée d'une manche en secondes (def. 60). */
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
|
|
@ -40,7 +43,7 @@ export interface RoomSettings {
|
||||||
|
|
||||||
/** Réglages par défaut d'une nouvelle room. */
|
/** Réglages par défaut d'une nouvelle room. */
|
||||||
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
export const DEFAULT_ROOM_SETTINGS: RoomSettings = {
|
||||||
gameType: "quiz",
|
gameType: "mixed",
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
tracksPerPlayer: 2,
|
tracksPerPlayer: 2,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import type {
|
import type {
|
||||||
Answer,
|
Answer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
|
GameType,
|
||||||
PlayerScore,
|
PlayerScore,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
|
|
@ -27,7 +28,7 @@ export interface RoomJoinPayload {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsPayload {
|
export interface UpdateSettingsPayload {
|
||||||
gameType: RoundType
|
gameType: GameType
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
tracksPerPlayer: number
|
tracksPerPlayer: number
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import type { QuizFormat } from "./domain"
|
||||||
export interface QuizQuestionPayload {
|
export interface QuizQuestionPayload {
|
||||||
format: QuizFormat
|
format: QuizFormat
|
||||||
prompt: string
|
prompt: string
|
||||||
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */
|
/** Choix proposés (mcq/truefalse). Absent pour `free` (saisie libre). */
|
||||||
choices: string[]
|
choices?: string[]
|
||||||
category?: string
|
category?: string
|
||||||
/** 1 (facile) .. 3 (difficile). */
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
difficulty?: number
|
difficulty?: number
|
||||||
|
|
@ -17,7 +17,10 @@ export interface QuizQuestionPayload {
|
||||||
|
|
||||||
/** Vérité révélée à tous (round:reveal → truth). */
|
/** Vérité révélée à tous (round:reveal → truth). */
|
||||||
export interface QuizRevealTruth {
|
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. */
|
/** Résultat d'un joueur sur la manche. */
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue