Merge pull request 'feature/blindtest' (#6) from feature/blindtest into dev
Reviewed-on: #6
This commit is contained in:
commit
aa386f39a2
37 changed files with 2392 additions and 266 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,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
|
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
|
||||||
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
|
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
|
||||||
|
|
||||||
import type { Answer, PlayerScore, RoundConfig } from "@nerdware/shared"
|
import type {
|
||||||
|
Answer,
|
||||||
|
MediaControlPayload,
|
||||||
|
PlayerScore,
|
||||||
|
RoundConfig,
|
||||||
|
} from "@nerdware/shared"
|
||||||
import type { IoServer } from "../socket"
|
import type { IoServer } from "../socket"
|
||||||
import type { RoomManager, ServerRoom } from "../rooms"
|
import type { RoomManager, ServerRoom } from "../rooms"
|
||||||
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
|
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
|
||||||
|
|
@ -11,6 +16,8 @@ import type { GameRound, RoomGameController, RoundContext } from "./round"
|
||||||
interface RoundRuntime {
|
interface RoundRuntime {
|
||||||
round: GameRound
|
round: GameRound
|
||||||
djId: string | null
|
djId: string | null
|
||||||
|
/** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */
|
||||||
|
secretPlayerId: string | null
|
||||||
votes: Map<string, Answer>
|
votes: Map<string, Answer>
|
||||||
startedAt: number
|
startedAt: number
|
||||||
endsAt: number
|
endsAt: number
|
||||||
|
|
@ -65,7 +72,18 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
this.room.status = "ended"
|
this.room.status = "ended"
|
||||||
this.broadcastState()
|
this.broadcastState()
|
||||||
this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
|
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
|
||||||
|
const tracks = this.room.blindtestTracks.map((t) => ({
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
youtubeId: t.youtubeId,
|
||||||
|
url: t.url,
|
||||||
|
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
|
||||||
|
}))
|
||||||
|
this.io.to(this.room.code).emit("game:end", {
|
||||||
|
finalScores: this.scoreboard(),
|
||||||
|
tracks: tracks.length > 0 ? tracks : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
|
||||||
|
|
@ -87,6 +105,22 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Relais média : seul le DJ de la manche peut piloter la lecture. */
|
||||||
|
handleMediaControl(playerId: string, payload: MediaControlPayload): void {
|
||||||
|
if (
|
||||||
|
!this.runtime ||
|
||||||
|
this.room.status !== "in_round" ||
|
||||||
|
playerId !== this.runtime.djId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.io.to(this.room.code).emit("media:sync", {
|
||||||
|
action: payload.action,
|
||||||
|
positionSec: payload.positionSec,
|
||||||
|
atServerTs: this.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async playRound(config: RoundConfig): Promise<void> {
|
private async playRound(config: RoundConfig): Promise<void> {
|
||||||
const round = this.createRound(config.type)
|
const round = this.createRound(config.type)
|
||||||
const start = round.start(this.room)
|
const start = round.start(this.room)
|
||||||
|
|
@ -99,6 +133,7 @@ export class GameEngine implements RoomGameController {
|
||||||
this.runtime = {
|
this.runtime = {
|
||||||
round,
|
round,
|
||||||
djId: start.djId ?? null,
|
djId: start.djId ?? null,
|
||||||
|
secretPlayerId: start.secretPlayerId ?? null,
|
||||||
votes: new Map(),
|
votes: new Map(),
|
||||||
startedAt,
|
startedAt,
|
||||||
endsAt,
|
endsAt,
|
||||||
|
|
@ -106,13 +141,24 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
this.room.status = "in_round"
|
this.room.status = "in_round"
|
||||||
this.broadcastState()
|
this.broadcastState()
|
||||||
this.io.to(this.room.code).emit("round:start", {
|
const base = {
|
||||||
type: round.type,
|
type: round.type,
|
||||||
djId: this.runtime.djId ?? undefined,
|
djId: this.runtime.djId ?? undefined,
|
||||||
startsAt: startedAt,
|
startsAt: startedAt,
|
||||||
endsAt,
|
endsAt,
|
||||||
payload: start.payload,
|
payload: start.payload,
|
||||||
})
|
}
|
||||||
|
// Le contributeur reçoit un round:start privé (mine: true) ; il reste secret
|
||||||
|
// pour les autres (qui ne reçoivent que `base`).
|
||||||
|
const secretSocketId = this.runtime.secretPlayerId
|
||||||
|
? this.room.players.get(this.runtime.secretPlayerId)?.socketId
|
||||||
|
: null
|
||||||
|
if (secretSocketId) {
|
||||||
|
this.io.to(secretSocketId).emit("round:start", { ...base, mine: true })
|
||||||
|
this.io.to(this.room.code).except(secretSocketId).emit("round:start", base)
|
||||||
|
} else {
|
||||||
|
this.io.to(this.room.code).emit("round:start", base)
|
||||||
|
}
|
||||||
|
|
||||||
// Attend la première condition de fin : timer écoulé OU tous votés.
|
// Attend la première condition de fin : timer écoulé OU tous votés.
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|
@ -156,10 +202,14 @@ export class GameEngine implements RoomGameController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Votants éligibles : tous les joueurs connectés (le DJ inclus). */
|
/**
|
||||||
|
* Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur
|
||||||
|
* (blindtest) qui n'a rien à saisir.
|
||||||
|
*/
|
||||||
private eligibleVoters(): string[] {
|
private eligibleVoters(): string[] {
|
||||||
|
const secret = this.runtime?.secretPlayerId
|
||||||
return [...this.room.players.values()]
|
return [...this.room.players.values()]
|
||||||
.filter((p) => p.connected)
|
.filter((p) => p.connected && p.name !== "" && p.id !== secret)
|
||||||
.map((p) => p.id)
|
.map((p) => p.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
apps/server/src/game/match.ts
Normal file
54
apps/server/src/game/match.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Matching tolérant pour les réponses libres (titre/artiste).
|
||||||
|
// Normalisation (minuscules, accents, ponctuation) + distance de Levenshtein.
|
||||||
|
|
||||||
|
/** Minuscule, sans accents, sans ponctuation, espaces compactés. */
|
||||||
|
export function normalize(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "") // accents
|
||||||
|
.replace(/\(.*?\)|\[.*?\]/g, " ") // contenu entre parenthèses/crochets
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ") // ponctuation
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance d'édition de Levenshtein. */
|
||||||
|
export function levenshtein(a: string, b: string): number {
|
||||||
|
if (a === b) return 0
|
||||||
|
if (a.length === 0) return b.length
|
||||||
|
if (b.length === 0) return a.length
|
||||||
|
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
||||||
|
for (let i = 1; i <= a.length; i++) {
|
||||||
|
const curr = [i]
|
||||||
|
for (let j = 1; j <= b.length; j++) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||||
|
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return prev[b.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si `guess` correspond à `truth` avec tolérance :
|
||||||
|
* - égalité après normalisation, ou
|
||||||
|
* - une normalisation contient l'autre (réponse partielle), ou
|
||||||
|
* - distance de Levenshtein ≤ ~20% de la longueur (fautes de frappe).
|
||||||
|
*/
|
||||||
|
export function fuzzyMatch(guess: string, truth: string): boolean {
|
||||||
|
const g = normalize(guess)
|
||||||
|
const t = normalize(truth)
|
||||||
|
if (!g || !t) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (g === t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Réponse contenue (ex: "zelda" pour "the legend of zelda").
|
||||||
|
if (g.length >= 3 && (t.includes(g) || g.includes(t))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const tolerance = Math.floor(Math.max(g.length, t.length) * 0.2)
|
||||||
|
return levenshtein(g, t) <= tolerance
|
||||||
|
}
|
||||||
128
apps/server/src/game/modes/blindtest/blindtest-round.test.ts
Normal file
128
apps/server/src/game/modes/blindtest/blindtest-round.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
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"
|
||||||
|
|
||||||
|
function track(submittedBy: string): BlindtestTrack {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
youtubeId: "abc12345678",
|
||||||
|
url: "https://youtu.be/abc12345678",
|
||||||
|
title: "The Legend of Zelda Main Theme",
|
||||||
|
artist: "Koji Kondo",
|
||||||
|
submittedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxFor(
|
||||||
|
room: ReturnType<RoomManager["create"]>["room"],
|
||||||
|
t: BlindtestTrack,
|
||||||
|
mode: "title_artist" | "who_added" | "mixed"
|
||||||
|
): RoundContext {
|
||||||
|
room.settings.blindtestMode = mode
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
djId: null,
|
||||||
|
votes: new Map(),
|
||||||
|
startedAt: 0,
|
||||||
|
endsAt: 10_000,
|
||||||
|
data: { track: t },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("match", () => {
|
||||||
|
test("normalize retire accents/ponctuation/casse", () => {
|
||||||
|
expect(normalize("Pokémon: Rouge & Bleu!")).toBe("pokemon rouge bleu")
|
||||||
|
})
|
||||||
|
test("fuzzyMatch tolère fautes et réponses partielles", () => {
|
||||||
|
expect(fuzzyMatch("zelda", "The Legend of Zelda")).toBe(true)
|
||||||
|
expect(fuzzyMatch("koji kondo", "Koji Kondo")).toBe(true)
|
||||||
|
expect(fuzzyMatch("mario", "The Legend of Zelda")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("BlindtestRound", () => {
|
||||||
|
test("start : le DJ est neutre (jamais le contributeur)", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
room.blindtestTracks = [track(a.id)]
|
||||||
|
prepareBlindtestForRoom(room)
|
||||||
|
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const start = round.start(room)
|
||||||
|
expect(start.djId).toBe(b.id) // Bob (pas Alice, la contributrice)
|
||||||
|
expect(start.payload).toEqual({ trackId: "t1", youtubeId: "abc12345678" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("who_added : le contributeur gagne par tromperie (mauvaises devinettes)", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
const { player: c } = rooms.join(room.code, "Carol", "sc")
|
||||||
|
const t = track(a.id) // Alice = contributrice/DJ
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const ctx = ctxFor(room, t, "who_added")
|
||||||
|
round.submitAnswer(ctx, b.id, { guessedPlayerId: c.id }) // faux
|
||||||
|
round.submitAnswer(ctx, c.id, { guessedPlayerId: a.id }) // juste
|
||||||
|
round.submitAnswer(ctx, a.id, { guessedPlayerId: b.id }) // contributrice : non scorée
|
||||||
|
const deltas = round.score(ctx)
|
||||||
|
// Carol devine juste → 100 ; Alice trompe Bob (1 mauvaise devinette) → 50
|
||||||
|
expect(deltas).toContainEqual({ playerId: c.id, delta: 100 })
|
||||||
|
expect(deltas).toContainEqual({ playerId: a.id, delta: 50 })
|
||||||
|
expect(deltas.find((d) => d.playerId === b.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("who_added : points si on devine le bon contributeur", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
const t = track(a.id)
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const ctx = ctxFor(room, t, "who_added")
|
||||||
|
round.submitAnswer(ctx, b.id, { guessedPlayerId: a.id }) // juste
|
||||||
|
const deltas = round.score(ctx)
|
||||||
|
expect(deltas).toEqual([{ playerId: b.id, delta: 100 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("title_artist : 60 titre + 40 artiste, fuzzy", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
const t = track(a.id)
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const ctx = ctxFor(room, t, "title_artist")
|
||||||
|
round.submitAnswer(ctx, b.id, { title: "zelda", artist: "koji kondo" })
|
||||||
|
const { perPlayerResult } = round.reveal(ctx)
|
||||||
|
expect(perPlayerResult[b.id]).toMatchObject({
|
||||||
|
titleCorrect: true,
|
||||||
|
artistCorrect: true,
|
||||||
|
points: 100,
|
||||||
|
})
|
||||||
|
expect(round.score(ctx)).toEqual([{ playerId: b.id, delta: 100 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("le contributeur ne marque pas sur son propre titre", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const t = track(a.id)
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const ctx = ctxFor(room, t, "who_added")
|
||||||
|
round.submitAnswer(ctx, a.id, { guessedPlayerId: a.id })
|
||||||
|
expect(round.score(ctx)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reveal expose le titre, l'artiste et le contributeur", () => {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const t = track(a.id)
|
||||||
|
const round = new BlindtestRound()
|
||||||
|
const ctx = ctxFor(room, t, "title_artist")
|
||||||
|
const { truth } = round.reveal(ctx)
|
||||||
|
expect(truth.submittedBy).toBe(a.id)
|
||||||
|
expect(truth.submittedByName).toBe("Alice")
|
||||||
|
expect(truth.title).toBe("The Legend of Zelda Main Theme")
|
||||||
|
})
|
||||||
|
})
|
||||||
155
apps/server/src/game/modes/blindtest/blindtest-round.ts
Normal file
155
apps/server/src/game/modes/blindtest/blindtest-round.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Épreuve Blindtest : un titre = une manche. Le contributeur du titre est le DJ
|
||||||
|
// (il pilote la lecture, son identité reste cachée aux autres). Il ne marque pas
|
||||||
|
// de points, SAUF en "qui l'a ajouté" : il gagne quand un joueur le devine mal.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Answer,
|
||||||
|
BlindtestMode,
|
||||||
|
BlindtestPerPlayerResult,
|
||||||
|
BlindtestPlayerResult,
|
||||||
|
BlindtestRevealTruth,
|
||||||
|
BlindtestRoundPayload,
|
||||||
|
ScoreDelta,
|
||||||
|
} from "@nerdware/shared"
|
||||||
|
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||||
|
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
||||||
|
import { fuzzyMatch } from "../../match"
|
||||||
|
import { takeTrack } from "./pool"
|
||||||
|
|
||||||
|
const TITLE_POINTS = 60
|
||||||
|
const ARTIST_POINTS = 40
|
||||||
|
const WHO_POINTS = 100
|
||||||
|
/** Points gagnés par le contributeur pour chaque joueur qui le devine mal. */
|
||||||
|
const MISDIRECTION_POINTS = 50
|
||||||
|
|
||||||
|
interface BlindtestRoundData {
|
||||||
|
track: BlindtestTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tire un DJ neutre : un joueur connecté qui n'a pas soumis ce titre. */
|
||||||
|
function pickDj(room: ServerRoom, submittedBy: string): string | null {
|
||||||
|
const eligible = [...room.players.values()].filter(
|
||||||
|
(p) => p.connected && p.id !== submittedBy
|
||||||
|
)
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return eligible[Math.floor(Math.random() * eligible.length)].id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat d'un joueur VOTANT (≠ contributeur) selon le mode. */
|
||||||
|
function voterResult(
|
||||||
|
mode: BlindtestMode,
|
||||||
|
answer: Answer | undefined,
|
||||||
|
track: BlindtestTrack
|
||||||
|
): BlindtestPlayerResult {
|
||||||
|
const a = (answer ?? {}) as {
|
||||||
|
title?: string
|
||||||
|
artist?: string
|
||||||
|
guessedPlayerId?: string
|
||||||
|
}
|
||||||
|
let points = 0
|
||||||
|
const result: BlindtestPlayerResult = { points: 0 }
|
||||||
|
|
||||||
|
if (mode === "title_artist" || mode === "mixed") {
|
||||||
|
result.titleCorrect = a.title ? fuzzyMatch(a.title, track.title) : false
|
||||||
|
result.artistCorrect = a.artist ? fuzzyMatch(a.artist, track.artist) : false
|
||||||
|
if (result.titleCorrect) points += TITLE_POINTS
|
||||||
|
if (result.artistCorrect) points += ARTIST_POINTS
|
||||||
|
}
|
||||||
|
if (mode === "who_added" || mode === "mixed") {
|
||||||
|
result.guessedCorrect = a.guessedPlayerId === track.submittedBy
|
||||||
|
if (result.guessedCorrect) points += WHO_POINTS
|
||||||
|
}
|
||||||
|
|
||||||
|
result.points = points
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BlindtestRound implements GameRound {
|
||||||
|
readonly type = "blindtest" as const
|
||||||
|
|
||||||
|
start(room: ServerRoom): RoundStart {
|
||||||
|
const track = takeTrack(room)
|
||||||
|
// DJ neutre (jamais le contributeur) : il pilote la lecture et vote à l'aveugle.
|
||||||
|
const djId = track ? pickDj(room, track.submittedBy) : null
|
||||||
|
const payload: BlindtestRoundPayload | null = track
|
||||||
|
? { trackId: track.id, youtubeId: track.youtubeId }
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
djId,
|
||||||
|
payload,
|
||||||
|
secretPlayerId: track?.submittedBy,
|
||||||
|
data: track ? { track } : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
||||||
|
// Premier vote verrouillé (idempotent). Le contributeur peut voter mais son
|
||||||
|
// vote ne lui rapporte rien (voir buildResults) ; ça évite de bloquer la fin.
|
||||||
|
if (ctx.votes.has(playerId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.votes.set(playerId, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat par joueur (votants + bonus de tromperie du contributeur). */
|
||||||
|
private buildResults(ctx: RoundContext): BlindtestPerPlayerResult {
|
||||||
|
const { track } = ctx.data as BlindtestRoundData
|
||||||
|
const mode = ctx.room.settings.blindtestMode
|
||||||
|
const perPlayer: BlindtestPerPlayerResult = {}
|
||||||
|
|
||||||
|
let wrongGuessers = 0
|
||||||
|
for (const player of ctx.room.players.values()) {
|
||||||
|
if (player.id === track.submittedBy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const result = voterResult(mode, ctx.votes.get(player.id), track)
|
||||||
|
perPlayer[player.id] = result
|
||||||
|
if (
|
||||||
|
(mode === "who_added" || mode === "mixed") &&
|
||||||
|
result.guessedCorrect === false
|
||||||
|
) {
|
||||||
|
wrongGuessers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributeur : pas de points hors who_added/mixed ; sinon tromperie.
|
||||||
|
const misdirection =
|
||||||
|
mode === "who_added" || mode === "mixed"
|
||||||
|
? wrongGuessers * MISDIRECTION_POINTS
|
||||||
|
: 0
|
||||||
|
perPlayer[track.submittedBy] = { points: misdirection }
|
||||||
|
|
||||||
|
return perPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(ctx: RoundContext): {
|
||||||
|
truth: BlindtestRevealTruth
|
||||||
|
perPlayerResult: BlindtestPerPlayerResult
|
||||||
|
} {
|
||||||
|
const { track } = ctx.data as BlindtestRoundData
|
||||||
|
const submitter = ctx.room.players.get(track.submittedBy)
|
||||||
|
return {
|
||||||
|
truth: {
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
youtubeId: track.youtubeId,
|
||||||
|
submittedBy: track.submittedBy,
|
||||||
|
submittedByName: submitter?.name ?? "?",
|
||||||
|
},
|
||||||
|
perPlayerResult: this.buildResults(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score(ctx: RoundContext): ScoreDelta[] {
|
||||||
|
const perPlayer = this.buildResults(ctx)
|
||||||
|
const deltas: ScoreDelta[] = []
|
||||||
|
for (const [playerId, result] of Object.entries(perPlayer)) {
|
||||||
|
if (result.points > 0) {
|
||||||
|
deltas.push({ playerId, delta: result.points })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/server/src/game/modes/blindtest/index.ts
Normal file
6
apps/server/src/game/modes/blindtest/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { registerRound } from "../../registry"
|
||||||
|
import { BlindtestRound } from "./blindtest-round"
|
||||||
|
|
||||||
|
registerRound("blindtest", () => new BlindtestRound())
|
||||||
|
|
||||||
|
export { BlindtestRound }
|
||||||
26
apps/server/src/game/modes/blindtest/pool.ts
Normal file
26
apps/server/src/game/modes/blindtest/pool.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// File d'attente des titres blindtest par room, préchargée (mélangée) au
|
||||||
|
// lancement de la partie. Le BlindtestRound y pioche un titre par manche.
|
||||||
|
|
||||||
|
import type { BlindtestTrack, ServerRoom } from "../../../rooms"
|
||||||
|
|
||||||
|
const queueByRoom = new WeakMap<ServerRoom, BlindtestTrack[]>()
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[]): T[] {
|
||||||
|
const arr = [...items]
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mélange les titres soumis dans une file pour la partie. */
|
||||||
|
export function prepareBlindtestForRoom(room: ServerRoom): void {
|
||||||
|
queueByRoom.set(room, shuffle(room.blindtestTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pioche le prochain titre (ou null si la file est vide). */
|
||||||
|
export function takeTrack(room: ServerRoom): BlindtestTrack | null {
|
||||||
|
const queue = queueByRoom.get(room)
|
||||||
|
return queue && queue.length > 0 ? (queue.shift() ?? null) : null
|
||||||
|
}
|
||||||
20
apps/server/src/game/modes/blindtest/youtube.test.ts
Normal file
20
apps/server/src/game/modes/blindtest/youtube.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { extractYoutubeId } from "./youtube"
|
||||||
|
|
||||||
|
describe("extractYoutubeId", () => {
|
||||||
|
test.each([
|
||||||
|
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
||||||
|
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
||||||
|
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
||||||
|
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
||||||
|
["https://youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
|
||||||
|
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
||||||
|
])("extrait l'ID de %s", (url, expected) => {
|
||||||
|
expect(extractYoutubeId(url)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renvoie null pour une URL non YouTube", () => {
|
||||||
|
expect(extractYoutubeId("https://example.com")).toBeNull()
|
||||||
|
expect(extractYoutubeId("")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
53
apps/server/src/game/modes/blindtest/youtube.ts
Normal file
53
apps/server/src/game/modes/blindtest/youtube.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Extraction d'ID YouTube + vérification via oEmbed (existe + embeddable),
|
||||||
|
// qui fournit aussi titre et auteur pour le reveal.
|
||||||
|
|
||||||
|
/** Extrait l'ID vidéo (11 chars) d'une URL YouTube (watch, youtu.be, shorts, embed). */
|
||||||
|
export function extractYoutubeId(input: string): string | null {
|
||||||
|
const url = input.trim()
|
||||||
|
// ID brut directement collé.
|
||||||
|
if (/^[\w-]{11}$/.test(url)) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
const patterns = [
|
||||||
|
/[?&]v=([\w-]{11})/, // watch?v=ID
|
||||||
|
/youtu\.be\/([\w-]{11})/, // youtu.be/ID
|
||||||
|
/\/shorts\/([\w-]{11})/, // /shorts/ID
|
||||||
|
/\/embed\/([\w-]{11})/, // /embed/ID
|
||||||
|
]
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = url.match(re)
|
||||||
|
if (m) {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeMeta {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie via oEmbed que la vidéo existe et est intégrable, et renvoie
|
||||||
|
* { title, artist }. null si introuvable/privée/embedding désactivé.
|
||||||
|
*/
|
||||||
|
export async function fetchYoutubeMeta(
|
||||||
|
youtubeId: string
|
||||||
|
): Promise<YoutubeMeta | null> {
|
||||||
|
const url = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${youtubeId}&format=json`
|
||||||
|
try {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
// 401/404 → vidéo privée, supprimée, ou embedding désactivé.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as { title?: string; author_name?: string }
|
||||||
|
return {
|
||||||
|
title: json.title ?? "",
|
||||||
|
artist: json.author_name ?? "",
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,19 @@
|
||||||
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
|
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
|
||||||
|
|
||||||
import "./quiz"
|
import "./quiz"
|
||||||
|
import "./blindtest"
|
||||||
|
|
||||||
import type { ServerRoom } from "../../rooms"
|
import type { ServerRoom } from "../../rooms"
|
||||||
import { prepareQuizForRoom } from "./quiz/pool"
|
import { prepareQuizForRoom } from "./quiz/pool"
|
||||||
|
import { prepareBlindtestForRoom } from "./blindtest/pool"
|
||||||
|
|
||||||
/** Précharge le contenu nécessaire avant de lancer la partie (ex: pool quiz). */
|
/** Précharge le contenu nécessaire avant de lancer la partie. */
|
||||||
export async function prepareRoom(room: ServerRoom): Promise<void> {
|
export async function prepareRoom(room: ServerRoom): Promise<void> {
|
||||||
if (room.settings.rounds.some((r) => r.type === "quiz")) {
|
const types = new Set(room.settings.rounds.map((r) => r.type))
|
||||||
|
if (types.has("quiz")) {
|
||||||
await prepareQuizForRoom(room)
|
await prepareQuizForRoom(room)
|
||||||
}
|
}
|
||||||
|
if (types.has("blindtest")) {
|
||||||
|
prepareBlindtestForRoom(room)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
return
|
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())
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
|
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
|
||||||
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
|
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
|
||||||
|
|
||||||
import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared"
|
import type {
|
||||||
|
Answer,
|
||||||
|
MediaControlPayload,
|
||||||
|
RevealPayload,
|
||||||
|
RoundType,
|
||||||
|
ScoreDelta,
|
||||||
|
} from "@nerdware/shared"
|
||||||
import type { ServerRoom } from "../rooms"
|
import type { ServerRoom } from "../rooms"
|
||||||
|
|
||||||
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
|
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
|
||||||
|
|
@ -12,6 +18,11 @@ export interface RoundStart {
|
||||||
durationSec?: number
|
durationSec?: number
|
||||||
/** Payload broadcasté aux clients, SANS la réponse. */
|
/** Payload broadcasté aux clients, SANS la réponse. */
|
||||||
payload: unknown
|
payload: unknown
|
||||||
|
/**
|
||||||
|
* Joueur recevant un round:start privé avec `mine: true` et exclu des votants
|
||||||
|
* (blindtest : le contributeur du titre). Jamais révélé aux autres.
|
||||||
|
*/
|
||||||
|
secretPlayerId?: string
|
||||||
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
|
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
|
||||||
data?: unknown
|
data?: unknown
|
||||||
}
|
}
|
||||||
|
|
@ -52,4 +63,6 @@ export interface GameRound {
|
||||||
export interface RoomGameController {
|
export interface RoomGameController {
|
||||||
run(): Promise<void>
|
run(): Promise<void>
|
||||||
handleVote(playerId: string, answer: Answer): void
|
handleVote(playerId: string, answer: Answer): void
|
||||||
|
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
|
||||||
|
handleMediaControl(playerId: string, payload: MediaControlPayload): void
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,14 @@ import type {
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import type { IoServer } from "./socket"
|
import type { IoServer } from "./socket"
|
||||||
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
|
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
import { GameEngine } from "./game/engine"
|
import { GameEngine } from "./game/engine"
|
||||||
import { hasRound } from "./game/registry"
|
import { hasRound } from "./game/registry"
|
||||||
import { prepareRoom } from "./game/modes"
|
import { prepareRoom } from "./game/modes"
|
||||||
|
import {
|
||||||
|
extractYoutubeId,
|
||||||
|
fetchYoutubeMeta,
|
||||||
|
} from "./game/modes/blindtest/youtube"
|
||||||
|
|
||||||
type AppSocket = Socket<
|
type AppSocket = Socket<
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
|
|
@ -48,11 +53,8 @@ export function registerRoomHandlers(
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on("room:create", (payload, ack) => {
|
socket.on("room:create", (payload, ack) => {
|
||||||
const name = cleanName(payload?.playerName)
|
// Le pseudo est choisi dans la room (player:setName) : on crée sans nom.
|
||||||
if (!name) {
|
const name = cleanName(payload?.playerName) ?? ""
|
||||||
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { room, player } = rooms.create(name, socket.id)
|
const { room, player } = rooms.create(name, socket.id)
|
||||||
socket.data.playerId = player.id
|
socket.data.playerId = player.id
|
||||||
|
|
@ -66,12 +68,8 @@ export function registerRoomHandlers(
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on("room:join", (payload, ack) => {
|
socket.on("room:join", (payload, ack) => {
|
||||||
const name = cleanName(payload?.playerName)
|
const name = cleanName(payload?.playerName) ?? ""
|
||||||
const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : ""
|
const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : ""
|
||||||
if (!name) {
|
|
||||||
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { room, player } = rooms.join(code, name, socket.id)
|
const { room, player } = rooms.join(code, name, socket.id)
|
||||||
socket.data.playerId = player.id
|
socket.data.playerId = player.id
|
||||||
|
|
@ -84,6 +82,22 @@ export function registerRoomHandlers(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("player:setName", (payload) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const playerId = socket.data.playerId
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
const name = cleanName(payload?.name)
|
||||||
|
if (!room || !playerId || !name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const player = room.players.get(playerId)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
player.name = name
|
||||||
|
broadcastState(room)
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("lobby:updateSettings", (payload) => {
|
socket.on("lobby:updateSettings", (payload) => {
|
||||||
const code = socket.data.roomCode
|
const code = socket.data.roomCode
|
||||||
const room = code ? rooms.get(code) : undefined
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
|
@ -91,8 +105,10 @@ export function registerRoomHandlers(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
room.settings = {
|
room.settings = {
|
||||||
|
gameType: payload.gameType,
|
||||||
blindtestMode: payload.blindtestMode,
|
blindtestMode: payload.blindtestMode,
|
||||||
roundDuration: payload.roundDuration,
|
roundDuration: payload.roundDuration,
|
||||||
|
tracksPerPlayer: payload.tracksPerPlayer,
|
||||||
rounds: payload.rounds,
|
rounds: payload.rounds,
|
||||||
}
|
}
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
|
|
@ -121,6 +137,39 @@ export function registerRoomHandlers(
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const hasBlindtest = room.settings.rounds.some((r) => r.type === "blindtest")
|
||||||
|
const connected = [...room.players.values()].filter(
|
||||||
|
(p) => p.connected && p.name !== ""
|
||||||
|
).length
|
||||||
|
if (hasBlindtest && connected < 3) {
|
||||||
|
ack({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "NEED_THREE",
|
||||||
|
message: "Le blindtest nécessite au moins 3 joueurs.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasBlindtest) {
|
||||||
|
const tpp = room.settings.tracksPerPlayer
|
||||||
|
const pending = [...room.players.values()].some(
|
||||||
|
(p) =>
|
||||||
|
p.connected &&
|
||||||
|
p.name !== "" &&
|
||||||
|
room.blindtestTracks.filter((t) => t.submittedBy === p.id).length < tpp
|
||||||
|
)
|
||||||
|
if (pending) {
|
||||||
|
ack({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "TRACKS_PENDING",
|
||||||
|
message: "Tous les joueurs n'ont pas encore soumis leurs titres.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
ack({ ok: true, data: null })
|
ack({ ok: true, data: null })
|
||||||
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
|
// Précharge le contenu (pool quiz depuis la DB), puis lance la boucle.
|
||||||
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
||||||
|
|
@ -133,6 +182,88 @@ export function registerRoomHandlers(
|
||||||
.catch((err) => console.error("[game] run failed", err))
|
.catch((err) => console.error("[game] run failed", err))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("blindtest:submitTrack", async (payload, ack) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const playerId = socket.data.playerId
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || !playerId || room.status !== "lobby") {
|
||||||
|
ack({ accepted: false, reason: "Soumission impossible maintenant." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const youtubeId = extractYoutubeId(payload?.youtubeUrl ?? "")
|
||||||
|
if (!youtubeId) {
|
||||||
|
ack({ accepted: false, reason: "Lien YouTube invalide." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mine = room.blindtestTracks.filter((t) => t.submittedBy === playerId)
|
||||||
|
if (mine.length >= room.settings.tracksPerPlayer) {
|
||||||
|
ack({ accepted: false, reason: "Quota de titres atteint." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (room.blindtestTracks.some((t) => t.youtubeId === youtubeId)) {
|
||||||
|
ack({ accepted: false, reason: "Titre déjà soumis." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const meta = await fetchYoutubeMeta(youtubeId)
|
||||||
|
if (!meta) {
|
||||||
|
ack({
|
||||||
|
accepted: false,
|
||||||
|
reason: "Vidéo introuvable ou non intégrable.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-vérifie la room après l'await (le joueur a pu se déconnecter).
|
||||||
|
if (room.status !== "lobby" || !room.players.has(playerId)) {
|
||||||
|
ack({ accepted: false, reason: "Soumission impossible maintenant." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trackId = randomUUID()
|
||||||
|
room.blindtestTracks.push({
|
||||||
|
id: trackId,
|
||||||
|
youtubeId,
|
||||||
|
url: payload.youtubeUrl,
|
||||||
|
title: meta.title,
|
||||||
|
artist: meta.artist,
|
||||||
|
submittedBy: playerId,
|
||||||
|
})
|
||||||
|
ack({
|
||||||
|
accepted: true,
|
||||||
|
trackId,
|
||||||
|
youtubeId,
|
||||||
|
title: meta.title,
|
||||||
|
count: mine.length + 1,
|
||||||
|
})
|
||||||
|
broadcastState(room)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("blindtest:removeTrack", (payload, ack) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const playerId = socket.data.playerId
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || !playerId || room.status !== "lobby") {
|
||||||
|
ack({ ok: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const before = room.blindtestTracks.length
|
||||||
|
room.blindtestTracks = room.blindtestTracks.filter(
|
||||||
|
(t) => !(t.id === payload.trackId && t.submittedBy === playerId)
|
||||||
|
)
|
||||||
|
const removed = room.blindtestTracks.length < before
|
||||||
|
ack({ ok: removed })
|
||||||
|
if (removed) {
|
||||||
|
broadcastState(room)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("media:control", (payload) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || !room.game || !socket.data.playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room.game.handleMediaControl(socket.data.playerId, payload)
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("round:vote", (payload) => {
|
socket.on("round:vote", (payload) => {
|
||||||
const code = socket.data.roomCode
|
const code = socket.data.roomCode
|
||||||
const room = code ? rooms.get(code) : undefined
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
|
@ -142,6 +273,22 @@ export function registerRoomHandlers(
|
||||||
room.game.handleVote(socket.data.playerId, payload.answer)
|
room.game.handleVote(socket.data.playerId, payload.answer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("lobby:return", () => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || room.hostId !== socket.data.playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Rejouer : retour au lobby, scores remis à zéro, joueurs et titres conservés.
|
||||||
|
room.status = "lobby"
|
||||||
|
room.currentRound = -1
|
||||||
|
room.game = null
|
||||||
|
for (const playerId of room.scores.keys()) {
|
||||||
|
room.scores.set(playerId, 0)
|
||||||
|
}
|
||||||
|
broadcastState(room)
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
const affected = rooms.handleDisconnect(socket.id)
|
const affected = rooms.handleDisconnect(socket.id)
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@ export interface ServerPlayer {
|
||||||
socketId: string | null
|
socketId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
|
||||||
|
export interface BlindtestTrack {
|
||||||
|
id: string
|
||||||
|
youtubeId: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
submittedBy: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */
|
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */
|
||||||
export interface ServerRoom {
|
export interface ServerRoom {
|
||||||
code: string
|
code: string
|
||||||
|
|
@ -27,6 +37,8 @@ export interface ServerRoom {
|
||||||
settings: RoomSettings
|
settings: RoomSettings
|
||||||
scores: Map<string, number>
|
scores: Map<string, number>
|
||||||
currentRound: number
|
currentRound: number
|
||||||
|
/** Titres soumis pour le blindtest (mélangés au lancement). */
|
||||||
|
blindtestTracks: BlindtestTrack[]
|
||||||
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */
|
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */
|
||||||
game: RoomGameController | null
|
game: RoomGameController | null
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +62,7 @@ export class RoomManager {
|
||||||
settings: { ...DEFAULT_ROOM_SETTINGS },
|
settings: { ...DEFAULT_ROOM_SETTINGS },
|
||||||
scores: new Map([[player.id, 0]]),
|
scores: new Map([[player.id, 0]]),
|
||||||
currentRound: -1,
|
currentRound: -1,
|
||||||
|
blindtestTracks: [],
|
||||||
game: null,
|
game: null,
|
||||||
}
|
}
|
||||||
this.rooms.set(code, room)
|
this.rooms.set(code, room)
|
||||||
|
|
@ -99,22 +112,28 @@ export class RoomManager {
|
||||||
|
|
||||||
/** Projette la room vers la vue publique diffusée aux clients (sans secrets). */
|
/** Projette la room vers la vue publique diffusée aux clients (sans secrets). */
|
||||||
toSnapshot(room: ServerRoom): RoomSnapshot {
|
toSnapshot(room: ServerRoom): RoomSnapshot {
|
||||||
|
// Seuls les joueurs ayant choisi un pseudo sont visibles dans la room.
|
||||||
|
const named = [...room.players.values()].filter((p) => p.name !== "")
|
||||||
return {
|
return {
|
||||||
code: room.code,
|
code: room.code,
|
||||||
status: room.status,
|
status: room.status,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
players: [...room.players.values()].map((p) => ({
|
players: named.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
connected: p.connected,
|
connected: p.connected,
|
||||||
})),
|
})),
|
||||||
settings: room.settings,
|
settings: room.settings,
|
||||||
scores: [...room.scores.entries()].map(([playerId, score]) => ({
|
scores: named.map((p) => ({
|
||||||
playerId,
|
playerId: p.id,
|
||||||
score,
|
score: room.scores.get(p.id) ?? 0,
|
||||||
})),
|
})),
|
||||||
currentRound: room.currentRound,
|
currentRound: room.currentRound,
|
||||||
totalRounds: room.settings.rounds.length,
|
totalRounds: room.settings.rounds.length,
|
||||||
|
submissions: named.map((p) => ({
|
||||||
|
playerId: p.id,
|
||||||
|
count: room.blindtestTracks.filter((t) => t.submittedBy === p.id).length,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { Route, Switch } from "wouter"
|
import { Route, Switch } from "wouter"
|
||||||
import { HomePage } from "@/pages/home"
|
import { HomePage } from "@/pages/home"
|
||||||
|
import { JoinPage } from "@/pages/join"
|
||||||
import { RoomPage } from "@/pages/room"
|
import { RoomPage } from "@/pages/room"
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={HomePage} />
|
<Route path="/" component={HomePage} />
|
||||||
|
<Route path="/join/:code">
|
||||||
|
{(params) => <JoinPage code={params.code.toUpperCase()} />}
|
||||||
|
</Route>
|
||||||
<Route path="/room/:code">
|
<Route path="/room/:code">
|
||||||
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
348
apps/web/src/components/blindtest-view.tsx
Normal file
348
apps/web/src/components/blindtest-view.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { Disc3, Pause, Play, Rewind } from "lucide-react"
|
||||||
|
import type {
|
||||||
|
BlindtestAnswer,
|
||||||
|
BlindtestMode,
|
||||||
|
BlindtestPerPlayerResult,
|
||||||
|
BlindtestRevealTruth,
|
||||||
|
BlindtestRoundPayload,
|
||||||
|
RoomSnapshot,
|
||||||
|
} from "@nerdware/shared"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { useYoutube, type YoutubeApi } from "@/lib/youtube"
|
||||||
|
import { Countdown } from "@/components/countdown"
|
||||||
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
function fmt(s: number): string {
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return `${m}:${sec.toString().padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
const round = useRoomStore((s) => s.round)
|
||||||
|
const reveal = useRoomStore((s) => s.reveal)
|
||||||
|
const mediaSync = useRoomStore((s) => s.mediaSync)
|
||||||
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
|
const hasVoted = useRoomStore((s) => s.hasVoted)
|
||||||
|
const voteBlindtest = useRoomStore((s) => s.voteBlindtest)
|
||||||
|
const mediaControl = useRoomStore((s) => s.mediaControl)
|
||||||
|
|
||||||
|
const track = round?.payload as BlindtestRoundPayload | undefined
|
||||||
|
const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "")
|
||||||
|
|
||||||
|
const isDj = !!playerId && round?.djId === playerId
|
||||||
|
const truth = reveal ? (reveal.truth as BlindtestRevealTruth) : null
|
||||||
|
const showReveal = truth !== null
|
||||||
|
|
||||||
|
// Non-DJ : on suit le DJ via media:sync (avec compensation de latence).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDj || !ready || !mediaSync) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const elapsed = (Date.now() - mediaSync.atServerTs) / 1000
|
||||||
|
if (mediaSync.action === "play") {
|
||||||
|
api.seek(mediaSync.positionSec + Math.max(0, elapsed))
|
||||||
|
api.play()
|
||||||
|
} else if (mediaSync.action === "pause") {
|
||||||
|
api.seek(mediaSync.positionSec)
|
||||||
|
api.pause()
|
||||||
|
} else {
|
||||||
|
api.seek(mediaSync.positionSec)
|
||||||
|
}
|
||||||
|
}, [mediaSync, isDj, ready, api])
|
||||||
|
|
||||||
|
// Au reveal, on coupe le son.
|
||||||
|
useEffect(() => {
|
||||||
|
if (showReveal && ready) {
|
||||||
|
api.pause()
|
||||||
|
}
|
||||||
|
}, [showReveal, ready, api])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-5">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs uppercase">
|
||||||
|
Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
||||||
|
</span>
|
||||||
|
{!showReveal && round && (
|
||||||
|
<Countdown
|
||||||
|
key={round.endsAt}
|
||||||
|
startsAt={round.startsAt}
|
||||||
|
endsAt={round.endsAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Cadre 16:9 : le lecteur YouTube est caché derrière le disque pendant la
|
||||||
|
manche (son seulement), puis révélé (bannière visible) au reveal. */}
|
||||||
|
<div className="bg-card relative aspect-video w-full overflow-hidden rounded-2xl">
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className="absolute inset-0 [&>iframe]:absolute [&>iframe]:inset-0 [&>iframe]:size-full"
|
||||||
|
/>
|
||||||
|
{!showReveal && (
|
||||||
|
<div className="bg-card absolute inset-0 flex items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
<Disc3 className="text-muted-foreground size-24" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDj && !showReveal && (
|
||||||
|
<DjControls ready={ready} api={api} mediaControl={mediaControl} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showReveal &&
|
||||||
|
(round?.mine ? (
|
||||||
|
<MisleadCard mode={snapshot.settings.blindtestMode} />
|
||||||
|
) : (
|
||||||
|
<VoteForm
|
||||||
|
mode={snapshot.settings.blindtestMode}
|
||||||
|
snapshot={snapshot}
|
||||||
|
playerId={playerId}
|
||||||
|
disabled={hasVoted}
|
||||||
|
onVote={voteBlindtest}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showReveal && truth && (
|
||||||
|
<RevealCard
|
||||||
|
truth={truth}
|
||||||
|
result={
|
||||||
|
(reveal?.perPlayerResult as BlindtestPerPlayerResult)[playerId ?? ""]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DjControls({
|
||||||
|
ready,
|
||||||
|
api,
|
||||||
|
mediaControl,
|
||||||
|
}: {
|
||||||
|
ready: boolean
|
||||||
|
api: YoutubeApi
|
||||||
|
mediaControl: (action: "play" | "pause" | "seek", positionSec: number) => void
|
||||||
|
}) {
|
||||||
|
const [pos, setPos] = useState(0)
|
||||||
|
const [dur, setDur] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setPos(api.time())
|
||||||
|
setDur(api.duration())
|
||||||
|
}, 400)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [ready, api])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="text-muted-foreground text-center text-xs uppercase">
|
||||||
|
Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => {
|
||||||
|
api.play()
|
||||||
|
mediaControl("play", api.time())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play /> Play
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => {
|
||||||
|
api.pause()
|
||||||
|
mediaControl("pause", api.time())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pause /> Pause
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => {
|
||||||
|
api.seek(0)
|
||||||
|
setPos(0)
|
||||||
|
mediaControl("seek", 0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Rewind /> Début
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs tabular-nums">
|
||||||
|
<span className="text-muted-foreground w-9 text-right">{fmt(pos)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(dur, 1)}
|
||||||
|
step="any"
|
||||||
|
value={Math.min(pos, dur || 0)}
|
||||||
|
disabled={!ready}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value)
|
||||||
|
setPos(v)
|
||||||
|
api.seek(v)
|
||||||
|
mediaControl("seek", v)
|
||||||
|
}}
|
||||||
|
className="accent-primary flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground w-9">{fmt(dur)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MisleadCard({ mode }: { mode: BlindtestMode }) {
|
||||||
|
return (
|
||||||
|
<div className="border-primary/40 bg-primary/5 flex flex-col items-center gap-2 rounded-xl border p-4 text-center">
|
||||||
|
<span className="text-2xl">🤫</span>
|
||||||
|
<p className="font-heading font-bold">C'est ton titre !</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{mode === "title_artist"
|
||||||
|
? "Tu ne votes pas. Savoure pendant que les autres cherchent."
|
||||||
|
: "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoteForm({
|
||||||
|
mode,
|
||||||
|
snapshot,
|
||||||
|
playerId,
|
||||||
|
disabled,
|
||||||
|
onVote,
|
||||||
|
}: {
|
||||||
|
mode: BlindtestMode
|
||||||
|
snapshot: RoomSnapshot
|
||||||
|
playerId: string | null
|
||||||
|
disabled: boolean
|
||||||
|
onVote: (answer: BlindtestAnswer) => void
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [artist, setArtist] = useState("")
|
||||||
|
const [guessed, setGuessed] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const needsText = mode === "title_artist" || mode === "mixed"
|
||||||
|
const needsWho = mode === "who_added" || mode === "mixed"
|
||||||
|
const canSubmit =
|
||||||
|
!disabled &&
|
||||||
|
(!needsText || title.trim().length > 0) &&
|
||||||
|
(!needsWho || guessed !== null)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const answer: {
|
||||||
|
title?: string
|
||||||
|
artist?: string
|
||||||
|
guessedPlayerId?: string
|
||||||
|
} = {}
|
||||||
|
if (needsText) {
|
||||||
|
answer.title = title.trim()
|
||||||
|
answer.artist = artist.trim()
|
||||||
|
}
|
||||||
|
if (needsWho && guessed) {
|
||||||
|
answer.guessedPlayerId = guessed
|
||||||
|
}
|
||||||
|
onVote(answer as BlindtestAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
Réponse envoyée — en attente des autres
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{needsText && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Titre"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Artiste"
|
||||||
|
value={artist}
|
||||||
|
onChange={(e) => setArtist(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{needsWho && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{snapshot.players
|
||||||
|
.filter((p) => p.id !== playerId)
|
||||||
|
.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setGuessed(p.id)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm ${
|
||||||
|
guessed === p.id
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-input hover:bg-muted/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar seed={p.name} className="size-5" />
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button disabled={!canSubmit} onClick={submit}>
|
||||||
|
Valider ma réponse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevealCard({
|
||||||
|
truth,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
truth: BlindtestRevealTruth
|
||||||
|
result?: BlindtestPerPlayerResult[string]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="font-heading text-lg font-bold text-balance">{truth.title}</p>
|
||||||
|
<p className="text-muted-foreground text-sm">{truth.artist}</p>
|
||||||
|
<p className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">Ajouté par</span>
|
||||||
|
<Avatar seed={truth.submittedByName} className="size-6" />
|
||||||
|
<span className="font-medium">{truth.submittedByName}</span>
|
||||||
|
</p>
|
||||||
|
{result && (
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${result.points > 0 ? "text-green-500" : "text-red-500"}`}
|
||||||
|
>
|
||||||
|
{result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from "wouter"
|
import { Link } from "wouter"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Crown } from "lucide-react"
|
import { Crown, ExternalLink, Music } from "lucide-react"
|
||||||
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
import type { PlayerScore, RoomSnapshot } 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"
|
||||||
|
|
@ -112,7 +112,10 @@ function PodiumColumn({
|
||||||
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const finalScores = useRoomStore((s) => s.finalScores)
|
const finalScores = useRoomStore((s) => s.finalScores)
|
||||||
|
const gameTracks = useRoomStore((s) => s.gameTracks)
|
||||||
const reset = useRoomStore((s) => s.reset)
|
const reset = useRoomStore((s) => s.reset)
|
||||||
|
const returnToLobby = useRoomStore((s) => s.returnToLobby)
|
||||||
|
const isHost = snapshot.hostId === playerId
|
||||||
|
|
||||||
const scores: PlayerScore[] = finalScores ?? snapshot.scores
|
const scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||||
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||||
|
|
@ -194,11 +197,62 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href="/">
|
{gameTracks && gameTracks.length > 0 && (
|
||||||
<Button variant="secondary" className="w-full" onClick={reset}>
|
<section className="flex flex-col gap-2 text-left">
|
||||||
Retour à l'accueil
|
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
|
||||||
</Button>
|
<Music className="size-4" /> Les musiques de la partie
|
||||||
</Link>
|
</h3>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{gameTracks.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.youtubeId}
|
||||||
|
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-16 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="line-clamp-1 text-xs font-medium">
|
||||||
|
{t.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground line-clamp-1 text-[10px]">
|
||||||
|
ajouté par {t.submittedByName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={t.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Ouvrir sur YouTube"
|
||||||
|
>
|
||||||
|
<Button size="icon-sm" variant="secondary">
|
||||||
|
<ExternalLink />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{isHost ? (
|
||||||
|
<Button className="w-full" onClick={returnToLobby}>
|
||||||
|
Rejouer
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
En attente que l'hôte relance une partie…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="secondary" className="w-full" onClick={reset}>
|
||||||
|
Quitter
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,156 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import type { RoomSnapshot } from "@nerdware/shared"
|
import {
|
||||||
|
Brain,
|
||||||
|
Headphones,
|
||||||
|
ListMusic,
|
||||||
|
Minus,
|
||||||
|
Music,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Shuffle,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
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 MAX_TRACKS = 10
|
||||||
|
const GAME_TYPES: { value: GameType; label: string; Icon: LucideIcon }[] = [
|
||||||
|
{ value: "mixed", label: "Mixte", Icon: Shuffle },
|
||||||
|
{ value: "quiz", label: "Quiz", Icon: Brain },
|
||||||
|
{ value: "blindtest", label: "Blindtest", Icon: Headphones },
|
||||||
|
]
|
||||||
|
const MODE_LABELS: Record<BlindtestMode, string> = {
|
||||||
|
title_artist: "Titre & artiste",
|
||||||
|
who_added: "Qui l'a ajouté ?",
|
||||||
|
mixed: "Mixte",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
function Stepper({
|
||||||
|
value,
|
||||||
|
min = 1,
|
||||||
|
max = MAX_TRACKS,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
const clamp = (v: number) => Math.max(min, Math.min(max, v))
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={value <= min}
|
||||||
|
onClick={() => onChange(clamp(value - 1))}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10)
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
onChange(clamp(n))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border-input bg-background h-8 w-14 rounded-md border text-center text-sm [appearance:textfield] focus-visible:outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={value >= max}
|
||||||
|
onClick={() => onChange(clamp(value + 1))}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[]): T[] {
|
||||||
|
const arr = [...items]
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 : ordre mélangé pour qu'on ne sache pas ce qui vient ensuite.
|
||||||
|
const rounds: RoundConfig[] = [
|
||||||
|
...Array.from({ length: quizCount }, () => ({ type: "quiz" as const })),
|
||||||
|
...Array.from({ length: totalTracks }, () => ({ type: "blindtest" as const })),
|
||||||
|
]
|
||||||
|
return shuffle(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 startGame = useRoomStore((s) => s.startGame)
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
const isHost = snapshot.hostId === playerId
|
const isHost = snapshot.hostId === playerId
|
||||||
|
const { gameType, blindtestMode, tracksPerPlayer } = snapshot.settings
|
||||||
|
|
||||||
const [count, setCount] = useState(5)
|
const [count, setCount] = useState(5)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function handleStart() {
|
const totalTracks = snapshot.submissions.reduce((n, s) => n + s.count, 0)
|
||||||
|
const myCount =
|
||||||
|
snapshot.submissions.find((s) => s.playerId === playerId)?.count ?? 0
|
||||||
|
|
||||||
|
// Le blindtest exige au moins 3 joueurs connectés (DJ + 2 devineurs).
|
||||||
|
const connectedCount = snapshot.players.filter((p) => p.connected).length
|
||||||
|
const blindtestAvailable = connectedCount >= 3
|
||||||
|
// Tant que le blindtest est indisponible, on retombe sur du quiz (sans
|
||||||
|
// toucher au réglage stocké) : repasse en mixte automatiquement à 3 joueurs.
|
||||||
|
const effectiveType: GameType = blindtestAvailable ? gameType : "quiz"
|
||||||
|
const showQuiz = effectiveType === "quiz" || effectiveType === "mixed"
|
||||||
|
const showBlindtest =
|
||||||
|
effectiveType === "blindtest" || effectiveType === "mixed"
|
||||||
|
|
||||||
|
const rounds = buildRounds(effectiveType, showQuiz ? count : 0, totalTracks)
|
||||||
|
// Blindtest : tout le monde doit avoir soumis son quota de titres.
|
||||||
|
const allSubmitted =
|
||||||
|
!showBlindtest ||
|
||||||
|
(snapshot.submissions.length > 0 &&
|
||||||
|
snapshot.submissions.every((s) => s.count >= tracksPerPlayer))
|
||||||
|
const canStart = rounds.length > 0 && allSubmitted
|
||||||
|
|
||||||
|
async function start() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await startGame(count)
|
await startGame(rounds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as { message?: string }).message ?? "Erreur")
|
setError((err as { message?: string }).message ?? "Erreur")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -57,35 +188,212 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isHost ? (
|
{isHost && (
|
||||||
<section className="flex flex-col gap-3">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex gap-2">
|
||||||
<span className="text-sm font-medium">Questions de quiz</span>
|
{GAME_TYPES.map(({ value, label, Icon }) => {
|
||||||
<div className="flex gap-1">
|
const needsThree = value !== "quiz" && !blindtestAvailable
|
||||||
{QUESTION_OPTIONS.map((n) => (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={n}
|
key={value}
|
||||||
size="sm"
|
className="flex-1"
|
||||||
variant={count === n ? "default" : "secondary"}
|
variant={effectiveType === value ? "default" : "secondary"}
|
||||||
onClick={() => setCount(n)}
|
disabled={needsThree}
|
||||||
|
title={needsThree ? "Blindtest : 3 joueurs minimum" : undefined}
|
||||||
|
onClick={() => updateSettings({ gameType: value })}
|
||||||
>
|
>
|
||||||
{n}
|
<Icon /> {label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!blindtestAvailable && (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
Le blindtest se débloque à 3 joueurs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHost && showQuiz && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Brain className="size-4" /> Questions de quiz
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{QUESTION_OPTIONS.map((n) => (
|
||||||
|
<Button
|
||||||
|
key={n}
|
||||||
|
size="sm"
|
||||||
|
variant={count === n ? "default" : "secondary"}
|
||||||
|
onClick={() => setCount(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHost && showBlindtest && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Music className="size-4" /> 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={blindtestMode === m ? "default" : "secondary"}
|
||||||
|
onClick={() => updateSettings({ blindtestMode: m })}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[m]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={busy} onClick={handleStart}>
|
<div className="flex items-center justify-between">
|
||||||
{busy ? "Lancement…" : "Lancer la partie"}
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<ListMusic className="size-4" /> Titres par joueur
|
||||||
|
</span>
|
||||||
|
<Stepper
|
||||||
|
value={tracksPerPlayer}
|
||||||
|
onChange={(v) => updateSettings({ tracksPerPlayer: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBlindtest && (
|
||||||
|
<TrackSubmission tracksPerPlayer={tracksPerPlayer} myCount={myCount} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHost ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button disabled={busy || !canStart} onClick={start}>
|
||||||
|
<Play /> {busy ? "Lancement…" : `Lancer (${rounds.length} manches)`}
|
||||||
</Button>
|
</Button>
|
||||||
{error && (
|
{showBlindtest && !allSubmitted && (
|
||||||
<p className="text-destructive text-center text-sm">{error}</p>
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "}
|
||||||
|
titre(s).
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
En attente du lancement de la partie par l'hôte…
|
En attente du lancement par l'hôte…
|
||||||
|
{showBlindtest && ` (${totalTracks} titres soumis)`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-destructive text-center text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyTrack {
|
||||||
|
trackId: string
|
||||||
|
title: string
|
||||||
|
youtubeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackSubmission({
|
||||||
|
tracksPerPlayer,
|
||||||
|
myCount,
|
||||||
|
}: {
|
||||||
|
tracksPerPlayer: number
|
||||||
|
myCount: number
|
||||||
|
}) {
|
||||||
|
const submitTrack = useRoomStore((s) => s.submitTrack)
|
||||||
|
const removeTrack = useRoomStore((s) => s.removeTrack)
|
||||||
|
const [url, setUrl] = useState("")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null)
|
||||||
|
const [tracks, setTracks] = useState<MyTrack[]>([])
|
||||||
|
const quotaReached = myCount >= tracksPerPlayer
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!url.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
setFeedback(null)
|
||||||
|
const res = await submitTrack(url.trim())
|
||||||
|
if (res.accepted && res.trackId && res.youtubeId) {
|
||||||
|
setTracks((t) => [
|
||||||
|
...t,
|
||||||
|
{ trackId: res.trackId!, title: res.title ?? "", youtubeId: res.youtubeId! },
|
||||||
|
])
|
||||||
|
setUrl("")
|
||||||
|
} else {
|
||||||
|
setFeedback(res.reason ?? "Refusé")
|
||||||
|
}
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(trackId: string) {
|
||||||
|
const ok = await removeTrack(trackId)
|
||||||
|
if (ok) {
|
||||||
|
setTracks((t) => t.filter((x) => x.trackId !== trackId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Tes titres ({myCount}/{tracksPerPlayer})
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{tracks.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.trackId}
|
||||||
|
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://img.youtube.com/vi/${t.youtubeId}/mqdefault.jpg`}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-16 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<span className="line-clamp-2 flex-1 text-xs">{t.title}</span>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => remove(t.trackId)}
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!quotaReached && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Lien YouTube"
|
||||||
|
value={url}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={submitting || !url.trim()}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{feedback && <p className="text-destructive text-xs">{feedback}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
apps/web/src/components/pseudo-screen.tsx
Normal file
45
apps/web/src/components/pseudo-screen.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||||
|
|
||||||
|
/** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */
|
||||||
|
export function PseudoScreen({ code }: { code: string }) {
|
||||||
|
const setName = useRoomStore((s) => s.setName)
|
||||||
|
const [name, setName_] = useState("")
|
||||||
|
const canSubmit = name.trim().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<div className="flex w-full max-w-xs flex-col items-center gap-5">
|
||||||
|
<p className="text-muted-foreground text-xs uppercase">
|
||||||
|
Room {code}
|
||||||
|
</p>
|
||||||
|
<Avatar
|
||||||
|
seed={name.trim() || "?"}
|
||||||
|
className="size-28 ring-2 ring-primary/30"
|
||||||
|
/>
|
||||||
|
<p className="font-heading text-lg font-bold">Choisis ton pseudo</p>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Ton pseudo"
|
||||||
|
value={name}
|
||||||
|
maxLength={24}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setName_(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && canSubmit && setName(name)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => setName(name)}
|
||||||
|
>
|
||||||
|
Entrer dans la room
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{isFree ? (
|
||||||
{question.choices.map((choice, index) => (
|
<FreeAnswer
|
||||||
<button
|
disabled={showReveal || hasVoted}
|
||||||
key={index}
|
onSubmit={voteText}
|
||||||
className={choiceClass(index)}
|
/>
|
||||||
disabled={showReveal || myChoiceIndex !== null}
|
) : (
|
||||||
onClick={() => vote(index)}
|
<div className="flex flex-col gap-2">
|
||||||
>
|
{(question.choices ?? []).map((choice, index) => (
|
||||||
{choice}
|
<button
|
||||||
</button>
|
key={index}
|
||||||
))}
|
className={choiceClass(index)}
|
||||||
</div>
|
disabled={showReveal || hasVoted}
|
||||||
|
onClick={() => vote(index)}
|
||||||
|
>
|
||||||
|
{choice}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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 && (
|
||||||
<p
|
<div className="flex flex-col items-center gap-1">
|
||||||
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
{isFree && (
|
||||||
>
|
<p className="text-sm">
|
||||||
{myResult?.correct
|
<span className="text-muted-foreground">Réponse : </span>
|
||||||
? "Bonne réponse ! 🎉"
|
<span className="font-medium">{truth?.answer}</span>
|
||||||
: myResult?.choiceIndex == null
|
</p>
|
||||||
? "Pas de réponse 😴"
|
)}
|
||||||
: "Raté 💥"}
|
<p
|
||||||
</p>
|
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||||
|
>
|
||||||
|
{myResult?.correct
|
||||||
|
? "Bonne réponse ! 🎉"
|
||||||
|
: !hasVoted
|
||||||
|
? "Pas de réponse 😴"
|
||||||
|
: "Raté 💥"}
|
||||||
|
</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,39 +1,56 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Check, Copy } from "lucide-react"
|
import { Check, Copy, Link2 } from "lucide-react"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
|
||||||
/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */
|
type Copied = "code" | "link" | null
|
||||||
|
|
||||||
|
/** Code de room copiable + bouton pour copier le lien d'invitation. */
|
||||||
export function RoomCode({ code }: { code: string }) {
|
export function RoomCode({ code }: { code: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState<Copied>(null)
|
||||||
|
|
||||||
async function copy() {
|
async function copy(what: Copied, text: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code)
|
await navigator.clipboard.writeText(text)
|
||||||
setCopied(true)
|
setCopied(what)
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(null), 1500)
|
||||||
} catch {
|
} catch {
|
||||||
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement
|
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const link = `${window.location.origin}/join/${code}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={copy}
|
<button
|
||||||
title="Copier le code"
|
onClick={() => copy("code", code)}
|
||||||
className="group flex items-center gap-2 text-left"
|
title="Copier le code"
|
||||||
>
|
className="group flex items-center gap-2 text-left"
|
||||||
<span>
|
>
|
||||||
<span className="text-muted-foreground text-xs uppercase">
|
<span>
|
||||||
Code room
|
<span className="text-muted-foreground text-xs uppercase">
|
||||||
|
Code room
|
||||||
|
</span>
|
||||||
|
<span className="font-heading block text-2xl font-bold tracking-widest">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-heading block text-2xl font-bold tracking-widest">
|
{copied === "code" ? (
|
||||||
{code}
|
<Check className="size-4 text-green-500" />
|
||||||
</span>
|
) : (
|
||||||
</span>
|
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
|
||||||
{copied ? (
|
)}
|
||||||
<Check className="size-4 text-green-500" />
|
</button>
|
||||||
) : (
|
|
||||||
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
|
<Button
|
||||||
)}
|
size="sm"
|
||||||
</button>
|
variant="secondary"
|
||||||
|
onClick={() => copy("link", link)}
|
||||||
|
title="Copier le lien d'invitation"
|
||||||
|
>
|
||||||
|
{copied === "link" ? <Check className="text-green-500" /> : <Link2 />}
|
||||||
|
{copied === "link" ? "Copié" : "Lien"}
|
||||||
|
</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,63 +89,74 @@ export function RoundTransition({ index }: { index: number }) {
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute size-[220vmax] opacity-60"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
|
||||||
|
}}
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{media ? (
|
{media ? (
|
||||||
<motion.img
|
<motion.img
|
||||||
src={media}
|
src={media}
|
||||||
alt=""
|
alt=""
|
||||||
className="max-h-[70vh] max-w-[80vw] object-contain"
|
className="relative max-h-[70vh] max-w-[80vw] object-contain"
|
||||||
initial={{ scale: 0.6, opacity: 0 }}
|
initial={{ scale: 0.6, opacity: 0 }}
|
||||||
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
|
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
|
||||||
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
|
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<motion.div
|
||||||
<motion.div
|
className="relative flex flex-col items-center"
|
||||||
aria-hidden
|
initial={{ scale: 0, rotate: -12 }}
|
||||||
className="absolute size-[220vmax] opacity-60"
|
animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }}
|
||||||
style={{
|
transition={{
|
||||||
backgroundImage:
|
duration: 0.5,
|
||||||
"repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
|
times: [0, 0.6, 1],
|
||||||
}}
|
delay: 0.12,
|
||||||
animate={{ rotate: 360 }}
|
ease: "easeOut",
|
||||||
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
}}
|
||||||
/>
|
>
|
||||||
|
{modeChanged ? (
|
||||||
<motion.div
|
<>
|
||||||
className="relative flex flex-col items-center"
|
<span className="text-[18vmin] leading-none">{theme.emoji}</span>
|
||||||
initial={{ scale: 0, rotate: -12 }}
|
<span
|
||||||
animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }}
|
className={`font-heading text-[12vmin] leading-none font-black tracking-tight ${theme.accent} drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]`}
|
||||||
transition={{
|
>
|
||||||
duration: 0.5,
|
{theme.label}
|
||||||
times: [0, 0.6, 1],
|
</span>
|
||||||
delay: 0.12,
|
</>
|
||||||
ease: "easeOut",
|
) : (
|
||||||
}}
|
<>
|
||||||
|
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
|
||||||
|
{theme.kind}
|
||||||
|
</span>
|
||||||
|
<motion.span
|
||||||
|
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,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: "reverse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</motion.span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="font-heading -rotate-3 text-3xl font-black text-white italic"
|
||||||
>
|
>
|
||||||
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
|
PRÊT ?!
|
||||||
Question
|
</motion.span>
|
||||||
</span>
|
</motion.div>
|
||||||
<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)]"
|
|
||||||
animate={{ scale: [1, 1.07, 1] }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.4,
|
|
||||||
repeat: Infinity,
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</motion.span>
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
className="font-heading -rotate-3 text-3xl font-black text-white italic"
|
|
||||||
>
|
|
||||||
PRÊT ?!
|
|
||||||
</motion.span>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
99
apps/web/src/lib/youtube.ts
Normal file
99
apps/web/src/lib/youtube.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
|
||||||
|
let apiPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
/** Charge l'API IFrame YouTube une seule fois. */
|
||||||
|
function loadYoutubeApi(): Promise<void> {
|
||||||
|
if (window.YT?.Player) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (apiPromise) {
|
||||||
|
return apiPromise
|
||||||
|
}
|
||||||
|
apiPromise = new Promise((resolve) => {
|
||||||
|
const previous = window.onYouTubeIframeAPIReady
|
||||||
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
|
previous?.()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const tag = document.createElement("script")
|
||||||
|
tag.src = "https://www.youtube.com/iframe_api"
|
||||||
|
document.head.appendChild(tag)
|
||||||
|
})
|
||||||
|
return apiPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubeApi {
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
seek: (seconds: number) => void
|
||||||
|
time: () => number
|
||||||
|
duration: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un lecteur YouTube pour `youtubeId`. Renvoie un ref à poser sur un div
|
||||||
|
* hôte, l'état "prêt", et une API impérative (play/pause/seek/time).
|
||||||
|
*/
|
||||||
|
export function useYoutube(youtubeId: string) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
|
const playerRef = useRef<YT.Player | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const host = hostRef.current
|
||||||
|
if (!host) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReady(false)
|
||||||
|
// YT remplace l'élément fourni par une iframe → on lui donne un enfant jetable
|
||||||
|
// pour que React ne gère jamais ce nœud.
|
||||||
|
const inner = document.createElement("div")
|
||||||
|
host.appendChild(inner)
|
||||||
|
|
||||||
|
loadYoutubeApi().then(() => {
|
||||||
|
if (cancelled || !window.YT) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playerRef.current = new window.YT.Player(inner, {
|
||||||
|
videoId: youtubeId,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: 0,
|
||||||
|
controls: 0,
|
||||||
|
disablekb: 1,
|
||||||
|
modestbranding: 1,
|
||||||
|
rel: 0,
|
||||||
|
playsinline: 1,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onReady: () => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
playerRef.current?.destroy()
|
||||||
|
playerRef.current = null
|
||||||
|
host.replaceChildren()
|
||||||
|
}
|
||||||
|
}, [youtubeId])
|
||||||
|
|
||||||
|
const api = useMemo<YoutubeApi>(
|
||||||
|
() => ({
|
||||||
|
play: () => playerRef.current?.playVideo(),
|
||||||
|
pause: () => playerRef.current?.pauseVideo(),
|
||||||
|
seek: (seconds) => playerRef.current?.seekTo(seconds, true),
|
||||||
|
time: () => playerRef.current?.getCurrentTime() ?? 0,
|
||||||
|
duration: () => playerRef.current?.getDuration() ?? 0,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { hostRef, ready, api }
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,38 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { LogIn, Sparkles } from "lucide-react"
|
||||||
import { Button } from "@workspace/ui/components/button"
|
import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||||
|
|
||||||
|
// Emojis geek qui flottent en fond.
|
||||||
|
const FLOATERS = [
|
||||||
|
{ emoji: "🎮", x: "8%", y: "18%", delay: 0 },
|
||||||
|
{ emoji: "🎧", x: "82%", y: "22%", delay: 0.6 },
|
||||||
|
{ emoji: "🧠", x: "15%", y: "72%", delay: 1.2 },
|
||||||
|
{ emoji: "👾", x: "78%", y: "70%", delay: 0.3 },
|
||||||
|
{ emoji: "🕹️", x: "45%", y: "12%", delay: 0.9 },
|
||||||
|
{ emoji: "🎬", x: "60%", y: "82%", delay: 1.5 },
|
||||||
|
]
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [, navigate] = useLocation()
|
const [, navigate] = useLocation()
|
||||||
const createRoom = useRoomStore((s) => s.createRoom)
|
const createRoom = useRoomStore((s) => s.createRoom)
|
||||||
const joinRoom = useRoomStore((s) => s.joinRoom)
|
const joinRoom = useRoomStore((s) => s.joinRoom)
|
||||||
const connected = useRoomStore((s) => s.connected)
|
const connected = useRoomStore((s) => s.connected)
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [code, setCode] = useState("")
|
const [code, setCode] = useState("")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const canSubmit = name.trim().length > 0 && connected && !busy
|
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const { roomCode } = await createRoom(name.trim())
|
const { roomCode } = await createRoom()
|
||||||
navigate(`/room/${roomCode}`)
|
navigate(`/room/${roomCode}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as { message?: string }).message ?? "Erreur")
|
setError((err as { message?: string }).message ?? "Erreur")
|
||||||
|
|
@ -36,7 +45,7 @@ export function HomePage() {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim())
|
const { roomCode } = await joinRoom(code.trim().toUpperCase())
|
||||||
navigate(`/room/${roomCode}`)
|
navigate(`/room/${roomCode}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as { message?: string }).message ?? "Erreur")
|
setError((err as { message?: string }).message ?? "Erreur")
|
||||||
|
|
@ -46,28 +55,63 @@ export function HomePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh items-center justify-center p-6">
|
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
|
||||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
{/* Halo animé */}
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -z-10 size-[120vmin] rounded-full bg-gradient-to-br from-fuchsia-500/20 via-purple-500/10 to-cyan-400/20 blur-3xl"
|
||||||
|
animate={{ scale: [1, 1.15, 1], rotate: [0, 30, 0] }}
|
||||||
|
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
{/* Emojis flottants */}
|
||||||
|
{FLOATERS.map((f) => (
|
||||||
|
<motion.span
|
||||||
|
key={f.emoji}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -z-10 text-4xl opacity-40 select-none sm:text-5xl"
|
||||||
|
style={{ left: f.x, top: f.y }}
|
||||||
|
animate={{ y: [0, -18, 0], rotate: [-8, 8, -8] }}
|
||||||
|
transition={{
|
||||||
|
duration: 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: f.delay,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.emoji}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex w-full max-w-sm flex-col gap-6"
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
>
|
||||||
<header className="text-center">
|
<header className="text-center">
|
||||||
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
<motion.h1
|
||||||
|
className="font-heading bg-gradient-to-r from-fuchsia-500 via-purple-500 to-cyan-400 bg-clip-text text-5xl font-black tracking-tight text-transparent"
|
||||||
|
initial={{ scale: 0.6, rotate: -6, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, rotate: 0, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 14 }}
|
||||||
|
>
|
||||||
NerdWare
|
NerdWare
|
||||||
</h1>
|
</motion.h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground mt-1 flex items-center justify-center gap-1 text-sm">
|
||||||
Party game culture geek
|
<Sparkles className="size-3.5" /> Party game culture geek
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<input
|
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||||
className={inputClass}
|
<Button
|
||||||
placeholder="Ton pseudo"
|
size="lg"
|
||||||
value={name}
|
className="w-full"
|
||||||
maxLength={24}
|
disabled={!connected || busy}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onClick={handleCreate}
|
||||||
/>
|
>
|
||||||
|
<Sparkles /> Créer une room
|
||||||
<Button disabled={!canSubmit} onClick={handleCreate}>
|
</Button>
|
||||||
Créer une room
|
</motion.div>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
|
|
@ -77,18 +121,19 @@ export function HomePage() {
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} uppercase`}
|
className={`${inputClass} text-center text-lg font-bold tracking-[0.3em] uppercase`}
|
||||||
placeholder="Code"
|
placeholder="CODE"
|
||||||
value={code}
|
value={code}
|
||||||
maxLength={4}
|
maxLength={4}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && code.trim() && handleJoin()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={!canSubmit || code.trim().length === 0}
|
disabled={!connected || busy || code.trim().length === 0}
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
>
|
>
|
||||||
Rejoindre
|
<LogIn /> Rejoindre
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -100,7 +145,7 @@ export function HomePage() {
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive text-center text-sm">{error}</p>
|
<p className="text-destructive text-center text-sm">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
apps/web/src/pages/join.tsx
Normal file
42
apps/web/src/pages/join.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Link, useLocation } from "wouter"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
||||||
|
/** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */
|
||||||
|
export function JoinPage({ code }: { code: string }) {
|
||||||
|
const [, navigate] = useLocation()
|
||||||
|
const joinRoom = useRoomStore((s) => s.joinRoom)
|
||||||
|
const connected = useRoomStore((s) => s.connected)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const tried = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected || tried.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tried.current = true
|
||||||
|
joinRoom(code)
|
||||||
|
.then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true }))
|
||||||
|
.catch((err) =>
|
||||||
|
setError((err as { message?: string }).message ?? "Room introuvable.")
|
||||||
|
)
|
||||||
|
}, [connected, code, joinRoom, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground text-sm">{error}</p>
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="secondary">Retour à l'accueil</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Connexion à la room {code}…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,22 +3,28 @@ import { Button } from "@workspace/ui/components/button"
|
||||||
import { useRoomStore } from "@/store/room"
|
import { useRoomStore } from "@/store/room"
|
||||||
import { LobbyView } from "@/components/lobby-view"
|
import { LobbyView } from "@/components/lobby-view"
|
||||||
import { QuizView } from "@/components/quiz-view"
|
import { QuizView } from "@/components/quiz-view"
|
||||||
|
import { BlindtestView } from "@/components/blindtest-view"
|
||||||
import { GameEndView } from "@/components/game-end-view"
|
import { GameEndView } from "@/components/game-end-view"
|
||||||
import { PlayerCards } from "@/components/player-cards"
|
import { PlayerCards } from "@/components/player-cards"
|
||||||
import { RoomCode } from "@/components/room-code"
|
import { RoomCode } from "@/components/room-code"
|
||||||
import { Boom } from "@/components/boom"
|
import { Boom } from "@/components/boom"
|
||||||
import { RoundTransition } from "@/components/round-transition"
|
import { RoundTransition } from "@/components/round-transition"
|
||||||
|
import { PseudoScreen } from "@/components/pseudo-screen"
|
||||||
|
|
||||||
export function RoomPage({ code }: { code: string }) {
|
export function RoomPage({ code }: { code: string }) {
|
||||||
const snapshot = useRoomStore((s) => s.snapshot)
|
const snapshot = useRoomStore((s) => s.snapshot)
|
||||||
|
const roomCode = useRoomStore((s) => s.roomCode)
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const connected = useRoomStore((s) => s.connected)
|
const connected = useRoomStore((s) => s.connected)
|
||||||
|
const pseudoSet = useRoomStore((s) => s.pseudoSet)
|
||||||
const boomKey = useRoomStore((s) => s.boomKey)
|
const boomKey = useRoomStore((s) => s.boomKey)
|
||||||
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 roundModeChanged = useRoomStore((s) => s.roundModeChanged)
|
||||||
|
|
||||||
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
|
||||||
if (!snapshot || snapshot.code !== code) {
|
if (roomCode !== code) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|
@ -31,6 +37,20 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pseudo pas encore choisi : on l'affiche d'abord.
|
||||||
|
if (!pseudoSet) {
|
||||||
|
return <PseudoScreen code={code} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// En attente du premier snapshot.
|
||||||
|
if (!snapshot || snapshot.code !== code) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
<p className="text-muted-foreground text-sm">Chargement…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Cards de scores visibles en permanence pendant le jeu (suivi continu).
|
// Cards de scores visibles en permanence pendant le jeu (suivi continu).
|
||||||
const inGame =
|
const inGame =
|
||||||
snapshot.status === "in_round" ||
|
snapshot.status === "in_round" ||
|
||||||
|
|
@ -58,13 +78,23 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
||||||
{inGame && <QuizView snapshot={snapshot} />}
|
{inGame &&
|
||||||
|
(round?.type === "blindtest" ? (
|
||||||
|
<BlindtestView snapshot={snapshot} />
|
||||||
|
) : (
|
||||||
|
<QuizView snapshot={snapshot} />
|
||||||
|
))}
|
||||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import type {
|
import {
|
||||||
ErrorPayload,
|
DEFAULT_ROOM_SETTINGS,
|
||||||
PlayerScore,
|
type BlindtestAnswer,
|
||||||
RoomCreatedPayload,
|
type BlindtestTrackInfo,
|
||||||
RoomSnapshot,
|
type ErrorPayload,
|
||||||
RoundStartPayload,
|
type MediaControlPayload,
|
||||||
VoteAckPayload,
|
type MediaSyncPayload,
|
||||||
|
type PlayerScore,
|
||||||
|
type RoomCreatedPayload,
|
||||||
|
type RoomSnapshot,
|
||||||
|
type RoundConfig,
|
||||||
|
type RoundStartPayload,
|
||||||
|
type SubmitOkPayload,
|
||||||
|
type UpdateSettingsPayload,
|
||||||
|
type VoteAckPayload,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { socket } from "@/lib/socket"
|
import { socket } from "@/lib/socket"
|
||||||
|
|
||||||
|
|
@ -13,6 +21,8 @@ import { socket } from "@/lib/socket"
|
||||||
export interface ActiveRound {
|
export interface ActiveRound {
|
||||||
type: RoundStartPayload["type"]
|
type: RoundStartPayload["type"]
|
||||||
djId?: string
|
djId?: string
|
||||||
|
/** Blindtest : true si c'est MON titre (je dois faire deviner, pas voter). */
|
||||||
|
mine?: boolean
|
||||||
startsAt: number
|
startsAt: number
|
||||||
endsAt: number
|
endsAt: number
|
||||||
payload: unknown
|
payload: unknown
|
||||||
|
|
@ -26,9 +36,10 @@ export interface RoundReveal {
|
||||||
|
|
||||||
interface RoomState {
|
interface RoomState {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
|
|
||||||
playerId: string | null
|
playerId: string | null
|
||||||
roomCode: string | null
|
roomCode: string | null
|
||||||
|
/** Le joueur local a-t-il choisi son pseudo dans la room ? */
|
||||||
|
pseudoSet: boolean
|
||||||
snapshot: RoomSnapshot | null
|
snapshot: RoomSnapshot | null
|
||||||
lastError: ErrorPayload | null
|
lastError: ErrorPayload | null
|
||||||
|
|
||||||
|
|
@ -37,16 +48,27 @@ interface RoomState {
|
||||||
voteProgress: VoteAckPayload | null
|
voteProgress: VoteAckPayload | null
|
||||||
reveal: RoundReveal | null
|
reveal: RoundReveal | null
|
||||||
myChoiceIndex: number | null
|
myChoiceIndex: number | null
|
||||||
|
hasVoted: boolean
|
||||||
finalScores: PlayerScore[] | null
|
finalScores: PlayerScore[] | null
|
||||||
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
gameTracks: BlindtestTrackInfo[] | null
|
||||||
|
mediaSync: MediaSyncPayload | null
|
||||||
boomKey: number
|
boomKey: number
|
||||||
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
|
|
||||||
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: () => Promise<RoomCreatedPayload>
|
||||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
joinRoom: (roomCode: string) => Promise<RoomCreatedPayload>
|
||||||
startGame: (questionCount: number) => Promise<void>
|
setName: (name: string) => void
|
||||||
|
updateSettings: (partial: Partial<UpdateSettingsPayload>) => void
|
||||||
|
startGame: (rounds: RoundConfig[]) => Promise<void>
|
||||||
|
submitTrack: (youtubeUrl: string) => Promise<SubmitOkPayload>
|
||||||
|
removeTrack: (trackId: string) => Promise<boolean>
|
||||||
|
returnToLobby: () => void
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
|
voteText: (text: string) => void
|
||||||
|
voteBlindtest: (answer: BlindtestAnswer) => void
|
||||||
|
mediaControl: (action: MediaControlPayload["action"], positionSec: number) => void
|
||||||
boom: () => void
|
boom: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
|
|
@ -56,21 +78,30 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
connected: socket.connected,
|
connected: socket.connected,
|
||||||
playerId: null,
|
playerId: null,
|
||||||
roomCode: null,
|
roomCode: null,
|
||||||
|
pseudoSet: false,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
round: null,
|
round: null,
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
|
hasVoted: false,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundModeChanged: false,
|
||||||
|
|
||||||
createRoom: (playerName) =>
|
createRoom: () =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
socket.emit("room:create", { playerName }, (res) => {
|
socket.emit("room:create", {}, (res) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
|
set({
|
||||||
|
playerId: res.data.playerId,
|
||||||
|
roomCode: res.data.roomCode,
|
||||||
|
pseudoSet: false,
|
||||||
|
})
|
||||||
resolve(res.data)
|
resolve(res.data)
|
||||||
} else {
|
} else {
|
||||||
set({ lastError: res.error })
|
set({ lastError: res.error })
|
||||||
|
|
@ -79,11 +110,15 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
joinRoom: (roomCode, playerName) =>
|
joinRoom: (roomCode) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
socket.emit("room:join", { roomCode, playerName }, (res) => {
|
socket.emit("room:join", { roomCode }, (res) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
|
set({
|
||||||
|
playerId: res.data.playerId,
|
||||||
|
roomCode: res.data.roomCode,
|
||||||
|
pseudoSet: false,
|
||||||
|
})
|
||||||
resolve(res.data)
|
resolve(res.data)
|
||||||
} else {
|
} else {
|
||||||
set({ lastError: res.error })
|
set({ lastError: res.error })
|
||||||
|
|
@ -92,17 +127,28 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
startGame: (questionCount) =>
|
setName: (name) => {
|
||||||
|
if (name.trim().length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket.emit("player:setName", { name: name.trim() })
|
||||||
|
set({ pseudoSet: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: (partial) => {
|
||||||
|
const current = get().snapshot?.settings ?? DEFAULT_ROOM_SETTINGS
|
||||||
|
socket.emit("lobby:updateSettings", {
|
||||||
|
gameType: partial.gameType ?? current.gameType,
|
||||||
|
blindtestMode: partial.blindtestMode ?? current.blindtestMode,
|
||||||
|
roundDuration: partial.roundDuration ?? current.roundDuration,
|
||||||
|
tracksPerPlayer: partial.tracksPerPlayer ?? current.tracksPerPlayer,
|
||||||
|
rounds: partial.rounds ?? current.rounds,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
startGame: (rounds) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const rounds = Array.from({ length: questionCount }, () => ({
|
get().updateSettings({ rounds })
|
||||||
type: "quiz" as const,
|
|
||||||
}))
|
|
||||||
// On pousse la séquence de manches, puis on lance.
|
|
||||||
socket.emit("lobby:updateSettings", {
|
|
||||||
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
|
|
||||||
roundDuration: 20,
|
|
||||||
rounds,
|
|
||||||
})
|
|
||||||
socket.emit("game:start", (res) => {
|
socket.emit("game:start", (res) => {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
resolve()
|
resolve()
|
||||||
|
|
@ -113,36 +159,91 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
submitTrack: (youtubeUrl) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
socket.emit("blindtest:submitTrack", { youtubeUrl }, (res) => resolve(res))
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeTrack: (trackId) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
socket.emit("blindtest:removeTrack", { trackId }, (res) => resolve(res.ok))
|
||||||
|
}),
|
||||||
|
|
||||||
|
returnToLobby: () => {
|
||||||
|
socket.emit("lobby:return")
|
||||||
|
},
|
||||||
|
|
||||||
vote: (choiceIndex) => {
|
vote: (choiceIndex) => {
|
||||||
if (get().myChoiceIndex !== null) {
|
if (get().hasVoted) {
|
||||||
return // vote déjà émis pour cette manche
|
return
|
||||||
}
|
}
|
||||||
set({ myChoiceIndex: choiceIndex })
|
set({ myChoiceIndex: choiceIndex, hasVoted: true })
|
||||||
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) => {
|
||||||
|
if (get().hasVoted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ hasVoted: true })
|
||||||
|
socket.emit("round:vote", { answer })
|
||||||
|
},
|
||||||
|
|
||||||
|
mediaControl: (action, positionSec) => {
|
||||||
|
socket.emit("media:control", { action, positionSec })
|
||||||
|
},
|
||||||
|
|
||||||
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
|
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
|
||||||
|
|
||||||
clearError: () => set({ lastError: null }),
|
clearError: () => set({ lastError: null }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
roomCode: null,
|
roomCode: null,
|
||||||
|
pseudoSet: false,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
round: null,
|
round: null,
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
|
hasVoted: false,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
|
mediaSync: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
roundKey: 0,
|
roundKey: 0,
|
||||||
|
roundModeChanged: false,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
|
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
|
||||||
socket.on("connect", () => useRoomStore.setState({ connected: true }))
|
socket.on("connect", () => useRoomStore.setState({ connected: true }))
|
||||||
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
|
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
|
||||||
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
|
socket.on("room:state", (snapshot) =>
|
||||||
|
useRoomStore.setState(
|
||||||
|
snapshot.status === "lobby"
|
||||||
|
? {
|
||||||
|
snapshot,
|
||||||
|
round: null,
|
||||||
|
reveal: null,
|
||||||
|
voteProgress: null,
|
||||||
|
myChoiceIndex: null,
|
||||||
|
hasVoted: false,
|
||||||
|
finalScores: null,
|
||||||
|
gameTracks: null,
|
||||||
|
mediaSync: null,
|
||||||
|
}
|
||||||
|
: { snapshot }
|
||||||
|
)
|
||||||
|
)
|
||||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||||
|
|
||||||
socket.on("round:start", (payload) =>
|
socket.on("round:start", (payload) =>
|
||||||
|
|
@ -150,6 +251,7 @@ socket.on("round:start", (payload) =>
|
||||||
round: {
|
round: {
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
djId: payload.djId,
|
djId: payload.djId,
|
||||||
|
mine: payload.mine,
|
||||||
startsAt: payload.startsAt,
|
startsAt: payload.startsAt,
|
||||||
endsAt: payload.endsAt,
|
endsAt: payload.endsAt,
|
||||||
payload: payload.payload,
|
payload: payload.payload,
|
||||||
|
|
@ -157,13 +259,17 @@ socket.on("round:start", (payload) =>
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
|
hasVoted: false,
|
||||||
|
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) =>
|
||||||
useRoomStore.setState({ voteProgress })
|
useRoomStore.setState({ voteProgress })
|
||||||
)
|
)
|
||||||
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||||
socket.on("game:end", ({ finalScores }) =>
|
socket.on("media:sync", (mediaSync) => useRoomStore.setState({ mediaSync }))
|
||||||
useRoomStore.setState({ finalScores })
|
socket.on("game:end", ({ finalScores, tracks }) =>
|
||||||
|
useRoomStore.setState({ finalScores, gameTracks: tracks ?? null })
|
||||||
)
|
)
|
||||||
|
|
|
||||||
44
apps/web/src/types/youtube.d.ts
vendored
Normal file
44
apps/web/src/types/youtube.d.ts
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Déclarations minimales de l'API YouTube IFrame Player (ce qu'on utilise).
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace YT {
|
||||||
|
interface PlayerEvent {
|
||||||
|
target: Player
|
||||||
|
data: number
|
||||||
|
}
|
||||||
|
interface PlayerOptions {
|
||||||
|
videoId?: string
|
||||||
|
playerVars?: Record<string, string | number>
|
||||||
|
events?: {
|
||||||
|
onReady?: (e: PlayerEvent) => void
|
||||||
|
onStateChange?: (e: PlayerEvent) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Player {
|
||||||
|
constructor(el: HTMLElement | string, opts: PlayerOptions)
|
||||||
|
playVideo(): void
|
||||||
|
pauseVideo(): void
|
||||||
|
seekTo(seconds: number, allowSeekAhead: boolean): void
|
||||||
|
getCurrentTime(): number
|
||||||
|
getDuration(): number
|
||||||
|
getPlayerState(): number
|
||||||
|
loadVideoById(id: string): void
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
const PlayerState: {
|
||||||
|
UNSTARTED: -1
|
||||||
|
ENDED: 0
|
||||||
|
PLAYING: 1
|
||||||
|
PAUSED: 2
|
||||||
|
BUFFERING: 3
|
||||||
|
CUED: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
YT?: typeof YT
|
||||||
|
onYouTubeIframeAPIReady?: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/shared/src/blindtest.ts
Normal file
37
packages/shared/src/blindtest.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Types concrets de l'épreuve Blindtest, partagés client/serveur.
|
||||||
|
// L'audio est joué localement par chaque client mais piloté par le DJ (media:sync).
|
||||||
|
|
||||||
|
/** Payload diffusé au lancement d'une manche blindtest (round:start), SANS la réponse. */
|
||||||
|
export interface BlindtestRoundPayload {
|
||||||
|
trackId: string
|
||||||
|
youtubeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérité révélée à tous (round:reveal → truth), inclut qui a soumis le titre. */
|
||||||
|
export interface BlindtestRevealTruth {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
youtubeId: string
|
||||||
|
submittedBy: string
|
||||||
|
submittedByName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat d'un joueur sur la manche (détail selon le mode). */
|
||||||
|
export interface BlindtestPlayerResult {
|
||||||
|
titleCorrect?: boolean
|
||||||
|
artistCorrect?: boolean
|
||||||
|
guessedCorrect?: boolean
|
||||||
|
points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** round:reveal → perPlayerResult : playerId → résultat. */
|
||||||
|
export type BlindtestPerPlayerResult = Record<string, BlindtestPlayerResult>
|
||||||
|
|
||||||
|
/** Titre récapitulé en fin de partie (export / récupération des liens). */
|
||||||
|
export interface BlindtestTrackInfo {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
youtubeId: string
|
||||||
|
url: string
|
||||||
|
submittedByName: string
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -27,17 +30,23 @@ 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). */
|
||||||
|
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
|
||||||
|
/** Nombre de titres que chaque joueur soumet (blindtest). */
|
||||||
|
tracksPerPlayer: number
|
||||||
/** Séquence d'épreuves de la partie. */
|
/** Séquence d'épreuves de la partie. */
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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: "mixed",
|
||||||
blindtestMode: "title_artist",
|
blindtestMode: "title_artist",
|
||||||
roundDuration: 60,
|
roundDuration: 60,
|
||||||
|
tracksPerPlayer: 2,
|
||||||
rounds: [],
|
rounds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,4 +95,6 @@ export interface RoomSnapshot {
|
||||||
currentRound: number
|
currentRound: number
|
||||||
/** Nombre total de manches planifiées. */
|
/** Nombre total de manches planifiées. */
|
||||||
totalRounds: number
|
totalRounds: number
|
||||||
|
/** Blindtest : nombre de titres soumis par joueur (comptes seulement, jamais les titres). */
|
||||||
|
submissions: { playerId: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,18 @@
|
||||||
import type {
|
import type {
|
||||||
Answer,
|
Answer,
|
||||||
BlindtestMode,
|
BlindtestMode,
|
||||||
|
GameType,
|
||||||
PlayerScore,
|
PlayerScore,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
RoundConfig,
|
RoundConfig,
|
||||||
RoundType,
|
RoundType,
|
||||||
} from "./domain"
|
} from "./domain"
|
||||||
|
import type { BlindtestTrackInfo } from "./blindtest"
|
||||||
|
|
||||||
// --- Payloads ------------------------------------------------------------
|
// --- Payloads ------------------------------------------------------------
|
||||||
|
|
||||||
export interface RoomCreatePayload {
|
export interface RoomCreatePayload {
|
||||||
playerName: string
|
playerName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomCreatedPayload {
|
export interface RoomCreatedPayload {
|
||||||
|
|
@ -23,12 +25,18 @@ export interface RoomCreatedPayload {
|
||||||
|
|
||||||
export interface RoomJoinPayload {
|
export interface RoomJoinPayload {
|
||||||
roomCode: string
|
roomCode: string
|
||||||
playerName: string
|
playerName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetNamePayload {
|
||||||
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsPayload {
|
export interface UpdateSettingsPayload {
|
||||||
|
gameType: GameType
|
||||||
blindtestMode: BlindtestMode
|
blindtestMode: BlindtestMode
|
||||||
roundDuration: number
|
roundDuration: number
|
||||||
|
tracksPerPlayer: number
|
||||||
rounds: RoundConfig[]
|
rounds: RoundConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +51,17 @@ export interface SubmitTrackPayload {
|
||||||
export interface SubmitOkPayload {
|
export interface SubmitOkPayload {
|
||||||
accepted: boolean
|
accepted: boolean
|
||||||
reason?: string
|
reason?: string
|
||||||
|
/** Id du titre créé (pour pouvoir le supprimer). */
|
||||||
|
trackId?: string
|
||||||
|
youtubeId?: string
|
||||||
|
/** Titre récupéré (oEmbed) si accepté — pour confirmation côté client. */
|
||||||
|
title?: string
|
||||||
|
/** Nombre total de titres soumis par ce joueur après cet ajout. */
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveTrackPayload {
|
||||||
|
trackId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoundStartPayload {
|
export interface RoundStartPayload {
|
||||||
|
|
@ -52,6 +71,8 @@ export interface RoundStartPayload {
|
||||||
startsAt: number
|
startsAt: number
|
||||||
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */
|
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */
|
||||||
endsAt: number
|
endsAt: number
|
||||||
|
/** Envoyé uniquement au contributeur du titre (blindtest) : "c'est le tien". */
|
||||||
|
mine?: boolean
|
||||||
/** Payload spécifique au type d'épreuve, SANS la réponse. */
|
/** Payload spécifique au type d'épreuve, SANS la réponse. */
|
||||||
payload: unknown
|
payload: unknown
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +114,8 @@ export interface ScoreUpdatePayload {
|
||||||
|
|
||||||
export interface GameEndPayload {
|
export interface GameEndPayload {
|
||||||
finalScores: PlayerScore[]
|
finalScores: PlayerScore[]
|
||||||
|
/** Titres joués (si la partie comportait du blindtest) — pour récupérer les liens. */
|
||||||
|
tracks?: BlindtestTrackInfo[]
|
||||||
spotifyExport?: unknown
|
spotifyExport?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,9 +133,18 @@ export type Ack<T> = (result: { ok: true; data: T } | { ok: false; error: ErrorP
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
|
"player:setName": (payload: SetNamePayload) => void
|
||||||
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
||||||
|
"lobby:return": () => void
|
||||||
"game:start": (ack: Ack<null>) => void
|
"game:start": (ack: Ack<null>) => void
|
||||||
"blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack<SubmitOkPayload>) => void
|
"blindtest:submitTrack": (
|
||||||
|
payload: SubmitTrackPayload,
|
||||||
|
ack: (res: SubmitOkPayload) => void
|
||||||
|
) => void
|
||||||
|
"blindtest:removeTrack": (
|
||||||
|
payload: RemoveTrackPayload,
|
||||||
|
ack: (res: { ok: boolean }) => void
|
||||||
|
) => void
|
||||||
"media:control": (payload: MediaControlPayload) => void
|
"media:control": (payload: MediaControlPayload) => void
|
||||||
"round:vote": (payload: VotePayload) => void
|
"round:vote": (payload: VotePayload) => void
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./domain"
|
export * from "./domain"
|
||||||
export * from "./events"
|
export * from "./events"
|
||||||
export * from "./quiz"
|
export * from "./quiz"
|
||||||
|
export * from "./blindtest"
|
||||||
|
|
|
||||||
|
|
@ -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