@ -74,25 +74,18 @@ type HeroMovement struct {
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time . Time
RoadsideThoughtNextAt time . Time
// Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires.
// Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0.
TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking)
TownNPCWalkTargetID int64
TownNPCWalkFromX float64
TownNPCWalkFromY float64
TownNPCWalkToX float64
TownNPCWalkToX float64
TownNPCWalkToY float64
TownNPCWalkToY float64
TownNPCWalkStart time . Time // when walk began
TownNPCWalkArrive time . Time // when hero reaches NPC
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time . Time
TownLeaveAt time . Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool
TownPlazaHealActive bool
// TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine).
// TownCenterWalk*: attractor stepping to plaza before road snap.
TownCenterWalkArrive time . Time
TownCenterWalkActive bool
TownCenterWalkStart time . Time
TownCenterWalkFromX float64
TownCenterWalkFromY float64
TownCenterWalkToX float64
TownCenterWalkToX float64
TownCenterWalkToY float64
TownCenterWalkToY float64
@ -130,6 +123,7 @@ type townPausePersistSignature struct {
RestKind model . RestKind
RestKind model . RestKind
RestUntil time . Time
RestUntil time . Time
ExcursionKind model . ExcursionKind
ExcursionPhase model . ExcursionPhase
ExcursionPhase model . ExcursionPhase
ExcursionStartedAt time . Time
ExcursionStartedAt time . Time
ExcursionOutUntil time . Time
ExcursionOutUntil time . Time
@ -138,6 +132,14 @@ type townPausePersistSignature struct {
ExcursionDepthWorldUnits float64
ExcursionDepthWorldUnits float64
ExcursionRoadFreezeWaypoint int
ExcursionRoadFreezeWaypoint int
ExcursionRoadFreezeFraction float64
ExcursionRoadFreezeFraction float64
ExcursionStartX float64
ExcursionStartY float64
ExcursionAttractorX float64
ExcursionAttractorY float64
ExcursionAttractorSet bool
ExcursionAdventureEndsAt time . Time
ExcursionWanderNextAt time . Time
ExcursionPendingReturn bool
// In-town NPC tour: coarse milestones only (not per-tick x,y during walks).
// In-town NPC tour: coarse milestones only (not per-tick x,y during walks).
InTown bool
InTown bool
@ -146,11 +148,12 @@ type townPausePersistSignature struct {
InTownVisitStarted time . Time
InTownVisitStarted time . Time
InTownVisitLogs int
InTownVisitLogs int
InTownNPCWalkTarget int64
InTownNPCWalkTarget int64
InTownNPCWalk Start time . Time
InTownNPCWalk ToX float64
InTownNPCWalk Arrive time . Time
InTownNPCWalk ToY float64
InTownPlazaHeal bool
InTownPlazaHeal bool
InTownCenterWalkStart time . Time
InTownCenterWalkActive bool
InTownCenterWalkArrive time . Time
InTownCenterWalkToX float64
InTownCenterWalkToY float64
InTownNPCQueueLen int
InTownNPCQueueLen int
InTownNPCQueueFP uint64
InTownNPCQueueFP uint64
InTownVisitName string
InTownVisitName string
@ -529,8 +532,6 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm . NextTownNPCRollAt = shift ( hm . NextTownNPCRollAt )
hm . NextTownNPCRollAt = shift ( hm . NextTownNPCRollAt )
hm . TownVisitStartedAt = shift ( hm . TownVisitStartedAt )
hm . TownVisitStartedAt = shift ( hm . TownVisitStartedAt )
hm . TownLeaveAt = shift ( hm . TownLeaveAt )
hm . TownLeaveAt = shift ( hm . TownLeaveAt )
hm . TownCenterWalkStart = shift ( hm . TownCenterWalkStart )
hm . TownCenterWalkArrive = shift ( hm . TownCenterWalkArrive )
hm . WanderingMerchantDeadline = shift ( hm . WanderingMerchantDeadline )
hm . WanderingMerchantDeadline = shift ( hm . WanderingMerchantDeadline )
hm . Excursion . StartedAt = shift ( hm . Excursion . StartedAt )
hm . Excursion . StartedAt = shift ( hm . Excursion . StartedAt )
hm . Excursion . OutUntil = shift ( hm . Excursion . OutUntil )
hm . Excursion . OutUntil = shift ( hm . Excursion . OutUntil )
@ -624,6 +625,9 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
// Heading returns the angle (radians) the hero is currently facing.
// Heading returns the angle (radians) the hero is currently facing.
func ( hm * HeroMovement ) Heading ( ) float64 {
func ( hm * HeroMovement ) Heading ( ) float64 {
if hm . excursionUsesAttractors ( ) && hm . Excursion . AttractorSet {
return math . Atan2 ( hm . Excursion . AttractorY - hm . CurrentY , hm . Excursion . AttractorX - hm . CurrentX )
}
if hm . Road == nil || hm . WaypointIndex >= len ( hm . Road . Waypoints ) - 1 {
if hm . Road == nil || hm . WaypointIndex >= len ( hm . Road . Waypoints ) - 1 {
return 0
return 0
}
}
@ -633,6 +637,9 @@ func (hm *HeroMovement) Heading() float64 {
// TargetPoint returns the next waypoint the hero is heading toward.
// TargetPoint returns the next waypoint the hero is heading toward.
func ( hm * HeroMovement ) TargetPoint ( ) ( float64 , float64 ) {
func ( hm * HeroMovement ) TargetPoint ( ) ( float64 , float64 ) {
if hm . excursionUsesAttractors ( ) && hm . Excursion . AttractorSet {
return hm . Excursion . AttractorX , hm . Excursion . AttractorY
}
if hm . Road == nil || hm . WaypointIndex >= len ( hm . Road . Waypoints ) - 1 {
if hm . Road == nil || hm . WaypointIndex >= len ( hm . Road . Waypoints ) - 1 {
return hm . CurrentX , hm . CurrentY
return hm . CurrentX , hm . CurrentY
}
}
@ -723,8 +730,9 @@ func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
return true
return true
}
}
// AdminStopExcursion ends an active excursion immediately (hero back on the road spine).
// AdminStopExcursion skips the remaining forest/wild leg and starts the return leg toward the road
// Works during walking phases or adventure-inline rest; rejects combat.
// (adventure: nearest point on the frozen road polyline; roadside: saved StartX/Y on the road).
// Does not end the session until the hero reaches the return attractor. Rejects combat.
func ( hm * HeroMovement ) AdminStopExcursion ( now time . Time ) bool {
func ( hm * HeroMovement ) AdminStopExcursion ( now time . Time ) bool {
if ! hm . Excursion . Active ( ) {
if ! hm . Excursion . Active ( ) {
return false
return false
@ -738,10 +746,24 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
hm . RestHealRemainder = 0
hm . RestHealRemainder = 0
hm . State = model . StateWalking
hm . State = model . StateWalking
hm . Hero . State = model . StateWalking
hm . Hero . State = model . StateWalking
hm . enterAdventureReturnToRoad ( )
hm . refreshSpeed ( now )
return true
}
}
hm . endExcursion ( now )
if hm . State == model . StateResting && hm . ActiveRestKind == model . RestKindRoadside {
hm . Excursion . Phase = model . ExcursionReturn
hm . setRoadsideReturnAttractor ( )
hm . RestUntil = time . Time { }
hm . RoadsideThoughtNextAt = time . Time { }
hm . refreshSpeed ( now )
return true
}
if hm . State == model . StateWalking && hm . Excursion . Kind == model . ExcursionKindAdventure {
hm . enterAdventureReturnToRoad ( )
hm . refreshSpeed ( now )
hm . refreshSpeed ( now )
return true
return true
}
return false
}
}
// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
@ -839,7 +861,200 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
return dx / L , dy / L
return dx / L , dy / L
}
}
func ( hm * HeroMovement ) excursionUsesAttractors ( ) bool {
return hm != nil && hm . Excursion . Active ( ) && hm . Excursion . Kind != model . ExcursionKindNone
}
func excursionArrivalEpsilon ( ) float64 {
cfg := tuning . Get ( )
eps := cfg . ExcursionArrivalEpsilonWorld
if eps <= 0 {
eps = tuning . DefaultValues ( ) . ExcursionArrivalEpsilonWorld
}
return eps
}
// stepTowardWorldPoint moves CurrentX/Y toward (tx, ty) at speed (world units per second).
// Uses the same arrival epsilon as excursion attractors.
func ( hm * HeroMovement ) stepTowardWorldPoint ( dt float64 , tx , ty , speed float64 ) bool {
if hm == nil || dt <= 0 {
return false
}
if speed <= 0 {
speed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
eps := excursionArrivalEpsilon ( )
dx := tx - hm . CurrentX
dy := ty - hm . CurrentY
dist := math . Hypot ( dx , dy )
if dist <= eps {
hm . CurrentX = tx
hm . CurrentY = ty
return true
}
step := speed * dt
if step >= dist {
hm . CurrentX = tx
hm . CurrentY = ty
return true
}
hm . CurrentX += dx / dist * step
hm . CurrentY += dy / dist * step
return false
}
// closestPointOnRoadSegments returns the closest point on the road polyline to (hx, hy).
func closestPointOnRoadSegments ( road * Road , hx , hy float64 ) ( float64 , float64 ) {
if road == nil || len ( road . Waypoints ) < 2 {
return hx , hy
}
bestDistSq := math . MaxFloat64
bestX , bestY := hx , hy
for i := 0 ; i < len ( road . Waypoints ) - 1 ; i ++ {
ax , ay := road . Waypoints [ i ] . X , road . Waypoints [ i ] . Y
bx , by := road . Waypoints [ i + 1 ] . X , road . Waypoints [ i + 1 ] . Y
dx , dy := bx - ax , by - ay
segLenSq := dx * dx + dy * dy
var t float64
if segLenSq < 1e-12 {
t = 0
} else {
t = ( ( hx - ax ) * dx + ( hy - ay ) * dy ) / segLenSq
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
}
px := ax + t * dx
py := ay + t * dy
dSq := ( hx - px ) * ( hx - px ) + ( hy - py ) * ( hy - py )
if dSq < bestDistSq {
bestDistSq = dSq
bestX , bestY = px , py
}
}
return bestX , bestY
}
func ( hm * HeroMovement ) pickExcursionForestAttractor ( depth float64 ) {
if depth <= 0 {
depth = 12
}
px , py := hm . roadPerpendicularUnit ( )
j := 0.85 + rand . Float64 ( ) * 0.3
d := depth * j
hm . Excursion . AttractorX = hm . CurrentX + px * d
hm . Excursion . AttractorY = hm . CurrentY + py * d
hm . Excursion . AttractorSet = true
}
func ( hm * HeroMovement ) setRoadsideReturnAttractor ( ) {
hm . Excursion . AttractorX = hm . Excursion . StartX
hm . Excursion . AttractorY = hm . Excursion . StartY
hm . Excursion . AttractorSet = true
}
func ( hm * HeroMovement ) enterAdventureReturnToRoad ( ) {
if hm . Road == nil {
return
}
rx , ry := closestPointOnRoadSegments ( hm . Road , hm . CurrentX , hm . CurrentY )
hm . Excursion . Phase = model . ExcursionReturn
hm . Excursion . PendingReturnAfterCombat = false
hm . Excursion . AttractorX = rx
hm . Excursion . AttractorY = ry
hm . Excursion . AttractorSet = true
}
// TryAdventureReturnAfterCombat runs after combat victory: if the adventure timer had elapsed
// (including while movement ticks were skipped during combat), or PendingReturnAfterCombat was
// set, transition to return phase toward the road.
func ( hm * HeroMovement ) TryAdventureReturnAfterCombat ( now time . Time ) {
if hm == nil || ! hm . Excursion . Active ( ) || hm . Excursion . Kind != model . ExcursionKindAdventure {
return
}
if hm . Excursion . Phase != model . ExcursionWild {
return
}
timerDone := ! hm . Excursion . AdventureEndsAt . IsZero ( ) && ! now . Before ( hm . Excursion . AdventureEndsAt )
if ! timerDone && ! hm . Excursion . PendingReturnAfterCombat {
return
}
hm . enterAdventureReturnToRoad ( )
}
func ( hm * HeroMovement ) adventureScheduleWanderRetarget ( now time . Time ) {
cfg := tuning . Get ( )
minMs := cfg . AdventureWanderRetargetMinMs
maxMs := cfg . AdventureWanderRetargetMaxMs
if minMs <= 0 {
minMs = tuning . DefaultValues ( ) . AdventureWanderRetargetMinMs
}
if maxMs <= 0 {
maxMs = tuning . DefaultValues ( ) . AdventureWanderRetargetMaxMs
}
hm . Excursion . WanderNextAt = now . Add ( randomDurationBetweenMs ( minMs , maxMs ) )
}
func ( hm * HeroMovement ) adventurePickWanderAttractor ( ) {
cfg := tuning . Get ( )
r := cfg . AdventureWanderRadius
if r <= 0 {
r = tuning . DefaultValues ( ) . AdventureWanderRadius
}
theta := rand . Float64 ( ) * 2 * math . Pi
rd := r * ( 0.25 + 0.75 * rand . Float64 ( ) )
hm . Excursion . AttractorX = hm . CurrentX + math . Cos ( theta ) * rd
hm . Excursion . AttractorY = hm . CurrentY + math . Sin ( theta ) * rd
hm . Excursion . AttractorSet = true
}
// stepTowardAttractor moves CurrentX/Y toward the excursion attractor. Returns true when arrived.
func ( hm * HeroMovement ) stepTowardAttractor ( now time . Time , dt float64 ) bool {
if ! hm . Excursion . AttractorSet {
return true
}
hm . refreshSpeed ( now )
eps := excursionArrivalEpsilon ( )
dx := hm . Excursion . AttractorX - hm . CurrentX
dy := hm . Excursion . AttractorY - hm . CurrentY
dist := math . Hypot ( dx , dy )
if dist <= eps {
hm . CurrentX = hm . Excursion . AttractorX
hm . CurrentY = hm . Excursion . AttractorY
return true
}
step := hm . Speed * dt
if step >= dist {
hm . CurrentX = hm . Excursion . AttractorX
hm . CurrentY = hm . Excursion . AttractorY
return true
}
hm . CurrentX += dx / dist * step
hm . CurrentY += dy / dist * step
return false
}
func ( hm * HeroMovement ) tryBeginAdventureReturn ( now time . Time ) {
if hm . Excursion . Kind != model . ExcursionKindAdventure || hm . Excursion . Phase != model . ExcursionWild {
return
}
if hm . Excursion . AdventureEndsAt . IsZero ( ) || now . Before ( hm . Excursion . AdventureEndsAt ) {
return
}
if hm . State == model . StateFighting {
hm . Excursion . PendingReturnAfterCombat = true
return
}
hm . enterAdventureReturnToRoad ( )
}
func ( hm * HeroMovement ) displayOffset ( now time . Time ) ( float64 , float64 ) {
func ( hm * HeroMovement ) displayOffset ( now time . Time ) ( float64 , float64 ) {
if hm . excursionUsesAttractors ( ) {
return 0 , 0
}
exc := & hm . Excursion
exc := & hm . Excursion
if exc . Active ( ) {
if exc . Active ( ) {
perpX , perpY := hm . roadPerpendicularUnit ( )
perpX , perpY := hm . roadPerpendicularUnit ( )
@ -858,7 +1073,7 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
retMs := float64 ( exc . ReturnUntil . Sub ( exc . WildUntil ) . Milliseconds ( ) )
retMs := float64 ( exc . ReturnUntil . Sub ( exc . WildUntil ) . Milliseconds ( ) )
if retMs > 0 {
if retMs > 0 {
elapsed := float64 ( now . Sub ( exc . WildUntil ) . Milliseconds ( ) )
elapsed := float64 ( now . Sub ( exc . WildUntil ) . Milliseconds ( ) )
t = 1.0 - smoothstep ( clamp01 ( elapsed / retMs ) )
t = 1.0 - smoothstep ( clamp01 ( elapsed / retMs ) )
}
}
}
}
d := depth * t
d := depth * t
@ -1006,19 +1221,12 @@ func townNPCStandPoint(npcX, npcY, fromX, fromY, standoff float64) (sx, sy float
// clearNPCWalk resets the walk-to-NPC sub-state.
// clearNPCWalk resets the walk-to-NPC sub-state.
func ( hm * HeroMovement ) clearNPCWalk ( ) {
func ( hm * HeroMovement ) clearNPCWalk ( ) {
hm . TownNPCWalkTargetID = 0
hm . TownNPCWalkTargetID = 0
hm . TownNPCWalkFromX = 0
hm . TownNPCWalkFromY = 0
hm . TownNPCWalkToX = 0
hm . TownNPCWalkToX = 0
hm . TownNPCWalkToY = 0
hm . TownNPCWalkToY = 0
hm . TownNPCWalkStart = time . Time { }
hm . TownNPCWalkArrive = time . Time { }
}
}
func ( hm * HeroMovement ) clearTownCenterWalk ( ) {
func ( hm * HeroMovement ) clearTownCenterWalk ( ) {
hm . TownCenterWalkArrive = time . Time { }
hm . TownCenterWalkActive = false
hm . TownCenterWalkStart = time . Time { }
hm . TownCenterWalkFromX = 0
hm . TownCenterWalkFromY = 0
hm . TownCenterWalkToX = 0
hm . TownCenterWalkToX = 0
hm . TownCenterWalkToY = 0
hm . TownCenterWalkToY = 0
}
}
@ -1110,8 +1318,10 @@ func (hm *HeroMovement) SyncToHero() {
}
}
}
}
hm . Hero . ExcursionPhase = model . ExcursionNone
hm . Hero . ExcursionPhase = model . ExcursionNone
hm . Hero . ExcursionKind = model . ExcursionKindNone
if hm . Excursion . Active ( ) {
if hm . Excursion . Active ( ) {
hm . Hero . ExcursionPhase = hm . Excursion . Phase
hm . Hero . ExcursionPhase = hm . Excursion . Phase
hm . Hero . ExcursionKind = hm . Excursion . Kind
}
}
hm . Hero . TownPause = hm . townPauseBlob ( )
hm . Hero . TownPause = hm . townPauseBlob ( )
}
}
@ -1144,6 +1354,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
if hm . Excursion . Active ( ) {
if hm . Excursion . Active ( ) {
s := hm . Excursion
s := hm . Excursion
sig . ExcursionKind = s . Kind
sig . ExcursionPhase = s . Phase
sig . ExcursionPhase = s . Phase
sig . ExcursionStartedAt = s . StartedAt
sig . ExcursionStartedAt = s . StartedAt
sig . ExcursionOutUntil = s . OutUntil
sig . ExcursionOutUntil = s . OutUntil
@ -1152,6 +1363,14 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig . ExcursionDepthWorldUnits = s . DepthWorldUnits
sig . ExcursionDepthWorldUnits = s . DepthWorldUnits
sig . ExcursionRoadFreezeWaypoint = s . RoadFreezeWaypoint
sig . ExcursionRoadFreezeWaypoint = s . RoadFreezeWaypoint
sig . ExcursionRoadFreezeFraction = s . RoadFreezeFraction
sig . ExcursionRoadFreezeFraction = s . RoadFreezeFraction
sig . ExcursionStartX = s . StartX
sig . ExcursionStartY = s . StartY
sig . ExcursionAttractorX = s . AttractorX
sig . ExcursionAttractorY = s . AttractorY
sig . ExcursionAttractorSet = s . AttractorSet
sig . ExcursionAdventureEndsAt = s . AdventureEndsAt
sig . ExcursionWanderNextAt = s . WanderNextAt
sig . ExcursionPendingReturn = s . PendingReturnAfterCombat
}
}
if hm . State == model . StateInTown {
if hm . State == model . StateInTown {
@ -1161,11 +1380,12 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig . InTownVisitStarted = hm . TownVisitStartedAt
sig . InTownVisitStarted = hm . TownVisitStartedAt
sig . InTownVisitLogs = hm . TownVisitLogsEmitted
sig . InTownVisitLogs = hm . TownVisitLogsEmitted
sig . InTownNPCWalkTarget = hm . TownNPCWalkTargetID
sig . InTownNPCWalkTarget = hm . TownNPCWalkTargetID
sig . InTownNPCWalk Start = hm . TownNPCWalkStart
sig . InTownNPCWalk ToX = hm . TownNPCWalkToX
sig . InTownNPCWalk Arrive = hm . TownNPCWalkArrive
sig . InTownNPCWalk ToY = hm . TownNPCWalkToY
sig . InTownPlazaHeal = hm . TownPlazaHealActive
sig . InTownPlazaHeal = hm . TownPlazaHealActive
sig . InTownCenterWalkStart = hm . TownCenterWalkStart
sig . InTownCenterWalkActive = hm . TownCenterWalkActive
sig . InTownCenterWalkArrive = hm . TownCenterWalkArrive
sig . InTownCenterWalkToX = hm . TownCenterWalkToX
sig . InTownCenterWalkToY = hm . TownCenterWalkToY
sig . InTownNPCQueueLen = len ( hm . TownNPCQueue )
sig . InTownNPCQueueLen = len ( hm . TownNPCQueue )
sig . InTownNPCQueueFP = npcQueueFingerprint ( hm . TownNPCQueue )
sig . InTownNPCQueueFP = npcQueueFingerprint ( hm . TownNPCQueue )
sig . InTownVisitName = hm . TownVisitNPCName
sig . InTownVisitName = hm . TownVisitNPCName
@ -1201,19 +1421,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
TownVisitNPCType : hm . TownVisitNPCType ,
TownVisitNPCType : hm . TownVisitNPCType ,
TownVisitLogsEmitted : hm . TownVisitLogsEmitted ,
TownVisitLogsEmitted : hm . TownVisitLogsEmitted ,
NPCWalkTargetID : hm . TownNPCWalkTargetID ,
NPCWalkTargetID : hm . TownNPCWalkTargetID ,
NPCWalkFromX : hm . TownNPCWalkFromX ,
NPCWalkFromY : hm . TownNPCWalkFromY ,
NPCWalkToX : hm . TownNPCWalkToX ,
NPCWalkToX : hm . TownNPCWalkToX ,
NPCWalkToY : hm . TownNPCWalkToY ,
NPCWalkToY : hm . TownNPCWalkToY ,
}
}
if ! hm . TownNPCWalkStart . IsZero ( ) {
t := hm . TownNPCWalkStart
p . NPCWalkStart = & t
}
if ! hm . TownNPCWalkArrive . IsZero ( ) {
t := hm . TownNPCWalkArrive
p . NPCWalkArrive = & t
}
if len ( hm . TownNPCQueue ) > 0 {
if len ( hm . TownNPCQueue ) > 0 {
p . NPCQueue = append ( [ ] int64 ( nil ) , hm . TownNPCQueue ... )
p . NPCQueue = append ( [ ] int64 ( nil ) , hm . TownNPCQueue ... )
}
}
@ -1232,17 +1442,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
if hm . TownPlazaHealActive {
if hm . TownPlazaHealActive {
p . TownPlazaHealActive = true
p . TownPlazaHealActive = true
}
}
p . CenterWalkFromX = hm . TownCenterWalkFromX
if hm . TownCenterWalkActive {
p . CenterWalkFromY = hm . TownCenterWalkFromY
p . CenterWalkActive = true
p . CenterWalkToX = hm . TownCenterWalkToX
p . CenterWalkToX = hm . TownCenterWalkToX
p . CenterWalkToY = hm . TownCenterWalkToY
p . CenterWalkToY = hm . TownCenterWalkToY
if ! hm . TownCenterWalkStart . IsZero ( ) {
t := hm . TownCenterWalkStart
p . CenterWalkStart = & t
}
if ! hm . TownCenterWalkArrive . IsZero ( ) {
t := hm . TownCenterWalkArrive
p . CenterWalkArrive = & t
}
}
}
}
@ -1262,10 +1465,17 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
func ( hm * HeroMovement ) excursionPersisted ( ) * model . ExcursionPersisted {
func ( hm * HeroMovement ) excursionPersisted ( ) * model . ExcursionPersisted {
s := & hm . Excursion
s := & hm . Excursion
ep := & model . ExcursionPersisted {
ep := & model . ExcursionPersisted {
Kind : string ( s . Kind ) ,
Phase : string ( s . Phase ) ,
Phase : string ( s . Phase ) ,
DepthWorldUnits : s . DepthWorldUnits ,
DepthWorldUnits : s . DepthWorldUnits ,
RoadFreezeWaypoint : s . RoadFreezeWaypoint ,
RoadFreezeWaypoint : s . RoadFreezeWaypoint ,
RoadFreezeFraction : s . RoadFreezeFraction ,
RoadFreezeFraction : s . RoadFreezeFraction ,
StartX : s . StartX ,
StartY : s . StartY ,
AttractorX : s . AttractorX ,
AttractorY : s . AttractorY ,
AttractorSet : s . AttractorSet ,
PendingReturnAfterCombat : s . PendingReturnAfterCombat ,
}
}
if ! s . StartedAt . IsZero ( ) {
if ! s . StartedAt . IsZero ( ) {
t := s . StartedAt
t := s . StartedAt
@ -1283,6 +1493,14 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s . ReturnUntil
t := s . ReturnUntil
ep . ReturnUntil = & t
ep . ReturnUntil = & t
}
}
if ! s . AdventureEndsAt . IsZero ( ) {
t := s . AdventureEndsAt
ep . AdventureEndsAt = & t
}
if ! s . WanderNextAt . IsZero ( ) {
t := s . WanderNextAt
ep . WanderNextAt = & t
}
return ep
return ep
}
}
@ -1321,26 +1539,14 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
}
}
hm . TownVisitLogsEmitted = blob . TownVisitLogsEmitted
hm . TownVisitLogsEmitted = blob . TownVisitLogsEmitted
hm . TownNPCWalkTargetID = blob . NPCWalkTargetID
hm . TownNPCWalkTargetID = blob . NPCWalkTargetID
hm . TownNPCWalkFromX = blob . NPCWalkFromX
hm . TownNPCWalkFromY = blob . NPCWalkFromY
hm . TownNPCWalkToX = blob . NPCWalkToX
hm . TownNPCWalkToX = blob . NPCWalkToX
hm . TownNPCWalkToY = blob . NPCWalkToY
hm . TownNPCWalkToY = blob . NPCWalkToY
if blob . NPCWalkStart != nil {
hm . TownNPCWalkStart = * blob . NPCWalkStart
}
if blob . NPCWalkArrive != nil {
hm . TownNPCWalkArrive = * blob . NPCWalkArrive
}
hm . TownPlazaHealActive = blob . TownPlazaHealActive
hm . TownPlazaHealActive = blob . TownPlazaHealActive
hm . TownCenterWalkFromX = blob . CenterWalkFromX
hm . TownCenterWalkFromY = blob . CenterWalkFromY
hm . TownCenterWalkToX = blob . CenterWalkToX
hm . TownCenterWalkToX = blob . CenterWalkToX
hm . TownCenterWalkToY = blob . CenterWalkToY
hm . TownCenterWalkToY = blob . CenterWalkToY
if blob . CenterWalkStart != nil {
hm . TownCenterWalkActive = blob . CenterWalkActive
hm . TownCenterWalkStart = * blob . CenterWalkStart
if ! hm . TownCenterWalkActive && blob . CenterWalkStart != nil && ! blob . CenterWalkStart . IsZero ( ) {
}
hm . TownCenterWalkActive = true
if blob . CenterWalkArrive != nil {
hm . TownCenterWalkArrive = * blob . CenterWalkArrive
}
}
}
}
@ -1351,6 +1557,12 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
}
}
func ( hm * HeroMovement ) applyExcursionFromBlob ( ep * model . ExcursionPersisted ) {
func ( hm * HeroMovement ) applyExcursionFromBlob ( ep * model . ExcursionPersisted ) {
// Legacy offset-only excursions (no kind) cannot resume with the attractor FSM.
if ep . Kind == "" && ep . Phase != "" {
hm . Excursion = model . ExcursionSession { }
return
}
hm . Excursion . Kind = model . ExcursionKind ( ep . Kind )
hm . Excursion . Phase = model . ExcursionPhase ( ep . Phase )
hm . Excursion . Phase = model . ExcursionPhase ( ep . Phase )
if ep . StartedAt != nil {
if ep . StartedAt != nil {
hm . Excursion . StartedAt = * ep . StartedAt
hm . Excursion . StartedAt = * ep . StartedAt
@ -1367,6 +1579,18 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm . Excursion . DepthWorldUnits = ep . DepthWorldUnits
hm . Excursion . DepthWorldUnits = ep . DepthWorldUnits
hm . Excursion . RoadFreezeWaypoint = ep . RoadFreezeWaypoint
hm . Excursion . RoadFreezeWaypoint = ep . RoadFreezeWaypoint
hm . Excursion . RoadFreezeFraction = ep . RoadFreezeFraction
hm . Excursion . RoadFreezeFraction = ep . RoadFreezeFraction
hm . Excursion . StartX = ep . StartX
hm . Excursion . StartY = ep . StartY
hm . Excursion . AttractorX = ep . AttractorX
hm . Excursion . AttractorY = ep . AttractorY
hm . Excursion . AttractorSet = ep . AttractorSet
hm . Excursion . PendingReturnAfterCombat = ep . PendingReturnAfterCombat
if ep . AdventureEndsAt != nil {
hm . Excursion . AdventureEndsAt = * ep . AdventureEndsAt
}
if ep . WanderNextAt != nil {
hm . Excursion . WanderNextAt = * ep . WanderNextAt
}
}
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@ -1532,54 +1756,31 @@ func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
func ( hm * HeroMovement ) beginExcursion ( now time . Time ) {
func ( hm * HeroMovement ) beginExcursion ( now time . Time ) {
cfg := tuning . Get ( )
cfg := tuning . Get ( )
depth := cfg . AdventureDepthWorldUnits
depth := cfg . AdventureDepthWorldUnits
if depth <= 0 {
hm . refreshSpeed ( now )
depth = tuning . DefaultValues ( ) . AdventureDepthWorldUnits
speed := hm . Speed
if speed < 0.1 {
speed = 0.1
}
}
outDur := time . Duration ( depth / speed * float64 ( time . Second ) )
minDur := cfg . AdventureDurationMinMs
maxDur := cfg . AdventureDurationMaxMs
outEnd := now . Add ( outDur )
if minDur <= 0 {
wildDur := randomDurationBetweenMs ( cfg . AdventureWildMinMs , cfg . AdventureWildMaxMs )
minDur = tuning . DefaultValues ( ) . AdventureDurationMinMs
wildEnd := outEnd . Add ( wildDur )
}
returnDur := time . Duration ( depth / speed * float64 ( time . Second ) )
if maxDur <= 0 {
maxDur = tuning . DefaultValues ( ) . AdventureDurationMaxMs
}
adventureEnds := now . Add ( randomDurationBetweenMs ( minDur , maxDur ) )
hm . Excursion = model . ExcursionSession {
hm . Excursion = model . ExcursionSession {
Kind : model . ExcursionKindAdventure ,
Phase : model . ExcursionOut ,
Phase : model . ExcursionOut ,
StartedAt : now ,
StartedAt : now ,
OutUntil : outEnd ,
WildUntil : wildEnd ,
ReturnUntil : wildEnd . Add ( returnDur ) ,
DepthWorldUnits : depth ,
DepthWorldUnits : depth ,
RoadFreezeWaypoint : hm . WaypointIndex ,
RoadFreezeWaypoint : hm . WaypointIndex ,
RoadFreezeFraction : hm . WaypointFraction ,
RoadFreezeFraction : hm . WaypointFraction ,
StartX : hm . CurrentX ,
StartY : hm . CurrentY ,
AdventureEndsAt : adventureEnds ,
}
}
}
hm . pickExcursionForestAttractor ( depth )
// advanceExcursionPhases progresses through out->wild->return and returns true when complete.
func ( hm * HeroMovement ) advanceExcursionPhases ( now time . Time ) ( ended bool ) {
exc := & hm . Excursion
if exc . Phase == model . ExcursionOut && ! now . Before ( exc . OutUntil ) {
exc . Phase = model . ExcursionWild
}
if exc . Phase == model . ExcursionWild && ! now . Before ( exc . WildUntil ) {
exc . Phase = model . ExcursionReturn
// Only recalculate return duration if we haven't already passed the original deadline
// (handles large time jumps from offline catch-up or timer-based exits).
if now . Before ( exc . ReturnUntil ) {
speed := hm . Speed
if speed < 0.1 {
speed = 0.1
}
exc . WildUntil = now
exc . ReturnUntil = now . Add ( time . Duration ( exc . DepthWorldUnits / speed * float64 ( time . Second ) ) )
}
}
if exc . Phase == model . ExcursionReturn && ! now . Before ( exc . ReturnUntil ) {
return true
}
return false
}
}
func ( hm * HeroMovement ) endExcursion ( now time . Time ) {
func ( hm * HeroMovement ) endExcursion ( now time . Time ) {
@ -1606,30 +1807,21 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
if depth <= 0 {
if depth <= 0 {
depth = 12.0
depth = 12.0
}
}
hm . refreshSpeed ( now )
speed := hm . Speed
if speed < 0.1 {
speed = 0.1
}
moveDur := time . Duration ( depth / speed * float64 ( time . Second ) )
outUntil := now . Add ( moveDur )
restDur := randomDurationBetweenMs ( cfg . RoadsideRestMinMs , cfg . RoadsideRestMaxMs )
restDur := randomDurationBetweenMs ( cfg . RoadsideRestMinMs , cfg . RoadsideRestMaxMs )
wildUntil := outUntil . Add ( restDur )
returnUntil := wildUntil . Add ( moveDur )
hm . Excursion = model . ExcursionSession {
hm . Excursion = model . ExcursionSession {
Kind : model . ExcursionKindRoadside ,
Phase : model . ExcursionOut ,
Phase : model . ExcursionOut ,
StartedAt : now ,
StartedAt : now ,
OutUntil : outUntil ,
WildUntil : wildUntil ,
ReturnUntil : returnUntil ,
DepthWorldUnits : depth ,
DepthWorldUnits : depth ,
RoadFreezeWaypoint : hm . WaypointIndex ,
RoadFreezeWaypoint : hm . WaypointIndex ,
RoadFreezeFraction : hm . WaypointFraction ,
RoadFreezeFraction : hm . WaypointFraction ,
StartX : hm . CurrentX ,
StartY : hm . CurrentY ,
}
}
// RestUntil tracks only the rest (wild) phase; travel out/return is separate.
hm . pickExcursionForestAttractor ( depth )
hm . RestUntil = wildUntil
// RestUntil caps the wild (heal) phase; out/return are movement phases.
hm . RestUntil = now . Add ( restDur )
hm . RoadsideThoughtNextAt = now . Add ( time . Duration ( 25 + rand . Intn ( 46 ) ) * time . Second )
hm . RoadsideThoughtNextAt = now . Add ( time . Duration ( 25 + rand . Intn ( 46 ) ) * time . Second )
}
}
@ -1740,20 +1932,14 @@ func ProcessSingleHeroMovementTick(
switch hm . ActiveRestKind {
switch hm . ActiveRestKind {
case model . RestKindRoadside :
case model . RestKindRoadside :
// For roadside rest, ensure Wild→Return always gets a fresh return
prevPhase := hm . Excursion . Phase
// deadline so the hero walks back to the road smoothly (prevents
hm . refreshSpeed ( now )
// advanceExcursionPhases from skipping the return phase on time jumps).
switch hm . Excursion . Phase {
if hm . Excursion . Phase == model . ExcursionWild && ! now . Before ( hm . Excursion . WildUntil ) {
case model . ExcursionOut :
hm . Excursion . Phase = model . ExcursionReturn
if hm . stepTowardAttractor ( now , dt ) {
speed := hm . Speed
hm . Excursion . Phase = model . ExcursionWild
if speed < 0.1 {
speed = 0.1
}
hm . Excursion . WildUntil = now
hm . Excursion . ReturnUntil = now . Add ( time . Duration ( hm . Excursion . DepthWorldUnits / speed * float64 ( time . Second ) ) )
}
}
excursionEnded := hm . advanceExcursionPhases ( now )
case model . ExcursionWild :
if hm . Excursion . Phase == model . ExcursionWild {
hm . applyRestHealTick ( dt )
hm . applyRestHealTick ( dt )
if adventureLog != nil {
if adventureLog != nil {
if hm . RoadsideThoughtNextAt . IsZero ( ) {
if hm . RoadsideThoughtNextAt . IsZero ( ) {
@ -1770,8 +1956,14 @@ func ProcessSingleHeroMovementTick(
hm . RoadsideThoughtNextAt = now . Add ( time . Duration ( 30 + rand . Intn ( 61 ) ) * time . Second )
hm . RoadsideThoughtNextAt = now . Add ( time . Duration ( 30 + rand . Intn ( 61 ) ) * time . Second )
}
}
}
}
cfg := tuning . Get ( )
hpFrac := float64 ( hm . Hero . HP ) / float64 ( hm . Hero . MaxHP )
if now . After ( hm . RestUntil ) || hpFrac >= cfg . RoadsideRestExitHp {
hm . Excursion . Phase = model . ExcursionReturn
hm . setRoadsideReturnAttractor ( )
}
}
if excursionEnded {
case model . ExcursionReturn :
if hm . stepTowardAttractor ( now , dt ) {
hm . endExcursion ( now )
hm . endExcursion ( now )
hm . ActiveRestKind = model . RestKindNone
hm . ActiveRestKind = model . RestKindNone
hm . RestUntil = time . Time { }
hm . RestUntil = time . Time { }
@ -1780,18 +1972,10 @@ func ProcessSingleHeroMovementTick(
hm . State = model . StateWalking
hm . State = model . StateWalking
hm . Hero . State = model . StateWalking
hm . Hero . State = model . StateWalking
hm . refreshSpeed ( now )
hm . refreshSpeed ( now )
} else if hm . Excursion . Phase == model . ExcursionWild {
cfg := tuning . Get ( )
hpFrac := float64 ( hm . Hero . HP ) / float64 ( hm . Hero . MaxHP )
if now . After ( hm . RestUntil ) || hpFrac >= cfg . RoadsideRestExitHp {
hm . Excursion . Phase = model . ExcursionReturn
speed := hm . Speed
if speed < 0.1 {
speed = 0.1
}
}
hm . Excursion . WildUntil = now
hm . Excursion . ReturnUntil = now . Add ( time . Duration ( hm . Excursion . DepthWorldUnits / speed * float64 ( time . Second ) ) )
}
}
if sender != nil && hm . Excursion . Phase != prevPhase {
sender . SendToHero ( heroID , "excursion_phase" , model . ExcursionPhasePayload { Phase : string ( hm . Excursion . Phase ) } )
}
}
hm . SyncToHero ( )
hm . SyncToHero ( )
if sender != nil && hm . Hero != nil {
if sender != nil && hm . Hero != nil {
@ -1801,16 +1985,9 @@ func ProcessSingleHeroMovementTick(
case model . RestKindAdventureInline :
case model . RestKindAdventureInline :
hm . applyRestHealTick ( dt )
hm . applyRestHealTick ( dt )
excursionEnded := hm . advanceExcursionPhases ( now )
cfg := tuning . Get ( )
cfg := tuning . Get ( )
hpFrac := float64 ( hm . Hero . HP ) / float64 ( hm . Hero . MaxHP )
hpFrac := float64 ( hm . Hero . HP ) / float64 ( hm . Hero . MaxHP )
if hpFrac >= cfg . AdventureRestTargetHp || excursionEnded {
if hpFrac >= cfg . AdventureRestTargetHp {
if excursionEnded {
hm . endExcursion ( now )
if sender != nil {
sender . SendToHero ( heroID , "excursion_end" , model . ExcursionEndPayload { } )
}
}
hm . ActiveRestKind = model . RestKindNone
hm . ActiveRestKind = model . RestKindNone
hm . RestHealRemainder = 0
hm . RestHealRemainder = 0
hm . State = model . StateWalking
hm . State = model . StateWalking
@ -1850,11 +2027,14 @@ func ProcessSingleHeroMovementTick(
}
}
hm . LastMoveTick = now
hm . LastMoveTick = now
// --- Walk back to town center after last NPC (avoids road-snap teleport) ---
// --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if ! hm . TownCenterWalkArrive . IsZero ( ) {
if hm . TownCenterWalkActive {
if ! now . Before ( hm . TownCenterWalkArrive ) {
walkSpeed := cfg . TownNPCWalkSpeed
hm . CurrentX = hm . TownCenterWalkToX
if walkSpeed <= 0 {
hm . CurrentY = hm . TownCenterWalkToY
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
arrived := hm . stepTowardWorldPoint ( dtTown , hm . TownCenterWalkToX , hm . TownCenterWalkToY , walkSpeed )
if arrived {
hm . clearTownCenterWalk ( )
hm . clearTownCenterWalk ( )
if sender != nil {
if sender != nil {
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
@ -1863,23 +2043,7 @@ func ProcessSingleHeroMovementTick(
Speed : 0 , Heading : 0 ,
Speed : 0 , Heading : 0 ,
} )
} )
}
}
} else {
} else if sender != nil {
totalMs := hm . TownCenterWalkArrive . Sub ( hm . TownCenterWalkStart ) . Milliseconds ( )
if totalMs <= 0 {
totalMs = 1
}
elapsed := now . Sub ( hm . TownCenterWalkStart ) . Milliseconds ( )
t := float64 ( elapsed ) / float64 ( totalMs )
if t > 1 {
t = 1
}
hm . CurrentX = hm . TownCenterWalkFromX + ( hm . TownCenterWalkToX - hm . TownCenterWalkFromX ) * t
hm . CurrentY = hm . TownCenterWalkFromY + ( hm . TownCenterWalkToY - hm . TownCenterWalkFromY ) * t
if sender != nil {
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
dx := hm . TownCenterWalkToX - hm . CurrentX
dx := hm . TownCenterWalkToX - hm . CurrentX
dy := hm . TownCenterWalkToY - hm . CurrentY
dy := hm . TownCenterWalkToY - hm . CurrentY
heading := math . Atan2 ( dy , dx )
heading := math . Atan2 ( dy , dx )
@ -1889,17 +2053,19 @@ func ProcessSingleHeroMovementTick(
Speed : walkSpeed , Heading : heading ,
Speed : walkSpeed , Heading : heading ,
} )
} )
}
}
}
hm . SyncToHero ( )
hm . SyncToHero ( )
return
return
}
}
// --- Sub-state: hero is walking toward an NPC inside the town ---
// --- Sub-state: hero is walking toward an NPC inside the town (attractor stepping) ---
if hm . TownNPCWalkTargetID != 0 {
if hm . TownNPCWalkTargetID != 0 {
if ! now . Before ( hm . TownNPCWalkArrive ) {
walkSpeed := cfg . TownNPCWalkSpeed
// Arrived at stand point (near NPC) — snap position and fire the visit event.
if walkSpeed <= 0 {
hm . CurrentX = hm . TownNPCWalkToX
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
hm . CurrentY = hm . TownNPCWalkToY
}
arrived := hm . stepTowardWorldPoint ( dtTown , hm . TownNPCWalkToX , hm . TownNPCWalkToY , walkSpeed )
if arrived {
// Arrived at stand point (near NPC) — fire the visit event.
npcID := hm . TownNPCWalkTargetID
npcID := hm . TownNPCWalkTargetID
standX := hm . TownNPCWalkToX
standX := hm . TownNPCWalkToX
standY := hm . TownNPCWalkToY
standY := hm . TownNPCWalkToY
@ -1967,24 +2133,7 @@ func ProcessSingleHeroMovementTick(
} else {
} else {
hm . NextTownNPCRollAt = now . Add ( townNPCLogInterval ( ) * ( townNPCVisitLogLines - 1 ) )
hm . NextTownNPCRollAt = now . Add ( townNPCLogInterval ( ) * ( townNPCVisitLogLines - 1 ) )
}
}
} else {
} else if sender != nil {
// Still walking — interpolate position.
totalMs := hm . TownNPCWalkArrive . Sub ( hm . TownNPCWalkStart ) . Milliseconds ( )
if totalMs <= 0 {
totalMs = 1
}
elapsed := now . Sub ( hm . TownNPCWalkStart ) . Milliseconds ( )
t := float64 ( elapsed ) / float64 ( totalMs )
if t > 1 {
t = 1
}
hm . CurrentX = hm . TownNPCWalkFromX + ( hm . TownNPCWalkToX - hm . TownNPCWalkFromX ) * t
hm . CurrentY = hm . TownNPCWalkFromY + ( hm . TownNPCWalkToY - hm . TownNPCWalkFromY ) * t
if sender != nil {
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
dx := hm . TownNPCWalkToX - hm . CurrentX
dx := hm . TownNPCWalkToX - hm . CurrentX
dy := hm . TownNPCWalkToY - hm . CurrentY
dy := hm . TownNPCWalkToY - hm . CurrentY
heading := math . Atan2 ( dy , dx )
heading := math . Atan2 ( dy , dx )
@ -1994,7 +2143,6 @@ func ProcessSingleHeroMovementTick(
Speed : walkSpeed , Heading : heading ,
Speed : walkSpeed , Heading : heading ,
} )
} )
}
}
}
hm . SyncToHero ( )
hm . SyncToHero ( )
return
return
}
}
@ -2028,22 +2176,13 @@ func ProcessSingleHeroMovementTick(
if dPlaza > plazaEps {
if dPlaza > plazaEps {
dx := cx - hm . CurrentX
dx := cx - hm . CurrentX
dy := cy - hm . CurrentY
dy := cy - hm . CurrentY
dist := math . Sqrt ( dx * dx + dy * dy )
walkSpeed := cfg . TownNPCWalkSpeed
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
}
const minWalkMs = 300
walkDur := time . Duration ( dist / walkSpeed * 1000 ) * time . Millisecond
if walkDur < minWalkMs * time . Millisecond {
walkDur = minWalkMs * time . Millisecond
}
hm . TownCenterWalkFromX = hm . CurrentX
hm . TownCenterWalkFromY = hm . CurrentY
hm . TownCenterWalkToX = cx
hm . TownCenterWalkToX = cx
hm . TownCenterWalkToY = cy
hm . TownCenterWalkToY = cy
hm . TownCenterWalkStart = now
hm . TownCenterWalkActive = true
hm . TownCenterWalkArrive = now . Add ( walkDur )
if sender != nil {
if sender != nil {
heading := math . Atan2 ( dy , dx )
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
@ -2131,23 +2270,13 @@ func ProcessSingleHeroMovementTick(
toX , toY := townNPCStandPoint ( npcWX , npcWY , hm . CurrentX , hm . CurrentY , standoff )
toX , toY := townNPCStandPoint ( npcWX , npcWY , hm . CurrentX , hm . CurrentY , standoff )
dx := toX - hm . CurrentX
dx := toX - hm . CurrentX
dy := toY - hm . CurrentY
dy := toY - hm . CurrentY
dist := math . Sqrt ( dx * dx + dy * dy )
walkSpeed := cfg . TownNPCWalkSpeed
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
}
const minWalkMs = 300
walkDur := time . Duration ( dist / walkSpeed * 1000 ) * time . Millisecond
if walkDur < minWalkMs * time . Millisecond {
walkDur = minWalkMs * time . Millisecond
}
hm . TownNPCWalkTargetID = npcID
hm . TownNPCWalkTargetID = npcID
hm . TownNPCWalkFromX = hm . CurrentX
hm . TownNPCWalkFromY = hm . CurrentY
hm . TownNPCWalkToX = toX
hm . TownNPCWalkToX = toX
hm . TownNPCWalkToY = toY
hm . TownNPCWalkToY = toY
hm . TownNPCWalkStart = now
hm . TownNPCWalkArrive = now . Add ( walkDur )
if sender != nil {
if sender != nil {
heading := math . Atan2 ( dy , dx )
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
@ -2193,20 +2322,31 @@ func ProcessSingleHeroMovementTick(
}
}
}
}
// --- Active excursion (mini-adventure) ---
// --- Active adventure excursion (attractor movement while walking) ---
if hm . Excursion . Active ( ) {
if hm . Excursion . Active ( ) && hm . Excursion . Kind == model . ExcursionKindAdventure {
dtAdv := now . Sub ( hm . LastMoveTick ) . Seconds ( )
if dtAdv <= 0 {
dtAdv = movementTickRate ( ) . Seconds ( )
}
prevPhase := hm . Excursion . Phase
prevPhase := hm . Excursion . Phase
excursionEnded := hm . advanceExcursionPhases ( now )
if excursionEnded {
hm . endExcursion ( now )
hm . refreshSpeed ( now )
hm . refreshSpeed ( now )
if sender != nil {
sender . SendToHero ( heroID , "excursion_end" , model . ExcursionEndPayload { } )
if hm . Excursion . Phase == model . ExcursionOut {
if hm . stepTowardAttractor ( now , dtAdv ) {
hm . Excursion . Phase = model . ExcursionWild
hm . adventureScheduleWanderRetarget ( now )
hm . adventurePickWanderAttractor ( )
}
}
} else {
if newPhase := hm . Excursion . Phase ; newPhase != prevPhase && sender != nil {
sender . SendToHero ( heroID , "excursion_phase" , model . ExcursionPhasePayload { Phase : string ( newPhase ) } )
}
}
if hm . Excursion . Phase == model . ExcursionWild {
hm . tryBeginAdventureReturn ( now )
}
if hm . Excursion . Phase == model . ExcursionWild {
if ! hm . Excursion . WanderNextAt . IsZero ( ) && ! now . Before ( hm . Excursion . WanderNextAt ) {
hm . adventurePickWanderAttractor ( )
hm . adventureScheduleWanderRetarget ( now )
}
_ = hm . stepTowardAttractor ( now , dtAdv )
if hm . isLowHP ( ) {
if hm . isLowHP ( ) {
hm . beginAdventureInlineRest ( now )
hm . beginAdventureInlineRest ( now )
hm . SyncToHero ( )
hm . SyncToHero ( )
@ -2214,16 +2354,16 @@ func ProcessSingleHeroMovementTick(
sender . SendToHero ( heroID , "hero_state" , hm . Hero )
sender . SendToHero ( heroID , "hero_state" , hm . Hero )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
}
}
hm . LastMoveTick = now
return
return
}
}
canEncounter := hm . Excursion . Phase == model . ExcursionWild ||
if onEncounter != nil || onMerchantEncounter != nil {
( hm . Excursion . Phase == model . ExcursionReturn && cfg . AdventureReturnEncounterEnabled )
if canEncounter && ( onEncounter != nil || onMerchantEncounter != nil ) {
monster , enemy , hit := hm . rollAdventureEncounter ( now , graph )
monster , enemy , hit := hm . rollAdventureEncounter ( now , graph )
if hit {
if hit {
if monster && onEncounter != nil {
if monster && onEncounter != nil {
hm . LastEncounterAt = now
hm . LastEncounterAt = now
onEncounter ( hm , & enemy , now )
onEncounter ( hm , & enemy , now )
hm . LastMoveTick = now
return
return
}
}
if ! monster {
if ! monster {
@ -2239,10 +2379,24 @@ func ProcessSingleHeroMovementTick(
if onMerchantEncounter != nil {
if onMerchantEncounter != nil {
onMerchantEncounter ( hm , now , cost )
onMerchantEncounter ( hm , now , cost )
}
}
hm . LastMoveTick = now
return
return
}
}
}
}
}
}
}
if hm . Excursion . Phase == model . ExcursionReturn {
if hm . stepTowardAttractor ( now , dtAdv ) {
hm . endExcursion ( now )
hm . refreshSpeed ( now )
if sender != nil {
sender . SendToHero ( heroID , "excursion_end" , model . ExcursionEndPayload { } )
}
}
}
if sender != nil && hm . Excursion . Phase != prevPhase {
sender . SendToHero ( heroID , "excursion_phase" , model . ExcursionPhasePayload { Phase : string ( hm . Excursion . Phase ) } )
}
hm . LastMoveTick = now
hm . LastMoveTick = now
if sender != nil {
if sender != nil {
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
@ -2250,7 +2404,6 @@ func ProcessSingleHeroMovementTick(
hm . SyncToHero ( )
hm . SyncToHero ( )
return
return
}
}
}
// --- Normal walking (no active excursion) ---
// --- Normal walking (no active excursion) ---
reachedTown := hm . AdvanceTick ( now , graph )
reachedTown := hm . AdvanceTick ( now , graph )