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) :
- `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**
- racine (`./`) → **communs**, fallback pour les deux modes
- racine (`./`) → **communs**, fallback pour tous les modes
Règles :

View file

@ -1,12 +1,15 @@
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
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)
// Médias custom déposés par l'utilisateur, rangés par mode :
// 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/* → communs (fallback)
// 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" }
) as Record<string, string>
const QUIZ_MEDIA: string[] = []
const BLINDTEST_MEDIA: string[] = []
const MEDIA: Record<TransitionKind, string[]> = { quiz: [], image: [], blindtest: [] }
const SHARED_MEDIA: string[] = []
for (const [path, url] of Object.entries(ALL_MEDIA)) {
if (path.includes("/transitions/quiz/")) QUIZ_MEDIA.push(url)
else if (path.includes("/transitions/blindtest/")) BLINDTEST_MEDIA.push(url)
if (path.includes("/transitions/quiz/")) MEDIA.quiz.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)
}
const THEME: Record<
RoundType,
TransitionKind,
{ gradient: string; accent: string; label: string; kind: string; emoji: string }
> = {
quiz: {
@ -35,6 +38,13 @@ const THEME: Record<
kind: "Question",
emoji: "🧠",
},
image: {
gradient: "from-emerald-500 via-teal-600 to-cyan-700",
accent: "text-lime-300",
label: "IMAGE",
kind: "Image",
emoji: "🖼️",
},
blindtest: {
gradient: "from-fuchsia-500 via-purple-600 to-indigo-700",
accent: "text-yellow-300",
@ -44,29 +54,28 @@ const THEME: Record<
},
}
function mediaFor(type: RoundType): string[] {
const own = type === "quiz" ? QUIZ_MEDIA : BLINDTEST_MEDIA
return [...own, ...SHARED_MEDIA]
function mediaFor(kind: TransitionKind): string[] {
return [...MEDIA[kind], ...SHARED_MEDIA]
}
/**
* Transition "WarioWare" entre manches, thémée par mode. Annonce le mode quand
* il change (quiz blindtest), sinon affiche un teaser léger de la manche.
* Transition "WarioWare" entre manches, thémée par mode (quiz / image / blindtest).
* 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.
*/
export function RoundTransition({
type,
kind,
index,
modeChanged,
}: {
type: RoundType
kind: TransitionKind
index: number
modeChanged: boolean
}) {
const [show, setShow] = useState(true)
const theme = THEME[type]
const theme = THEME[kind]
const [media] = useState(() => {
const pool = mediaFor(type)
const pool = mediaFor(kind)
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 voteProgress = useRoomStore((s) => s.voteProgress)
const round = useRoomStore((s) => s.round)
const roundKind = useRoomStore((s) => s.roundKind)
const roundModeChanged = useRoomStore((s) => s.roundModeChanged)
// 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 && (
<RoundTransition
key={roundKey}
type={round.type}
kind={roundKind}
index={snapshot.currentRound}
modeChanged={roundModeChanged}
/>

View file

@ -54,7 +54,9 @@ interface RoomState {
mediaSync: MediaSyncPayload | null
boomKey: 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
createRoom: () => Promise<RoomCreatedPayload>
@ -91,6 +93,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
createRoom: () =>
@ -221,6 +224,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
mediaSync: null,
boomKey: 0,
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
}),
}))
@ -248,7 +252,15 @@ socket.on("room:state", (snapshot) =>
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
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: {
type: payload.type,
djId: payload.djId,
@ -263,8 +275,10 @@ socket.on("round:start", (payload) =>
hasVoted: false,
mediaSync: null,
roundKey: s.roundKey + 1,
roundModeChanged: (s.round?.type ?? null) !== payload.type,
}))
roundKind: kind,
roundModeChanged: s.roundKind !== kind,
}
})
)
socket.on("round:voteAck", (voteProgress) =>
useRoomStore.setState({ voteProgress })