|
|
|
|
@ -13,8 +13,6 @@ import (
|
|
|
|
|
const (
|
|
|
|
|
// townNPCVisitLogLines is how many log lines to emit per NPC visit.
|
|
|
|
|
townNPCVisitLogLines = 6
|
|
|
|
|
|
|
|
|
|
restKindTown = "town"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func movementTickRate() time.Duration {
|
|
|
|
|
@ -81,6 +79,19 @@ type HeroMovement struct {
|
|
|
|
|
// WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only).
|
|
|
|
|
WanderingMerchantDeadline time.Time
|
|
|
|
|
|
|
|
|
|
// Excursion holds the live mini-adventure session state.
|
|
|
|
|
// When Excursion.Phase == ExcursionNone the hero is on the road (normal walk / town / roadside rest).
|
|
|
|
|
Excursion model.ExcursionSession
|
|
|
|
|
|
|
|
|
|
// ActiveRestKind discriminates the current rest context when State == StateResting.
|
|
|
|
|
ActiveRestKind model.RestKind
|
|
|
|
|
|
|
|
|
|
// RestHealRemainder accumulates fractional HP between ticks for roadside / adventure-inline rest.
|
|
|
|
|
RestHealRemainder float64
|
|
|
|
|
|
|
|
|
|
// LastExcursionEndedAt is used for adventure cooldown (not persisted; resets on reconnect).
|
|
|
|
|
LastExcursionEndedAt time.Time
|
|
|
|
|
|
|
|
|
|
// spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad
|
|
|
|
|
// instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
|
|
|
|
|
spawnAtRoadStart bool
|
|
|
|
|
@ -156,6 +167,19 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
|
|
|
|
|
}
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
|
|
|
|
|
// Restore excursion session from persisted blob (hero may have disconnected mid-adventure).
|
|
|
|
|
if hero.TownPause != nil && hero.TownPause.Excursion != nil {
|
|
|
|
|
hm.applyExcursionFromBlob(hero.TownPause.Excursion)
|
|
|
|
|
if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 {
|
|
|
|
|
hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
|
|
|
|
|
hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
|
|
|
|
|
from := hm.Road.Waypoints[hm.WaypointIndex]
|
|
|
|
|
to := hm.Road.Waypoints[hm.WaypointIndex+1]
|
|
|
|
|
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
|
|
|
|
|
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hm
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -409,6 +433,10 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
|
|
|
|
|
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
|
|
|
|
|
hm.TownLeaveAt = shift(hm.TownLeaveAt)
|
|
|
|
|
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
|
|
|
|
|
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
|
|
|
|
|
hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil)
|
|
|
|
|
hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil)
|
|
|
|
|
hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt)
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -426,6 +454,11 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hm.Excursion.Active() {
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dt := now.Sub(hm.LastMoveTick).Seconds()
|
|
|
|
|
if dt <= 0 {
|
|
|
|
|
dt = movementTickRate().Seconds()
|
|
|
|
|
@ -540,6 +573,9 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
|
|
|
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
t := graph.Towns[townID]
|
|
|
|
|
hm.CurrentX = t.WorldX
|
|
|
|
|
hm.CurrentY = t.WorldY
|
|
|
|
|
@ -568,6 +604,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
|
|
|
|
|
}
|
|
|
|
|
hm.State = model.StateResting
|
|
|
|
|
hm.Hero.State = model.StateResting
|
|
|
|
|
hm.ActiveRestKind = model.RestKindTown
|
|
|
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
@ -618,7 +655,39 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
|
|
|
|
|
_ = now
|
|
|
|
|
exc := &hm.Excursion
|
|
|
|
|
if exc.Active() {
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
perpX, perpY := hm.roadPerpendicularUnit()
|
|
|
|
|
depth := exc.DepthWorldUnits
|
|
|
|
|
var t float64
|
|
|
|
|
switch exc.Phase {
|
|
|
|
|
case model.ExcursionOut:
|
|
|
|
|
outMs := float64(cfg.AdventureOutDurationMs)
|
|
|
|
|
if outMs > 0 {
|
|
|
|
|
elapsed := float64(now.Sub(exc.StartedAt).Milliseconds())
|
|
|
|
|
t = smoothstep(clamp01(elapsed / outMs))
|
|
|
|
|
}
|
|
|
|
|
case model.ExcursionWild:
|
|
|
|
|
t = 1.0
|
|
|
|
|
case model.ExcursionReturn:
|
|
|
|
|
retMs := float64(cfg.AdventureReturnDurationMs)
|
|
|
|
|
if retMs > 0 {
|
|
|
|
|
returnStart := exc.ReturnUntil.Add(-time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
|
|
|
|
|
elapsed := float64(now.Sub(returnStart).Milliseconds())
|
|
|
|
|
t = 1.0 - smoothstep(clamp01(elapsed / retMs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
d := depth * t
|
|
|
|
|
return perpX * d, perpY * d
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
|
|
|
|
|
perpX, perpY := hm.roadPerpendicularUnit()
|
|
|
|
|
const roadsideDepth = 2.0
|
|
|
|
|
return perpX * roadsideDepth, perpY * roadsideDepth
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0, 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -666,11 +735,15 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
hm.TownLeaveAt = time.Time{}
|
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
|
|
|
|
|
ids := graph.TownNPCIDs(destID)
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
hm.State = model.StateResting
|
|
|
|
|
hm.Hero.State = model.StateResting
|
|
|
|
|
hm.ActiveRestKind = model.RestKindTown
|
|
|
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
@ -695,6 +768,9 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
|
|
|
|
|
hm.TownLeaveAt = time.Time{}
|
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
|
hm.RestUntil = time.Time{}
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
|
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
|
|
|
|
|
@ -754,28 +830,38 @@ func (hm *HeroMovement) SyncToHero() {
|
|
|
|
|
hm.Hero.DestinationTownID = nil
|
|
|
|
|
}
|
|
|
|
|
hm.Hero.MoveState = string(hm.State)
|
|
|
|
|
hm.Hero.RestKind = ""
|
|
|
|
|
hm.Hero.RestKind = model.RestKindNone
|
|
|
|
|
if hm.State == model.StateResting {
|
|
|
|
|
hm.Hero.RestKind = restKindTown
|
|
|
|
|
if hm.ActiveRestKind != model.RestKindNone {
|
|
|
|
|
hm.Hero.RestKind = hm.ActiveRestKind
|
|
|
|
|
} else {
|
|
|
|
|
hm.Hero.RestKind = model.RestKindTown
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hm.Hero.TownPause = hm.townPauseBlob()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
|
|
|
|
|
var p *model.TownPausePersisted
|
|
|
|
|
|
|
|
|
|
switch hm.State {
|
|
|
|
|
case model.StateResting:
|
|
|
|
|
if hm.RestUntil.IsZero() {
|
|
|
|
|
return nil
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
t := hm.RestUntil
|
|
|
|
|
p := &model.TownPausePersisted{
|
|
|
|
|
rk := model.RestKindTown
|
|
|
|
|
if hm.ActiveRestKind != model.RestKindNone {
|
|
|
|
|
rk = hm.ActiveRestKind
|
|
|
|
|
}
|
|
|
|
|
p = &model.TownPausePersisted{
|
|
|
|
|
RestUntil: &t,
|
|
|
|
|
RestKind: restKindTown,
|
|
|
|
|
RestKind: rk,
|
|
|
|
|
TownRestHealRemainder: hm.TownRestHealRemainder,
|
|
|
|
|
RestHealRemainder: hm.RestHealRemainder,
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
case model.StateInTown:
|
|
|
|
|
p := &model.TownPausePersisted{
|
|
|
|
|
p = &model.TownPausePersisted{
|
|
|
|
|
TownVisitNPCName: hm.TownVisitNPCName,
|
|
|
|
|
TownVisitNPCType: hm.TownVisitNPCType,
|
|
|
|
|
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
|
|
|
|
|
@ -795,10 +881,42 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
|
|
|
|
|
t := hm.TownVisitStartedAt
|
|
|
|
|
p.TownVisitStartedAt = &t
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
default:
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Persist active excursion session regardless of hero state (the hero can be fighting
|
|
|
|
|
// or resting while an excursion is in progress).
|
|
|
|
|
if hm.Excursion.Active() {
|
|
|
|
|
ep := hm.excursionPersisted()
|
|
|
|
|
if p == nil {
|
|
|
|
|
p = &model.TownPausePersisted{}
|
|
|
|
|
}
|
|
|
|
|
p.Excursion = ep
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
|
|
|
|
|
s := &hm.Excursion
|
|
|
|
|
ep := &model.ExcursionPersisted{
|
|
|
|
|
Phase: string(s.Phase),
|
|
|
|
|
DepthWorldUnits: s.DepthWorldUnits,
|
|
|
|
|
RoadFreezeWaypoint: s.RoadFreezeWaypoint,
|
|
|
|
|
RoadFreezeFraction: s.RoadFreezeFraction,
|
|
|
|
|
}
|
|
|
|
|
if !s.StartedAt.IsZero() {
|
|
|
|
|
t := s.StartedAt
|
|
|
|
|
ep.StartedAt = &t
|
|
|
|
|
}
|
|
|
|
|
if !s.WildUntil.IsZero() {
|
|
|
|
|
t := s.WildUntil
|
|
|
|
|
ep.WildUntil = &t
|
|
|
|
|
}
|
|
|
|
|
if !s.ReturnUntil.IsZero() {
|
|
|
|
|
t := s.ReturnUntil
|
|
|
|
|
ep.ReturnUntil = &t
|
|
|
|
|
}
|
|
|
|
|
return ep
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) {
|
|
|
|
|
@ -807,11 +925,13 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
|
|
|
|
|
case model.StateResting:
|
|
|
|
|
if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() {
|
|
|
|
|
hm.RestUntil = *blob.RestUntil
|
|
|
|
|
hm.ActiveRestKind = blob.RestKind
|
|
|
|
|
hm.TownRestHealRemainder = blob.TownRestHealRemainder
|
|
|
|
|
return
|
|
|
|
|
hm.RestHealRemainder = blob.RestHealRemainder
|
|
|
|
|
} else {
|
|
|
|
|
// Legacy row without town_pause: treat rest as already elapsed so offline/ reconnect unblocks.
|
|
|
|
|
hm.RestUntil = now.Add(-time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
// Legacy row without town_pause: treat rest as already elapsed so offline/ reconnect unblocks.
|
|
|
|
|
hm.RestUntil = now.Add(-time.Millisecond)
|
|
|
|
|
case model.StateInTown:
|
|
|
|
|
if blob == nil {
|
|
|
|
|
return
|
|
|
|
|
@ -832,6 +952,27 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
|
|
|
|
|
}
|
|
|
|
|
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore excursion session from blob (may exist alongside any hero state).
|
|
|
|
|
if blob != nil && blob.Excursion != nil {
|
|
|
|
|
hm.applyExcursionFromBlob(blob.Excursion)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
|
|
|
|
|
hm.Excursion.Phase = model.ExcursionPhase(ep.Phase)
|
|
|
|
|
if ep.StartedAt != nil {
|
|
|
|
|
hm.Excursion.StartedAt = *ep.StartedAt
|
|
|
|
|
}
|
|
|
|
|
if ep.WildUntil != nil {
|
|
|
|
|
hm.Excursion.WildUntil = *ep.WildUntil
|
|
|
|
|
}
|
|
|
|
|
if ep.ReturnUntil != nil {
|
|
|
|
|
hm.Excursion.ReturnUntil = *ep.ReturnUntil
|
|
|
|
|
}
|
|
|
|
|
hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits
|
|
|
|
|
hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint
|
|
|
|
|
hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
|
|
|
|
|
@ -986,6 +1127,167 @@ func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Excursion (mini-adventure) FSM helpers ---
|
|
|
|
|
|
|
|
|
|
func smoothstep(t float64) float64 {
|
|
|
|
|
return t * t * (3 - 2*t)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clamp01(v float64) float64 {
|
|
|
|
|
if v < 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
if v > 1 {
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) isLowHP() bool {
|
|
|
|
|
if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) < tuning.Get().LowHpThreshold
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
if hm.Excursion.Active() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
cooldown := time.Duration(cfg.AdventureCooldownMs) * time.Millisecond
|
|
|
|
|
if !hm.LastExcursionEndedAt.IsZero() && now.Sub(hm.LastExcursionEndedAt) < cooldown {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
remaining := len(hm.Road.Waypoints) - 1 - hm.WaypointIndex
|
|
|
|
|
if remaining < 2 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return rand.Float64() < cfg.AdventureStartChance
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) beginExcursion(now time.Time) {
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
hm.Excursion = model.ExcursionSession{
|
|
|
|
|
Phase: model.ExcursionOut,
|
|
|
|
|
StartedAt: now,
|
|
|
|
|
DepthWorldUnits: cfg.AdventureDepthWorldUnits,
|
|
|
|
|
RoadFreezeWaypoint: hm.WaypointIndex,
|
|
|
|
|
RoadFreezeFraction: hm.WaypointFraction,
|
|
|
|
|
}
|
|
|
|
|
outEnd := now.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
|
|
|
|
|
wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs)
|
|
|
|
|
wildEnd := outEnd.Add(wildDur)
|
|
|
|
|
hm.Excursion.WildUntil = wildEnd
|
|
|
|
|
hm.Excursion.ReturnUntil = wildEnd.Add(time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// advanceExcursionPhases progresses through out->wild->return and returns true when complete.
|
|
|
|
|
func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) {
|
|
|
|
|
exc := &hm.Excursion
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
if exc.Phase == model.ExcursionOut {
|
|
|
|
|
outEnd := exc.StartedAt.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
|
|
|
|
|
if !now.Before(outEnd) {
|
|
|
|
|
exc.Phase = model.ExcursionWild
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) {
|
|
|
|
|
exc.Phase = model.ExcursionReturn
|
|
|
|
|
}
|
|
|
|
|
if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) endExcursion(now time.Time) {
|
|
|
|
|
hm.LastExcursionEndedAt = now
|
|
|
|
|
hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
|
|
|
|
|
hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
|
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
if hm.Road != nil && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
|
|
|
|
|
from := hm.Road.Waypoints[hm.WaypointIndex]
|
|
|
|
|
to := hm.Road.Waypoints[hm.WaypointIndex+1]
|
|
|
|
|
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
|
|
|
|
|
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
hm.State = model.StateResting
|
|
|
|
|
hm.Hero.State = model.StateResting
|
|
|
|
|
hm.ActiveRestKind = model.RestKindRoadside
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
dur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
|
|
|
|
|
hm.RestUntil = now.Add(dur)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) {
|
|
|
|
|
_ = now
|
|
|
|
|
hm.State = model.StateResting
|
|
|
|
|
hm.Hero.State = model.StateResting
|
|
|
|
|
hm.ActiveRestKind = model.RestKindAdventureInline
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) applyRestHealTick(dt float64) {
|
|
|
|
|
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
var hpPerS float64
|
|
|
|
|
switch hm.ActiveRestKind {
|
|
|
|
|
case model.RestKindRoadside:
|
|
|
|
|
hpPerS = cfg.RoadsideRestHpPerS
|
|
|
|
|
case model.RestKindAdventureInline:
|
|
|
|
|
hpPerS = cfg.AdventureRestHpPerS
|
|
|
|
|
default:
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
rawGain := float64(hm.Hero.MaxHP)*hpPerS*dt + hm.RestHealRemainder
|
|
|
|
|
gain := int(math.Floor(rawGain))
|
|
|
|
|
hm.RestHealRemainder = rawGain - float64(gain)
|
|
|
|
|
if gain <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
hm.Hero.HP += gain
|
|
|
|
|
if hm.Hero.HP > hm.Hero.MaxHP {
|
|
|
|
|
hm.Hero.HP = hm.Hero.MaxHP
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
|
|
|
|
|
if now.Sub(hm.LastEncounterAt) < cooldown {
|
|
|
|
|
return false, model.Enemy{}, false
|
|
|
|
|
}
|
|
|
|
|
if rand.Float64() >= cfg.EncounterActivityBase {
|
|
|
|
|
return false, model.Enemy{}, false
|
|
|
|
|
}
|
|
|
|
|
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus
|
|
|
|
|
merchantW := cfg.MerchantEncounterWeightBase
|
|
|
|
|
total := monsterW + merchantW
|
|
|
|
|
r := rand.Float64() * total
|
|
|
|
|
if r < monsterW {
|
|
|
|
|
e := PickEnemyForLevel(hm.Hero.Level)
|
|
|
|
|
return true, e, true
|
|
|
|
|
}
|
|
|
|
|
return false, model.Enemy{}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
|
|
|
|
|
if maxMs <= minMs {
|
|
|
|
|
return time.Duration(minMs) * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
return time.Duration(minMs+rand.Int63n(maxMs-minMs+1)) * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
|
|
|
|
|
// It mirrors the online engine's configured movement cadence.
|
|
|
|
|
// steps (plus a final partial step to real time) for catch-up simulation.
|
|
|
|
|
@ -1014,25 +1316,70 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
case model.StateResting:
|
|
|
|
|
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
|
|
|
|
|
dt := now.Sub(hm.LastMoveTick).Seconds()
|
|
|
|
|
if dt <= 0 {
|
|
|
|
|
dt = movementTickRate().Seconds()
|
|
|
|
|
}
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
hm.applyTownRestHeal(dt)
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
if now.After(hm.RestUntil) {
|
|
|
|
|
hm.LeaveTown(graph, now)
|
|
|
|
|
|
|
|
|
|
switch hm.ActiveRestKind {
|
|
|
|
|
case model.RestKindRoadside:
|
|
|
|
|
hm.applyRestHealTick(dt)
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
|
|
|
|
|
if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestUntil = time.Time{}
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
|
hm.refreshSpeed(now)
|
|
|
|
|
}
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
|
|
|
|
|
if route := hm.RoutePayload(); route != nil {
|
|
|
|
|
sender.SendToHero(heroID, "route_assigned", route)
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case model.RestKindAdventureInline:
|
|
|
|
|
hm.applyRestHealTick(dt)
|
|
|
|
|
excursionEnded := hm.advanceExcursionPhases(now)
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
|
|
|
|
|
if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded {
|
|
|
|
|
if excursionEnded {
|
|
|
|
|
hm.endExcursion(now)
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
|
hm.refreshSpeed(now)
|
|
|
|
|
}
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
hm.applyTownRestHeal(dt)
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
if now.After(hm.RestUntil) {
|
|
|
|
|
hm.LeaveTown(graph, now)
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
|
|
|
|
|
if route := hm.RoutePayload(); route != nil {
|
|
|
|
|
sender.SendToHero(heroID, "route_assigned", route)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -1137,6 +1484,65 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Active excursion (mini-adventure) ---
|
|
|
|
|
if hm.Excursion.Active() {
|
|
|
|
|
prevPhase := hm.Excursion.Phase
|
|
|
|
|
excursionEnded := hm.advanceExcursionPhases(now)
|
|
|
|
|
if excursionEnded {
|
|
|
|
|
hm.endExcursion(now)
|
|
|
|
|
hm.refreshSpeed(now)
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)})
|
|
|
|
|
}
|
|
|
|
|
if hm.isLowHP() {
|
|
|
|
|
hm.beginAdventureInlineRest(now)
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
if hit {
|
|
|
|
|
if monster && onEncounter != nil {
|
|
|
|
|
hm.LastEncounterAt = now
|
|
|
|
|
onEncounter(hm, &enemy, now)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !monster {
|
|
|
|
|
cost := WanderingMerchantCost(hm.Hero.Level)
|
|
|
|
|
hm.LastEncounterAt = now
|
|
|
|
|
if sender != nil {
|
|
|
|
|
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
|
|
|
|
|
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
|
|
|
|
|
NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if onMerchantEncounter != nil {
|
|
|
|
|
onMerchantEncounter(hm, now, cost)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Normal walking (no active excursion) ---
|
|
|
|
|
reachedTown := hm.AdvanceTick(now, graph)
|
|
|
|
|
|
|
|
|
|
if reachedTown {
|
|
|
|
|
@ -1169,6 +1575,16 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hm.isLowHP() {
|
|
|
|
|
hm.beginRoadsideRest(now)
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
if sender != nil && hm.Hero != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_state", hm.Hero)
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
|
|
|
|
|
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
|
|
|
|
|
monster, enemy, hit := hm.rollRoadEncounter(now)
|
|
|
|
|
@ -1179,7 +1595,6 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
onEncounter(hm, &enemy, now)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// No monster handler — skip consuming the roll (extremely rare).
|
|
|
|
|
} else {
|
|
|
|
|
cost := WanderingMerchantCost(hm.Hero.Level)
|
|
|
|
|
if sender != nil || onMerchantEncounter != nil {
|
|
|
|
|
@ -1202,6 +1617,17 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hm.mayStartExcursion(now) {
|
|
|
|
|
hm.beginExcursion(now)
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{
|
|
|
|
|
DepthWorldUnits: hm.Excursion.DepthWorldUnits,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
|
|
|
}
|
|
|
|
|
|