|
|
|
|
@ -79,8 +79,18 @@ type HeroMovement struct {
|
|
|
|
|
TownNPCWalkStart time.Time // when walk began
|
|
|
|
|
TownNPCWalkArrive time.Time // when hero reaches NPC
|
|
|
|
|
|
|
|
|
|
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
|
|
|
|
|
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
|
|
|
|
|
TownLeaveAt time.Time
|
|
|
|
|
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
|
|
|
|
|
TownPlazaHealActive bool
|
|
|
|
|
|
|
|
|
|
// TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine).
|
|
|
|
|
TownCenterWalkArrive time.Time
|
|
|
|
|
TownCenterWalkStart time.Time
|
|
|
|
|
TownCenterWalkFromX float64
|
|
|
|
|
TownCenterWalkFromY float64
|
|
|
|
|
TownCenterWalkToX float64
|
|
|
|
|
TownCenterWalkToY float64
|
|
|
|
|
|
|
|
|
|
// Accumulates fractional town-rest regen between ticks.
|
|
|
|
|
TownRestHealRemainder float64
|
|
|
|
|
@ -188,7 +198,7 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
|
|
|
|
|
if hm.DestinationTownID == 0 {
|
|
|
|
|
hm.pickDestination(graph)
|
|
|
|
|
}
|
|
|
|
|
hm.assignRoad(graph)
|
|
|
|
|
hm.assignRoad(graph, false)
|
|
|
|
|
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
|
|
|
|
|
@ -206,10 +216,10 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
|
|
|
|
|
if hm.DestinationTownID == 0 {
|
|
|
|
|
hm.pickDestination(graph)
|
|
|
|
|
}
|
|
|
|
|
hm.assignRoad(graph)
|
|
|
|
|
hm.assignRoad(graph, false)
|
|
|
|
|
if hm.Road == nil {
|
|
|
|
|
hm.pickDestination(graph)
|
|
|
|
|
hm.assignRoad(graph)
|
|
|
|
|
hm.assignRoad(graph, false)
|
|
|
|
|
}
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
|
|
|
|
|
@ -361,7 +371,9 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
|
|
|
|
|
|
|
|
|
|
// assignRoad finds and configures the road from CurrentTownID to DestinationTownID.
|
|
|
|
|
// If no road exists (hero is mid-road), it finds the nearest town and routes from there.
|
|
|
|
|
func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
|
|
|
|
|
// startAtFirstWaypoint: place hero at jittered waypoint 0 (departure town) without nearest-point snap —
|
|
|
|
|
// use when leaving town so an off-road NPC position does not snap to an arbitrary polyline point.
|
|
|
|
|
func (hm *HeroMovement) assignRoad(graph *RoadGraph, startAtFirstWaypoint bool) {
|
|
|
|
|
road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID)
|
|
|
|
|
if road == nil {
|
|
|
|
|
// Try finding a road from any nearby town.
|
|
|
|
|
@ -409,6 +421,12 @@ func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
|
|
|
|
|
hm.WaypointIndex = 0
|
|
|
|
|
hm.WaypointFraction = 0
|
|
|
|
|
hm.spawnAtRoadStart = false
|
|
|
|
|
} else if startAtFirstWaypoint {
|
|
|
|
|
wp0 := jitteredRoad.Waypoints[0]
|
|
|
|
|
hm.CurrentX = wp0.X
|
|
|
|
|
hm.CurrentY = wp0.Y
|
|
|
|
|
hm.WaypointIndex = 0
|
|
|
|
|
hm.WaypointFraction = 0
|
|
|
|
|
} else {
|
|
|
|
|
// Restore progress along this hero's jittered polyline from saved world position.
|
|
|
|
|
hm.snapProgressToNearestPointOnRoad()
|
|
|
|
|
@ -479,6 +497,8 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
|
|
|
|
|
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
|
|
|
|
|
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
|
|
|
|
|
hm.TownLeaveAt = shift(hm.TownLeaveAt)
|
|
|
|
|
hm.TownCenterWalkStart = shift(hm.TownCenterWalkStart)
|
|
|
|
|
hm.TownCenterWalkArrive = shift(hm.TownCenterWalkArrive)
|
|
|
|
|
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
|
|
|
|
|
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
|
|
|
|
|
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
|
|
|
|
|
@ -726,6 +746,9 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
hm.clearTownCenterWalk()
|
|
|
|
|
hm.TownPlazaHealActive = false
|
|
|
|
|
hm.TownLeaveAt = time.Time{}
|
|
|
|
|
if graph != nil && hm.CurrentTownID == 0 {
|
|
|
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
|
|
|
}
|
|
|
|
|
@ -858,6 +881,8 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
|
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
hm.clearTownCenterWalk()
|
|
|
|
|
hm.TownPlazaHealActive = false
|
|
|
|
|
|
|
|
|
|
ids := graph.TownNPCIDs(destID)
|
|
|
|
|
if len(ids) == 0 {
|
|
|
|
|
@ -892,12 +917,15 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
|
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
hm.clearTownCenterWalk()
|
|
|
|
|
hm.TownPlazaHealActive = false
|
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
|
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
hm.pickDestination(graph)
|
|
|
|
|
hm.assignRoad(graph)
|
|
|
|
|
// Start exactly at the road origin (current town); snap from an NPC-tile would jump to the wrong spine point.
|
|
|
|
|
hm.assignRoad(graph, true)
|
|
|
|
|
hm.refreshSpeed(now)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -947,6 +975,15 @@ func (hm *HeroMovement) clearNPCWalk() {
|
|
|
|
|
hm.TownNPCWalkArrive = time.Time{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (hm *HeroMovement) clearTownCenterWalk() {
|
|
|
|
|
hm.TownCenterWalkArrive = time.Time{}
|
|
|
|
|
hm.TownCenterWalkStart = time.Time{}
|
|
|
|
|
hm.TownCenterWalkFromX = 0
|
|
|
|
|
hm.TownCenterWalkFromY = 0
|
|
|
|
|
hm.TownCenterWalkToX = 0
|
|
|
|
|
hm.TownCenterWalkToY = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// StartFighting pauses movement for combat.
|
|
|
|
|
func (hm *HeroMovement) StartFighting() {
|
|
|
|
|
hm.State = model.StateFighting
|
|
|
|
|
@ -1096,6 +1133,21 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
|
|
|
|
|
t := hm.TownVisitStartedAt
|
|
|
|
|
p.TownVisitStartedAt = &t
|
|
|
|
|
}
|
|
|
|
|
if hm.TownPlazaHealActive {
|
|
|
|
|
p.TownPlazaHealActive = true
|
|
|
|
|
}
|
|
|
|
|
p.CenterWalkFromX = hm.TownCenterWalkFromX
|
|
|
|
|
p.CenterWalkFromY = hm.TownCenterWalkFromY
|
|
|
|
|
p.CenterWalkToX = hm.TownCenterWalkToX
|
|
|
|
|
p.CenterWalkToY = hm.TownCenterWalkToY
|
|
|
|
|
if !hm.TownCenterWalkStart.IsZero() {
|
|
|
|
|
t := hm.TownCenterWalkStart
|
|
|
|
|
p.CenterWalkStart = &t
|
|
|
|
|
}
|
|
|
|
|
if !hm.TownCenterWalkArrive.IsZero() {
|
|
|
|
|
t := hm.TownCenterWalkArrive
|
|
|
|
|
p.CenterWalkArrive = &t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Persist active excursion session regardless of hero state (the hero can be fighting
|
|
|
|
|
@ -1183,6 +1235,17 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
|
|
|
|
|
if blob.NPCWalkArrive != nil {
|
|
|
|
|
hm.TownNPCWalkArrive = *blob.NPCWalkArrive
|
|
|
|
|
}
|
|
|
|
|
hm.TownPlazaHealActive = blob.TownPlazaHealActive
|
|
|
|
|
hm.TownCenterWalkFromX = blob.CenterWalkFromX
|
|
|
|
|
hm.TownCenterWalkFromY = blob.CenterWalkFromY
|
|
|
|
|
hm.TownCenterWalkToX = blob.CenterWalkToX
|
|
|
|
|
hm.TownCenterWalkToY = blob.CenterWalkToY
|
|
|
|
|
if blob.CenterWalkStart != nil {
|
|
|
|
|
hm.TownCenterWalkStart = *blob.CenterWalkStart
|
|
|
|
|
}
|
|
|
|
|
if blob.CenterWalkArrive != nil {
|
|
|
|
|
hm.TownCenterWalkArrive = *blob.CenterWalkArrive
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore excursion session from blob (may exist alongside any hero state).
|
|
|
|
|
@ -1275,6 +1338,10 @@ 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 emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
|
|
|
|
|
if log == nil || hm.TownVisitStartedAt.IsZero() {
|
|
|
|
|
return
|
|
|
|
|
@ -1606,6 +1673,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.
|
|
|
|
|
// townNPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior.
|
|
|
|
|
func ProcessSingleHeroMovementTick(
|
|
|
|
|
heroID int64,
|
|
|
|
|
hm *HeroMovement,
|
|
|
|
|
@ -1616,6 +1684,7 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
onMerchantEncounter MerchantEncounterHook,
|
|
|
|
|
adventureLog AdventureLogWriter,
|
|
|
|
|
persistAfterTownEnter AfterTownEnterPersist,
|
|
|
|
|
townNPCOfflineInteract TownNPCOfflineInteractHook,
|
|
|
|
|
) {
|
|
|
|
|
if graph == nil {
|
|
|
|
|
return
|
|
|
|
|
@ -1722,8 +1791,56 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
|
|
|
|
|
case model.StateInTown:
|
|
|
|
|
cfg := tuning.Get()
|
|
|
|
|
dtTown := now.Sub(hm.LastMoveTick).Seconds()
|
|
|
|
|
if dtTown <= 0 {
|
|
|
|
|
dtTown = movementTickRate().Seconds()
|
|
|
|
|
}
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
|
|
|
|
|
// --- Walk back to town center after last NPC (avoids road-snap teleport) ---
|
|
|
|
|
if !hm.TownCenterWalkArrive.IsZero() {
|
|
|
|
|
if !now.Before(hm.TownCenterWalkArrive) {
|
|
|
|
|
hm.CurrentX = hm.TownCenterWalkToX
|
|
|
|
|
hm.CurrentY = hm.TownCenterWalkToY
|
|
|
|
|
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 {
|
|
|
|
|
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
|
|
|
|
|
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()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Sub-state: hero is walking toward an NPC inside the town ---
|
|
|
|
|
if hm.TownNPCWalkTargetID != 0 {
|
|
|
|
|
if !now.Before(hm.TownNPCWalkArrive) {
|
|
|
|
|
@ -1736,36 +1853,51 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
|
|
|
|
|
if npc, ok := graph.NPCByID[npcID]; ok {
|
|
|
|
|
fullVisit := false
|
|
|
|
|
if sender != nil {
|
|
|
|
|
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
|
|
|
|
|
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
|
|
|
|
|
WorldX: standX, WorldY: standY,
|
|
|
|
|
})
|
|
|
|
|
// Snap client interpolation to the NPC tile (visit message alone left the
|
|
|
|
|
// hero short of the last hero_move segment).
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
hm.TownVisitNPCName = npc.Name
|
|
|
|
|
hm.TownVisitNPCType = npc.Type
|
|
|
|
|
hm.TownVisitStartedAt = now
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
if npc.Type == "merchant" {
|
|
|
|
|
share := cfg.MerchantTownAutoSellShare
|
|
|
|
|
if share <= 0 || share > 1 {
|
|
|
|
|
share = tuning.DefaultValues().MerchantTownAutoSellShare
|
|
|
|
|
|
|
|
|
|
if fullVisit {
|
|
|
|
|
hm.TownVisitNPCName = npc.Name
|
|
|
|
|
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, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
|
|
|
|
|
if soldItems > 0 && adventureLog != nil {
|
|
|
|
|
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
|
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
|
} else {
|
|
|
|
|
if adventureLog != nil {
|
|
|
|
|
adventureLog(heroID, fmt.Sprintf("You notice %s but decide not to stop.", npc.Name))
|
|
|
|
|
}
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
} else {
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
|
}
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
|
} else {
|
|
|
|
|
// Still walking — interpolate position.
|
|
|
|
|
totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds()
|
|
|
|
|
@ -1808,14 +1940,80 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
|
|
|
|
|
if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 {
|
|
|
|
|
town := graph.Towns[hm.CurrentTownID]
|
|
|
|
|
if town == nil {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
dist := math.Sqrt(dx*dx + dy*dy)
|
|
|
|
|
walkSpeed := cfg.TownNPCWalkSpeed
|
|
|
|
|
if walkSpeed <= 0 {
|
|
|
|
|
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.TownCenterWalkToY = cy
|
|
|
|
|
hm.TownCenterWalkStart = now
|
|
|
|
|
hm.TownCenterWalkArrive = now.Add(walkDur)
|
|
|
|
|
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() {
|
|
|
|
|
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
|
|
|
|
|
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 {
|
|
|
|
|
@ -1830,53 +2028,66 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if rand.Float64() < cfg.TownNPCVisitChance {
|
|
|
|
|
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
|
|
|
|
|
dist := math.Sqrt(dx*dx + dy*dy)
|
|
|
|
|
walkSpeed := cfg.TownNPCWalkSpeed
|
|
|
|
|
if walkSpeed <= 0 {
|
|
|
|
|
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.TownNPCWalkFromX = hm.CurrentX
|
|
|
|
|
hm.TownNPCWalkFromY = hm.CurrentY
|
|
|
|
|
hm.TownNPCWalkToX = toX
|
|
|
|
|
hm.TownNPCWalkToY = toY
|
|
|
|
|
hm.TownNPCWalkStart = now
|
|
|
|
|
hm.TownNPCWalkArrive = now.Add(walkDur)
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
|
} else {
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
|
|
|
|
|
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
|
|
|
|
|
dist := math.Sqrt(dx*dx + dy*dy)
|
|
|
|
|
walkSpeed := cfg.TownNPCWalkSpeed
|
|
|
|
|
if walkSpeed <= 0 {
|
|
|
|
|
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.TownNPCWalkFromX = hm.CurrentX
|
|
|
|
|
hm.TownNPCWalkFromY = hm.CurrentY
|
|
|
|
|
hm.TownNPCWalkToX = toX
|
|
|
|
|
hm.TownNPCWalkToY = toY
|
|
|
|
|
hm.TownNPCWalkStart = now
|
|
|
|
|
hm.TownNPCWalkArrive = now.Add(walkDur)
|
|
|
|
|
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:
|
|
|
|
|
@ -1885,7 +2096,7 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
if hadNoRoad {
|
|
|
|
|
hm.Road = nil
|
|
|
|
|
hm.pickDestination(graph)
|
|
|
|
|
hm.assignRoad(graph)
|
|
|
|
|
hm.assignRoad(graph, false)
|
|
|
|
|
if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 {
|
|
|
|
|
if route := hm.RoutePayload(); route != nil {
|
|
|
|
|
sender.SendToHero(heroID, "route_assigned", route)
|
|
|
|
|
|