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