From 9f2fc343165f55bf5fa9f47a461b1d36c15b0b32 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Sun, 5 Apr 2026 23:35:35 +0300 Subject: [PATCH] small refactoring + autopotions --- backend/cmd/balanceall/grid.go | 5 +- backend/cmd/balanceall/main.go | 3 +- backend/cmd/balancesim/main.go | 5 +- backend/internal/constants/combat.go | 11 ++++ backend/internal/game/balance_monte_carlo.go | 3 +- backend/internal/game/combat.go | 1 + backend/internal/game/combat_log_phrase.go | 2 + backend/internal/game/combat_sim.go | 42 ++----------- backend/internal/game/engine.go | 63 +++++++++++++------ backend/internal/game/offline.go | 3 +- backend/internal/game/offline_test.go | 2 +- backend/internal/game/progression_sim.go | 3 +- backend/internal/handler/admin.go | 3 +- backend/internal/handler/game.go | 11 +--- backend/internal/hero/actions.go | 35 +++++++++++ .../model/adventure_log_phrase_keys.go | 1 + backend/internal/model/combat.go | 2 +- frontend/package-lock.json | 7 +++ frontend/package.json | 1 + frontend/src/App.tsx | 26 ++++++-- frontend/src/game/engine.ts | 2 +- frontend/src/game/types.ts | 2 +- frontend/src/ui/CombatLogPanel.tsx | 2 +- frontend/src/ui/CombatOverlay.tsx | 17 ++++- 24 files changed, 165 insertions(+), 87 deletions(-) create mode 100644 backend/internal/constants/combat.go create mode 100644 backend/internal/hero/actions.go diff --git a/backend/cmd/balanceall/grid.go b/backend/cmd/balanceall/grid.go index 3e65b46..4ad6bd3 100644 --- a/backend/cmd/balanceall/grid.go +++ b/backend/cmd/balanceall/grid.go @@ -8,6 +8,7 @@ import ( "sort" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" ) @@ -96,10 +97,10 @@ func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int, if maxH <= 0 { maxH = 1 } - e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv) + e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv, nil) survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{ TickRate: 100 * time.Millisecond, - MaxSteps: game.CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, }) if survived { wins++ diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go index 93361d3..77084f2 100644 --- a/backend/cmd/balanceall/main.go +++ b/backend/cmd/balanceall/main.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/game" @@ -626,7 +627,7 @@ func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, se e := enemy survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{ TickRate: 100 * time.Millisecond, - MaxSteps: game.CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, }) allDur = append(allDur, elapsed) if survived { diff --git a/backend/cmd/balancesim/main.go b/backend/cmd/balancesim/main.go index 8addddf..ed8c32d 100644 --- a/backend/cmd/balancesim/main.go +++ b/backend/cmd/balancesim/main.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/game" @@ -156,11 +157,11 @@ func main() { if hero.HP <= 0 { hero.HP = hero.MaxHP } - enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv) + enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv, nil) survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{ TickRate: 100 * time.Millisecond, WallClockDelay: time.Duration(*delayMs) * time.Millisecond, - MaxSteps: game.CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, }) if survived { wins++ diff --git a/backend/internal/constants/combat.go b/backend/internal/constants/combat.go new file mode 100644 index 0000000..3b4887a --- /dev/null +++ b/backend/internal/constants/combat.go @@ -0,0 +1,11 @@ +package constants + +const ( + OfflineAutoPotionChance = 0.10 + OfflineAutoPotionHPThresh = 0.60 + + // CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests). + CombatSimMaxStepsDefault = 200_000 + // CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early. + CombatSimMaxStepsLong = 3_000_000 +) \ No newline at end of file diff --git a/backend/internal/game/balance_monte_carlo.go b/backend/internal/game/balance_monte_carlo.go index 0b6bd69..534b6ff 100644 --- a/backend/internal/game/balance_monte_carlo.go +++ b/backend/internal/game/balance_monte_carlo.go @@ -5,6 +5,7 @@ import ( "sort" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/denisovdennis/autohero/internal/model" ) @@ -63,7 +64,7 @@ func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile Ref survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{ TickRate: 100 * time.Millisecond, - MaxSteps: CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, }) if survived { wins++ diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 31bfbe1..22c8d37 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -13,6 +13,7 @@ const ( attackOutcomeDodge = "dodge" attackOutcomeBlock = "block" attackOutcomeStun = "stun" + attackOutcomeHeal = "heal" ) type DamageBreakdown struct { diff --git a/backend/internal/game/combat_log_phrase.go b/backend/internal/game/combat_log_phrase.go index 459c2df..46ce6a3 100644 --- a/backend/internal/game/combat_log_phrase.go +++ b/backend/internal/game/combat_log_phrase.go @@ -11,6 +11,8 @@ func combatLogPhraseKey(source, outcome string) string { return model.LogPhraseCombatHeroStun case attackOutcomeDodge: return model.LogPhraseCombatHeroDodge + case attackOutcomeHeal: + return model.LogPhraseCombatHeroHeal default: return model.LogPhraseCombatHeroHit } diff --git a/backend/internal/game/combat_sim.go b/backend/internal/game/combat_sim.go index 4da1510..645ce80 100644 --- a/backend/internal/game/combat_sim.go +++ b/backend/internal/game/combat_sim.go @@ -1,22 +1,12 @@ package game import ( - "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" - "github.com/denisovdennis/autohero/internal/tuning" + "github.com/denisovdennis/autohero/internal/constants" ) -const ( - offlineAutoPotionChance = 0.02 - offlineAutoPotionHPThresh = 0.40 - - // CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests). - CombatSimMaxStepsDefault = 200_000 - // CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early. - CombatSimMaxStepsLong = 3_000_000 -) // CombatSimDeterministicStart is the fixed combat timeline origin for balance tools and admin sim parity (avoids wall-clock drift in tests). var CombatSimDeterministicStart = time.Unix(1_700_000_000, 0) @@ -27,7 +17,7 @@ type CombatSimOptions struct { TickRate time.Duration // AutoUsePotion decides whether to consume a potion after damage ticks/attacks. // It should return true when a potion was used. - AutoUsePotion func(hero *model.Hero, now time.Time) bool + AutoUsePotion func(hero *model.Hero, force bool) int // WallClockDelay adds optional real-time delay between simulation steps. // 0 means instant simulation (default). WallClockDelay time.Duration @@ -69,7 +59,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o step := 0 maxSteps := opts.MaxSteps if maxSteps <= 0 { - maxSteps = CombatSimMaxStepsDefault + maxSteps = constants.CombatSimMaxStepsDefault } for step < maxSteps { @@ -144,7 +134,7 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o return false, now.Sub(start) } if opts.AutoUsePotion != nil { - _ = opts.AutoUsePotion(hero, now) + _ = opts.AutoUsePotion(hero, false) } enemyNext = now.Add(attackIntervalEnemy(enemy.Speed)) } @@ -166,26 +156,4 @@ func simStepDelay(opts CombatSimOptions) { } } -// OfflineAutoPotionHook is a low-probability offline-only potion usage policy. -func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool { - if hero == nil || hero.Potions <= 0 || hero.HP <= 0 { - return false - } - hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh) - if hero.HP >= hpThresh { - return false - } - if rand.Float64() >= offlineAutoPotionChance { - return false - } - hero.Potions-- - healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) - if healAmount < 1 { - healAmount = 1 - } - hero.HP += healAmount - if hero.HP > hero.MaxHP { - hero.HP = hero.MaxHP - } - return true -} + diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 38f41f2..c95bf58 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -9,6 +9,7 @@ import ( "sync" "time" + hero_actions "github.com/denisovdennis/autohero/internal/hero" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" @@ -614,11 +615,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { } hero := hm.Hero - // Validate: hero is in combat, has potions, is alive. - if hm.State != model.StateFighting { - e.sendError(msg.HeroID, "not_fighting", "hero is not in combat") - return - } if hero.Potions <= 0 { e.sendError(msg.HeroID, "no_potions", "no potions available") return @@ -628,15 +624,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { return } - hero.Potions-- - healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) - if healAmount < 1 { - healAmount = 1 - } - hero.HP += healAmount - if hero.HP > hero.MaxHP { - hero.HP = hero.MaxHP - } + healAmount := hero_actions.UsePotionOnHero(hero, true) hm.SyncToHero() @@ -653,7 +641,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { } } - if e.adventureLog != nil { + if e.adventureLog != nil && healAmount > 0 { e.adventureLog(msg.HeroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseUsedHealingPotion, @@ -676,8 +664,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { HeroHP: hero.HP, EnemyHP: enemyHP, }) - hero.EnsureGearMap() - hero.RefreshDerivedCombatStats(time.Now()) e.sender.SendToHero(msg.HeroID, "hero_state", hero) } } @@ -1619,6 +1605,8 @@ func (e *Engine) processCombatTickLocked(now time.Time) { dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now) regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder) summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) + + cs.LastTickAt = now if e.sender != nil { if dotDmg > 0 { @@ -1643,6 +1631,7 @@ func (e *Engine) processCombatTickLocked(now time.Time) { EnemyHP: cs.Enemy.HP, }) } + } if CheckDeath(cs.Hero, now) { @@ -1667,7 +1656,6 @@ func (e *Engine) processCombatTickLocked(now time.Time) { } } - // Process all attacks that are due. for e.queue.Len() > 0 { next := e.queue[0] if next.NextAttackAt.After(now) { @@ -1766,11 +1754,36 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { } combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now) + healAmount := hero_actions.UsePotionOnHero(cs.Hero, false) + // Process all attacks that are due. + e.emitEvent(combatEvt) e.logCombatAttack(cs, combatEvt) // Push attack envelope. if e.sender != nil { + if healAmount > 0 { + e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{ + Source: "potion", + Damage: -healAmount, // negative = heal + HeroHP: cs.Hero.HP, + EnemyHP: cs.Enemy.HP, + }) + + usePotionEvent := model.CombatEvent{ + Type: "attack", + HeroID: cs.HeroID, + Damage: healAmount, + Source: "potion", + Outcome: attackOutcomeHeal, + HeroHP: cs.Hero.HP, + EnemyHP: cs.Enemy.HP, + Timestamp: now, + } + e.emitEvent(usePotionEvent) + e.logUsePotion(cs, usePotionEvent) + } + e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{ Source: combatEvt.Source, Damage: combatEvt.Damage, @@ -1883,6 +1896,20 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) { }) } +func (e *Engine) logUsePotion(cs *model.CombatState, evt model.CombatEvent) { + if e.adventureLog == nil || cs == nil { + return + } + + e.adventureLog(cs.HeroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPhraseUsedHealingPotion, + Args: map[string]any{"amount": evt.Damage}}, + }) +} + + + func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { hero := cs.Hero enemy := &cs.Enemy diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index c2bfeb6..7a147f5 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -8,6 +8,7 @@ import ( "math/rand" "time" + hero_actions "github.com/denisovdennis/autohero/internal/hero" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" @@ -456,7 +457,7 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{ TickRate: tickRate, - AutoUsePotion: OfflineAutoPotionHook, + AutoUsePotion: hero_actions.UsePotionOnHero, }) if !survived || hero.HP <= 0 { diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index 9571a48..a98b723 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -124,7 +124,7 @@ func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) { HP: 100, Potions: 3, } - if used := OfflineAutoPotionHook(hero, time.Now()); used { + if used := OfflineAutoPotionHook(hero); used { t.Fatal("expected no potion usage when hero is above threshold") } if hero.Potions != 3 { diff --git a/backend/internal/game/progression_sim.go b/backend/internal/game/progression_sim.go index 65da756..0df07e2 100644 --- a/backend/internal/game/progression_sim.go +++ b/backend/internal/game/progression_sim.go @@ -7,6 +7,7 @@ import ( "sort" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/denisovdennis/autohero/internal/model" ) @@ -368,7 +369,7 @@ func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds floa survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{ TickRate: 100 * time.Millisecond, - MaxSteps: CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, }) cycle := elapsed.Seconds() + p.RestAfterCombat.Seconds() xp := float64(enemy.XPReward) diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 55efc9d..fe0a68d 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/denisovdennis/autohero/internal/constants" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -2976,7 +2977,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) { opts := game.CombatSimOptions{ TickRate: tickRate, WallClockDelay: wallClockDelay, - MaxSteps: game.CombatSimMaxStepsLong, + MaxSteps: constants.CombatSimMaxStepsLong, OnEvent: func(evt model.CombatEvent) { if len(events) < maxEvents { events = append(events, evt) diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index bf1d4d9..508f695 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -13,6 +13,7 @@ import ( "time" "unicode/utf8" + hero_actions "github.com/denisovdennis/autohero/internal/hero" "github.com/go-chi/chi/v5" "golang.org/x/text/unicode/norm" @@ -1465,15 +1466,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) { } // Heal 30% of maxHP, capped at maxHP. - healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) - if healAmount < 1 { - healAmount = 1 - } - hero.HP += healAmount - if hero.HP > hero.MaxHP { - hero.HP = hero.MaxHP - } - hero.Potions-- + healAmount := hero_actions.UsePotionOnHero(hero,true) if err := h.store.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err) diff --git a/backend/internal/hero/actions.go b/backend/internal/hero/actions.go new file mode 100644 index 0000000..b250451 --- /dev/null +++ b/backend/internal/hero/actions.go @@ -0,0 +1,35 @@ +package hero + +import ( + "math/rand" + + "github.com/denisovdennis/autohero/internal/constants" + "github.com/denisovdennis/autohero/internal/model" + "github.com/denisovdennis/autohero/internal/tuning" +) + +func UsePotionOnHero(hero *model.Hero, force bool) int { + if force != true { + if hero == nil || hero.Potions <= 0 || hero.HP <= 0 { + return 0 + } + hpThresh := int(float64(hero.MaxHP) * constants.OfflineAutoPotionHPThresh) + if hero.HP >= hpThresh { + return 0 + } + if rand.Float64() >= (1.0 - (1.0 - float64(hero.HP / hpThresh))) / 1.2 + constants.OfflineAutoPotionChance { + return 0 + } + } + + hero.Potions-- + healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) + if healAmount < 1 { + healAmount = 1 + } + hero.HP += healAmount + if hero.HP > hero.MaxHP { + hero.HP = hero.MaxHP + } + return healAmount; +} \ No newline at end of file diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go index 990414f..1bd7186 100644 --- a/backend/internal/model/adventure_log_phrase_keys.go +++ b/backend/internal/model/adventure_log_phrase_keys.go @@ -37,6 +37,7 @@ const ( LogPhraseQuestAccepted = "log.quest_accepted" LogPhraseCombatHeroHit = "log.combat.hero_hit" LogPhraseCombatHeroDodge = "log.combat.hero_dodge" + LogPhraseCombatHeroHeal = "log.combat.hero_heal" LogPhraseCombatHeroStun = "log.combat.hero_stun" LogPhraseCombatEnemyHit = "log.combat.enemy_hit" LogPhraseCombatEnemyBlock = "log.combat.enemy_block" diff --git a/backend/internal/model/combat.go b/backend/internal/model/combat.go index c4706dc..8d5b8ba 100644 --- a/backend/internal/model/combat.go +++ b/backend/internal/model/combat.go @@ -75,7 +75,7 @@ func (q *AttackQueue) Pop() any { // CombatEvent is broadcast over WebSocket to clients observing combat. type CombatEvent struct { - Type string `json:"type"` // "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end" + Type string `json:"type"` // "heal", "attack", "death", "buff_applied", "debuff_applied", "combat_start", "combat_end" HeroID int64 `json:"heroId"` Damage int `json:"damage,omitempty"` Source string `json:"source"` // "hero" or "enemy" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 16a2a94..6b6d1f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "pixi.js": "^8.6.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "ts-pattern": "^5.9.0", "yaml": "^2.7.0" }, "devDependencies": { @@ -2808,6 +2809,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 508d713..d9a0c6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "pixi.js": "^8.6.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "ts-pattern": "^5.9.0", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea7a416..2fe782d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; +import { match } from 'ts-pattern'; import { GameEngine } from './game/engine'; import { GamePhase, @@ -83,7 +84,7 @@ import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels'; import type { AdventureLogLinePayload } from './game/types'; import { HUD } from './ui/HUD'; import { DeathScreen } from './ui/DeathScreen'; -import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay'; +import {CombatOverlay, type CombatOverlayEvent, CombatOverlayTarget} from './ui/CombatOverlay'; import { CombatLogPanel } from './ui/CombatLogPanel'; import { GameToast } from './ui/GameToast'; import { OfflineReport } from './ui/OfflineReport'; @@ -569,15 +570,19 @@ export function App() { }, []); const handleCombatAttack = useCallback((p: AttackPayload) => { - if (p.source === 'potion') { - return; - } + const defender: CombatOverlayEvent['target'] = match(p.source) + .returnType() + .with('enemy', () => 'hero') + .with('hero', () => 'enemy') + .with('potion', () => 'hero') + .with('dot', () => 'hero') + .with('summon', () => 'hero') + .exhaustive() - 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 isHealed = p.outcome === 'heal'; const isCrit = Boolean(p.isCrit); if (isStunned) { @@ -596,6 +601,15 @@ export function App() { value: 0, createdAt: performance.now(), }); + } else if (isHealed) { + appendCombatEvent({ + id: Date.now() + Math.random(), + kind: 'heal', + target: defender, + value: p.damage, + isCrit, + createdAt: performance.now(), + }); } else { appendCombatEvent({ id: Date.now() + Math.random(), diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 6a9a33b..ccef9c7 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -578,7 +578,7 @@ export class GameEngine { isCrit: boolean, heroHp: number, enemyHp: number, - outcome?: 'hit' | 'dodge' | 'block' | 'stun', + outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal', ): void { void source; void damage; diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index bfc74ec..8dc8ba4 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -570,7 +570,7 @@ export interface AttackPayload { source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon'; damage: number; isCrit?: boolean; - outcome?: 'hit' | 'dodge' | 'block' | 'stun'; + outcome?: 'hit' | 'dodge' | 'block' | 'stun' | 'heal'; heroHp: number; enemyHp: number; debuffApplied?: string; diff --git a/frontend/src/ui/CombatLogPanel.tsx b/frontend/src/ui/CombatLogPanel.tsx index e194723..a4c5411 100644 --- a/frontend/src/ui/CombatLogPanel.tsx +++ b/frontend/src/ui/CombatLogPanel.tsx @@ -5,7 +5,7 @@ const MAX_VISIBLE_LINES = 5; const panelBase: CSSProperties = { position: 'absolute', - top: '26%', + bottom: '26%', zIndex: 50, maxWidth: 200, padding: '8px 10px', diff --git a/frontend/src/ui/CombatOverlay.tsx b/frontend/src/ui/CombatOverlay.tsx index 75330ec..dd530d7 100644 --- a/frontend/src/ui/CombatOverlay.tsx +++ b/frontend/src/ui/CombatOverlay.tsx @@ -1,7 +1,7 @@ import { useMemo, type CSSProperties } from 'react'; import { getViewport } from '../shared/telegram'; -export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned'; +export type CombatOverlayKind = 'damage' | 'blocked' | 'evaded' | 'regen' | 'stunned' | 'heal'; export type CombatOverlayTarget = 'hero' | 'enemy'; export interface CombatOverlayEvent { @@ -13,9 +13,10 @@ export interface CombatOverlayEvent { createdAt: number; } -const FLOAT_DURATION_MS = 2600; +const FLOAT_DURATION_MS = 6000; const FEEDBACK_DURATION_MS = 4800; const CRIT_DURATION_MS = 6000; +const HEAL_DURATION_MS = 7000; const FLOAT_RISE_PX = 96; const FLOAT_DRIFT_PX = 44; @@ -44,6 +45,9 @@ function durationMs(evt: CombatOverlayEvent): number { if (evt.kind === 'damage' && evt.isCrit) { return CRIT_DURATION_MS; } + if (evt.kind === 'heal') { + return HEAL_DURATION_MS; + } return FLOAT_DURATION_MS; } @@ -57,11 +61,17 @@ function overlayText(evt: CombatOverlayEvent): string { } if (evt.kind === 'blocked') return 'BLOCKED'; if (evt.kind === 'evaded') return 'EVADED'; + if (evt.kind === 'heal') return `HEAL ${Math.round(evt.value)}`; return 'STUNNED'; } function overlayColor(evt: CombatOverlayEvent): string { - if (evt.kind === 'regen') return '#44dd66'; + if (evt.kind === 'regen') { + return evt.target === 'hero' ? '#44dd66' : '#ff5566'; + } + if (evt.kind === 'heal') { + return '#44dd66'; + } if (evt.kind === 'stunned') return '#ffaa44'; if (evt.kind === 'blocked' || evt.kind === 'evaded') { return evt.target === 'hero' ? '#44dd66' : '#ff5566'; @@ -86,6 +96,7 @@ export function CombatOverlay({ events, onExpire }: CombatOverlayProps) { const yMid = viewport.height / 2 - 42; return { hero: { x: xHeroSide, y: yMid, driftDir: -1 }, + potion: { x: xHeroSide, y: yMid, driftDir: -1 }, enemy: { x: xEnemySide, y: yMid, driftDir: 1 }, }; }, [viewport.height, viewport.width]);