- 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>
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
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"
|
|
|
|
/** Secondes restantes jusqu'à `endsAt` (timestamp serveur). */
|
|
function secondsLeft(endsAt: number): number {
|
|
return Math.max(0, Math.ceil((endsAt - Date.now()) / 1000))
|
|
}
|
|
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t
|
|
}
|
|
|
|
/** Blanc → rouge sur les DANGER_FROM dernières secondes. */
|
|
function dangerColor(t: number): string {
|
|
const r = Math.round(lerp(255, 239, t))
|
|
const g = Math.round(lerp(255, 68, t))
|
|
const b = Math.round(lerp(255, 68, t))
|
|
return `rgb(${r}, ${g}, ${b})`
|
|
}
|
|
|
|
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
|
|
|
|
// Plus on approche de 0, plus le "battement" est ample et rapide.
|
|
const peak = 1.15 + intensity * 0.5
|
|
const wiggle = 4 + intensity * 12
|
|
const duration = 0.7 - intensity * 0.46
|
|
|
|
return (
|
|
<motion.span
|
|
className="font-heading inline-flex items-center gap-1 text-2xl font-bold tabular-nums"
|
|
style={{ color: danger ? dangerColor(intensity) : undefined }}
|
|
animate={
|
|
danger
|
|
? {
|
|
scale: [1, peak, 0.92, peak * 0.97, 1],
|
|
rotate: [0, -wiggle, wiggle, -wiggle * 0.6, 0],
|
|
}
|
|
: { scale: 1, rotate: 0 }
|
|
}
|
|
transition={
|
|
danger
|
|
? { duration, repeat: Infinity, ease: "easeInOut" }
|
|
: { duration: 0.2 }
|
|
}
|
|
>
|
|
{danger && <span aria-hidden>💣</span>}
|
|
{exploded ? "💥" : left}
|
|
</motion.span>
|
|
)
|
|
}
|