From 2d336bfdcd1af545e915aad00d8cdd05f3a5a12a Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Tue, 31 Mar 2026 01:26:49 +0300 Subject: [PATCH] combat ui reworked --- backend/internal/game/combat.go | 6 ++- backend/internal/game/engine.go | 8 +++- backend/internal/model/ws_message.go | 6 +++ frontend/src/App.tsx | 12 +++--- frontend/src/game/engine.ts | 61 ++++++++++++++++++++++------ frontend/src/game/types.ts | 11 +++++ frontend/src/game/ws-handler.ts | 8 +++- frontend/src/shared/constants.ts | 2 +- frontend/src/ui/FloatingDamage.tsx | 24 ++++++++--- 9 files changed, 111 insertions(+), 27 deletions(-) diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 649c61c..edca49f 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -372,12 +372,16 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int { if healed < 1 { healed = 1 } + before := enemy.HP enemy.HP += healed if enemy.HP > enemy.MaxHP { enemy.HP = enemy.MaxHP } - return healed + if enemy.HP <= before { + return 0 + } + return enemy.HP - before } // CheckDeath checks if the hero is dead and attempts resurrection if a buff is active. diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 9808769..cde2319 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1170,9 +1170,15 @@ func (e *Engine) processCombatTick(now time.Time) { } ProcessDebuffDamage(cs.Hero, tickDur, now) - ProcessEnemyRegen(&cs.Enemy, tickDur) + regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur) ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) cs.LastTickAt = now + if regenHealed > 0 && e.sender != nil { + e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{ + Amount: regenHealed, + EnemyHP: cs.Enemy.HP, + }) + } if CheckDeath(cs.Hero, now) { e.emitEvent(model.CombatEvent{ diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index e745736..e2fd009 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -92,6 +92,12 @@ type AttackPayload struct { DebuffApplied string `json:"debuffApplied,omitempty"` } +// EnemyRegenPayload is sent when an enemy regenerates HP during combat. +type EnemyRegenPayload struct { + Amount int `json:"amount"` + EnemyHP int `json:"enemyHp"` +} + // CombatEndPayload is sent when the hero wins a fight. type CombatEndPayload struct { XPGained int64 `json:"xpGained"` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5fe8957..866f14f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -414,12 +414,14 @@ export function App() { // Wire up damage events from the engine engine.onDamage((dmg: FloatingDamageData) => { setDamages((prev) => [...prev, dmg]); - if (dmg.isCrit) { - hapticImpact('heavy'); - } else { - hapticImpact('light'); + if (dmg.kind === 'damage') { + if (dmg.isCrit) { + hapticImpact('heavy'); + } else { + hapticImpact('light'); + } + engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150); } - engine.camera.shake(dmg.isCrit ? 8 : 4, dmg.isCrit ? 250 : 150); }); engine.init(container).then(async () => { diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index e79d60d..6b196ac 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -4,6 +4,8 @@ import type { EnemyState, HeroState, FloatingDamageData, + FloatingDamageKind, + FloatingDamageTarget, LootDrop, TownData, NearbyHeroData, @@ -420,6 +422,7 @@ export class GameEngine { isCrit: boolean, heroHp: number, enemyHp: number, + outcome?: 'hit' | 'dodge' | 'block' | 'stun', ): void { if (this._gameState.hero) { this._gameState.hero.hp = heroHp; @@ -430,25 +433,53 @@ export class GameEngine { // Emit floating damage at appropriate screen position const viewport = getViewport(); - if (source === 'hero') { - // Damage on enemy (right side of screen) + const isBlocked = outcome === 'block'; + const isEvaded = outcome === 'dodge'; + const defender: FloatingDamageTarget = source === 'enemy' ? 'hero' : 'enemy'; + if (source === 'hero' || source === 'enemy') { + if (isBlocked || isEvaded) { + this._emitDamage( + 0, + defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60, + viewport.height / 2 - 30, + false, + isBlocked ? 'blocked' : 'evaded', + defender, + ); + } else { + this._emitDamage( + damage, + defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60, + viewport.height / 2 - 30, + source === 'hero' ? isCrit : false, + 'damage', + defender, + ); + } + } + // potion source: no floating damage + + this._notifyStateChange(); + } + + /** + * Called when server sends enemy_regen. + * Updates enemy HP and emits floating regen numbers. + */ + applyEnemyRegen(amount: number, enemyHp: number): void { + if (!this._gameState.enemy) return; + this._gameState.enemy.hp = enemyHp; + if (amount > 0) { + const viewport = getViewport(); this._emitDamage( - damage, + amount, viewport.width / 2 + 60, viewport.height / 2 - 30, - isCrit, - ); - } else if (source === 'enemy') { - // Damage on hero (left side of screen) - this._emitDamage( - damage, - viewport.width / 2 - 60, - viewport.height / 2 - 30, false, + 'regen', + 'enemy', ); } - // potion source: no floating damage - this._notifyStateChange(); } @@ -914,6 +945,8 @@ export class GameEngine { x: number, y: number, isCrit: boolean, + kind: FloatingDamageKind, + target: FloatingDamageTarget, ): void { if (!this._onDamage) return; this._onDamage({ @@ -923,6 +956,8 @@ export class GameEngine { y, isCrit, createdAt: performance.now(), + kind, + target, }); } diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 2ad4567..c7078e7 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -380,6 +380,9 @@ export interface NearbyHeroData { // ---- Floating Damage ---- +export type FloatingDamageKind = 'damage' | 'blocked' | 'evaded' | 'regen'; +export type FloatingDamageTarget = 'hero' | 'enemy'; + export interface FloatingDamageData { id: number; value: number; @@ -387,6 +390,8 @@ export interface FloatingDamageData { y: number; isCrit: boolean; createdAt: number; + kind: FloatingDamageKind; + target: FloatingDamageTarget; } // ---- Server -> Client Message Payloads ---- @@ -398,6 +403,7 @@ export type ServerMessageType = | 'route_assigned' | 'combat_start' | 'attack' + | 'enemy_regen' | 'combat_end' | 'hero_died' | 'hero_revived' @@ -464,6 +470,11 @@ export interface AttackPayload { debuffApplied?: string; } +export interface EnemyRegenPayload { + amount: number; + enemyHp: number; +} + export interface CombatEndPayload { xpGained: number; goldGained: number; diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index c0ba8be..d595c84 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -6,6 +6,7 @@ import type { RouteAssignedPayload, CombatStartPayload, AttackPayload, + EnemyRegenPayload, CombatEndPayload, HeroDiedPayload, HeroRevivedPayload, @@ -113,7 +114,12 @@ export function wireWSHandler( ws.on('attack', (msg: ServerMessage) => { const p = msg.payload as AttackPayload; - engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp); + engine.applyAttack(p.source, p.damage, p.isCrit, p.heroHp, p.enemyHp, p.outcome); + }); + + ws.on('enemy_regen', (msg: ServerMessage) => { + const p = msg.payload as EnemyRegenPayload; + engine.applyEnemyRegen(p.amount, p.enemyHp); }); ws.on('debuff_applied', (msg: ServerMessage) => { diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts index 0e5454f..0860ea6 100644 --- a/frontend/src/shared/constants.ts +++ b/frontend/src/shared/constants.ts @@ -47,7 +47,7 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000; export const MAX_ACCUMULATED_MS = 250; /** Floating damage number duration in milliseconds */ -export const DAMAGE_NUMBER_DURATION_MS = 1200; +export const DAMAGE_NUMBER_DURATION_MS = 1800; /** Floating damage rise distance in pixels */ export const DAMAGE_NUMBER_RISE_PX = 60; diff --git a/frontend/src/ui/FloatingDamage.tsx b/frontend/src/ui/FloatingDamage.tsx index abc3cdb..9832be4 100644 --- a/frontend/src/ui/FloatingDamage.tsx +++ b/frontend/src/ui/FloatingDamage.tsx @@ -36,7 +36,14 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) { const offsetY = -progress * DAMAGE_NUMBER_RISE_PX; const opacity = 1 - progress * progress; // ease-out fade - const scale = data.isCrit ? 1.4 - progress * 0.4 : 1; + const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1; + const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded'; + const color = data.kind === 'regen' + ? '#44dd66' + : isOutcomeText + ? (data.target === 'hero' ? '#44dd66' : '#ff5566') + : (data.isCrit ? '#ffdd44' : '#ffffff'); + const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18); const style: CSSProperties = { position: 'absolute', @@ -44,8 +51,8 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) { top: data.y + offsetY, transform: `translate(-50%, -50%) scale(${scale})`, opacity, - color: data.isCrit ? '#ffdd44' : '#ffffff', - fontSize: data.isCrit ? 24 : 18, + 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', @@ -54,8 +61,15 @@ function DamageNumber({ data, onExpire }: DamageNumberProps) { return (
- {data.isCrit && 'CRIT '} - {Math.round(data.value)} + {data.kind === 'damage' && ( + <> + {data.isCrit && 'CRIT '} + {Math.round(data.value)} + + )} + {data.kind === 'regen' && `+${Math.round(data.value)}`} + {data.kind === 'blocked' && 'BLOCKED'} + {data.kind === 'evaded' && 'EVADED'}
); }