import { useEffect, useState, type CSSProperties } from 'react'; import { DAMAGE_NUMBER_DURATION_MS, DAMAGE_NUMBER_RISE_PX } from '../shared/constants'; import type { FloatingDamageData } from '../game/types'; interface FloatingDamageProps { damages: FloatingDamageData[]; } interface DamageNumberProps { data: FloatingDamageData; onExpire: (id: number) => void; } function DamageNumber({ data, onExpire }: DamageNumberProps) { const [progress, setProgress] = useState(0); useEffect(() => { let rafId: number; const start = data.createdAt; const animate = () => { const elapsed = performance.now() - start; const p = Math.min(1, elapsed / DAMAGE_NUMBER_DURATION_MS); setProgress(p); if (p < 1) { rafId = requestAnimationFrame(animate); } else { onExpire(data.id); } }; rafId = requestAnimationFrame(animate); return () => cancelAnimationFrame(rafId); }, [data.createdAt, data.id, onExpire]); const offsetY = -progress * DAMAGE_NUMBER_RISE_PX; const opacity = 1 - progress * progress; // ease-out fade const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1; const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded'; const color = data.kind === 'regen' ? '#44dd66' : isOutcomeText ? (data.target === 'hero' ? '#44dd66' : '#ff5566') : (data.isCrit ? '#ffdd44' : '#ffffff'); const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18); const style: CSSProperties = { position: 'absolute', left: data.x, top: data.y + offsetY, transform: `translate(-50%, -50%) scale(${scale})`, opacity, color, fontSize, fontWeight: 900, textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)', pointerEvents: 'none', willChange: 'transform, opacity', }; return (
{data.kind === 'damage' && ( <> {data.isCrit && 'CRIT '} {Math.round(data.value)} )} {data.kind === 'regen' && `+${Math.round(data.value)}`} {data.kind === 'blocked' && 'BLOCKED'} {data.kind === 'evaded' && 'EVADED'}
); } /** * Floating damage numbers overlay. * Renders above the game canvas in screen space. */ export function FloatingDamage({ damages }: FloatingDamageProps) { const [activeDamages, setActiveDamages] = useState([]); // Sync incoming damages useEffect(() => { setActiveDamages((prev) => { const existingIds = new Set(prev.map((d) => d.id)); const newDamages = damages.filter((d) => !existingIds.has(d.id)); return [...prev, ...newDamages]; }); }, [damages]); const handleExpire = (id: number) => { setActiveDamages((prev) => prev.filter((d) => d.id !== id)); }; return (
{activeDamages.map((d) => ( ))}
); }