update combat on front
parent
1aca5d265b
commit
485254d6cd
@ -0,0 +1,134 @@
|
||||
import { useMemo, type CSSProperties } from 'react';
|
||||
import { getViewport } from '../shared/telegram';
|
||||
|
||||
export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned';
|
||||
export type CombatOverlayTarget = 'hero' | 'enemy';
|
||||
|
||||
export interface CombatOverlayEvent {
|
||||
id: number;
|
||||
kind: CombatOverlayKind;
|
||||
target: CombatOverlayTarget;
|
||||
value: number;
|
||||
isCrit?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const FLOAT_DURATION_MS = 2600;
|
||||
const FEEDBACK_DURATION_MS = 4800;
|
||||
const CRIT_DURATION_MS = 6000;
|
||||
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;
|
||||
}
|
||||
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';
|
||||
return 'STUNNED';
|
||||
}
|
||||
|
||||
function overlayColor(evt: CombatOverlayEvent): string {
|
||||
if (evt.kind === 'regen') 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 },
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import { useCallback, useEffect, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
DAMAGE_NUMBER_DURATION_MS,
|
||||
DAMAGE_NUMBER_DRIFT_PX,
|
||||
DAMAGE_NUMBER_FEEDBACK_DURATION_MS,
|
||||
DAMAGE_NUMBER_CRIT_DURATION_MS,
|
||||
DAMAGE_NUMBER_RISE_PX,
|
||||
} from '../shared/constants';
|
||||
import type { FloatingDamageData } from '../game/types';
|
||||
|
||||
/** One-shot float + fade; not driven by rAF+setState so parent frame ticks cannot reset it */
|
||||
const FLOAT_DAMAGE_KEYFRAMES = `
|
||||
@keyframes autoheroFloatDamage {
|
||||
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 FloatingDamageProps {
|
||||
damages: FloatingDamageData[];
|
||||
}
|
||||
|
||||
interface DamageNumberProps {
|
||||
data: FloatingDamageData;
|
||||
onExpire: (id: number) => void;
|
||||
}
|
||||
|
||||
function feedbackDurationMs(data: FloatingDamageData): number {
|
||||
if (data.kind === 'blocked' || data.kind === 'evaded' || data.kind === 'stunned') {
|
||||
return DAMAGE_NUMBER_FEEDBACK_DURATION_MS;
|
||||
}
|
||||
if (data.kind === 'damage' && Boolean(data.isCrit)) {
|
||||
return DAMAGE_NUMBER_CRIT_DURATION_MS;
|
||||
}
|
||||
return DAMAGE_NUMBER_DURATION_MS;
|
||||
}
|
||||
|
||||
function DamageNumber({ data, onExpire }: DamageNumberProps) {
|
||||
const durationMs = feedbackDurationMs(data);
|
||||
const driftDir = data.target === 'enemy' ? 1 : -1;
|
||||
const isOutcomeText =
|
||||
data.kind === 'blocked' || data.kind === 'evaded' || data.kind === 'stunned';
|
||||
const isCritDamage = data.kind === 'damage' && Boolean(data.isCrit);
|
||||
const color = data.kind === 'regen'
|
||||
? '#44dd66'
|
||||
: data.kind === 'stunned'
|
||||
? '#ffaa44'
|
||||
: isOutcomeText
|
||||
? (data.target === 'hero' ? '#44dd66' : '#ff5566')
|
||||
: (isCritDamage ? '#ffdd44' : '#ffffff');
|
||||
const fontSize = isOutcomeText ? 16 : (isCritDamage ? 24 : 18);
|
||||
|
||||
const style: CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: data.x,
|
||||
top: data.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: `autoheroFloatDamage ${durationMs}ms cubic-bezier(0.2, 0.75, 0.35, 1) forwards`,
|
||||
['--ah-drift' as string]: `${DAMAGE_NUMBER_DRIFT_PX * driftDir}px`,
|
||||
['--ah-rise' as string]: `${DAMAGE_NUMBER_RISE_PX}px`,
|
||||
['--ah-s0' as string]: isCritDamage ? '1.4' : '1',
|
||||
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',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
onAnimationEnd={() => {
|
||||
onExpire(data.id);
|
||||
}}
|
||||
>
|
||||
{data.kind === 'damage' && (
|
||||
<>
|
||||
{isCritDamage && 'CRIT '}
|
||||
{Math.round(data.value)}
|
||||
</>
|
||||
)}
|
||||
{data.kind === 'regen' && `+${Math.round(data.value)}`}
|
||||
{data.kind === 'blocked' && 'BLOCKED'}
|
||||
{data.kind === 'evaded' && 'EVADED'}
|
||||
{data.kind === 'stunned' && 'STUNNED'}
|
||||
</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 = useCallback((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',
|
||||
}}>
|
||||
<style dangerouslySetInnerHTML={{ __html: FLOAT_DAMAGE_KEYFRAMES }} />
|
||||
{activeDamages.map((d) => (
|
||||
<DamageNumber key={d.id} data={d} onExpire={handleExpire} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue