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