feature/quiz-mode #4
3 changed files with 92 additions and 2 deletions
80
apps/web/src/components/round-transition.tsx
Normal file
80
apps/web/src/components/round-transition.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition "WarioWare" jouée entre les manches : wipe coloré plein écran,
|
||||||
|
* sunburst qui tourne, gros numéro de question 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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setShow(false), VISIBLE_MS)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
key="round-transition"
|
||||||
|
className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-gradient-to-br from-fuchsia-500 via-purple-600 to-indigo-700"
|
||||||
|
initial={{ clipPath: "inset(0 0 100% 0)" }}
|
||||||
|
animate={{ clipPath: "inset(0 0 0% 0)" }}
|
||||||
|
exit={{
|
||||||
|
clipPath: "inset(100% 0 0 0)",
|
||||||
|
transition: { duration: 0.3, ease: "easeIn" },
|
||||||
|
}}
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,14 @@ import { GameEndView } from "@/components/game-end-view"
|
||||||
import { PlayerCards } from "@/components/player-cards"
|
import { PlayerCards } from "@/components/player-cards"
|
||||||
import { RoomCode } from "@/components/room-code"
|
import { RoomCode } from "@/components/room-code"
|
||||||
import { Boom } from "@/components/boom"
|
import { Boom } from "@/components/boom"
|
||||||
|
import { RoundTransition } from "@/components/round-transition"
|
||||||
|
|
||||||
export function RoomPage({ code }: { code: string }) {
|
export function RoomPage({ code }: { code: string }) {
|
||||||
const snapshot = useRoomStore((s) => s.snapshot)
|
const snapshot = useRoomStore((s) => s.snapshot)
|
||||||
const playerId = useRoomStore((s) => s.playerId)
|
const playerId = useRoomStore((s) => s.playerId)
|
||||||
const connected = useRoomStore((s) => s.connected)
|
const connected = useRoomStore((s) => s.connected)
|
||||||
const boomKey = useRoomStore((s) => s.boomKey)
|
const boomKey = useRoomStore((s) => s.boomKey)
|
||||||
|
const roundKey = useRoomStore((s) => s.roundKey)
|
||||||
const voteProgress = useRoomStore((s) => s.voteProgress)
|
const voteProgress = useRoomStore((s) => s.voteProgress)
|
||||||
|
|
||||||
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
// Pas de snapshot pour ce code (accès direct / refresh) : retour à l'accueil.
|
||||||
|
|
@ -61,6 +63,9 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{boomKey > 0 && <Boom key={boomKey} />}
|
{boomKey > 0 && <Boom key={boomKey} />}
|
||||||
|
{roundKey > 0 && (
|
||||||
|
<RoundTransition key={roundKey} index={snapshot.currentRound} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ interface RoomState {
|
||||||
finalScores: PlayerScore[] | null
|
finalScores: PlayerScore[] | null
|
||||||
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
||||||
boomKey: number
|
boomKey: number
|
||||||
|
/** Compteur de manches : incrémenté à chaque round:start (transition WarioWare). */
|
||||||
|
roundKey: number
|
||||||
|
|
||||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||||
|
|
@ -61,6 +63,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
|
roundKey: 0,
|
||||||
|
|
||||||
createRoom: (playerName) =>
|
createRoom: (playerName) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|
@ -131,6 +134,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
boomKey: 0,
|
boomKey: 0,
|
||||||
|
roundKey: 0,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -141,7 +145,7 @@ socket.on("room:state", (snapshot) => useRoomStore.setState({ 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({
|
useRoomStore.setState((s) => ({
|
||||||
round: {
|
round: {
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
djId: payload.djId,
|
djId: payload.djId,
|
||||||
|
|
@ -151,7 +155,8 @@ socket.on("round:start", (payload) =>
|
||||||
voteProgress: null,
|
voteProgress: null,
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
})
|
roundKey: s.roundKey + 1,
|
||||||
|
}))
|
||||||
)
|
)
|
||||||
socket.on("round:voteAck", (voteProgress) =>
|
socket.on("round:voteAck", (voteProgress) =>
|
||||||
useRoomStore.setState({ voteProgress })
|
useRoomStore.setState({ voteProgress })
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue