Compare commits

...

11 commits

Author SHA1 Message Date
1004065b40 Merge pull request 'feature/quiz-mode' (#4) from feature/quiz-mode into dev
Reviewed-on: #4
2026-06-10 09:54:23 +00:00
AyoubBenziza
7dda00c65c feat: lead-in timer compensation + GIF support for round transitions
Server (option 2 — don't eat answer time):
- engine: leadMs prep delay (default 1600ms) before the clock starts; the
  round timer now fires at lead + duration, and scoring uses the post-lead
  start so the speed bonus spans the full answer window
- shared: round:start carries startsAt (answers begin) alongside endsAt
- engine test passes leadMs:0

Client:
- Countdown shows the full duration (no ticking) until startsAt, then counts
  down — so the WarioWare transition plays in full without stealing time
- store/ActiveRound carry startsAt

Custom transition media:
- RoundTransition auto-loads any gif/webp/apng/png/avif dropped in
  src/assets/transitions/ (import.meta.glob), plays one at random per round,
  falls back to the Framer animation when none are present
- README documenting how to add animations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:25:17 +02:00
AyoubBenziza
723d91943e feat(web): WarioWare-style transition between rounds
- RoundTransition overlay: fullscreen colored wipe-in, spinning sunburst,
  big bouncing "QUESTION N" + "PRÊT ?!", then wipe-out (Framer Motion)
- store: roundKey counter incremented on round:start
- rendered at RoomPage, keyed by roundKey so it plays once per round

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:15:36 +02:00
AyoubBenziza
0c2534f3a4 feat(web): podium on the end screen + larger avatars
- GameEndView: top-3 podium — 2nd left, champion center (taller), 3rd right,
  with gold/silver/bronze pedestals, staggered rise-in (climax on the champion),
  crown + pulsing gold glow kept on #1; ranks 4+ listed below
- bigger avatars where they read too small: lobby (size-7→11) and in-game
  player cards (size-9→14, slightly wider cards)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:05:54 +02:00
AyoubBenziza
c3e00ab9d9 refactor(web): generate DiceBear avatars locally instead of via HTTP API
Self-hosted over the HTTP API: no runtime dependency on api.dicebear.com,
works offline/LAN, and no player pseudo leaks to a third party.

- @dicebear/core@10 + @dicebear/styles@10 (lorelei.json), CC0 1.0
- Avatar builds a data URI locally (new Style/Avatar), memoized per seed
- same component API (seed/className) → no call-site changes
- enable resolveJsonModule for the style import

Cost: ~64 kB gzip added to the bundle — acceptable for a self-hosted game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:58:23 +02:00
AyoubBenziza
4f10d86fb0 feat(web): DiceBear lorelei avatars for players
- Avatar component using DiceBear 9.x lorelei, seeded by the player name
  (stable, recognizable across screens)
- shown in the lobby list, the in-game player cards (gold ring on leader),
  and the final leaderboard (large on the winner, small on runners-up)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:50:17 +02:00
AyoubBenziza
2fce63530b feat(quiz): show per-player answer status during the round
- voteAck now carries `voted` (playerIds who validated) — never their answer,
  so no leak; the count/total stay for the progress text
- PlayerCards show a green "Prêt" check for players who answered and a
  pulsing "en attente" dot for those who haven't, during in_round only
- score stays visible on the cards alongside the status
- engine test updated for the new voteAck shape

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:30:03 +02:00
AyoubBenziza
0c9eaca312 feat(web): glorify the winner on the final leaderboard
- dedicated winner card: gold border, gold gradient, pulsing gold glow,
  spring scale-in, and a Crown that drops & bounces in (Framer Motion)
- champion label + big gold score; runners-up listed below from rank 2
- drop the generic Scoreboard component (now unused)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:25:28 +02:00
AyoubBenziza
c9f3197300 feat(web): real explosion at 0 — fullscreen flash + giant 💥
- store: boomKey counter + boom() action
- Countdown fires boom() once when it reaches 0 (timer expiry only)
- Boom component (portal to body) plays a fullscreen white flash + giant 💥
- rendered at RoomPage level, keyed by boomKey so it plays fully (independent
  of the countdown unmounting on reveal) and replays each round

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:05:42 +02:00
AyoubBenziza
f82c7d9700 feat(web): bomb countdown, copyable room code, persistent score cards
- Countdown: under 10s it ramps into a "bomb" — white→red color and an
  escalating scale/wiggle pulse (faster & wider as it nears 0) via Framer Motion
- RoomCode: click-to-copy the room code with visual feedback
- PlayerCards: always-on score HUD during the game (cards per player,
  animated reorder, crown on the leader) so scores are tracked continuously
  instead of only appearing at reveal
- quiz view: drop the reveal-only scoreboard (now covered by PlayerCards)
- add framer-motion dependency

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:56:57 +02:00
AyoubBenziza
5cc9113b8c feat(quiz): quiz game mode end-to-end (server mode + playable client UI)
Server:
- quiz mode (GameRound): in-memory FR question bank (mcq + truefalse),
  per-room anti-repeat, first-vote lock, score = base + speed bonus ∝ time
  left; registered via registerRound, loaded at startup
- shared: typed quiz payloads (QuizQuestionPayload / reveal truth / result)
- tests: quiz scoring, vote lock, bounds, reveal (bun test)

Client:
- room store handles round:start / voteAck / reveal / game:end
- RoomPage dispatches by status: lobby (host start controls) → quiz view
  (question, countdown, vote, reveal + scoreboard) → game-end view
- replaces standalone lobby page

Roadmap V1 step 4. Verified end-to-end over the wire (start→vote→reveal→
score→next round→game:end, no answer leaked, anti-repeat works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:43:17 +02:00
28 changed files with 1372 additions and 81 deletions

View file

@ -66,6 +66,7 @@ function setup(roundDurationSec: number): {
const options: GameEngineOptions = {
createRound: () => dummyRound,
revealPauseMs: 0,
leadMs: 0,
}
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 {
count: 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 })
await run

View file

@ -22,11 +22,15 @@ export interface GameEngineOptions {
createRound?: (type: RoundConfig["type"]) => GameRound
/** Pause d'affichage du reveal/scores entre deux manches (ms). */
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. */
now?: () => number
}
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 {
private runtime: RoundRuntime | null = null
@ -34,6 +38,7 @@ export class GameEngine implements RoomGameController {
private resolveRoundEnd: (() => void) | null = null
private readonly createRound: (type: RoundConfig["type"]) => GameRound
private readonly revealPauseMs: number
private readonly leadMs: number
private readonly now: () => number
constructor(
@ -44,6 +49,7 @@ export class GameEngine implements RoomGameController {
) {
this.createRound = options.createRound ?? defaultCreateRound
this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS
this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS
this.now = options.now ?? Date.now
}
@ -73,6 +79,7 @@ export class GameEngine implements RoomGameController {
this.io.to(this.room.code).emit("round:voteAck", {
count: this.runtime.votes.size,
total,
voted: [...this.runtime.votes.keys()],
})
// Fin anticipée : tous les éligibles ont voté.
if (total > 0 && this.runtime.votes.size >= total) {
@ -84,7 +91,9 @@ export class GameEngine implements RoomGameController {
const round = this.createRound(config.type)
const start = round.start(this.room)
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
this.runtime = {
@ -100,6 +109,7 @@ export class GameEngine implements RoomGameController {
this.io.to(this.room.code).emit("round:start", {
type: round.type,
djId: this.runtime.djId ?? undefined,
startsAt: startedAt,
endsAt,
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.
await new Promise<void>((resolve) => {
this.resolveRoundEnd = resolve
this.timer = setTimeout(() => this.endRound(), durationSec * 1000)
this.timer = setTimeout(() => this.endRound(), endsAt - now)
})
const ctx = this.context(this.runtime)

View 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"

View 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 }

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

View 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)
})
})

View 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
}
}

View file

@ -2,6 +2,7 @@ import Fastify from "fastify"
import cors from "@fastify/cors"
import { env, isDev } from "./env"
import { createSocketServer } from "./socket"
import "./game/modes" // enregistre les épreuves (registerRound)
const app = Fastify({
logger: isDev

View file

@ -12,8 +12,11 @@
"preview": "vite preview"
},
"dependencies": {
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",

View file

@ -1,13 +1,13 @@
import { Route, Switch } from "wouter"
import { HomePage } from "@/pages/home"
import { LobbyPage } from "@/pages/lobby"
import { RoomPage } from "@/pages/room"
export function App() {
return (
<Switch>
<Route path="/" component={HomePage} />
<Route path="/room/:code">
{(params) => <LobbyPage code={params.code.toUpperCase()} />}
{(params) => <RoomPage code={params.code.toUpperCase()} />}
</Route>
<Route>
<div className="flex min-h-svh items-center justify-center p-6">

View 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).

View 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 ?? ""}`}
/>
)
}

View 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
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
)
}

View file

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

View 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>
)
}

View file

@ -1,30 +1,70 @@
import { create } from "zustand"
import type {
ErrorPayload,
PlayerScore,
RoomCreatedPayload,
RoomSnapshot,
RoundStartPayload,
VoteAckPayload,
} from "@nerdware/shared"
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 {
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
roomCode: string | null
snapshot: RoomSnapshot | 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>
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
startGame: (questionCount: number) => Promise<void>
vote: (choiceIndex: number) => void
boom: () => void
clearError: () => void
reset: () => void
}
export const useRoomStore = create<RoomState>((set) => ({
export const useRoomStore = create<RoomState>((set, get) => ({
connected: socket.connected,
playerId: null,
roomCode: null,
snapshot: null,
lastError: null,
round: null,
voteProgress: null,
reveal: null,
myChoiceIndex: null,
finalScores: null,
boomKey: 0,
roundKey: 0,
createRoom: (playerName) =>
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 }),
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é).
@ -61,3 +144,26 @@ socket.on("connect", () => useRoomStore.setState({ connected: true }))
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
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 })
)

View file

@ -9,6 +9,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",

View file

@ -35,8 +35,11 @@
"name": "web",
"version": "0.0.1",
"dependencies": {
"@dicebear/core": "^10",
"@dicebear/styles": "^10",
"@nerdware/shared": "workspace:*",
"@workspace/ui": "workspace:*",
"framer-motion": "^11",
"lucide-react": "^1.17.0",
"react": "^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=="],
"@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=="],
"@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=="],
"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=="],
"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=="],
"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=="],
"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=="],

View file

@ -48,7 +48,9 @@ export interface SubmitOkPayload {
export interface RoundStartPayload {
type: RoundType
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
/** Payload spécifique au type d'épreuve, SANS la réponse. */
payload: unknown
@ -73,10 +75,11 @@ export interface VotePayload {
answer: Answer
}
/** Progression anonyme des votes. */
/** Progression des votes. `voted` = playerIds ayant validé (jamais leur réponse). */
export interface VoteAckPayload {
count: number
total: number
voted: string[]
}
export interface RevealPayload {

View file

@ -1,2 +1,3 @@
export * from "./domain"
export * from "./events"
export * from "./quiz"

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