nerdware/apps/web/src/components/countdown.tsx
AyoubBenziza c9f3197300 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>
2026-06-08 16:05:42 +02:00

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>
)
}