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 (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 (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); 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 { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { GameEngine } from './game/engine'; 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 type { NPCEncounterEvent } from './game/types';
import { GameWebSocket } from './network/websocket'; import { GameWebSocket } from './network/websocket';
import { import {
@ -54,7 +54,7 @@ import {
import { parseAdventureLogLine } from './game/adventureLogMarkers'; import { parseAdventureLogLine } from './game/adventureLogMarkers';
import { HUD } from './ui/HUD'; import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen'; import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage'; import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay';
import { CombatLogPanel } from './ui/CombatLogPanel'; import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast'; import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport'; import { OfflineReport } from './ui/OfflineReport';
@ -320,7 +320,7 @@ export function App() {
routeWaypoints: null, routeWaypoints: null,
}); });
const [damages, setDamages] = useState<FloatingDamageData[]>([]); const [combatEvents, setCombatEvents] = useState<CombatOverlayEvent[]>([]);
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [wsEverConnected, setWsEverConnected] = useState(false); const [wsEverConnected, setWsEverConnected] = useState(false);
const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState< 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 refreshEquipment = useCallback(() => {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
getHeroEquipment(telegramId) getHeroEquipment(telegramId)
@ -430,22 +519,6 @@ export function App() {
setGameState(state); 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 () => { engine.init(container).then(async () => {
let shouldOpenWS = false; let shouldOpenWS = false;
try { try {
@ -626,7 +699,10 @@ export function App() {
wireWSHandler(ws, engine, { wireWSHandler(ws, engine, {
onCombatStart: () => { onCombatStart: () => {
setCombatLogLines([]); setCombatLogLines([]);
clearCombatEvents();
}, },
onCombatAttack: handleCombatAttack,
onEnemyRegen: handleEnemyRegen,
onHeroStateReceived: (payload) => { onHeroStateReceived: (payload) => {
// Convert raw payload to HeroResponse shape and apply // Convert raw payload to HeroResponse shape and apply
@ -637,6 +713,7 @@ export function App() {
onCombatEnd: (p) => { onCombatEnd: (p) => {
setCombatLogLines([]); setCombatLogLines([]);
clearCombatEvents();
const loot = buildLootFromCombatEnd(p); const loot = buildLootFromCombatEnd(p);
engine.applyLoot(loot); engine.applyLoot(loot);
hapticNotification('success'); hapticNotification('success');
@ -678,6 +755,7 @@ export function App() {
onHeroRevived: () => { onHeroRevived: () => {
setCombatLogLines([]); setCombatLogLines([]);
clearCombatEvents();
setToast({ message: tr.heroRevived, color: '#44cc44' }); setToast({ message: tr.heroRevived, color: '#44cc44' });
// "Hero revived" comes from server log + WS // "Hero revived" comes from server log + WS
}, },
@ -1249,8 +1327,8 @@ export function App() {
/> />
)} )}
{/* Floating Damage Numbers */} {/* Combat overlay */}
<FloatingDamage damages={damages} /> <CombatOverlay events={combatEvents} onExpire={expireCombatEvent} />
<CombatLogPanel <CombatLogPanel
visible={ visible={

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

@ -3,9 +3,6 @@ import type {
GameState, GameState,
EnemyState, EnemyState,
HeroState, HeroState,
FloatingDamageData,
FloatingDamageKind,
FloatingDamageTarget,
LootDrop, LootDrop,
TownData, TownData,
NearbyHeroData, NearbyHeroData,
@ -50,10 +47,6 @@ const HERO_THOUGHTS = [
'Almost leveled up!', 'Almost leveled up!',
]; ];
// ---- Callbacks ----
export type DamageCallback = (damage: FloatingDamageData) => void;
/** Drift threshold in world tiles before snapping to server position. */ /** Drift threshold in world tiles before snapping to server position. */
const POSITION_DRIFT_SNAP_THRESHOLD = 2.0; const POSITION_DRIFT_SNAP_THRESHOLD = 2.0;
@ -115,7 +108,6 @@ export class GameEngine {
/** Callbacks */ /** Callbacks */
private _onStateChange: ((state: GameState) => void) | null = null; private _onStateChange: ((state: GameState) => void) | null = null;
private _onDamage: DamageCallback | null = null;
private _handleResize: (() => void) | null = null; private _handleResize: (() => void) | null = null;
constructor() { constructor() {
@ -149,10 +141,6 @@ export class GameEngine {
} }
/** Register a callback for damage events (floating numbers) */ /** Register a callback for damage events (floating numbers) */
onDamage(cb: DamageCallback): void {
this._onDamage = cb;
}
// ---- Data Setters (static data from REST init) ---- // ---- Data Setters (static data from REST init) ----
/** Set the hero display name on the renderer label. */ /** Set the hero display name on the renderer label. */
@ -446,7 +434,7 @@ export class GameEngine {
/** /**
* Called when server sends attack. * Called when server sends attack.
* Updates HP values and emits floating damage numbers. * Updates HP values.
*/ */
applyAttack( applyAttack(
source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon', source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon',
@ -463,71 +451,16 @@ export class GameEngine {
this._gameState.enemy.hp = enemyHp; 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(); this._notifyStateChange();
} }
/** /**
* Called when server sends enemy_regen. * Called when server sends enemy_regen.
* Updates enemy HP and emits floating regen numbers. * Updates enemy HP.
*/ */
applyEnemyRegen(amount: number, enemyHp: number): void { applyEnemyRegen(amount: number, enemyHp: number): void {
if (!this._gameState.enemy) return; if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp; 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(); this._notifyStateChange();
} }
@ -993,27 +926,6 @@ export class GameEngine {
this._onStateChange?.(this._gameState); 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 { private _showThought(): void {
this._thoughtText = this._thoughtText =
HERO_THOUGHTS[Math.floor(Math.random() * HERO_THOUGHTS.length)] ?? HERO_THOUGHTS[Math.floor(Math.random() * HERO_THOUGHTS.length)] ??

@ -421,22 +421,6 @@ export interface NearbyHeroData {
positionY: number; 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 ---- // ---- Server -> Client Message Payloads ----
export type ServerMessageType = export type ServerMessageType =

@ -37,6 +37,8 @@ import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
export interface WSHandlerCallbacks { export interface WSHandlerCallbacks {
/** Fires after combat_start is applied (clear transient combat UI). */ /** Fires after combat_start is applied (clear transient combat UI). */
onCombatStart?: () => void; onCombatStart?: () => void;
onCombatAttack?: (payload: AttackPayload) => void;
onEnemyRegen?: (payload: EnemyRegenPayload) => void;
onCombatEnd?: (payload: CombatEndPayload) => void; onCombatEnd?: (payload: CombatEndPayload) => void;
onHeroDied?: (payload: HeroDiedPayload) => void; onHeroDied?: (payload: HeroDiedPayload) => void;
onHeroRevived?: (payload: HeroRevivedPayload) => void; onHeroRevived?: (payload: HeroRevivedPayload) => void;
@ -130,11 +132,13 @@ export function wireWSHandler(
p.enemyHp, p.enemyHp,
p.outcome, p.outcome,
); );
callbacks.onCombatAttack?.(p);
}); });
ws.on('enemy_regen', (msg: ServerMessage) => { ws.on('enemy_regen', (msg: ServerMessage) => {
const p = msg.payload as EnemyRegenPayload; const p = msg.payload as EnemyRegenPayload;
engine.applyEnemyRegen(p.amount, p.enemyHp); engine.applyEnemyRegen(p.amount, p.enemyHp);
callbacks.onEnemyRegen?.(p);
}); });
ws.on('debuff_applied', (msg: ServerMessage) => { 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) */ /** Max accumulated time before we drop frames (prevents spiral of death) */
export const MAX_ACCUMULATED_MS = 250; 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 */ /** Buff cooldown overlay animation fps */
export const BUFF_OVERLAY_FPS = 30; 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