release/0.1.0 #18

Merged
ayoub merged 68 commits from release/0.1.0 into main 2026-06-11 18:36:42 +00:00
8 changed files with 130 additions and 53 deletions
Showing only changes of commit 7dda00c65c - Show all commits

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

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

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

@ -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(() => {

View file

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

View file

@ -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" }}
>
<motion.div
aria-hidden
className="absolute size-[220vmax] opacity-60"
style={{
backgroundImage:
"repeating-conic-gradient(rgba(255,255,255,0.12) 0deg 12deg, transparent 12deg 24deg)",
}}
animate={{ rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
{media ? (
<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
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>,

View file

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

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