You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

113 lines
3.1 KiB
TypeScript

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 (
<div style={style}>
{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'}
</div>
);
}
/**
* Floating damage numbers overlay.
* Renders above the game canvas in screen space.
*/
export function FloatingDamage({ damages }: FloatingDamageProps) {
const [activeDamages, setActiveDamages] = useState<FloatingDamageData[]>([]);
// 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 (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
}}>
{activeDamages.map((d) => (
<DamageNumber key={d.id} data={d} onExpire={handleExpire} />
))}
</div>
);
}