update combat on front

master
Denis Ranneft 1 month ago
parent 1aca5d265b
commit 485254d6cd

@ -1,4 +1,4 @@
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (1, 'wolf_l1_1_meadow', 'wolf', 'meadow', 'Elder Verdant Wolf', 89, 89, 19, 1, 1.7460, 0.0500, 1, 1, 1, 1, ARRAY[]::text[], false, now(), 1, 0.3, 5, 7.8681, 2.7054, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (1, 'wolf_l1_1_meadow', 'wolf', 'meadow', 'Elder Verdant Wolf', 89, 89, 19, 1, 1.7460, 0.0500, 1, 1, 1, 1, ARRAY[]::text[], false, now(), 1, 0.3, 5, 7.8681, 2.7054, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (2, 'wolf_l1_1_forest', 'wolf', 'forest', 'Woodland Elder Wolf', 98, 98, 21, 1, 1.7460, 0.0500, 1, 1, 2, 1, ARRAY[]::text[], false, now(), 1, 0.3, 5, 7.8681, 2.7054, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (3, 'wolf_l2_2_forest', 'wolf', 'forest', 'Young Woodland Wolf', 92, 92, 19, 1, 1.7640, 0.0500, 2, 2, 4, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.4975, 2.8677, 1.2600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (4, 'wolf_l2_2_ruins', 'wolf', 'ruins', 'Forgotten Young Wolf', 101, 101, 21, 1, 1.7640, 0.0500, 2, 2, 5, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.4975, 2.8677, 1.2600, 2.0, 1.2);

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { GameEngine } from './game/engine';
import { GamePhase, BuffType, type GameState, type FloatingDamageData, type ActiveBuff, type NPCData } from './game/types';
import { GamePhase, BuffType, type GameState, type ActiveBuff, type NPCData, type AttackPayload, type EnemyRegenPayload } from './game/types';
import type { NPCEncounterEvent } from './game/types';
import { GameWebSocket } from './network/websocket';
import {
@ -54,7 +54,7 @@ import {
import { parseAdventureLogLine } from './game/adventureLogMarkers';
import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage';
import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport';
@ -320,7 +320,7 @@ export function App() {
routeWaypoints: null,
});
const [damages, setDamages] = useState<FloatingDamageData[]>([]);
const [combatEvents, setCombatEvents] = useState<CombatOverlayEvent[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [wsEverConnected, setWsEverConnected] = useState(false);
const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState<
@ -378,6 +378,95 @@ export function App() {
}
}, []);
const appendCombatEvent = useCallback((evt: CombatOverlayEvent) => {
setCombatEvents((prev) => [...prev, evt]);
}, []);
const clearCombatEvents = useCallback(() => {
setCombatEvents([]);
}, []);
const expireCombatEvent = useCallback((id: number) => {
setCombatEvents((prev) => prev.filter((evt) => evt.id !== id));
}, []);
const handleCombatAttack = useCallback((p: AttackPayload) => {
if (p.source === 'potion') {
return;
}
const defender: CombatOverlayEvent['target'] =
p.source === 'enemy' ? 'hero' : 'enemy';
const isBlocked = p.outcome === 'block';
const isEvaded = p.outcome === 'dodge';
const isStunned = p.outcome === 'stun';
const isCrit = Boolean(p.isCrit);
if (isStunned) {
appendCombatEvent({
id: Date.now() + Math.random(),
kind: 'stunned',
target: 'hero',
value: 0,
createdAt: performance.now(),
});
} else if (isBlocked || isEvaded) {
appendCombatEvent({
id: Date.now() + Math.random(),
kind: isBlocked ? 'blocked' : 'evaded',
target: defender,
value: 0,
createdAt: performance.now(),
});
} else {
appendCombatEvent({
id: Date.now() + Math.random(),
kind: 'damage',
target: defender,
value: p.damage,
isCrit,
createdAt: performance.now(),
});
}
const engine = engineRef.current;
if (!engine) return;
const isDotLike = p.source === 'dot' || p.source === 'summon';
if (isDotLike) return;
if (isStunned) {
hapticImpact('light');
engine.camera.shake(3, 120);
return;
}
if (isBlocked || isEvaded) {
hapticImpact('light');
engine.camera.shake(3, 120);
return;
}
if (isCrit) {
hapticImpact('heavy');
engine.camera.shake(8, 250);
} else {
hapticImpact('light');
engine.camera.shake(4, 150);
}
}, [appendCombatEvent]);
const handleEnemyRegen = useCallback((p: EnemyRegenPayload) => {
if (p.amount <= 0) return;
appendCombatEvent({
id: Date.now() + Math.random(),
kind: 'regen',
target: 'enemy',
value: p.amount,
createdAt: performance.now(),
});
}, [appendCombatEvent]);
const refreshEquipment = useCallback(() => {
const telegramId = getTelegramUserId() ?? 1;
getHeroEquipment(telegramId)
@ -430,22 +519,6 @@ export function App() {
setGameState(state);
});
// Wire up damage events from the engine
engine.onDamage((dmg: FloatingDamageData) => {
setDamages((prev) => [...prev, dmg]);
if (dmg.kind === 'damage') {
if (dmg.isCrit) {
hapticImpact('heavy');
} else {
hapticImpact('light');
}
engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150);
} else if (dmg.kind === 'stunned') {
hapticImpact('light');
engine.camera.shake(3, 120);
}
});
engine.init(container).then(async () => {
let shouldOpenWS = false;
try {
@ -626,7 +699,10 @@ export function App() {
wireWSHandler(ws, engine, {
onCombatStart: () => {
setCombatLogLines([]);
clearCombatEvents();
},
onCombatAttack: handleCombatAttack,
onEnemyRegen: handleEnemyRegen,
onHeroStateReceived: (payload) => {
// Convert raw payload to HeroResponse shape and apply
@ -637,6 +713,7 @@ export function App() {
onCombatEnd: (p) => {
setCombatLogLines([]);
clearCombatEvents();
const loot = buildLootFromCombatEnd(p);
engine.applyLoot(loot);
hapticNotification('success');
@ -678,6 +755,7 @@ export function App() {
onHeroRevived: () => {
setCombatLogLines([]);
clearCombatEvents();
setToast({ message: tr.heroRevived, color: '#44cc44' });
// "Hero revived" comes from server log + WS
},
@ -1249,8 +1327,8 @@ export function App() {
/>
)}
{/* Floating Damage Numbers */}
<FloatingDamage damages={damages} />
{/* Combat overlay */}
<CombatOverlay events={combatEvents} onExpire={expireCombatEvent} />
<CombatLogPanel
visible={

@ -613,8 +613,8 @@ const HEAD_SHAPE_ORDER: HeadShape[] = ['circle', 'horns', 'crown', 'none', 'fang
function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualConfig {
const h = hashString(slug);
const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length;
const bodyShape = BODY_SHAPE_ORDER[h % BODY_SHAPE_ORDER.length];
const headShape = HEAD_SHAPE_ORDER[h2 % HEAD_SHAPE_ORDER.length];
const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape;
const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape;
const sizeMul = 0.86 + ((h ^ h2) % 29) / 100;
return {
...base,
@ -645,8 +645,8 @@ export function resolveEnemyVisual(slug: string, archetype?: string): EnemyVisua
} else if (arch && ARCHETYPE_VISUAL_KEY[arch]) {
key = ARCHETYPE_VISUAL_KEY[arch];
} else {
const first = slugLower.split('_')[0];
if (ARCHETYPE_VISUAL_KEY[first]) {
const first = slugLower.split('_')[0] ?? '';
if (first && ARCHETYPE_VISUAL_KEY[first]) {
key = ARCHETYPE_VISUAL_KEY[first];
}
}

@ -3,9 +3,6 @@ import type {
GameState,
EnemyState,
HeroState,
FloatingDamageData,
FloatingDamageKind,
FloatingDamageTarget,
LootDrop,
TownData,
NearbyHeroData,
@ -50,10 +47,6 @@ const HERO_THOUGHTS = [
'Almost leveled up!',
];
// ---- Callbacks ----
export type DamageCallback = (damage: FloatingDamageData) => void;
/** Drift threshold in world tiles before snapping to server position. */
const POSITION_DRIFT_SNAP_THRESHOLD = 2.0;
@ -115,7 +108,6 @@ export class GameEngine {
/** Callbacks */
private _onStateChange: ((state: GameState) => void) | null = null;
private _onDamage: DamageCallback | null = null;
private _handleResize: (() => void) | null = null;
constructor() {
@ -149,10 +141,6 @@ export class GameEngine {
}
/** Register a callback for damage events (floating numbers) */
onDamage(cb: DamageCallback): void {
this._onDamage = cb;
}
// ---- Data Setters (static data from REST init) ----
/** Set the hero display name on the renderer label. */
@ -446,7 +434,7 @@ export class GameEngine {
/**
* Called when server sends attack.
* Updates HP values and emits floating damage numbers.
* Updates HP values.
*/
applyAttack(
source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon',
@ -463,71 +451,16 @@ export class GameEngine {
this._gameState.enemy.hp = enemyHp;
}
const viewport = getViewport();
const isBlocked = outcome === 'block';
const isEvaded = outcome === 'dodge';
const isStun = outcome === 'stun';
/** Who receives hit-style floating text (hero = left anchor, enemy = right). */
const defender: FloatingDamageTarget =
source === 'enemy' || source === 'dot' || source === 'summon' ? 'hero' : 'enemy';
const xEnemySide = viewport.width / 2 + 88;
const xHeroSide = viewport.width / 2 - 88;
const yMid = viewport.height / 2 - 42;
const showSwingFloat =
source === 'hero' || source === 'enemy' || source === 'dot' || source === 'summon';
if (showSwingFloat) {
if (source === 'hero' && isStun) {
this._emitDamage(0, xHeroSide, yMid, false, 'stunned', 'hero');
} else if (isBlocked || isEvaded) {
const d: FloatingDamageTarget = defender;
this._emitDamage(
0,
d === 'enemy' ? xEnemySide : xHeroSide,
yMid,
false,
isBlocked ? 'blocked' : 'evaded',
d,
);
} else {
const crit =
(source === 'hero' || source === 'enemy') && Boolean(isCrit);
const d: FloatingDamageTarget = defender;
this._emitDamage(
damage,
d === 'enemy' ? xEnemySide : xHeroSide,
yMid,
crit,
'damage',
d,
);
}
}
// potion source: HP already updated; no floating combat text
this._notifyStateChange();
}
/**
* Called when server sends enemy_regen.
* Updates enemy HP and emits floating regen numbers.
* Updates enemy HP.
*/
applyEnemyRegen(amount: number, enemyHp: number): void {
if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp;
if (amount > 0) {
const viewport = getViewport();
this._emitDamage(
amount,
viewport.width / 2 + 88,
viewport.height / 2 - 42,
false,
'regen',
'enemy',
);
}
this._notifyStateChange();
}
@ -993,27 +926,6 @@ export class GameEngine {
this._onStateChange?.(this._gameState);
}
private _emitDamage(
value: number,
x: number,
y: number,
isCrit: boolean,
kind: FloatingDamageKind,
target: FloatingDamageTarget,
): void {
if (!this._onDamage) return;
this._onDamage({
id: Date.now() + Math.random(),
value,
x,
y,
isCrit,
createdAt: performance.now(),
kind,
target,
});
}
private _showThought(): void {
this._thoughtText =
HERO_THOUGHTS[Math.floor(Math.random() * HERO_THOUGHTS.length)] ??

@ -421,22 +421,6 @@ export interface NearbyHeroData {
positionY: number;
}
// ---- Floating Damage ----
export type FloatingDamageKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned';
export type FloatingDamageTarget = 'hero' | 'enemy';
export interface FloatingDamageData {
id: number;
value: number;
x: number;
y: number;
isCrit: boolean;
createdAt: number;
kind: FloatingDamageKind;
target: FloatingDamageTarget;
}
// ---- Server -> Client Message Payloads ----
export type ServerMessageType =

@ -37,6 +37,8 @@ import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
export interface WSHandlerCallbacks {
/** Fires after combat_start is applied (clear transient combat UI). */
onCombatStart?: () => void;
onCombatAttack?: (payload: AttackPayload) => void;
onEnemyRegen?: (payload: EnemyRegenPayload) => void;
onCombatEnd?: (payload: CombatEndPayload) => void;
onHeroDied?: (payload: HeroDiedPayload) => void;
onHeroRevived?: (payload: HeroRevivedPayload) => void;
@ -130,11 +132,13 @@ export function wireWSHandler(
p.enemyHp,
p.outcome,
);
callbacks.onCombatAttack?.(p);
});
ws.on('enemy_regen', (msg: ServerMessage) => {
const p = msg.payload as EnemyRegenPayload;
engine.applyEnemyRegen(p.amount, p.enemyHp);
callbacks.onEnemyRegen?.(p);
});
ws.on('debuff_applied', (msg: ServerMessage) => {

@ -46,21 +46,6 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
/** Max accumulated time before we drop frames (prevents spiral of death) */
export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds (normal hits, regen) */
export const DAMAGE_NUMBER_DURATION_MS = 2600;
/** Longer float for blocked / evaded so combat feedback stays readable */
export const DAMAGE_NUMBER_FEEDBACK_DURATION_MS = 4800;
/** Crit numbers stay on screen longer than normal hits (must differ from DAMAGE_NUMBER_DURATION_MS) */
export const DAMAGE_NUMBER_CRIT_DURATION_MS = 6000;
/** Floating damage rise distance in pixels (vertical flight from anchor) */
export const DAMAGE_NUMBER_RISE_PX = 96;
/** Horizontal drift during float (per target side; scales with progress) */
export const DAMAGE_NUMBER_DRIFT_PX = 44;
/** Buff cooldown overlay animation fps */
export const BUFF_OVERLAY_FPS = 30;

@ -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…
Cancel
Save