@ -120,6 +120,9 @@ type HeroMovement struct {
// lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can
// persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature
// sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
sentTownTourWireSig string
}
// townPausePersistSignature captures the excursion/rest fields that should trigger persistence.
@ -539,6 +542,14 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm . TownVisitStartedAt = shift ( hm . TownVisitStartedAt )
hm . TownLastNPCLingerUntil = shift ( hm . TownLastNPCLingerUntil )
hm . TownLeaveAt = shift ( hm . TownLeaveAt )
if hm . Excursion . Kind == model . ExcursionKindTown {
ex := & hm . Excursion
ex . TownTourEndsAt = shift ( ex . TownTourEndsAt )
ex . WanderNextAt = shift ( ex . WanderNextAt )
ex . TownWelcomeUntil = shift ( ex . TownWelcomeUntil )
ex . TownServiceUntil = shift ( ex . TownServiceUntil )
ex . TownRestUntil = shift ( ex . TownRestUntil )
}
hm . WanderingMerchantDeadline = shift ( hm . WanderingMerchantDeadline )
hm . Excursion . StartedAt = shift ( hm . Excursion . StartedAt )
hm . Excursion . OutUntil = shift ( hm . Excursion . OutUntil )
@ -744,6 +755,9 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if ! hm . Excursion . Active ( ) {
return false
}
if hm . Excursion . Kind == model . ExcursionKindTown {
return false
}
if hm . State == model . StateFighting {
return false
}
@ -870,7 +884,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
}
func ( hm * HeroMovement ) excursionUsesAttractors ( ) bool {
return hm != nil && hm . Excursion . Active ( ) && hm . Excursion . Kind != model . ExcursionKindNone
return hm != nil && hm . Excursion . Active ( ) && hm . Excursion . Kind != model . ExcursionKindNone && hm . Excursion . Kind != model . ExcursionKindTown
}
func excursionArrivalEpsilon ( ) float64 {
@ -1123,28 +1137,20 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
return false , model . Enemy { } , true
}
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
// EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func ( hm * HeroMovement ) EnterTown ( now time . Time , graph * RoadGraph ) {
destID := hm . DestinationTownID
hm . CurrentTownID = destID
hm . DestinationTownID = 0
hm . Road = nil
hm . TownNPCQueue = nil
hm . NextTownNPCRollAt = time . Time { }
hm . TownVisitNPCName = ""
hm . TownVisitNPCType = ""
hm . TownVisitStartedAt = time . Time { }
hm . TownVisitLogsEmitted = 0
hm . TownLeaveAt = time . Time { }
hm . TownLastNPCLingerUntil = time . Time { }
clearLegacyTownNPCState ( hm )
hm . TownRestHealRemainder = 0
hm . Excursion = model . ExcursionSession { }
hm . sentTownTourWireSig = ""
hm . ActiveRestKind = model . RestKindNone
hm . RestHealRemainder = 0
hm . clearNPCWalk ( )
hm . clearTownCenterWalk ( )
hm . TownPlazaHealActive = false
ids := graph . TownNPCIDs ( destID )
if len ( ids ) == 0 {
@ -1155,31 +1161,20 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
return
}
q := make ( [ ] int64 , len ( ids ) )
copy ( q , ids )
rand . Shuffle ( len ( q ) , func ( i , j int ) { q [ i ] , q [ j ] = q [ j ] , q [ i ] } )
hm . TownNPCQueue = q
hm . State = model . StateInTown
hm . Hero . State = model . StateInTown
hm . NextTownNPCRollAt = now . Add ( randomTownNPCDelay ( ) )
beginTownTourExcursion ( hm , now , graph )
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func ( hm * HeroMovement ) LeaveTown ( graph * RoadGraph , now time . Time ) {
hm . TownNPCQueue = nil
hm . NextTownNPCRollAt = time . Time { }
hm . TownVisitNPCName = ""
hm . TownVisitNPCType = ""
hm . TownVisitStartedAt = time . Time { }
hm . TownVisitLogsEmitted = 0
hm . TownLeaveAt = time . Time { }
hm . TownLastNPCLingerUntil = time . Time { }
clearLegacyTownNPCState ( hm )
hm . TownRestHealRemainder = 0
hm . RestUntil = time . Time { }
hm . ActiveRestKind = model . RestKindNone
hm . RestHealRemainder = 0
hm . Excursion = model . ExcursionSession { }
hm . clearNPCWalk ( )
hm . sentTownTourWireSig = ""
hm . clearTownCenterWalk ( )
hm . TownPlazaHealActive = false
hm . State = model . StateWalking
@ -1329,9 +1324,19 @@ func (hm *HeroMovement) SyncToHero() {
}
hm . Hero . ExcursionPhase = model . ExcursionNone
hm . Hero . ExcursionKind = model . ExcursionKindNone
hm . Hero . TownTourPhase = ""
hm . Hero . TownTourNpcID = 0
hm . Hero . TownTourExitPending = false
if hm . Excursion . Active ( ) {
hm . Hero . ExcursionPhase = hm . Excursion . Phase
hm . Hero . ExcursionKind = hm . Excursion . Kind
if hm . Excursion . Kind == model . ExcursionKindTown {
hm . Hero . ExcursionPhase = model . ExcursionWild
hm . Hero . TownTourPhase = hm . Excursion . TownTourPhase
hm . Hero . TownTourNpcID = hm . Excursion . TownTourNpcID
hm . Hero . TownTourExitPending = hm . Excursion . TownExitPending
} else {
hm . Hero . ExcursionPhase = hm . Excursion . Phase
}
}
hm . Hero . TownPause = hm . townPauseBlob ( )
}
@ -1516,6 +1521,31 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s . WanderNextAt
ep . WanderNextAt = & t
}
if s . Kind == model . ExcursionKindTown {
ep . TownTourPhase = s . TownTourPhase
ep . TownTourNpcID = s . TownTourNpcID
ep . TownTourStandX = s . TownTourStandX
ep . TownTourStandY = s . TownTourStandY
ep . TownExitPending = s . TownExitPending
ep . TownTourDialogOpen = s . TownTourDialogOpen
ep . TownTourInteractionOpen = s . TownTourInteractionOpen
if ! s . TownTourEndsAt . IsZero ( ) {
t := s . TownTourEndsAt
ep . TownTourEndsAt = & t
}
if ! s . TownWelcomeUntil . IsZero ( ) {
t := s . TownWelcomeUntil
ep . TownWelcomeUntil = & t
}
if ! s . TownServiceUntil . IsZero ( ) {
t := s . TownServiceUntil
ep . TownServiceUntil = & t
}
if ! s . TownRestUntil . IsZero ( ) {
t := s . TownRestUntil
ep . TownRestUntil = & t
}
}
return ep
}
@ -1609,6 +1639,27 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
if ep . WanderNextAt != nil {
hm . Excursion . WanderNextAt = * ep . WanderNextAt
}
if ep . Kind == string ( model . ExcursionKindTown ) {
hm . Excursion . TownTourPhase = ep . TownTourPhase
hm . Excursion . TownTourNpcID = ep . TownTourNpcID
hm . Excursion . TownTourStandX = ep . TownTourStandX
hm . Excursion . TownTourStandY = ep . TownTourStandY
hm . Excursion . TownExitPending = ep . TownExitPending
hm . Excursion . TownTourDialogOpen = ep . TownTourDialogOpen
hm . Excursion . TownTourInteractionOpen = ep . TownTourInteractionOpen
if ep . TownTourEndsAt != nil {
hm . Excursion . TownTourEndsAt = * ep . TownTourEndsAt
}
if ep . TownWelcomeUntil != nil {
hm . Excursion . TownWelcomeUntil = * ep . TownWelcomeUntil
}
if ep . TownServiceUntil != nil {
hm . Excursion . TownServiceUntil = * ep . TownServiceUntil
}
if ep . TownRestUntil != nil {
hm . Excursion . TownRestUntil = * ep . TownRestUntil
}
}
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@ -1676,26 +1727,6 @@ type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func ( hero * model . Hero )
// TownNPCOfflineInteractHook runs when the hero reaches a town NPC with no WS client (offline catch-up).
// Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping.
type TownNPCOfflineInteractHook func ( heroID int64 , hm * HeroMovement , graph * RoadGraph , npc TownNPC , now time . Time , adventureLog AdventureLogWriter ) bool
func townLastNpcLingerDuration ( ) time . Duration {
ms := tuning . Get ( ) . TownLastNpcLingerMs
if ms <= 0 {
ms = tuning . DefaultValues ( ) . TownLastNpcLingerMs
}
return time . Duration ( ms ) * time . Millisecond
}
// scheduleLastNPCLingerFrom starts the “stand near last NPC” window when the NPC tour queue is empty.
func ( hm * HeroMovement ) scheduleLastNPCLingerFrom ( now time . Time ) {
if hm == nil || hm . State != model . StateInTown || len ( hm . TownNPCQueue ) != 0 {
return
}
hm . TownLastNPCLingerUntil = now . Add ( townLastNpcLingerDuration ( ) )
}
func emitTownNPCVisitLogs ( heroID int64 , hm * HeroMovement , now time . Time , log AdventureLogWriter ) {
if log == nil || hm . TownVisitStartedAt . IsZero ( ) || hm . TownNPCUILock {
return
@ -1716,28 +1747,6 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
}
}
// skipTownNPCNarrationForDialogClose clears the per-NPC narration window after the player
// closes shop / healer / quest UI so the next movement tick can roll the next queued NPC or plaza rest.
func ( hm * HeroMovement ) skipTownNPCNarrationForDialogClose ( now time . Time ) {
if hm == nil || hm . State != model . StateInTown {
return
}
if hm . TownNPCWalkTargetID != 0 {
return
}
wasInVisit := ! hm . TownVisitStartedAt . IsZero ( )
hm . TownVisitNPCName = ""
hm . TownVisitNPCKey = ""
hm . TownVisitNPCType = ""
hm . TownVisitStartedAt = time . Time { }
hm . TownVisitLogsEmitted = 0
hm . NextTownNPCRollAt = time . Time { }
hm . TownNPCUILock = false
if wasInVisit && len ( hm . TownNPCQueue ) == 0 {
hm . scheduleLastNPCLingerFrom ( now )
}
}
// --- Excursion (mini-adventure) FSM helpers ---
func smoothstep ( t float64 ) float64 {
@ -1954,7 +1963,7 @@ func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block).
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
// town NPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior .
// town TourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up .
func ProcessSingleHeroMovementTick (
heroID int64 ,
hm * HeroMovement ,
@ -1965,7 +1974,7 @@ func ProcessSingleHeroMovementTick(
onMerchantEncounter MerchantEncounterHook ,
adventureLog AdventureLogWriter ,
persistAfterTownEnter AfterTownEnterPersist ,
town NPCOfflineInteract TownNPCOfflineInteractHook ,
town TourOffline TownTourOfflineAtNPC ,
) {
if graph == nil {
return
@ -2076,163 +2085,12 @@ func ProcessSingleHeroMovementTick(
}
case model . StateInTown :
cfg := tuning . Get ( )
dtTown := now . Sub ( hm . LastMoveTick ) . Seconds ( )
if dtTown <= 0 {
dtTown = movementTickRate ( ) . Seconds ( )
}
hm . LastMoveTick = now
// While a town NPC dialog (shop / quests) is open, freeze narration deadlines by shifting anchors.
if hm . TownNPCUILock && dtTown > 0 {
shift := time . Duration ( dtTown * float64 ( time . Second ) )
if ! hm . TownVisitStartedAt . IsZero ( ) {
hm . TownVisitStartedAt = hm . TownVisitStartedAt . Add ( shift )
}
if ! hm . NextTownNPCRollAt . IsZero ( ) {
hm . NextTownNPCRollAt = hm . NextTownNPCRollAt . Add ( shift )
}
if ! hm . TownLastNPCLingerUntil . IsZero ( ) {
hm . TownLastNPCLingerUntil = hm . TownLastNPCLingerUntil . Add ( shift )
}
}
// --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if hm . TownCenterWalkActive {
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
arrived := hm . stepTowardWorldPoint ( dtTown , hm . TownCenterWalkToX , hm . TownCenterWalkToY , walkSpeed )
if arrived {
hm . clearTownCenterWalk ( )
if sender != nil {
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : hm . CurrentX , TargetY : hm . CurrentY ,
Speed : 0 , Heading : 0 ,
} )
}
} else if sender != nil {
dx := hm . TownCenterWalkToX - hm . CurrentX
dy := hm . TownCenterWalkToY - hm . CurrentY
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : hm . TownCenterWalkToX , TargetY : hm . TownCenterWalkToY ,
Speed : walkSpeed , Heading : heading ,
} )
}
hm . SyncToHero ( )
if hm . Excursion . Kind == model . ExcursionKindTown {
processTownTourMovement ( heroID , hm , graph , now , sender , adventureLog , townTourOffline )
return
}
// --- Sub-state: hero is walking toward an NPC inside the town (attractor stepping) ---
if hm . TownNPCWalkTargetID != 0 {
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
arrived := hm . stepTowardWorldPoint ( dtTown , hm . TownNPCWalkToX , hm . TownNPCWalkToY , walkSpeed )
if arrived {
// Arrived at stand point (near NPC) — fire the visit event.
npcID := hm . TownNPCWalkTargetID
standX := hm . TownNPCWalkToX
standY := hm . TownNPCWalkToY
hm . clearNPCWalk ( )
if npc , ok := graph . NPCByID [ npcID ] ; ok {
fullVisit := false
townNameKey := ""
if tt := graph . Towns [ hm . CurrentTownID ] ; tt != nil {
townNameKey = tt . NameKey
}
if sender != nil {
sender . SendToHero ( heroID , "town_npc_visit" , model . TownNPCVisitPayload {
NPCID : npc . ID , Name : npc . Name , NameKey : npc . NameKey , Type : npc . Type , TownID : hm . CurrentTownID ,
TownNameKey : townNameKey ,
WorldX : standX , WorldY : standY ,
} )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : hm . CurrentX , TargetY : hm . CurrentY ,
Speed : 0 , Heading : 0 ,
} )
fullVisit = true
} else if townNPCOfflineInteract != nil {
fullVisit = townNPCOfflineInteract ( heroID , hm , graph , npc , now , adventureLog )
} else {
fullVisit = true
}
if fullVisit {
hm . TownVisitNPCName = npc . Name
hm . TownVisitNPCKey = npc . NameKey
hm . TownVisitNPCType = npc . Type
hm . TownVisitStartedAt = now
hm . TownVisitLogsEmitted = 0
legacyMerchantSell := npc . Type == "merchant" && ( sender != nil || townNPCOfflineInteract == nil )
if legacyMerchantSell {
share := cfg . MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning . DefaultValues ( ) . MerchantTownAutoSellShare
}
soldItems , soldGold := AutoSellRandomInventoryShare ( hm . Hero , share , nil )
if soldItems > 0 && adventureLog != nil {
adventureLog ( heroID , model . AdventureLogLine {
Event : & model . AdventureLogEvent {
Code : model . LogPhraseSoldItemsMerchant ,
Args : map [ string ] any { "count" : soldItems , "npcKey" : npc . NameKey , "gold" : soldGold } ,
} ,
} )
}
}
emitTownNPCVisitLogs ( heroID , hm , now , adventureLog )
hm . NextTownNPCRollAt = now . Add ( townNPCLogInterval ( ) * ( townNPCVisitLogLines - 1 ) )
} else {
if adventureLog != nil {
adventureLog ( heroID , model . AdventureLogLine {
Event : & model . AdventureLogEvent {
Code : model . LogPhraseNPCSkippedVisit ,
Args : map [ string ] any { "npcKey" : npc . NameKey } ,
} ,
} )
}
hm . NextTownNPCRollAt = now . Add ( time . Duration ( cfg . TownNPCRetryMs ) * time . Millisecond )
}
} else {
hm . NextTownNPCRollAt = now . Add ( townNPCLogInterval ( ) * ( townNPCVisitLogLines - 1 ) )
}
} else if sender != nil {
dx := hm . TownNPCWalkToX - hm . CurrentX
dy := hm . TownNPCWalkToY - hm . CurrentY
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : hm . TownNPCWalkToX , TargetY : hm . TownNPCWalkToY ,
Speed : walkSpeed , Heading : heading ,
} )
}
hm . SyncToHero ( )
return
}
// NPC visit pause ended: clear visit log state before the next roll.
if ! hm . TownNPCUILock && ! hm . TownVisitStartedAt . IsZero ( ) && ! now . Before ( hm . NextTownNPCRollAt ) {
hm . TownVisitNPCName = ""
hm . TownVisitNPCKey = ""
hm . TownVisitNPCType = ""
hm . TownVisitStartedAt = time . Time { }
hm . TownVisitLogsEmitted = 0
if len ( hm . TownNPCQueue ) == 0 {
hm . scheduleLastNPCLingerFrom ( now )
}
}
emitTownNPCVisitLogs ( heroID , hm , now , adventureLog )
if len ( hm . TownNPCQueue ) == 0 && hm . TownNPCWalkTargetID == 0 {
town := graph . Towns [ hm . CurrentTownID ]
if town == nil {
// Legacy in-town row without town excursion: force exit.
if graph != nil {
hm . LeaveTown ( graph , now )
hm . SyncToHero ( )
if sender != nil {
@ -2241,142 +2099,8 @@ func ProcessSingleHeroMovementTick(
sender . SendToHero ( heroID , "route_assigned" , route )
}
}
return
}
// After the last NPC: stay at the stand point until linger ends and dialog is not open.
if ! hm . TownLastNPCLingerUntil . IsZero ( ) {
if hm . TownNPCUILock || now . Before ( hm . TownLastNPCLingerUntil ) {
if sender != nil && hm . Hero != nil {
sender . SendToHero ( heroID , "hero_state" , hm . Hero )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : hm . CurrentX , TargetY : hm . CurrentY ,
Speed : 0 , Heading : 0 ,
} )
}
hm . SyncToHero ( )
return
}
hm . TownLastNPCLingerUntil = time . Time { }
}
cx , cy := town . WorldX , town . WorldY
const plazaEps = 0.55
dPlaza := math . Hypot ( hm . CurrentX - cx , hm . CurrentY - cy )
if dPlaza > plazaEps {
dx := cx - hm . CurrentX
dy := cy - hm . CurrentY
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
hm . TownCenterWalkToX = cx
hm . TownCenterWalkToY = cy
hm . TownCenterWalkActive = true
if sender != nil {
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : cx , TargetY : cy ,
Speed : walkSpeed , Heading : heading ,
} )
}
hm . SyncToHero ( )
return
}
if hm . TownLeaveAt . IsZero ( ) {
restCh := cfg . TownAfterNPCRestChance
if restCh <= 0 {
restCh = tuning . DefaultValues ( ) . TownAfterNPCRestChance
}
if restCh > 1 {
restCh = 1
}
if rand . Float64 ( ) < restCh {
hm . TownPlazaHealActive = true
hm . TownLeaveAt = now . Add ( randomRestDuration ( ) )
} else {
hm . TownPlazaHealActive = false
hm . TownLeaveAt = now . Add ( time . Duration ( cfg . TownNPCPauseMs ) * time . Millisecond )
}
}
if hm . TownPlazaHealActive {
hm . applyTownRestHeal ( dtTown )
}
if now . Before ( hm . TownLeaveAt ) {
if sender != nil && hm . Hero != nil {
sender . SendToHero ( heroID , "hero_state" , hm . Hero )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
}
hm . SyncToHero ( )
return
}
hm . TownLeaveAt = time . Time { }
hm . TownPlazaHealActive = false
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 )
}
}
return
}
if now . Before ( hm . NextTownNPCRollAt ) {
hm . SyncToHero ( )
return
}
if rand . Float64 ( ) >= cfg . TownNPCVisitChance {
hm . NextTownNPCRollAt = now . Add ( time . Duration ( cfg . TownNPCRetryMs ) * time . Millisecond )
hm . SyncToHero ( )
return
}
approachCh := cfg . TownNPCApproachChance
if approachCh <= 0 {
approachCh = tuning . DefaultValues ( ) . TownNPCApproachChance
}
if approachCh > 1 {
approachCh = 1
}
if rand . Float64 ( ) >= approachCh {
hm . NextTownNPCRollAt = now . Add ( time . Duration ( cfg . TownNPCRetryMs ) * time . Millisecond )
hm . SyncToHero ( )
return
}
npcID := hm . TownNPCQueue [ 0 ]
hm . TownNPCQueue = hm . TownNPCQueue [ 1 : ]
if npc , ok := graph . NPCByID [ npcID ] ; ok {
npcWX , npcWY , posOk := graph . NPCWorldPos ( npcID , hm . CurrentTownID )
if ! posOk {
if town := graph . Towns [ hm . CurrentTownID ] ; town != nil {
npcWX , npcWY = town . WorldX + npc . OffsetX , town . WorldY + npc . OffsetY
}
}
standoff := cfg . TownNPCStandoffWorld
if standoff <= 0 {
standoff = tuning . DefaultValues ( ) . TownNPCStandoffWorld
}
toX , toY := townNPCStandPoint ( npcWX , npcWY , hm . CurrentX , hm . CurrentY , standoff )
dx := toX - hm . CurrentX
dy := toY - hm . CurrentY
walkSpeed := cfg . TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning . DefaultValues ( ) . TownNPCWalkSpeed
}
hm . TownNPCWalkTargetID = npcID
hm . TownNPCWalkToX = toX
hm . TownNPCWalkToY = toY
if sender != nil {
heading := math . Atan2 ( dy , dx )
sender . SendToHero ( heroID , "hero_move" , model . HeroMovePayload {
X : hm . CurrentX , Y : hm . CurrentY ,
TargetX : toX , TargetY : toY ,
Speed : walkSpeed , Heading : heading ,
} )
}
}
hm . NextTownNPCRollAt = now . Add ( townNPCLogInterval ( ) * ( townNPCVisitLogLines - 1 ) )
hm . SyncToHero ( )
case model . StateWalking :
cfg := tuning . Get ( )