feature/quiz-mode #4
4 changed files with 60 additions and 2 deletions
35
apps/web/src/components/boom.tsx
Normal file
35
apps/web/src/components/boom.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explosion plein écran : flash blanc + 💥 géant.
|
||||||
|
* Rendue dans document.body via portal pour échapper aux transforms parents,
|
||||||
|
* et montée avec une `key` qui change → l'animation rejoue à chaque boom.
|
||||||
|
*/
|
||||||
|
export function Boom() {
|
||||||
|
return createPortal(
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0, 0.9, 0] }}
|
||||||
|
transition={{ duration: 0.5, times: [0, 0.12, 1], ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
className="relative leading-none select-none"
|
||||||
|
style={{ fontSize: "45vmin" }}
|
||||||
|
initial={{ scale: 0, rotate: -20, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0, 1.35, 1.1],
|
||||||
|
rotate: [-20, 12, 0],
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
x: [0, 12, -8, 0],
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.8, times: [0, 0.3, 1], ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
💥
|
||||||
|
</motion.span>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
import { useRoomStore } from "@/store/room"
|
||||||
|
|
||||||
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
const DANGER_FROM = 10 // secondes : seuil d'entrée en mode "bombe"
|
||||||
|
|
||||||
|
|
@ -22,12 +23,23 @@ function dangerColor(t: number): string {
|
||||||
|
|
||||||
export function Countdown({ endsAt }: { endsAt: number }) {
|
export function Countdown({ endsAt }: { endsAt: number }) {
|
||||||
const [left, setLeft] = useState(() => secondsLeft(endsAt))
|
const [left, setLeft] = useState(() => secondsLeft(endsAt))
|
||||||
|
const boom = useRoomStore((s) => s.boom)
|
||||||
|
const boomed = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250)
|
const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [endsAt])
|
}, [endsAt])
|
||||||
|
|
||||||
|
// Déclenche l'explosion une seule fois au passage à 0 (fin au timer).
|
||||||
|
useEffect(() => {
|
||||||
|
if (left === 0 && !boomed.current) {
|
||||||
|
boomed.current = true
|
||||||
|
boom()
|
||||||
|
}
|
||||||
|
}, [left, boom])
|
||||||
|
|
||||||
|
const exploded = left === 0
|
||||||
const danger = left <= DANGER_FROM && left > 0
|
const danger = left <= DANGER_FROM && left > 0
|
||||||
// 0 (début du danger) → 1 (explosion imminente)
|
// 0 (début du danger) → 1 (explosion imminente)
|
||||||
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
|
const intensity = danger ? Math.min(1, (DANGER_FROM - left) / DANGER_FROM) : 0
|
||||||
|
|
@ -56,7 +68,7 @@ export function Countdown({ endsAt }: { endsAt: number }) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{danger && <span aria-hidden>💣</span>}
|
{danger && <span aria-hidden>💣</span>}
|
||||||
{left}
|
{exploded ? "💥" : left}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import { QuizView } from "@/components/quiz-view"
|
||||||
import { GameEndView } from "@/components/game-end-view"
|
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"
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
// 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.
|
||||||
if (!snapshot || snapshot.code !== code) {
|
if (!snapshot || snapshot.code !== code) {
|
||||||
|
|
@ -49,6 +51,8 @@ export function RoomPage({ code }: { code: string }) {
|
||||||
{inGame && <QuizView snapshot={snapshot} />}
|
{inGame && <QuizView snapshot={snapshot} />}
|
||||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{boomKey > 0 && <Boom key={boomKey} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,14 @@ interface RoomState {
|
||||||
reveal: RoundReveal | null
|
reveal: RoundReveal | null
|
||||||
myChoiceIndex: number | null
|
myChoiceIndex: number | null
|
||||||
finalScores: PlayerScore[] | null
|
finalScores: PlayerScore[] | null
|
||||||
|
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
||||||
|
boomKey: 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>
|
||||||
startGame: (questionCount: number) => Promise<void>
|
startGame: (questionCount: number) => Promise<void>
|
||||||
vote: (choiceIndex: number) => void
|
vote: (choiceIndex: number) => void
|
||||||
|
boom: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +60,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
boomKey: 0,
|
||||||
|
|
||||||
createRoom: (playerName) =>
|
createRoom: (playerName) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
|
@ -113,6 +117,8 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
socket.emit("round:vote", { answer: { choiceIndex } })
|
socket.emit("round:vote", { answer: { choiceIndex } })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
|
||||||
|
|
||||||
clearError: () => set({ lastError: null }),
|
clearError: () => set({ lastError: null }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
|
|
@ -124,6 +130,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
||||||
reveal: null,
|
reveal: null,
|
||||||
myChoiceIndex: null,
|
myChoiceIndex: null,
|
||||||
finalScores: null,
|
finalScores: null,
|
||||||
|
boomKey: 0,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue