From 7dda00c65c39bd558c3c982777842ea03de09938 Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Tue, 9 Jun 2026 11:25:17 +0200 Subject: [PATCH] feat: lead-in timer compensation + GIF support for round transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/server/src/game/engine.test.ts | 1 + apps/server/src/game/engine.ts | 13 ++- apps/web/src/assets/transitions/README.md | 15 +++ apps/web/src/components/countdown.tsx | 27 +++-- apps/web/src/components/quiz-view.tsx | 6 +- apps/web/src/components/round-transition.tsx | 115 ++++++++++++------- apps/web/src/store/room.ts | 2 + packages/shared/src/events.ts | 4 +- 8 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/assets/transitions/README.md diff --git a/apps/server/src/game/engine.test.ts b/apps/server/src/game/engine.test.ts index 7cbf67c..73f8fa7 100644 --- a/apps/server/src/game/engine.test.ts +++ b/apps/server/src/game/engine.test.ts @@ -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 } } diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts index 6c56f59..3bef317 100644 --- a/apps/server/src/game/engine.ts +++ b/apps/server/src/game/engine.ts @@ -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 } @@ -85,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 = { @@ -101,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, }) @@ -108,7 +117,7 @@ export class GameEngine implements RoomGameController { // Attend la première condition de fin : timer écoulé OU tous votés. await new Promise((resolve) => { this.resolveRoundEnd = resolve - this.timer = setTimeout(() => this.endRound(), durationSec * 1000) + this.timer = setTimeout(() => this.endRound(), endsAt - now) }) const ctx = this.context(this.runtime) diff --git a/apps/web/src/assets/transitions/README.md b/apps/web/src/assets/transitions/README.md new file mode 100644 index 0000000..eb8792a --- /dev/null +++ b/apps/web/src/assets/transitions/README.md @@ -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). diff --git a/apps/web/src/components/countdown.tsx b/apps/web/src/components/countdown.tsx index 7847867..e50e4b1 100644 --- a/apps/web/src/components/countdown.tsx +++ b/apps/web/src/components/countdown.tsx @@ -4,9 +4,16 @@ import { useRoomStore } from "@/store/room" const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe" -/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */ -function secondsLeft(endsAt: number): number { - return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)) +/** + * 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 { @@ -21,15 +28,21 @@ function dangerColor(t: number): string { return `rgb(${r}, ${g}, ${b})` } -export function Countdown({ endsAt }: { endsAt: number }) { - const [left, setLeft] = useState(() => secondsLeft(endsAt)) +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(endsAt)), 250) + const id = setInterval(() => setLeft(secondsLeft(startsAt, endsAt)), 250) return () => clearInterval(id) - }, [endsAt]) + }, [startsAt, endsAt]) // Déclenche l'explosion une seule fois au passage à 0 (fin au timer). useEffect(() => { diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index cbc231a..c4035da 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -54,7 +54,11 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {question.category ? ` · ${question.category}` : ""} {!showReveal && ( - + )} diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 3e4bef9..7d33241 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -2,15 +2,29 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" -const VISIBLE_MS = 1100 // durée d'affichage avant le wipe de sortie +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, - * sunburst qui tourne, gros numéro de question qui rebondit, puis sortie. + * 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) @@ -31,47 +45,64 @@ export function RoundTransition({ index }: { index: number }) { }} transition={{ duration: 0.25, ease: "easeOut" }} > - + {media ? ( + + ) : ( + <> + - - - Question - - - {index + 1} - - - PRÊT ?! - - + + + Question + + + {index + 1} + + + PRÊT ?! + + + + )} )} , diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts index 70c7cb1..bf8d558 100644 --- a/apps/web/src/store/room.ts +++ b/apps/web/src/store/room.ts @@ -13,6 +13,7 @@ import { socket } from "@/lib/socket" export interface ActiveRound { type: RoundStartPayload["type"] djId?: string + startsAt: number endsAt: number payload: unknown } @@ -149,6 +150,7 @@ socket.on("round:start", (payload) => round: { type: payload.type, djId: payload.djId, + startsAt: payload.startsAt, endsAt: payload.endsAt, payload: payload.payload, }, diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index d8d2ee4..d18b5b2 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -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