feature/image-reveal #10

Merged
ayoub merged 5 commits from feature/image-reveal into dev 2026-06-10 22:38:34 +00:00
4 changed files with 60 additions and 35 deletions
Showing only changes of commit 5e96182489 - Show all commits

View file

@ -6,8 +6,9 @@ formats supportés `*.gif`, `*.webp`, `*.apng`, `*.png`, `*.avif`.
Rangement par mode (recommandé pour des transitions personnalisées) : Rangement par mode (recommandé pour des transitions personnalisées) :
- `quiz/` → jouées avant une manche de **quiz** - `quiz/` → jouées avant une manche de **quiz**
- `image/` → jouées avant une manche du mode **images**
- `blindtest/` → jouées avant une manche de **blindtest** - `blindtest/` → jouées avant une manche de **blindtest**
- racine (`./`) → **communs**, fallback pour les deux modes - racine (`./`) → **communs**, fallback pour tous les modes
Règles : Règles :

View file

@ -1,12 +1,15 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import type { RoundType } from "@nerdware/shared"
/** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */
export type TransitionKind = "quiz" | "image" | "blindtest"
const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur) const VISIBLE_MS = 1300 // durée d'affichage avant le wipe de sortie (≈ leadMs serveur)
// Médias custom déposés par l'utilisateur, rangés par mode : // Médias custom déposés par l'utilisateur, rangés par mode :
// src/assets/transitions/quiz/* → transitions de quiz // src/assets/transitions/quiz/* → transitions de quiz
// src/assets/transitions/image/* → transitions du mode images
// src/assets/transitions/blindtest/* → transitions de blindtest // src/assets/transitions/blindtest/* → transitions de blindtest
// src/assets/transitions/* → communs (fallback) // src/assets/transitions/* → communs (fallback)
// Détectés au build ; sinon, animation Framer par défaut. // Détectés au build ; sinon, animation Framer par défaut.
@ -15,17 +18,17 @@ const ALL_MEDIA = import.meta.glob(
{ eager: true, import: "default" } { eager: true, import: "default" }
) as Record<string, string> ) as Record<string, string>
const QUIZ_MEDIA: string[] = [] const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
const BLINDTEST_MEDIA: string[] = []
const SHARED_MEDIA: string[] = [] const SHARED_MEDIA: string[] = []
for (const [path, url] of Object.entries(ALL_MEDIA)) { for (const [path, url] of Object.entries(ALL_MEDIA)) {
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url) if (path.includes("/transitions/quiz/")) MEDIA.quiz.push(url)
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url) else if (path.includes("/transitions/image/")) MEDIA.image.push(url)
else if (path.includes("/transitions/blindtest/")) MEDIA.blindtest.push(url)
else SHARED_MEDIA.push(url) else SHARED_MEDIA.push(url)
} }
const THEME: Record< const THEME: Record<
RoundType, TransitionKind,
{ gradient: string; accent: string; label: string; kind: string; emoji: string } { gradient: string; accent: string; label: string; kind: string; emoji: string }
> = { > = {
quiz: { quiz: {
@ -35,6 +38,13 @@ const THEME: Record<
kind: "Question", kind: "Question",
emoji: "🧠", emoji: "🧠",
}, },
image: {
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
accent: "text-lime-300",
label: "IMAGE",
kind: "Image",
emoji: "🖼️",
},
blindtest: { blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700", gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300", accent: "text-yellow-300",
@ -44,29 +54,28 @@ const THEME: Record<
}, },
} }
function mediaFor(type: RoundType): string[] { function mediaFor(kind: TransitionKind): string[] {
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA return [...MEDIA[kind], ...SHARED_MEDIA]
return [...own, ...SHARED_MEDIA]
} }
/** /**
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand * Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
* il change (quiz blindtest), sinon affiche un teaser léger de la manche. * Annonce le mode quand il change, sinon affiche un teaser léger de la manche.
* Montée avec une `key` qui change rejoue à chaque manche. * Montée avec une `key` qui change rejoue à chaque manche.
*/ */
export function RoundTransition({ export function RoundTransition({
type, kind,
index, index,
modeChanged, modeChanged,
}: { }: {
type: RoundType kind: TransitionKind
index: number index: number
modeChanged: boolean modeChanged: boolean
}) { }) {
const [show, setShow] = useState(true) const [show, setShow] = useState(true)
const theme = THEME[type] const theme = THEME[kind]
const [media] = useState(() => { const [media] = useState(() => {
const pool = mediaFor(type) const pool = mediaFor(kind)
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null return pool.length ? pool[Math.floor(Math.random() * pool.length)] : null
}) })

View file

@ -22,6 +22,7 @@ export function RoomPage({ code }: { code: string }) {
const roundKey = useRoomStore((s) => s.roundKey) const roundKey = useRoomStore((s) => s.roundKey)
const voteProgress = useRoomStore((s) => s.voteProgress) const voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round) const round = useRoomStore((s) => s.round)
const roundKind = useRoomStore((s) => s.roundKind)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged) const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil. // Pas dans cette room (accès direct sans rejoindre / refresh) : retour accueil.
@ -106,7 +107,7 @@ export function RoomPage({ code }: { code: string }) {
{roundKey > 0 && round && ( {roundKey > 0 && round && (
<RoundTransition <RoundTransition
key={roundKey} key={roundKey}
type={round.type} kind={roundKind}
index={snapshot.currentRound} index={snapshot.currentRound}
modeChanged={roundModeChanged} modeChanged={roundModeChanged}
/> />

View file

@ -54,7 +54,9 @@ interface RoomState {
mediaSync: MediaSyncPayload | null mediaSync: MediaSyncPayload | null
boomKey: number boomKey: number
roundKey: number roundKey: number
/** Le type d'épreuve a-t-il changé par rapport à la manche précédente ? */ /** Catégorie visuelle de la manche (image_reveal distinct du quiz). */
roundKind: "quiz" | "image" | "blindtest"
/** Le mode a-t-il changé par rapport à la manche précédente ? */
roundModeChanged: boolean roundModeChanged: boolean
createRoom: () => Promise<RoomCreatedPayload> createRoom: () => Promise<RoomCreatedPayload>
@ -91,6 +93,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundKind: "quiz",
roundModeChanged: false, roundModeChanged: false,
createRoom: () => createRoom: () =>
@ -221,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null, mediaSync: null,
boomKey: 0, boomKey: 0,
roundKey: 0, roundKey: 0,
roundKind: "quiz",
roundModeChanged: false, roundModeChanged: false,
}), }),
})) }))
@ -248,7 +252,15 @@ socket.on("room:state", (snapshot) =>
socket.on("error", (error) => useRoomStore.setState({ lastError: error })) socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
socket.on("round:start", (payload) => socket.on("round:start", (payload) =>
useRoomStore.setState((s) => ({ useRoomStore.setState((s) => {
const fmt = (payload.payload as { format?: string } | null)?.format
const kind =
payload.type === "blindtest"
? "blindtest"
: fmt === "image_reveal"
? "image"
: "quiz"
return {
round: { round: {
type: payload.type, type: payload.type,
djId: payload.djId, djId: payload.djId,
@ -263,8 +275,10 @@ socket.on("round:start", (payload) =>
hasVoted: false, hasVoted: false,
mediaSync: null, mediaSync: null,
roundKey: s.roundKey + 1, roundKey: s.roundKey + 1,
roundModeChanged: (s.round?.type ?? null) !== payload.type, roundKind: kind,
})) roundModeChanged: s.roundKind !== kind,
}
})
) )
socket.on("round:voteAck", (voteProgress) => socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress }) useRoomStore.setState({ voteProgress })