From 485254d6cdc2f138734d5262658937e2a3738c71 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 01:45:41 +0300 Subject: [PATCH] update combat on front --- backend/migrations/000006b_enemy_data.sql | 2 +- frontend/src/App.tsx | 120 +++++++++++++++---- frontend/src/game/enemyVisuals.ts | 8 +- frontend/src/game/engine.ts | 92 +-------------- frontend/src/game/types.ts | 16 --- frontend/src/game/ws-handler.ts | 4 + frontend/src/shared/constants.ts | 15 --- frontend/src/ui/CombatOverlay.tsx | 134 ++++++++++++++++++++++ frontend/src/ui/FloatingDamage.tsx | 132 --------------------- 9 files changed, 244 insertions(+), 279 deletions(-) create mode 100644 frontend/src/ui/CombatOverlay.tsx delete mode 100644 frontend/src/ui/FloatingDamage.tsx diff --git a/backend/migrations/000006b_enemy_data.sql b/backend/migrations/000006b_enemy_data.sql index fb7b16c..7cc32ff 100644 --- a/backend/migrations/000006b_enemy_data.sql +++ b/backend/migrations/000006b_enemy_data.sql @@ -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); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10669c0..09086ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); + const [combatEvents, setCombatEvents] = useState([]); 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 */} - + {/* Combat overlay */} + >> 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]; } } diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index dd19817..985a2cf 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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)] ?? diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index d7505ff..ece220f 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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 = diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index 6df7378..efecd1b 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -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) => { diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts index bc1a098..4a5a358 100644 --- a/frontend/src/shared/constants.ts +++ b/frontend/src/shared/constants.ts @@ -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; diff --git a/frontend/src/ui/CombatOverlay.tsx b/frontend/src/ui/CombatOverlay.tsx new file mode 100644 index 0000000..75330ec --- /dev/null +++ b/frontend/src/ui/CombatOverlay.tsx @@ -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 ( +
+