diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index f928c5b..90eb64d 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -914,6 +914,19 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { return } } + if e.roadGraph != nil { + var wx, wy float64 + if hm, ok := e.movements[hero.ID]; ok && hm.Hero != nil { + ox, oy := hm.displayOffset(now) + wx, wy = hm.CurrentX+ox, hm.CurrentY+oy + } else if hero != nil { + wx, wy = hero.PositionX, hero.PositionY + } + if e.roadGraph.HeroInTownAt(wx, wy) { + e.logger.Debug("skip combat start: hero inside town radius", "hero_id", hero.ID) + return + } + } cs := &model.CombatState{ HeroID: hero.ID, diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 1badf9d..ece6730 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -868,11 +868,14 @@ func WanderingMerchantCost(level int) int64 { } // rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat. -func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) { +func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) { cfg := tuning.Get() if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false, model.Enemy{}, false } + if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) { + return false, model.Enemy{}, false + } if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond { return false, model.Enemy{}, false } @@ -1030,13 +1033,22 @@ func (hm *HeroMovement) Die() { hm.State = model.StateDead } +// worldPositionAt returns hero world (x,y) matching SyncToHero / hero_move (spine + display offset). +func (hm *HeroMovement) worldPositionAt(now time.Time) (x, y float64) { + if hm == nil || hm.Hero == nil { + return 0, 0 + } + ox, oy := hm.displayOffset(now) + return hm.CurrentX + ox, hm.CurrentY + oy +} + // SyncToHero writes movement state back to the hero model for persistence. // Position uses the same world coordinates as hero_move / position_sync (road spine + display offset). func (hm *HeroMovement) SyncToHero() { now := time.Now() - ox, oy := hm.displayOffset(now) - hm.Hero.PositionX = hm.CurrentX + ox - hm.Hero.PositionY = hm.CurrentY + oy + x, y := hm.worldPositionAt(now) + hm.Hero.PositionX = x + hm.Hero.PositionY = y hm.Hero.State = hm.State if hm.CurrentTownID != 0 { id := hm.CurrentTownID @@ -1683,8 +1695,11 @@ func (hm *HeroMovement) applyRestHealTick(dt float64) { } } -func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) { +func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) { cfg := tuning.Get() + if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) { + return false, model.Enemy{}, false + } cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond if now.Sub(hm.LastEncounterAt) < cooldown { return false, model.Enemy{}, false @@ -2195,7 +2210,7 @@ func ProcessSingleHeroMovementTick( canEncounter := hm.Excursion.Phase == model.ExcursionWild || (hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled) if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) { - monster, enemy, hit := hm.rollAdventureEncounter(now) + monster, enemy, hit := hm.rollAdventureEncounter(now, graph) if hit { if monster && onEncounter != nil { hm.LastEncounterAt = now @@ -2272,7 +2287,7 @@ func ProcessSingleHeroMovementTick( canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2 if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) { - monster, enemy, hit := hm.rollRoadEncounter(now) + monster, enemy, hit := hm.rollRoadEncounter(now, graph) if hit { if monster { if onEncounter != nil {