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