feature/quiz-mode #4
28 changed files with 1372 additions and 81 deletions
|
|
@ -66,6 +66,7 @@ function setup(roundDurationSec: number): {
|
||||||
const options: GameEngineOptions = {
|
const options: GameEngineOptions = {
|
||||||
createRound: () => dummyRound,
|
createRound: () => dummyRound,
|
||||||
revealPauseMs: 0,
|
revealPauseMs: 0,
|
||||||
|
leadMs: 0,
|
||||||
}
|
}
|
||||||
return { io, emits, rooms, room, p1: a.id, p2: b.id, options }
|
return { io, emits, rooms, room, p1: a.id, p2: b.id, options }
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +110,9 @@ describe("GameEngine", () => {
|
||||||
const ack = emits.find((e) => e.event === "round:voteAck")!.payload as {
|
const ack = emits.find((e) => e.event === "round:voteAck")!.payload as {
|
||||||
count: number
|
count: number
|
||||||
total: number
|
total: number
|
||||||
|
voted: string[]
|
||||||
}
|
}
|
||||||
expect(ack).toEqual({ count: 1, total: 2 })
|
expect(ack).toEqual({ count: 1, total: 2, voted: [p1] })
|
||||||
|
|
||||||
engine.handleVote(p2, { choiceIndex: 0 })
|
engine.handleVote(p2, { choiceIndex: 0 })
|
||||||
await run
|
await run
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,15 @@ export interface GameEngineOptions {
|
||||||
createRound?: (type: RoundConfig["type"]) => GameRound
|
createRound?: (type: RoundConfig["type"]) => GameRound
|
||||||
/** Pause d'affichage du reveal/scores entre deux manches (ms). */
|
/** Pause d'affichage du reveal/scores entre deux manches (ms). */
|
||||||
revealPauseMs?: number
|
revealPauseMs?: number
|
||||||
|
/** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */
|
||||||
|
leadMs?: number
|
||||||
/** Permet de patcher l'horloge en test ; défaut = Date.now. */
|
/** Permet de patcher l'horloge en test ; défaut = Date.now. */
|
||||||
now?: () => number
|
now?: () => number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_REVEAL_PAUSE_MS = 5000
|
const DEFAULT_REVEAL_PAUSE_MS = 5000
|
||||||
|
// Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche.
|
||||||
|
const DEFAULT_LEAD_MS = 1600
|
||||||
|
|
||||||
export class GameEngine implements RoomGameController {
|
export class GameEngine implements RoomGameController {
|
||||||
private runtime: RoundRuntime | null = null
|
private runtime: RoundRuntime | null = null
|
||||||
|
|
@ -34,6 +38,7 @@ export class GameEngine implements RoomGameController {
|
||||||
private resolveRoundEnd: (() => void) | null = null
|
private resolveRoundEnd: (() => void) | null = null
|
||||||
private readonly createRound: (type: RoundConfig["type"]) => GameRound
|
private readonly createRound: (type: RoundConfig["type"]) => GameRound
|
||||||
private readonly revealPauseMs: number
|
private readonly revealPauseMs: number
|
||||||
|
private readonly leadMs: number
|
||||||
private readonly now: () => number
|
private readonly now: () => number
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -44,6 +49,7 @@ export class GameEngine implements RoomGameController {
|
||||||
) {
|
) {
|
||||||
this.createRound = options.createRound ?? defaultCreateRound
|
this.createRound = options.createRound ?? defaultCreateRound
|
||||||
this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS
|
this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS
|
||||||
|
this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS
|
||||||
this.now = options.now ?? Date.now
|
this.now = options.now ?? Date.now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +79,7 @@ export class GameEngine implements RoomGameController {
|
||||||
this.io.to(this.room.code).emit("round:voteAck", {
|
this.io.to(this.room.code).emit("round:voteAck", {
|
||||||
count: this.runtime.votes.size,
|
count: this.runtime.votes.size,
|
||||||
total,
|
total,
|
||||||
|
voted: [...this.runtime.votes.keys()],
|
||||||
})
|
})
|
||||||
// Fin anticipée : tous les éligibles ont voté.
|
// Fin anticipée : tous les éligibles ont voté.
|
||||||
if (total > 0 && this.runtime.votes.size >= total) {
|
if (total > 0 && this.runtime.votes.size >= total) {
|
||||||
|
|
@ -84,7 +91,9 @@ export class GameEngine implements RoomGameController {
|
||||||
const round = this.createRound(config.type)
|
const round = this.createRound(config.type)
|
||||||
const start = round.start(this.room)
|
const start = round.start(this.room)
|
||||||
const durationSec = start.durationSec ?? this.room.settings.roundDuration
|
const durationSec = start.durationSec ?? this.room.settings.roundDuration
|
||||||
const startedAt = this.now()
|
const now = this.now()
|
||||||
|
// Les réponses ne comptent qu'après le délai de préparation (transition).
|
||||||
|
const startedAt = now + this.leadMs
|
||||||
const endsAt = startedAt + durationSec * 1000
|
const endsAt = startedAt + durationSec * 1000
|
||||||
|
|
||||||
this.runtime = {
|
this.runtime = {
|
||||||
|
|
@ -100,6 +109,7 @@ export class GameEngine implements RoomGameController {
|
||||||
this.io.to(this.room.code).emit("round:start", {
|
this.io.to(this.room.code).emit("round:start", {
|
||||||
type: round.type,
|
type: round.type,
|
||||||
djId: this.runtime.djId ?? undefined,
|
djId: this.runtime.djId ?? undefined,
|
||||||
|
startsAt: startedAt,
|
||||||
endsAt,
|
endsAt,
|
||||||
payload: start.payload,
|
payload: start.payload,
|
||||||
})
|
})
|
||||||
|
|
@ -107,7 +117,7 @@ export class GameEngine implements RoomGameController {
|
||||||
// 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) => {
|
||||||
this.resolveRoundEnd = resolve
|
this.resolveRoundEnd = resolve
|
||||||
this.timer = setTimeout(() => this.endRound(), durationSec * 1000)
|
this.timer = setTimeout(() => this.endRound(), endsAt - now)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ctx = this.context(this.runtime)
|
const ctx = this.context(this.runtime)
|
||||||
|
|
|
||||||
4
apps/server/src/game/modes/index.ts
Normal file
4
apps/server/src/game/modes/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Point d'enregistrement de toutes les épreuves. Importé une fois au démarrage
|
||||||
|
// pour ses effets de bord (registerRound). Ajouter un mode = une ligne ici.
|
||||||
|
|
||||||
|
import "./quiz"
|
||||||
7
apps/server/src/game/modes/quiz/index.ts
Normal file
7
apps/server/src/game/modes/quiz/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { registerRound } from "../../registry"
|
||||||
|
import { QuizRound } from "./quiz-round"
|
||||||
|
|
||||||
|
// Brancher l'épreuve = un seul registerRound, aucune plomberie à toucher.
|
||||||
|
registerRound("quiz", () => new QuizRound())
|
||||||
|
|
||||||
|
export { QuizRound }
|
||||||
110
apps/server/src/game/modes/quiz/questions.ts
Normal file
110
apps/server/src/game/modes/quiz/questions.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Banque de questions en dur pour la V1 (mode quiz culture geek).
|
||||||
|
// Le seed Open Trivia DB + le back-office manuel arrivent aux étapes 5 et 7 ;
|
||||||
|
// d'ici là, ce petit jeu suffit à jouer une partie complète de bout en bout.
|
||||||
|
|
||||||
|
import type { QuizFormat } from "@nerdware/shared"
|
||||||
|
|
||||||
|
export interface QuizQuestion {
|
||||||
|
id: string
|
||||||
|
format: QuizFormat
|
||||||
|
prompt: string
|
||||||
|
choices: string[]
|
||||||
|
correctIndex: number
|
||||||
|
category: string
|
||||||
|
difficulty: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRUE_FALSE = ["Vrai", "Faux"]
|
||||||
|
|
||||||
|
export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "q-zelda-princess",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Dans la saga The Legend of Zelda, quel est le nom du héros ?",
|
||||||
|
choices: ["Zelda", "Link", "Ganon", "Navi"],
|
||||||
|
correctIndex: 1,
|
||||||
|
category: "Jeux vidéo",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-mario-plumber",
|
||||||
|
format: "truefalse",
|
||||||
|
prompt: "Mario est plombier de profession.",
|
||||||
|
choices: TRUE_FALSE,
|
||||||
|
correctIndex: 0,
|
||||||
|
category: "Jeux vidéo",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-onepiece-captain",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Qui est le capitaine de l'équipage du Chapeau de paille dans One Piece ?",
|
||||||
|
choices: ["Zoro", "Sanji", "Luffy", "Usopp"],
|
||||||
|
correctIndex: 2,
|
||||||
|
category: "Manga",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-pokemon-first",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Quel Pokémon porte le numéro 001 dans le Pokédex national ?",
|
||||||
|
choices: ["Salamèche", "Carapuce", "Pikachu", "Bulbizarre"],
|
||||||
|
correctIndex: 3,
|
||||||
|
category: "Jeux vidéo",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-naruto-village",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "De quel village ninja Naruto est-il originaire ?",
|
||||||
|
choices: ["Konoha", "Suna", "Kiri", "Iwa"],
|
||||||
|
correctIndex: 0,
|
||||||
|
category: "Manga",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-minecraft-creeper",
|
||||||
|
format: "truefalse",
|
||||||
|
prompt: "Dans Minecraft, le Creeper explose au contact du joueur.",
|
||||||
|
choices: TRUE_FALSE,
|
||||||
|
correctIndex: 0,
|
||||||
|
category: "Jeux vidéo",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-starwars-vador",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Dans Star Wars, qui est le père de Luke Skywalker ?",
|
||||||
|
choices: ["Obi-Wan Kenobi", "Dark Vador", "Yoda", "Palpatine"],
|
||||||
|
correctIndex: 1,
|
||||||
|
category: "Pop culture",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-dbz-kamehameha",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Dans Dragon Ball, quelle attaque emblématique Sangoku utilise-t-il ?",
|
||||||
|
choices: ["Genkidama", "Kamehameha", "Final Flash", "Makankosappo"],
|
||||||
|
correctIndex: 1,
|
||||||
|
category: "Manga",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-tetris-origin",
|
||||||
|
format: "truefalse",
|
||||||
|
prompt: "Le jeu Tetris a été créé par un développeur soviétique.",
|
||||||
|
choices: TRUE_FALSE,
|
||||||
|
correctIndex: 0,
|
||||||
|
category: "Jeux vidéo",
|
||||||
|
difficulty: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q-matrix-pill",
|
||||||
|
format: "mcq",
|
||||||
|
prompt: "Dans Matrix, quelle pilule Neo doit-il prendre pour découvrir la vérité ?",
|
||||||
|
choices: ["La bleue", "La rouge", "La verte", "La jaune"],
|
||||||
|
correctIndex: 1,
|
||||||
|
category: "Pop culture",
|
||||||
|
difficulty: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
59
apps/server/src/game/modes/quiz/quiz-round.test.ts
Normal file
59
apps/server/src/game/modes/quiz/quiz-round.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { RoomManager } from "../../../rooms"
|
||||||
|
import type { RoundContext } from "../../round"
|
||||||
|
import { QuizRound } from "./quiz-round"
|
||||||
|
import { QUIZ_QUESTIONS } from "./questions"
|
||||||
|
|
||||||
|
const question = QUIZ_QUESTIONS[0] // "Link" → correctIndex 1
|
||||||
|
|
||||||
|
function makeCtx(): { ctx: RoundContext; p1: string; p2: string } {
|
||||||
|
const rooms = new RoomManager()
|
||||||
|
const { room, player: a } = rooms.create("Alice", "sa")
|
||||||
|
const { player: b } = rooms.join(room.code, "Bob", "sb")
|
||||||
|
const ctx: RoundContext = {
|
||||||
|
room,
|
||||||
|
djId: null,
|
||||||
|
votes: new Map(),
|
||||||
|
startedAt: 0,
|
||||||
|
endsAt: 10_000,
|
||||||
|
data: { question, votedAt: new Map() },
|
||||||
|
}
|
||||||
|
return { ctx, p1: a.id, p2: b.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("QuizRound", () => {
|
||||||
|
test("verrouille le premier vote (idempotent)", () => {
|
||||||
|
const round = new QuizRound(() => 1000)
|
||||||
|
const { ctx, p1 } = makeCtx()
|
||||||
|
round.submitAnswer(ctx, p1, { choiceIndex: 1 })
|
||||||
|
round.submitAnswer(ctx, p1, { choiceIndex: 0 }) // ignoré
|
||||||
|
expect(ctx.votes.get(p1)).toEqual({ choiceIndex: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignore un index hors bornes", () => {
|
||||||
|
const round = new QuizRound(() => 1000)
|
||||||
|
const { ctx, p1 } = makeCtx()
|
||||||
|
round.submitAnswer(ctx, p1, { choiceIndex: 99 })
|
||||||
|
expect(ctx.votes.has(p1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reveal expose la vérité + le résultat par joueur", () => {
|
||||||
|
const round = new QuizRound(() => 1000)
|
||||||
|
const { ctx, p1, p2 } = makeCtx()
|
||||||
|
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste
|
||||||
|
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
|
||||||
|
const { truth, perPlayerResult } = round.reveal(ctx)
|
||||||
|
expect(truth.correctIndex).toBe(question.correctIndex)
|
||||||
|
expect(perPlayerResult[p1]).toEqual({ choiceIndex: 1, correct: true })
|
||||||
|
expect(perPlayerResult[p2]).toEqual({ choiceIndex: 0, correct: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("score = base + bonus rapidité pour les bonnes réponses uniquement", () => {
|
||||||
|
const round = new QuizRound(() => 1000) // vote à t=1000 sur 10000 → reste 90%
|
||||||
|
const { ctx, p1, p2 } = makeCtx()
|
||||||
|
round.submitAnswer(ctx, p1, { choiceIndex: 1 }) // juste, rapide
|
||||||
|
round.submitAnswer(ctx, p2, { choiceIndex: 0 }) // faux
|
||||||
|
const deltas = round.score(ctx)
|
||||||
|
expect(deltas).toEqual([{ playerId: p1, delta: 190 }]) // 100 + round(100 * 0.9)
|
||||||
|
})
|
||||||
|
})
|
||||||
115
apps/server/src/game/modes/quiz/quiz-round.ts
Normal file
115
apps/server/src/game/modes/quiz/quiz-round.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
// Épreuve Quiz : une question = une manche. Pas de DJ, pas d'audio.
|
||||||
|
// Implémente le contrat GameRound ; le moteur fait tout le reste.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Answer,
|
||||||
|
QuizPerPlayerResult,
|
||||||
|
QuizQuestionPayload,
|
||||||
|
QuizRevealTruth,
|
||||||
|
ScoreDelta,
|
||||||
|
} from "@nerdware/shared"
|
||||||
|
import type { GameRound, RoundContext, RoundStart } from "../../round"
|
||||||
|
import type { ServerRoom } from "../../../rooms"
|
||||||
|
import { QUIZ_QUESTIONS, type QuizQuestion } from "./questions"
|
||||||
|
|
||||||
|
const BASE_POINTS = 100
|
||||||
|
const SPEED_BONUS_MAX = 100
|
||||||
|
|
||||||
|
/** Données privées de la manche (jamais diffusées). */
|
||||||
|
interface QuizRoundData {
|
||||||
|
question: QuizQuestion
|
||||||
|
/** Premier instant de vote par joueur, pour le bonus rapidité. */
|
||||||
|
votedAt: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-répétition à l'échelle d'une partie : questions déjà jouées par room.
|
||||||
|
const playedByRoom = new WeakMap<ServerRoom, Set<string>>()
|
||||||
|
|
||||||
|
function pickQuestion(room: ServerRoom): QuizQuestion {
|
||||||
|
let played = playedByRoom.get(room)
|
||||||
|
if (!played) {
|
||||||
|
played = new Set()
|
||||||
|
playedByRoom.set(room, played)
|
||||||
|
}
|
||||||
|
let pool = QUIZ_QUESTIONS.filter((q) => !played.has(q.id))
|
||||||
|
if (pool.length === 0) {
|
||||||
|
// Banque épuisée sur cette partie : on autorise à nouveau les répétitions.
|
||||||
|
played.clear()
|
||||||
|
pool = QUIZ_QUESTIONS
|
||||||
|
}
|
||||||
|
const question = pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
played.add(question.id)
|
||||||
|
return question
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuizAnswer(answer: Answer): answer is { choiceIndex: number } {
|
||||||
|
return (
|
||||||
|
typeof (answer as { choiceIndex?: unknown }).choiceIndex === "number"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuizRound implements GameRound {
|
||||||
|
readonly type = "quiz" as const
|
||||||
|
|
||||||
|
constructor(private readonly now: () => number = Date.now) {}
|
||||||
|
|
||||||
|
start(room: ServerRoom): RoundStart {
|
||||||
|
const question = pickQuestion(room)
|
||||||
|
const payload: QuizQuestionPayload = {
|
||||||
|
format: question.format,
|
||||||
|
prompt: question.prompt,
|
||||||
|
choices: question.choices,
|
||||||
|
category: question.category,
|
||||||
|
difficulty: question.difficulty,
|
||||||
|
}
|
||||||
|
const data: QuizRoundData = { question, votedAt: new Map() }
|
||||||
|
return { djId: null, payload, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void {
|
||||||
|
// Premier vote verrouillé (idempotent) : on ignore les votes suivants.
|
||||||
|
if (ctx.votes.has(playerId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isQuizAnswer(answer)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { question, votedAt } = ctx.data as QuizRoundData
|
||||||
|
if (answer.choiceIndex < 0 || answer.choiceIndex >= question.choices.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.votes.set(playerId, { choiceIndex: answer.choiceIndex })
|
||||||
|
votedAt.set(playerId, this.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(ctx: RoundContext): { truth: QuizRevealTruth; perPlayerResult: QuizPerPlayerResult } {
|
||||||
|
const { question } = ctx.data as QuizRoundData
|
||||||
|
const perPlayerResult: QuizPerPlayerResult = {}
|
||||||
|
for (const player of ctx.room.players.values()) {
|
||||||
|
const vote = ctx.votes.get(player.id) as { choiceIndex: number } | undefined
|
||||||
|
const choiceIndex = vote ? vote.choiceIndex : null
|
||||||
|
perPlayerResult[player.id] = {
|
||||||
|
choiceIndex,
|
||||||
|
correct: choiceIndex === question.correctIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { truth: { correctIndex: question.correctIndex }, perPlayerResult }
|
||||||
|
}
|
||||||
|
|
||||||
|
score(ctx: RoundContext): ScoreDelta[] {
|
||||||
|
const { question, votedAt } = ctx.data as QuizRoundData
|
||||||
|
const total = ctx.endsAt - ctx.startedAt
|
||||||
|
const deltas: ScoreDelta[] = []
|
||||||
|
for (const [playerId, vote] of ctx.votes) {
|
||||||
|
if ((vote as { choiceIndex: number }).choiceIndex !== question.correctIndex) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Bonus rapidité : proportionnel au temps restant au moment du vote.
|
||||||
|
const at = votedAt.get(playerId) ?? ctx.endsAt
|
||||||
|
const remaining = Math.max(0, Math.min(1, (ctx.endsAt - at) / total))
|
||||||
|
const bonus = Math.round(SPEED_BONUS_MAX * remaining)
|
||||||
|
deltas.push({ playerId, delta: BASE_POINTS + bonus })
|
||||||
|
}
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import Fastify from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import { env, isDev } from "./env"
|
import { env, isDev } from "./env"
|
||||||
import { createSocketServer } from "./socket"
|
import { createSocketServer } from "./socket"
|
||||||
|
import "./game/modes" // enregistre les épreuves (registerRound)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: isDev
|
logger: isDev
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dicebear/core": "^10",
|
||||||
|
"@dicebear/styles": "^10",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
|
"framer-motion": "^11",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { Route, Switch } from "wouter"
|
import { Route, Switch } from "wouter"
|
||||||
import { HomePage } from "@/pages/home"
|
import { HomePage } from "@/pages/home"
|
||||||
import { LobbyPage } from "@/pages/lobby"
|
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="/room/:code">
|
<Route path="/room/:code">
|
||||||
{(params) => <LobbyPage code={params.code.toUpperCase()} />}
|
{(params) => <RoomPage code={params.code.toUpperCase()} />}
|
||||||
</Route>
|
</Route>
|
||||||
<Route>
|
<Route>
|
||||||
<div className="flex min-h-svh items-center justify-center p-6">
|
<div className="flex min-h-svh items-center justify-center p-6">
|
||||||
|
|
|
||||||
15
apps/web/src/assets/transitions/README.md
Normal file
15
apps/web/src/assets/transitions/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Animations de transition (style WarioWare)
|
||||||
|
|
||||||
|
Dépose ici des fichiers d'animation pour les transitions entre manches :
|
||||||
|
formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
|
||||||
|
|
||||||
|
- Ils sont détectés automatiquement au build (`import.meta.glob`).
|
||||||
|
- À chaque manche, un fichier est tiré **au hasard** parmi ceux présents.
|
||||||
|
- S'il n'y en a aucun, l'animation Framer par défaut (numéro + « PRÊT ?! ») est jouée.
|
||||||
|
|
||||||
|
Le média est centré, limité à 70vh / 80vw, et joué par-dessus le wipe coloré
|
||||||
|
plein écran. Durée d'affichage ≈ 1,3 s (voir `VISIBLE_MS` dans
|
||||||
|
`src/components/round-transition.tsx`), calée sur le délai de préparation
|
||||||
|
serveur (`leadMs`) pour ne pas rogner le temps de réponse.
|
||||||
|
|
||||||
|
> ⚠️ N'utilise que des médias dont tu as les droits (pas d'assets Nintendo).
|
||||||
28
apps/web/src/components/avatar.tsx
Normal file
28
apps/web/src/components/avatar.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Avatar as DicebearAvatar, Style } from "@dicebear/core"
|
||||||
|
import loreleiDefinition from "@dicebear/styles/lorelei.json"
|
||||||
|
|
||||||
|
// Génération locale (pas d'appel à l'API DiceBear) : self-hosted, offline,
|
||||||
|
// aucun pseudo envoyé à un tiers. Lorelei par Lisa Wischofsky — licence CC0 1.0.
|
||||||
|
const style = new Style(loreleiDefinition)
|
||||||
|
const cache = new Map<string, string>()
|
||||||
|
|
||||||
|
/** Data URI de l'avatar pour un seed donné, mémoïsé. */
|
||||||
|
function avatarUri(seed: string): string {
|
||||||
|
let uri = cache.get(seed)
|
||||||
|
if (!uri) {
|
||||||
|
uri = new DicebearAvatar(style, { seed }).toDataUri()
|
||||||
|
cache.set(seed, uri)
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avatar DiceBear (style lorelei), seedé par le pseudo → stable et reconnaissable. */
|
||||||
|
export function Avatar({ seed, className }: { seed: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={avatarUri(seed)}
|
||||||
|
alt={`Avatar de ${seed}`}
|
||||||
|
className={`bg-muted shrink-0 rounded-full ${className ?? ""}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
apps/web/src/components/boom.tsx
Normal file
35
apps/web/src/components/boom.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explosion plein écran : flash blanc + 💥 géant.
|
||||||
|
* Rendue dans document.body via portal pour échapper aux transforms parents,
|
||||||
|
* et montée avec une `key` qui change → l'animation rejoue à chaque boom.
|
||||||
|
*/
|
||||||
|
export function Boom() {
|
||||||
|
return createPortal(
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0, 0.9, 0] }}
|
||||||
|
transition={{ duration: 0.5, times: [0, 0.12, 1], ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
className="relative leading-none select-none"
|
||||||
|
style={{ fontSize: "45vmin" }}
|
||||||
|
initial={{ scale: 0, rotate: -20, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0, 1.35, 1.1],
|
||||||
|
rotate: [-20, 12, 0],
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
x: [0, 12, -8, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.8, times: [0, 0.3, 1], ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
💥
|
||||||
|
</motion.span>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
87
apps/web/src/components/countdown.tsx
Normal file
87
apps/web/src/components/countdown.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
||||||
|
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondes restantes. Avant `startsAt` (phase de préparation / transition),
|
||||||
|
* on affiche la durée pleine sans décompter.
|
||||||
|
*/
|
||||||
|
function secondsLeft(startsAt: number, endsAt: number): number {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now < startsAt) {
|
||||||
|
return Math.ceil((endsAt - startsAt) / 1000)
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.ceil((endsAt - now) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blanc → rouge sur les DANGER_FROM dernières secondes. */
|
||||||
|
function dangerColor(t: number): string {
|
||||||
|
const r = Math.round(lerp(255, 239, t))
|
||||||
|
const g = Math.round(lerp(255, 68, t))
|
||||||
|
const b = Math.round(lerp(255, 68, t))
|
||||||
|
return `rgb(${r}, ${g}, ${b})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Countdown({
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
}: {
|
||||||
|
startsAt: number
|
||||||
|
endsAt: number
|
||||||
|
}) {
|
||||||
|
const [left, setLeft] = useState(() => secondsLeft(startsAt, endsAt))
|
||||||
|
const boom = useRoomStore((s) => s.boom)
|
||||||
|
const boomed = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [startsAt, endsAt])
|
||||||
|
|
||||||
|
// Déclenche l'explosion une seule fois au passage à 0 (fin au timer).
|
||||||
|
useEffect(() => {
|
||||||
|
if (left === 0 && !boomed.current) {
|
||||||
|
boomed.current = true
|
||||||
|
boom()
|
||||||
|
}
|
||||||
|
}, [left, boom])
|
||||||
|
|
||||||
|
const exploded = left === 0
|
||||||
|
const danger = left <= DANGER_FROM && left > 0
|
||||||
|
// 0 (début du danger) → 1 (explosion imminente)
|
||||||
|
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
|
||||||
|
|
||||||
|
// Plus on approche de 0, plus le "battement" est ample et rapide.
|
||||||
|
const peak = 1.15 + intensity * 0.5
|
||||||
|
const wiggle = 4 + intensity * 12
|
||||||
|
const duration = 0.7 - intensity * 0.46
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
|
||||||
|
style={{ color: danger ? dangerColor(intensity) : undefined }}
|
||||||
|
animate={
|
||||||
|
danger
|
||||||
|
? {
|
||||||
|
scale: [1, peak, 0.92, peak * 0.97, 1],
|
||||||
|
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
|
||||||
|
}
|
||||||
|
: { scale: 1, rotate: 0 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
danger
|
||||||
|
? { duration, repeat: Infinity, ease: "easeInOut" }
|
||||||
|
: { duration: 0.2 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{danger && <span aria-hidden>💣</span>}
|
||||||
|
{exploded ? "💥" : left}
|
||||||
|
</motion.span>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
apps/web/src/components/game-end-view.tsx
Normal file
204
apps/web/src/components/game-end-view.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { Link } from "wouter"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { Crown } from "lucide-react"
|
||||||
|
import type { PlayerScore, RoomSnapshot } from "@nerdware/shared"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
|
type Place = 1 | 2 | 3
|
||||||
|
|
||||||
|
// Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place.
|
||||||
|
const PODIUM: Record<Place, {
|
||||||
|
bar: string
|
||||||
|
pedestal: string
|
||||||
|
text: string
|
||||||
|
ring: string
|
||||||
|
avatar: string
|
||||||
|
}> = {
|
||||||
|
1: {
|
||||||
|
bar: "h-24",
|
||||||
|
pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5",
|
||||||
|
text: "text-amber-400",
|
||||||
|
ring: "ring-amber-400",
|
||||||
|
avatar: "size-20",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
bar: "h-16",
|
||||||
|
pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5",
|
||||||
|
text: "text-zinc-300",
|
||||||
|
ring: "ring-zinc-300",
|
||||||
|
avatar: "size-16",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
bar: "h-12",
|
||||||
|
pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5",
|
||||||
|
text: "text-amber-600",
|
||||||
|
ring: "ring-amber-700",
|
||||||
|
avatar: "size-16",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodiumColumn({
|
||||||
|
entry,
|
||||||
|
place,
|
||||||
|
name,
|
||||||
|
isMe,
|
||||||
|
delay,
|
||||||
|
}: {
|
||||||
|
entry: PlayerScore
|
||||||
|
place: Place
|
||||||
|
name: string
|
||||||
|
isMe: boolean
|
||||||
|
delay: number
|
||||||
|
}) {
|
||||||
|
const cfg = PODIUM[place]
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 30, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 22, delay }}
|
||||||
|
className="flex w-24 flex-col items-center gap-1"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{place === 1 && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ y: -26, rotate: -25, opacity: 0 }}
|
||||||
|
animate={{ y: 0, rotate: 0, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 14, delay: delay + 0.25 }}
|
||||||
|
className="absolute -top-5 left-1/2 z-10 -translate-x-1/2"
|
||||||
|
>
|
||||||
|
<Crown className="size-8 fill-amber-400 text-amber-400 drop-shadow" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
animate={
|
||||||
|
place === 1
|
||||||
|
? {
|
||||||
|
boxShadow: [
|
||||||
|
"0 0 0px 0px rgba(251,191,36,0)",
|
||||||
|
"0 0 24px 4px rgba(251,191,36,0.5)",
|
||||||
|
"0 0 12px 2px rgba(251,191,36,0.25)",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse" }}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar seed={name} className={`${cfg.avatar} ring-2 ${cfg.ring}`} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="max-w-24 truncate text-sm font-medium">
|
||||||
|
{name}
|
||||||
|
{isMe && <span className="text-muted-foreground"> (toi)</span>}
|
||||||
|
</span>
|
||||||
|
<span className={`font-heading text-lg font-black tabular-nums ${cfg.text}`}>
|
||||||
|
{entry.score}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mt-1 flex w-full items-start justify-center rounded-t-lg border-x border-t-2 pt-1 ${cfg.bar} ${cfg.pedestal}`}
|
||||||
|
>
|
||||||
|
<span className={`font-heading text-2xl font-black ${cfg.text}`}>
|
||||||
|
{place}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
|
const finalScores = useRoomStore((s) => s.finalScores)
|
||||||
|
const reset = useRoomStore((s) => s.reset)
|
||||||
|
|
||||||
|
const scores: PlayerScore[] = finalScores ?? snapshot.scores
|
||||||
|
const ranked = [...scores].sort((a, b) => b.score - a.score)
|
||||||
|
const nameOf = (id: string) =>
|
||||||
|
snapshot.players.find((p) => p.id === id)?.name ?? "?"
|
||||||
|
const isMe = (id: string) => id === playerId
|
||||||
|
|
||||||
|
const [first, second, third] = ranked
|
||||||
|
const rest = ranked.slice(3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6 text-center">
|
||||||
|
<header>
|
||||||
|
<p className="text-muted-foreground text-xs uppercase">
|
||||||
|
Partie terminée
|
||||||
|
</p>
|
||||||
|
<h2 className="font-heading text-2xl font-bold">
|
||||||
|
🏆 {first ? nameOf(first.playerId) : "?"} l'emporte !
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
|
||||||
|
<div className="mt-4 flex items-end justify-center gap-2">
|
||||||
|
{second && (
|
||||||
|
<PodiumColumn
|
||||||
|
entry={second}
|
||||||
|
place={2}
|
||||||
|
name={nameOf(second.playerId)}
|
||||||
|
isMe={isMe(second.playerId)}
|
||||||
|
delay={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{first && (
|
||||||
|
<PodiumColumn
|
||||||
|
entry={first}
|
||||||
|
place={1}
|
||||||
|
name={nameOf(first.playerId)}
|
||||||
|
isMe={isMe(first.playerId)}
|
||||||
|
delay={0.3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{third && (
|
||||||
|
<PodiumColumn
|
||||||
|
entry={third}
|
||||||
|
place={3}
|
||||||
|
name={nameOf(third.playerId)}
|
||||||
|
isMe={isMe(third.playerId)}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<ol className="flex flex-col gap-1">
|
||||||
|
{rest.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={s.playerId}
|
||||||
|
className={`bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm ${
|
||||||
|
isMe(s.playerId) ? "ring-primary ring-2" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground w-5 text-right tabular-nums">
|
||||||
|
{i + 4}
|
||||||
|
</span>
|
||||||
|
<Avatar seed={nameOf(s.playerId)} className="size-7" />
|
||||||
|
<span>
|
||||||
|
{nameOf(s.playerId)}
|
||||||
|
{isMe(s.playerId) && (
|
||||||
|
<span className="text-muted-foreground"> (toi)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-heading font-bold tabular-nums">
|
||||||
|
{s.score}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="secondary" className="w-full" onClick={reset}>
|
||||||
|
Retour à l'accueil
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
apps/web/src/components/lobby-view.tsx
Normal file
91
apps/web/src/components/lobby-view.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import type { RoomSnapshot } from "@nerdware/shared"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
|
const QUESTION_OPTIONS = [3, 5, 10]
|
||||||
|
|
||||||
|
export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
|
const startGame = useRoomStore((s) => s.startGame)
|
||||||
|
const isHost = snapshot.hostId === playerId
|
||||||
|
|
||||||
|
const [count, setCount] = useState(5)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await startGame(count)
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as { message?: string }).message ?? "Erreur")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6">
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-muted-foreground text-sm font-medium">
|
||||||
|
Joueurs ({snapshot.players.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{snapshot.players.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-2 ${p.connected ? "" : "opacity-40"}`}
|
||||||
|
>
|
||||||
|
<Avatar seed={p.name} className="size-11" />
|
||||||
|
{p.name}
|
||||||
|
{p.id === playerId && (
|
||||||
|
<span className="text-muted-foreground"> (toi)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{p.id === snapshot.hostId ? "Hôte" : ""}
|
||||||
|
{!p.connected && " · hors ligne"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isHost ? (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">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>
|
||||||
|
<Button disabled={busy} onClick={handleStart}>
|
||||||
|
{busy ? "Lancement…" : "Lancer la partie"}
|
||||||
|
</Button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive text-center text-sm">{error}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
En attente du lancement de la partie par l'hôte…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
apps/web/src/components/player-cards.tsx
Normal file
107
apps/web/src/components/player-cards.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { Check, Crown } from "lucide-react"
|
||||||
|
import type { RoomSnapshot } from "@nerdware/shared"
|
||||||
|
import { Avatar } from "@/components/avatar"
|
||||||
|
|
||||||
|
interface PlayerCardsProps {
|
||||||
|
snapshot: RoomSnapshot
|
||||||
|
playerId: string | null
|
||||||
|
/** Affiche l'état de vote (manche en cours uniquement). */
|
||||||
|
showVoteStatus?: boolean
|
||||||
|
/** playerIds ayant déjà validé leur réponse. */
|
||||||
|
votedIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HUD persistant : une card par joueur avec son score, couronne au leader. */
|
||||||
|
export function PlayerCards({
|
||||||
|
snapshot,
|
||||||
|
playerId,
|
||||||
|
showVoteStatus = false,
|
||||||
|
votedIds = [],
|
||||||
|
}: PlayerCardsProps) {
|
||||||
|
const scoreOf = (id: string) =>
|
||||||
|
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
|
||||||
|
|
||||||
|
const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id)))
|
||||||
|
const ranked = [...snapshot.players].sort(
|
||||||
|
(a, b) => scoreOf(b.id) - scoreOf(a.id)
|
||||||
|
)
|
||||||
|
const voted = new Set(votedIds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{ranked.map((p) => {
|
||||||
|
const score = scoreOf(p.id)
|
||||||
|
const isLeader = max > 0 && score === max
|
||||||
|
const isMe = p.id === playerId
|
||||||
|
const hasVoted = voted.has(p.id)
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={p.id}
|
||||||
|
layout
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 40 }}
|
||||||
|
className={`relative flex min-w-24 flex-col items-center gap-1 rounded-xl border px-3 py-2.5 ${
|
||||||
|
isLeader
|
||||||
|
? "border-amber-400/60 bg-amber-400/10"
|
||||||
|
: "border-input bg-muted/40"
|
||||||
|
} ${isMe ? "ring-primary ring-2" : ""} ${
|
||||||
|
p.connected ? "" : "opacity-40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLeader && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0, rotate: -30 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 600, damping: 20 }}
|
||||||
|
className="absolute -top-2.5 text-amber-400"
|
||||||
|
>
|
||||||
|
<Crown className="size-4 fill-amber-400" />
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
<Avatar
|
||||||
|
seed={p.name}
|
||||||
|
className={`size-14 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="max-w-24 truncate text-xs font-medium">
|
||||||
|
{p.name}
|
||||||
|
{isMe && <span className="text-muted-foreground"> (toi)</span>}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<motion.span
|
||||||
|
key={score}
|
||||||
|
initial={{ scale: 1.4 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 25 }}
|
||||||
|
className="font-heading text-lg font-bold tabular-nums"
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
{showVoteStatus &&
|
||||||
|
(hasVoted ? (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 600, damping: 18 }}
|
||||||
|
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
|
||||||
|
>
|
||||||
|
<Check className="size-3" /> Prêt
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-[10px]">
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||||
|
transition={{ duration: 1.1, repeat: Infinity }}
|
||||||
|
className="bg-muted-foreground size-1.5 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">en attente</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
apps/web/src/components/quiz-view.tsx
Normal file
109
apps/web/src/components/quiz-view.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type {
|
||||||
|
QuizPerPlayerResult,
|
||||||
|
QuizQuestionPayload,
|
||||||
|
QuizRevealTruth,
|
||||||
|
RoomSnapshot,
|
||||||
|
} from "@nerdware/shared"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { Countdown } from "@/components/countdown"
|
||||||
|
|
||||||
|
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
||||||
|
const round = useRoomStore((s) => s.round)
|
||||||
|
const reveal = useRoomStore((s) => s.reveal)
|
||||||
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
|
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
|
||||||
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
|
const vote = useRoomStore((s) => s.vote)
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">Préparation de la manche…</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = round.payload as QuizQuestionPayload
|
||||||
|
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
|
||||||
|
const showReveal = truth !== null
|
||||||
|
const myResult = reveal
|
||||||
|
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
function choiceClass(index: number): string {
|
||||||
|
const base =
|
||||||
|
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
|
||||||
|
if (showReveal) {
|
||||||
|
if (index === truth?.correctIndex) {
|
||||||
|
return `${base} border-green-500 bg-green-500/15 font-medium`
|
||||||
|
}
|
||||||
|
if (index === myChoiceIndex) {
|
||||||
|
return `${base} border-red-500 bg-red-500/10`
|
||||||
|
}
|
||||||
|
return `${base} border-input opacity-60`
|
||||||
|
}
|
||||||
|
if (index === myChoiceIndex) {
|
||||||
|
return `${base} border-primary bg-primary/10 font-medium`
|
||||||
|
}
|
||||||
|
return `${base} border-input hover:bg-muted/60`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
|
||||||
|
{question.category ? ` · ${question.category}` : ""}
|
||||||
|
</span>
|
||||||
|
{!showReveal && (
|
||||||
|
<Countdown
|
||||||
|
key={round.endsAt}
|
||||||
|
startsAt={round.startsAt}
|
||||||
|
endsAt={round.endsAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h2 className="font-heading text-xl font-semibold text-balance">
|
||||||
|
{question.prompt}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{question.choices.map((choice, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={choiceClass(index)}
|
||||||
|
disabled={showReveal || myChoiceIndex !== null}
|
||||||
|
onClick={() => vote(index)}
|
||||||
|
>
|
||||||
|
{choice}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showReveal &&
|
||||||
|
(myChoiceIndex !== null ? (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
Réponse envoyée — en attente des autres
|
||||||
|
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
voteProgress && (
|
||||||
|
<p className="text-muted-foreground text-center text-xs">
|
||||||
|
{voteProgress.count}/{voteProgress.total} ont répondu
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showReveal && (
|
||||||
|
<p
|
||||||
|
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
|
||||||
|
>
|
||||||
|
{myResult?.correct
|
||||||
|
? "Bonne réponse ! 🎉"
|
||||||
|
: myResult?.choiceIndex == null
|
||||||
|
? "Pas de réponse 😴"
|
||||||
|
: "Raté 💥"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
apps/web/src/components/room-code.tsx
Normal file
39
apps/web/src/components/room-code.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Check, Copy } from "lucide-react"
|
||||||
|
|
||||||
|
/** Code de room cliquable : copie dans le presse-papier avec retour visuel. */
|
||||||
|
export function RoomCode({ code }: { code: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
// presse-papier indisponible (http non sécurisé) : on ignore silencieusement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
title="Copier le code"
|
||||||
|
className="group flex items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<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>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="text-muted-foreground group-hover:text-foreground size-4 transition-colors" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
apps/web/src/components/round-transition.tsx
Normal file
111
apps/web/src/components/round-transition.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
|
||||||
|
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
|
||||||
|
|
||||||
|
// Animations custom déposées par l'utilisateur (gif/webp/apng/png/mp4...).
|
||||||
|
// Glisse simplement des fichiers dans src/assets/transitions/ : ils sont
|
||||||
|
// détectés au build et joués aléatoirement. Sinon, fallback animation Framer.
|
||||||
|
const CUSTOM = Object.values(
|
||||||
|
import.meta.glob("../assets/transitions/*.{gif,webp,apng,png,avif}", {
|
||||||
|
eager: true,
|
||||||
|
import: "default",
|
||||||
|
})
|
||||||
|
) as string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition "WarioWare" jouée entre les manches : wipe coloré plein écran,
|
||||||
|
* média custom (si présent) ou sunburst + gros numéro qui rebondit, puis sortie.
|
||||||
|
* Montée avec une `key` qui change → rejoue à chaque manche.
|
||||||
|
*/
|
||||||
|
export function RoundTransition({ index }: { index: number }) {
|
||||||
|
const [show, setShow] = useState(true)
|
||||||
|
// Choix stable d'un média custom pour ce montage (varie d'une manche à l'autre).
|
||||||
|
const [media] = useState(() =>
|
||||||
|
CUSTOM.length ? CUSTOM[Math.floor(Math.random() * CUSTOM.length)] : null
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setShow(false), VISIBLE_MS)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
key="round-transition"
|
||||||
|
className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br from-fuchsia-500 via-purple-600 to-indigo-700"
|
||||||
|
initial={{ clipPath: "inset(0 0 100% 0)" }}
|
||||||
|
animate={{ clipPath: "inset(0 0 0% 0)" }}
|
||||||
|
exit={{
|
||||||
|
clipPath: "inset(100% 0 0 0)",
|
||||||
|
transition: { duration: 0.3, ease: "easeIn" },
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{media ? (
|
||||||
|
<motion.img
|
||||||
|
src={media}
|
||||||
|
alt=""
|
||||||
|
className="max-h-[70vh] max-w-[80vw] object-contain"
|
||||||
|
initial={{ scale: 0.6, opacity: 0 }}
|
||||||
|
animate={{ scale: [0.6, 1.08, 1], opacity: 1 }}
|
||||||
|
transition={{ duration: 0.45, times: [0, 0.6, 1], ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute size-[220vmax] opacity-60"
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative flex flex-col items-center"
|
||||||
|
initial={{ scale: 0, rotate: -12 }}
|
||||||
|
animate={{ scale: [0, 1.25, 1], rotate: [-12, 6, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.5,
|
||||||
|
times: [0, 0.6, 1],
|
||||||
|
delay: 0.12,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-heading text-2xl font-black tracking-[0.35em] text-white uppercase drop-shadow">
|
||||||
|
Question
|
||||||
|
</span>
|
||||||
|
<motion.span
|
||||||
|
className="font-heading text-[26vmin] leading-none font-black text-yellow-300 drop-shadow-[0_5px_0_rgba(0,0,0,0.25)]"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { Link } from "wouter"
|
|
||||||
import { Button } from "@workspace/ui/components/button"
|
|
||||||
import { useRoomStore } from "@/store/room"
|
|
||||||
|
|
||||||
export function LobbyPage({ code }: { code: string }) {
|
|
||||||
const snapshot = useRoomStore((s) => s.snapshot)
|
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
|
||||||
const connected = useRoomStore((s) => s.connected)
|
|
||||||
|
|
||||||
// Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil.
|
|
||||||
if (!snapshot || snapshot.code !== code) {
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
Room introuvable ou session perdue.
|
|
||||||
</p>
|
|
||||||
<Link href="/">
|
|
||||||
<Button variant="secondary">Retour à l'accueil</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-svh items-center justify-center p-6">
|
|
||||||
<div className="flex w-full max-w-md flex-col gap-6">
|
|
||||||
<header className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-xs uppercase">Code room</p>
|
|
||||||
<p className="font-heading text-3xl font-bold tracking-widest">
|
|
||||||
{snapshot.code}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
|
||||||
title={connected ? "Connecté" : "Déconnecté"}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="flex flex-col gap-2">
|
|
||||||
<h2 className="text-muted-foreground text-sm font-medium">
|
|
||||||
Joueurs ({snapshot.players.length})
|
|
||||||
</h2>
|
|
||||||
<ul className="flex flex-col gap-1">
|
|
||||||
{snapshot.players.map((p) => (
|
|
||||||
<li
|
|
||||||
key={p.id}
|
|
||||||
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className={p.connected ? "" : "opacity-40"}>
|
|
||||||
{p.name}
|
|
||||||
{p.id === playerId && (
|
|
||||||
<span className="text-muted-foreground"> (toi)</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{p.id === snapshot.hostId ? "Hôte" : ""}
|
|
||||||
{!p.connected && " · hors ligne"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-center text-xs">
|
|
||||||
En attente du lancement de la partie par l'hôte…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
71
apps/web/src/pages/room.tsx
Normal file
71
apps/web/src/pages/room.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Link } from "wouter"
|
||||||
|
import { Button } from "@workspace/ui/components/button"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
import { LobbyView } from "@/components/lobby-view"
|
||||||
|
import { QuizView } from "@/components/quiz-view"
|
||||||
|
import { GameEndView } from "@/components/game-end-view"
|
||||||
|
import { PlayerCards } from "@/components/player-cards"
|
||||||
|
import { RoomCode } from "@/components/room-code"
|
||||||
|
import { Boom } from "@/components/boom"
|
||||||
|
import { RoundTransition } from "@/components/round-transition"
|
||||||
|
|
||||||
|
export function RoomPage({ code }: { code: string }) {
|
||||||
|
const snapshot = useRoomStore((s) => s.snapshot)
|
||||||
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
|
const connected = useRoomStore((s) => s.connected)
|
||||||
|
const boomKey = useRoomStore((s) => s.boomKey)
|
||||||
|
const roundKey = useRoomStore((s) => s.roundKey)
|
||||||
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
|
|
||||||
|
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
||||||
|
if (!snapshot || snapshot.code !== code) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Room introuvable ou session perdue.
|
||||||
|
</p>
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="secondary">Retour à l'accueil</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards de scores visibles en permanence pendant le jeu (suivi continu).
|
||||||
|
const inGame =
|
||||||
|
snapshot.status === "in_round" ||
|
||||||
|
snapshot.status === "reveal" ||
|
||||||
|
snapshot.status === "scores"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-start justify-center p-6 pt-12">
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<RoomCode code={snapshot.code} />
|
||||||
|
<span
|
||||||
|
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||||
|
title={connected ? "Connecté" : "Déconnecté"}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{inGame && (
|
||||||
|
<PlayerCards
|
||||||
|
snapshot={snapshot}
|
||||||
|
playerId={playerId}
|
||||||
|
showVoteStatus={snapshot.status === "in_round"}
|
||||||
|
votedIds={voteProgress?.voted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot.status === "lobby" && <LobbyView snapshot={snapshot} />}
|
||||||
|
{inGame && <QuizView snapshot={snapshot} />}
|
||||||
|
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boomKey > 0 && <Boom key={boomKey} />}
|
||||||
|
{roundKey > 0 && (
|
||||||
|
<RoundTransition key={roundKey} index={snapshot.currentRound} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,70 @@
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import type {
|
import type {
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
|
PlayerScore,
|
||||||
RoomCreatedPayload,
|
RoomCreatedPayload,
|
||||||
RoomSnapshot,
|
RoomSnapshot,
|
||||||
|
RoundStartPayload,
|
||||||
|
VoteAckPayload,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import { socket } from "@/lib/socket"
|
import { socket } from "@/lib/socket"
|
||||||
|
|
||||||
|
/** Manche en cours côté client (depuis round:start). */
|
||||||
|
export interface ActiveRound {
|
||||||
|
type: RoundStartPayload["type"]
|
||||||
|
djId?: string
|
||||||
|
startsAt: number
|
||||||
|
endsAt: number
|
||||||
|
payload: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveal reçu (round:reveal), payloads typés par mode au moment de l'affichage. */
|
||||||
|
export interface RoundReveal {
|
||||||
|
truth: unknown
|
||||||
|
perPlayerResult: unknown
|
||||||
|
}
|
||||||
|
|
||||||
interface RoomState {
|
interface RoomState {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
/** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */
|
/** Identité locale, pour reconnaître "moi" dans le snapshot. */
|
||||||
playerId: string | null
|
playerId: string | null
|
||||||
roomCode: string | null
|
roomCode: string | null
|
||||||
snapshot: RoomSnapshot | null
|
snapshot: RoomSnapshot | null
|
||||||
lastError: ErrorPayload | null
|
lastError: ErrorPayload | null
|
||||||
|
|
||||||
|
// État de jeu (poussé par le moteur serveur).
|
||||||
|
round: ActiveRound | null
|
||||||
|
voteProgress: VoteAckPayload | null
|
||||||
|
reveal: RoundReveal | null
|
||||||
|
myChoiceIndex: number | null
|
||||||
|
finalScores: PlayerScore[] | null
|
||||||
|
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
||||||
|
boomKey: number
|
||||||
|
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
|
||||||
|
roundKey: number
|
||||||
|
|
||||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||||
|
startGame: (questionCount: number) => Promise<void>
|
||||||
|
vote: (choiceIndex: number) => void
|
||||||
|
boom: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRoomStore = create<RoomState>((set) => ({
|
export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
connected: socket.connected,
|
connected: socket.connected,
|
||||||
playerId: null,
|
playerId: null,
|
||||||
roomCode: null,
|
roomCode: null,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
round: null,
|
||||||
|
voteProgress: null,
|
||||||
|
reveal: null,
|
||||||
|
myChoiceIndex: null,
|
||||||
|
finalScores: null,
|
||||||
|
boomKey: 0,
|
||||||
|
roundKey: 0,
|
||||||
|
|
||||||
createRoom: (playerName) =>
|
createRoom: (playerName) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|
@ -52,8 +92,51 @@ export const useRoomStore = create<RoomState>((set) => ({
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
startGame: (questionCount) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const rounds = Array.from({ length: questionCount }, () => ({
|
||||||
|
type: "quiz" as const,
|
||||||
|
}))
|
||||||
|
// On pousse la séquence de manches, puis on lance.
|
||||||
|
socket.emit("lobby:updateSettings", {
|
||||||
|
blindtestMode: get().snapshot?.settings.blindtestMode ?? "title_artist",
|
||||||
|
roundDuration: 20,
|
||||||
|
rounds,
|
||||||
|
})
|
||||||
|
socket.emit("game:start", (res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
set({ lastError: res.error })
|
||||||
|
reject(res.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
vote: (choiceIndex) => {
|
||||||
|
if (get().myChoiceIndex !== null) {
|
||||||
|
return // vote déjà émis pour cette manche
|
||||||
|
}
|
||||||
|
set({ myChoiceIndex: choiceIndex })
|
||||||
|
socket.emit("round:vote", { answer: { choiceIndex } })
|
||||||
|
},
|
||||||
|
|
||||||
|
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
|
||||||
|
|
||||||
clearError: () => set({ lastError: null }),
|
clearError: () => set({ lastError: null }),
|
||||||
reset: () => set({ roomCode: null, snapshot: null, lastError: null }),
|
reset: () =>
|
||||||
|
set({
|
||||||
|
roomCode: null,
|
||||||
|
snapshot: null,
|
||||||
|
lastError: null,
|
||||||
|
round: null,
|
||||||
|
voteProgress: null,
|
||||||
|
reveal: null,
|
||||||
|
myChoiceIndex: null,
|
||||||
|
finalScores: null,
|
||||||
|
boomKey: 0,
|
||||||
|
roundKey: 0,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 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é).
|
||||||
|
|
@ -61,3 +144,26 @@ 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 }))
|
||||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||||
|
|
||||||
|
socket.on("round:start", (payload) =>
|
||||||
|
useRoomStore.setState((s) => ({
|
||||||
|
round: {
|
||||||
|
type: payload.type,
|
||||||
|
djId: payload.djId,
|
||||||
|
startsAt: payload.startsAt,
|
||||||
|
endsAt: payload.endsAt,
|
||||||
|
payload: payload.payload,
|
||||||
|
},
|
||||||
|
voteProgress: null,
|
||||||
|
reveal: null,
|
||||||
|
myChoiceIndex: null,
|
||||||
|
roundKey: s.roundKey + 1,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
socket.on("round:voteAck", (voteProgress) =>
|
||||||
|
useRoomStore.setState({ voteProgress })
|
||||||
|
)
|
||||||
|
socket.on("round:reveal", (reveal) => useRoomStore.setState({ reveal }))
|
||||||
|
socket.on("game:end", ({ finalScores }) =>
|
||||||
|
useRoomStore.setState({ finalScores })
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|
|
||||||
13
bun.lock
13
bun.lock
|
|
@ -35,8 +35,11 @@
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dicebear/core": "^10",
|
||||||
|
"@dicebear/styles": "^10",
|
||||||
"@nerdware/shared": "workspace:*",
|
"@nerdware/shared": "workspace:*",
|
||||||
"@workspace/ui": "workspace:*",
|
"@workspace/ui": "workspace:*",
|
||||||
|
"framer-motion": "^11",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
|
@ -162,6 +165,10 @@
|
||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@dicebear/core": ["@dicebear/core@10.1.0", "", {}, "sha512-OzwokGKfSJCTHNATS87Rl3Fbf00FvqKamitVaBsgm535paz09Vdgwv4QxwHR6CZUsrCdMJn+j/RfZX4o2iNk5g=="],
|
||||||
|
|
||||||
|
"@dicebear/styles": ["@dicebear/styles@10.2.0", "", {}, "sha512-o4rw0+7Q7sSAyr9YxYrUe3jCxlOAzEcBvbQvl4hQKQ+9WNmBK4p7wGYvsmGeJbxFh8y/Ux6ltrCPyXBIDV6qYA=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="],
|
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.71.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag=="],
|
||||||
|
|
||||||
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
|
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
|
||||||
|
|
@ -872,6 +879,8 @@
|
||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
|
"framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
"fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||||
|
|
@ -1076,6 +1085,10 @@
|
||||||
|
|
||||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
|
|
||||||
|
"motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],
|
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ export interface SubmitOkPayload {
|
||||||
export interface RoundStartPayload {
|
export interface RoundStartPayload {
|
||||||
type: RoundType
|
type: RoundType
|
||||||
djId?: string
|
djId?: string
|
||||||
/** Timestamp serveur de fin de manche (startedAt + roundDuration). */
|
/** Timestamp serveur de début des réponses (après le délai de préparation / transition). */
|
||||||
|
startsAt: number
|
||||||
|
/** Timestamp serveur de fin de manche (startsAt + roundDuration). */
|
||||||
endsAt: number
|
endsAt: number
|
||||||
/** 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
|
||||||
|
|
@ -73,10 +75,11 @@ export interface VotePayload {
|
||||||
answer: Answer
|
answer: Answer
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Progression anonyme des votes. */
|
/** Progression des votes. `voted` = playerIds ayant validé (jamais leur réponse). */
|
||||||
export interface VoteAckPayload {
|
export interface VoteAckPayload {
|
||||||
count: number
|
count: number
|
||||||
total: number
|
total: number
|
||||||
|
voted: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevealPayload {
|
export interface RevealPayload {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./domain"
|
export * from "./domain"
|
||||||
export * from "./events"
|
export * from "./events"
|
||||||
|
export * from "./quiz"
|
||||||
|
|
|
||||||
30
packages/shared/src/quiz.ts
Normal file
30
packages/shared/src/quiz.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Types concrets de l'épreuve Quiz, partagés client/serveur.
|
||||||
|
// Le payload générique du moteur est `unknown` ; le client le narrow via type === "quiz".
|
||||||
|
// V1 : formats mcq + truefalse (free / image_reveal arrivent avec la DB).
|
||||||
|
|
||||||
|
import type { QuizFormat } from "./domain"
|
||||||
|
|
||||||
|
/** Payload diffusé au lancement d'une manche quiz (round:start), SANS la réponse. */
|
||||||
|
export interface QuizQuestionPayload {
|
||||||
|
format: QuizFormat
|
||||||
|
prompt: string
|
||||||
|
/** Choix proposés (truefalse = ["Vrai", "Faux"]). */
|
||||||
|
choices: string[]
|
||||||
|
category?: string
|
||||||
|
/** 1 (facile) .. 3 (difficile). */
|
||||||
|
difficulty?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérité révélée à tous (round:reveal → truth). */
|
||||||
|
export interface QuizRevealTruth {
|
||||||
|
correctIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat d'un joueur sur la manche. */
|
||||||
|
export interface QuizPlayerResult {
|
||||||
|
choiceIndex: number | null
|
||||||
|
correct: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** round:reveal → perPlayerResult : playerId → résultat. */
|
||||||
|
export type QuizPerPlayerResult = Record<string, QuizPlayerResult>
|
||||||
Loading…
Add table
Reference in a new issue