feat(web): real explosion at 0 — fullscreen flash + giant 💥
- store: boomKey counter + boom() action
- Countdown fires boom() once when it reaches 0 (timer expiry only)
- Boom component (portal to body) plays a fullscreen white flash + giant 💥
- rendered at RoomPage level, keyed by boomKey so it plays fully (independent
of the countdown unmounting on reveal) and replays each round
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f82c7d9700
commit
c9f3197300
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 { useRoomStore } from "@/store/room"
|
||||
|
||||
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 }) {
|
||||
const [left, setLeft] = useState(() => secondsLeft(endsAt))
|
||||
const boom = useRoomStore((s) => s.boom)
|
||||
const boomed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setLeft(secondsLeft(endsAt)), 250)
|
||||
return () => clearInterval(id)
|
||||
}, [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
|
||||
// 0 (début du danger) → 1 (explosion imminente)
|
||||
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>}
|
||||
{left}
|
||||
{exploded ? "💥" : left}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { QuizView } from "@/components/quiz-view"
|
|||
import { GameEndView } from "@/components/game-end-view"
|
||||
import { PlayerCards } from "@/components/player-cards"
|
||||
import { RoomCode } from "@/components/room-code"
|
||||
import { Boom } from "@/components/boom"
|
||||
|
||||
export function RoomPage({ code }: { code: string }) {
|
||||
const snapshot = useRoomStore((s) => s.snapshot)
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
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.
|
||||
if (!snapshot || snapshot.code !== code) {
|
||||
|
|
@ -49,6 +51,8 @@ export function RoomPage({ code }: { code: string }) {
|
|||
{inGame && <QuizView snapshot={snapshot} />}
|
||||
{snapshot.status === "ended" && <GameEndView snapshot={snapshot} />}
|
||||
</div>
|
||||
|
||||
{boomKey > 0 && <Boom key={boomKey} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,14 @@ interface RoomState {
|
|||
reveal: RoundReveal | null
|
||||
myChoiceIndex: number | null
|
||||
finalScores: PlayerScore[] | null
|
||||
/** Compteur d'explosions : incrémenté à chaque fin de manche au timer (boom à 0). */
|
||||
boomKey: number
|
||||
|
||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||
startGame: (questionCount: number) => Promise<void>
|
||||
vote: (choiceIndex: number) => void
|
||||
boom: () => void
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
|
@ -57,6 +60,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
reveal: null,
|
||||
myChoiceIndex: null,
|
||||
finalScores: null,
|
||||
boomKey: 0,
|
||||
|
||||
createRoom: (playerName) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -113,6 +117,8 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
socket.emit("round:vote", { answer: { choiceIndex } })
|
||||
},
|
||||
|
||||
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
reset: () =>
|
||||
set({
|
||||
|
|
@ -124,6 +130,7 @@ export const useRoomStore = create<RoomState>((set, get) => ({
|
|||
reveal: null,
|
||||
myChoiceIndex: null,
|
||||
finalScores: null,
|
||||
boomKey: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue