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.

146 lines
4.1 KiB
TypeScript

import { useMemo, type CSSProperties } from 'react';
import { getViewport } from '../shared/telegram';
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned' | 'heal';
export type CombatOverlayTarget = 'hero' | 'enemy';
export interface CombatOverlayEvent {
id: number;
kind: CombatOverlayKind;
target: CombatOverlayTarget;
value: number;
isCrit?: boolean;
createdAt: number;
}
const FLOAT_DURATION_MS = 6000;
const FEEDBACK_DURATION_MS = 4800;
const CRIT_DURATION_MS = 6000;
const HEAL_DURATION_MS = 7000;
const FLOAT_RISE_PX = 96;
const FLOAT_DRIFT_PX = 44;
const FLOAT_KEYFRAMES = `
@keyframes autoheroCombatFloat {
from {
opacity: 1;
transform: translate(-50%, -50%) translate(0, 0) scale(var(--ah-s0, 1));
}
to {
opacity: 0;
transform: translate(-50%, -50%) translate(var(--ah-drift, 0px), calc(-1 * var(--ah-rise, 0px))) scale(1);
}
}
`;
interface CombatOverlayProps {
events: CombatOverlayEvent[];
onExpire?: (id: number) => void;
}
function durationMs(evt: CombatOverlayEvent): number {
if (evt.kind === 'blocked' || evt.kind === 'evaded' || evt.kind === 'stunned') {
return FEEDBACK_DURATION_MS;
}
if (evt.kind === 'damage' && evt.isCrit) {
return CRIT_DURATION_MS;
}
if (evt.kind === 'heal') {
return HEAL_DURATION_MS;
}
return FLOAT_DURATION_MS;
}
function overlayText(evt: CombatOverlayEvent): string {
if (evt.kind === 'damage') {
const prefix = evt.isCrit ? 'CRIT ' : '';
return `${prefix}${Math.round(evt.value)}`;
}
if (evt.kind === 'regen') {
return `+${Math.round(evt.value)}`;
}
if (evt.kind === 'blocked') return 'BLOCKED';
if (evt.kind === 'evaded') return 'EVADED';
if (evt.kind === 'heal') return `HEAL ${Math.round(evt.value)}`;
return 'STUNNED';
}
function overlayColor(evt: CombatOverlayEvent): string {
if (evt.kind === 'regen') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
}
if (evt.kind === 'heal') {
return '#44dd66';
}
if (evt.kind === 'stunned') return '#ffaa44';
if (evt.kind === 'blocked' || evt.kind === 'evaded') {
return evt.target === 'hero' ? '#44dd66' : '#ff5566';
}
return evt.isCrit ? '#ffdd44' : '#ffffff';
}
function overlayFontSize(evt: CombatOverlayEvent): number {
if (evt.kind === 'blocked' || evt.kind === 'evaded' || evt.kind === 'stunned') {
return 16;
}
if (evt.kind === 'damage' && evt.isCrit) return 24;
return 18;
}
export function CombatOverlay({ events, onExpire }: CombatOverlayProps) {
const viewport = getViewport();
const anchors = useMemo(() => {
const xEnemySide = viewport.width / 2 + 88;
const xHeroSide = viewport.width / 2 - 88;
const yMid = viewport.height / 2 - 42;
return {
hero: { x: xHeroSide, y: yMid, driftDir: -1 },
potion: { x: xHeroSide, y: yMid, driftDir: -1 },
enemy: { x: xEnemySide, y: yMid, driftDir: 1 },
};
}, [viewport.height, viewport.width]);
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
}}>
<style dangerouslySetInnerHTML={{ __html: FLOAT_KEYFRAMES }} />
{events.map((evt) => {
const anchor = anchors[evt.target];
const style: CSSProperties = {
position: 'absolute',
left: anchor.x,
top: anchor.y,
transform: 'translate(-50%, -50%)',
animation: `autoheroCombatFloat ${durationMs(evt)}ms cubic-bezier(0.2, 0.75, 0.35, 1) forwards`,
['--ah-drift' as string]: `${FLOAT_DRIFT_PX * anchor.driftDir}px`,
['--ah-rise' as string]: `${FLOAT_RISE_PX}px`,
['--ah-s0' as string]: evt.isCrit ? '1.4' : '1',
color: overlayColor(evt),
fontSize: overlayFontSize(evt),
fontWeight: 900,
textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)',
pointerEvents: 'none',
};
return (
<div
key={evt.id}
style={style}
onAnimationEnd={() => onExpire?.(evt.id)}
>
{overlayText(evt)}
</div>
);
})}
</div>
);
}