feature/quiz-mode #4
8 changed files with 130 additions and 53 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
|
|
|
|||
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).
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
|
|||
{question.category ? ` · ${question.category}` : ""}
|
||||
</span>
|
||||
{!showReveal && (
|
||||
<Countdown key={round.endsAt} endsAt={round.endsAt} />
|
||||
<Countdown
|
||||
key={round.endsAt}
|
||||
startsAt={round.startsAt}
|
||||
endsAt={round.endsAt}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +45,17 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
}}
|
||||
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"
|
||||
|
|
@ -59,7 +84,11 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
<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" }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</motion.span>
|
||||
|
|
@ -72,6 +101,8 @@ export function RoundTransition({ index }: { index: number }) {
|
|||
PRÊT ?!
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue