You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2317 lines
70 KiB
Go
2317 lines
70 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
"github.com/denisovdennis/autohero/internal/tuning"
|
|
)
|
|
|
|
const (
|
|
// townNPCVisitLogLines is how many log lines to emit per NPC visit.
|
|
townNPCVisitLogLines = 6
|
|
)
|
|
|
|
func movementTickRate() time.Duration {
|
|
ms := tuning.Get().MovementTickRateMs
|
|
if ms <= 0 {
|
|
ms = tuning.DefaultValues().MovementTickRateMs
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
func positionSyncRate() time.Duration {
|
|
ms := tuning.Get().PositionSyncRateMs
|
|
if ms <= 0 {
|
|
ms = tuning.DefaultValues().PositionSyncRateMs
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
func townNPCLogInterval() time.Duration {
|
|
ms := tuning.Get().TownNPCLogIntervalMs
|
|
if ms <= 0 {
|
|
ms = tuning.DefaultValues().TownNPCLogIntervalMs
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
// AdventureLogWriter persists or pushes one adventure log line for a hero (optional).
|
|
type AdventureLogWriter func(heroID int64, line model.AdventureLogLine)
|
|
|
|
// HeroMovement holds the live movement state for a single online hero.
|
|
type HeroMovement struct {
|
|
HeroID int64
|
|
Hero *model.Hero // live reference, owned by the engine
|
|
CurrentX float64
|
|
CurrentY float64
|
|
Speed float64 // effective world-units/sec
|
|
State model.GameState
|
|
DestinationTownID int64
|
|
CurrentTownID int64
|
|
Road *Road
|
|
WaypointIndex int // index of the waypoint we are heading toward
|
|
WaypointFraction float64 // 0..1 within the current segment
|
|
LastEncounterAt time.Time
|
|
RestUntil time.Time
|
|
LastMoveTick time.Time
|
|
Direction int // +1 forward along TownOrder, -1 backward
|
|
|
|
// TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown.
|
|
TownNPCQueue []int64
|
|
NextTownNPCRollAt time.Time
|
|
|
|
// Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit.
|
|
TownVisitNPCName string
|
|
TownVisitNPCKey string
|
|
TownVisitNPCType string
|
|
TownVisitStartedAt time.Time
|
|
TownVisitLogsEmitted int
|
|
|
|
// TownNPCUILock: while true, town NPC visit narration timers do not advance (hero opened shop/quest UI).
|
|
TownNPCUILock bool
|
|
|
|
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
|
|
RoadsideThoughtNextAt time.Time
|
|
|
|
// Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0.
|
|
TownNPCWalkTargetID int64
|
|
TownNPCWalkToX float64
|
|
TownNPCWalkToY float64
|
|
|
|
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
|
|
TownLeaveAt time.Time
|
|
// TownLastNPCLingerUntil: after the final queued NPC visit ends, wait near them until this time before walking to plaza (shifted while TownNPCUILock).
|
|
TownLastNPCLingerUntil time.Time
|
|
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
|
|
TownPlazaHealActive bool
|
|
|
|
// TownCenterWalk*: attractor stepping to plaza before road snap.
|
|
TownCenterWalkActive bool
|
|
TownCenterWalkToX float64
|
|
TownCenterWalkToY float64
|
|
|
|
// Accumulates fractional town-rest regen between ticks.
|
|
TownRestHealRemainder float64
|
|
|
|
// 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
|
|
|
|
// 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.
|
|
// Keep this small to avoid persisting every tick due to healing remainders.
|
|
type townPausePersistSignature struct {
|
|
RestKind model.RestKind
|
|
RestUntil time.Time
|
|
|
|
ExcursionKind model.ExcursionKind
|
|
ExcursionPhase model.ExcursionPhase
|
|
ExcursionStartedAt time.Time
|
|
ExcursionOutUntil time.Time
|
|
ExcursionWildUntil time.Time
|
|
ExcursionReturnUntil time.Time
|
|
ExcursionDepthWorldUnits float64
|
|
ExcursionRoadFreezeWaypoint int
|
|
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).
|
|
InTown bool
|
|
InTownNextRoll time.Time
|
|
InTownLeave time.Time
|
|
InTownVisitStarted time.Time
|
|
InTownVisitLogs int
|
|
InTownNPCWalkTarget int64
|
|
InTownNPCWalkToX float64
|
|
InTownNPCWalkToY float64
|
|
InTownPlazaHeal bool
|
|
InTownCenterWalkActive bool
|
|
InTownCenterWalkToX float64
|
|
InTownCenterWalkToY float64
|
|
InTownNPCQueueLen int
|
|
InTownNPCQueueFP uint64
|
|
InTownVisitName string
|
|
InTownVisitType string
|
|
InTownLastNPCLinger time.Time
|
|
}
|
|
|
|
func npcQueueFingerprint(q []int64) uint64 {
|
|
const prime64 = 1099511628211
|
|
var h uint64 = 1469598103934665603
|
|
for _, id := range q {
|
|
h ^= uint64(id)
|
|
h *= prime64
|
|
}
|
|
h ^= uint64(len(q)) << 32
|
|
return h
|
|
}
|
|
|
|
// NewHeroMovement creates a HeroMovement for a hero that just connected.
|
|
// It initializes position, state, and picks the first destination if needed.
|
|
func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement {
|
|
// Randomize direction per hero so they don't all walk the same way.
|
|
dir := 1
|
|
if hero.ID%2 == 0 {
|
|
dir = -1
|
|
}
|
|
|
|
// Persisted (x,y) already include any in-world offset from prior sessions; do not add
|
|
// lateral jitter again on reconnect (that doubled the shift every reload).
|
|
freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0
|
|
var curX, curY float64
|
|
if freshWorldSpawn {
|
|
curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road
|
|
} else {
|
|
curX = hero.PositionX
|
|
curY = hero.PositionY
|
|
}
|
|
|
|
hm := &HeroMovement{
|
|
HeroID: hero.ID,
|
|
Hero: hero,
|
|
CurrentX: curX,
|
|
CurrentY: curY,
|
|
State: hero.State,
|
|
LastMoveTick: now,
|
|
Direction: dir,
|
|
spawnAtRoadStart: freshWorldSpawn,
|
|
}
|
|
|
|
// Restore persisted movement state.
|
|
if hero.CurrentTownID != nil {
|
|
hm.CurrentTownID = *hero.CurrentTownID
|
|
}
|
|
if hero.DestinationTownID != nil {
|
|
hm.DestinationTownID = *hero.DestinationTownID
|
|
}
|
|
|
|
hm.refreshSpeed(now)
|
|
|
|
// If the hero is dead, keep them dead.
|
|
if hero.State == model.StateDead || hero.HP <= 0 {
|
|
hm.State = model.StateDead
|
|
return hm
|
|
}
|
|
|
|
// If fighting, leave as-is (engine combat system manages it).
|
|
if hero.State == model.StateFighting {
|
|
return hm
|
|
}
|
|
|
|
// Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause).
|
|
if hero.State == model.StateResting || hero.State == model.StateInTown {
|
|
hm.State = hero.State
|
|
hm.applyTownPauseFromHero(hero, now)
|
|
|
|
// For roadside / adventure-inline rest the hero needs a road for display offset calculations.
|
|
if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline {
|
|
if hm.DestinationTownID == 0 {
|
|
hm.pickDestination(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
|
|
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
|
|
}
|
|
|
|
// Walking state: assign a road if we don't have a destination.
|
|
if hm.DestinationTownID == 0 {
|
|
hm.pickDestination(graph)
|
|
}
|
|
hm.assignRoad(graph, false)
|
|
if hm.Road == nil {
|
|
hm.pickDestination(graph)
|
|
hm.assignRoad(graph, false)
|
|
}
|
|
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
|
|
}
|
|
|
|
// firstReachableOnRing returns the first town along TownOrder (stepping by Direction)
|
|
// that has a direct road from CurrentTownID, or 0 if none.
|
|
func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 {
|
|
n := len(graph.TownOrder)
|
|
if n < 2 || fromIdx < 0 {
|
|
return 0
|
|
}
|
|
for step := 1; step < n; step++ {
|
|
raw := fromIdx + hm.Direction*step
|
|
nextIdx := ((raw % n) + n) % n
|
|
candidate := graph.TownOrder[nextIdx]
|
|
if candidate == hm.CurrentTownID {
|
|
continue
|
|
}
|
|
if graph.FindRoad(hm.CurrentTownID, candidate) != nil {
|
|
return candidate
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 {
|
|
for _, r := range graph.TownRoads[hm.CurrentTownID] {
|
|
if r != nil && r.ToTownID != hm.CurrentTownID {
|
|
return r.ToTownID
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
|
|
for _, tid := range graph.TownOrder {
|
|
if tid == hm.CurrentTownID {
|
|
continue
|
|
}
|
|
if graph.FindRoad(hm.CurrentTownID, tid) != nil {
|
|
return tid
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// avoidSelfLoopDestination forces a different destination than CurrentTownID when the world
|
|
// has multiple towns. Roads are always between distinct towns; dest == current yields no road
|
|
// and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0).
|
|
func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) {
|
|
if graph == nil || len(graph.TownOrder) <= 1 {
|
|
return
|
|
}
|
|
if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID {
|
|
return
|
|
}
|
|
if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID {
|
|
hm.DestinationTownID = d
|
|
return
|
|
}
|
|
if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID {
|
|
hm.DestinationTownID = nxt
|
|
}
|
|
}
|
|
|
|
// crossRoadChance is the probability of picking a cross-road instead of following the ring.
|
|
const crossRoadChance = 0.3
|
|
|
|
// pickDestination selects the next town the hero should walk toward.
|
|
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
|
|
// When multiple outgoing roads exist, there's a chance the hero takes a cross-road.
|
|
func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
|
|
defer hm.avoidSelfLoopDestination(graph)
|
|
|
|
if hm.CurrentTownID == 0 {
|
|
if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 {
|
|
hm.CurrentTownID = graph.TownOrder[0]
|
|
} else {
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
}
|
|
}
|
|
|
|
n := len(graph.TownOrder)
|
|
if n == 0 {
|
|
hm.DestinationTownID = 0
|
|
return
|
|
}
|
|
if n == 1 {
|
|
hm.DestinationTownID = hm.CurrentTownID
|
|
return
|
|
}
|
|
|
|
idx := graph.TownOrderIndex(hm.CurrentTownID)
|
|
if idx < 0 {
|
|
if d := hm.firstOutgoingDestination(graph); d != 0 {
|
|
hm.DestinationTownID = d
|
|
return
|
|
}
|
|
if d := hm.firstReachableAny(graph); d != 0 {
|
|
hm.DestinationTownID = d
|
|
return
|
|
}
|
|
if len(graph.TownOrder) > 0 {
|
|
hm.DestinationTownID = graph.TownOrder[0]
|
|
}
|
|
return
|
|
}
|
|
|
|
// When multiple roads are available, sometimes pick a non-ring neighbor at random
|
|
// (includes both ring directions when there are exactly two outgoing roads).
|
|
outgoing := graph.TownRoads[hm.CurrentTownID]
|
|
if len(outgoing) >= 2 && rand.Float64() < crossRoadChance {
|
|
pick := outgoing[rand.Intn(len(outgoing))]
|
|
if pick != nil && pick.ToTownID != hm.CurrentTownID {
|
|
hm.DestinationTownID = pick.ToTownID
|
|
return
|
|
}
|
|
}
|
|
|
|
if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
|
|
hm.DestinationTownID = dest
|
|
return
|
|
}
|
|
if d := hm.firstOutgoingDestination(graph); d != 0 {
|
|
hm.DestinationTownID = d
|
|
return
|
|
}
|
|
if d := hm.firstReachableAny(graph); d != 0 {
|
|
hm.DestinationTownID = d
|
|
return
|
|
}
|
|
hm.DestinationTownID = hm.CurrentTownID
|
|
}
|
|
|
|
// 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.
|
|
// startAtFirstWaypoint: force hero to jittered waypoint 0 (departure town). Otherwise
|
|
// snapProgressToNearestPointOnRoad projects CurrentX/Y onto the polyline (used after LeaveTown).
|
|
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.
|
|
nearest := graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
hm.CurrentTownID = nearest
|
|
road = graph.FindRoad(nearest, hm.DestinationTownID)
|
|
}
|
|
if road == nil {
|
|
// No road available, will retry next tick.
|
|
return
|
|
}
|
|
|
|
// Create a per-hero jittered copy of waypoints so heroes don't overlap on the same road.
|
|
jitteredWaypoints := make([]Point, len(road.Waypoints))
|
|
copy(jitteredWaypoints, road.Waypoints)
|
|
heroSeed := float64(hm.HeroID)
|
|
lateralJitter := (math.Sin(heroSeed*1.7) * 1.5) // ±1.5 tiles lateral offset
|
|
for i := 1; i < len(jitteredWaypoints)-1; i++ {
|
|
// Apply perpendicular offset (don't jitter start/end = town centers)
|
|
dx := jitteredWaypoints[i].X - jitteredWaypoints[max(0, i-1)].X
|
|
dy := jitteredWaypoints[i].Y - jitteredWaypoints[max(0, i-1)].Y
|
|
segLen := math.Hypot(dx, dy)
|
|
if segLen > 0.1 {
|
|
perpX := -dy / segLen
|
|
perpY := dx / segLen
|
|
jitter := lateralJitter * (0.7 + 0.3*math.Sin(heroSeed*0.3+float64(i)*0.5))
|
|
jitteredWaypoints[i].X += perpX * jitter
|
|
jitteredWaypoints[i].Y += perpY * jitter
|
|
}
|
|
}
|
|
|
|
jitteredRoad := &Road{
|
|
ID: road.ID,
|
|
FromTownID: road.FromTownID,
|
|
ToTownID: road.ToTownID,
|
|
Distance: road.Distance,
|
|
Waypoints: jitteredWaypoints,
|
|
}
|
|
|
|
hm.Road = jitteredRoad
|
|
if hm.spawnAtRoadStart {
|
|
wp0 := jitteredRoad.Waypoints[0]
|
|
hm.CurrentX = wp0.X
|
|
hm.CurrentY = wp0.Y
|
|
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()
|
|
}
|
|
}
|
|
|
|
// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y
|
|
// to the closest point on the current road polyline to the incoming position.
|
|
func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
hm.WaypointIndex = 0
|
|
hm.WaypointFraction = 0
|
|
return
|
|
}
|
|
hx, hy := hm.CurrentX, hm.CurrentY
|
|
bestIdx := 0
|
|
bestT := 0.0
|
|
bestDistSq := math.MaxFloat64
|
|
bestX, bestY := hx, hy
|
|
for i := 0; i < len(hm.Road.Waypoints)-1; i++ {
|
|
ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y
|
|
bx, by := hm.Road.Waypoints[i+1].X, hm.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
|
|
bestIdx = i
|
|
bestT = t
|
|
bestX, bestY = px, py
|
|
}
|
|
}
|
|
hm.WaypointIndex = bestIdx
|
|
hm.WaypointFraction = bestT
|
|
hm.CurrentX = bestX
|
|
hm.CurrentY = bestY
|
|
}
|
|
|
|
// ShiftGameDeadlines advances movement-related deadlines by d (wall time spent paused) so
|
|
// simulation does not “catch up” after resume. LastMoveTick is set to now to avoid a huge dt on the next tick.
|
|
func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
|
|
if d <= 0 {
|
|
hm.LastMoveTick = now
|
|
return
|
|
}
|
|
shift := func(t time.Time) time.Time {
|
|
if t.IsZero() {
|
|
return t
|
|
}
|
|
return t.Add(d)
|
|
}
|
|
hm.LastEncounterAt = shift(hm.LastEncounterAt)
|
|
hm.RestUntil = shift(hm.RestUntil)
|
|
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
|
|
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)
|
|
hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil)
|
|
hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil)
|
|
hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt)
|
|
hm.LastMoveTick = now
|
|
}
|
|
|
|
// refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.
|
|
func (hm *HeroMovement) refreshSpeed(now time.Time) {
|
|
// Per-hero speed variation: ±10% based on hero ID for natural spread.
|
|
heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10
|
|
hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
|
|
}
|
|
|
|
// AdvanceTick moves the hero along the road for one movement tick.
|
|
// Returns true if the hero reached the destination town this tick.
|
|
func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) {
|
|
if hm.skipMovementSimulation() {
|
|
return false
|
|
}
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return false
|
|
}
|
|
|
|
if hm.Excursion.Active() {
|
|
hm.LastMoveTick = now
|
|
return false
|
|
}
|
|
|
|
dt := now.Sub(hm.LastMoveTick).Seconds()
|
|
if dt <= 0 {
|
|
dt = movementTickRate().Seconds()
|
|
}
|
|
hm.LastMoveTick = now
|
|
|
|
hm.refreshSpeed(now)
|
|
distThisTick := hm.Speed * dt
|
|
|
|
for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
|
|
from := hm.Road.Waypoints[hm.WaypointIndex]
|
|
to := hm.Road.Waypoints[hm.WaypointIndex+1]
|
|
segLen := math.Hypot(to.X-from.X, to.Y-from.Y)
|
|
if segLen < 0.001 {
|
|
hm.WaypointIndex++
|
|
hm.WaypointFraction = 0
|
|
continue
|
|
}
|
|
|
|
// How far along this segment we already are.
|
|
currentDist := hm.WaypointFraction * segLen
|
|
remaining := segLen - currentDist
|
|
arrivalRadius := tuning.Get().TownArrivalRadius
|
|
if arrivalRadius < 0 {
|
|
arrivalRadius = 0
|
|
}
|
|
|
|
if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) {
|
|
// Move to next waypoint.
|
|
distThisTick -= remaining
|
|
hm.WaypointIndex++
|
|
hm.WaypointFraction = 0
|
|
if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
|
|
// Reached final waypoint = destination town.
|
|
last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1]
|
|
hm.CurrentX = last.X
|
|
hm.CurrentY = last.Y
|
|
return true
|
|
}
|
|
} else {
|
|
// Partial advance within this segment.
|
|
hm.WaypointFraction = (currentDist + distThisTick) / segLen
|
|
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
|
|
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
|
|
distThisTick = 0
|
|
}
|
|
}
|
|
|
|
// Update position to the current waypoint position.
|
|
if hm.WaypointIndex < len(hm.Road.Waypoints) {
|
|
wp := hm.Road.Waypoints[hm.WaypointIndex]
|
|
if hm.WaypointFraction == 0 {
|
|
hm.CurrentX = wp.X
|
|
hm.CurrentY = wp.Y
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Heading returns the angle (radians) the hero is currently facing.
|
|
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 {
|
|
return 0
|
|
}
|
|
to := hm.Road.Waypoints[hm.WaypointIndex+1]
|
|
return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX)
|
|
}
|
|
|
|
// TargetPoint returns the next waypoint the hero is heading toward.
|
|
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 {
|
|
return hm.CurrentX, hm.CurrentY
|
|
}
|
|
wp := hm.Road.Waypoints[hm.WaypointIndex+1]
|
|
return wp.X, wp.Y
|
|
}
|
|
|
|
func (hm *HeroMovement) applyTownRestHeal(dt float64) {
|
|
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
|
|
return
|
|
}
|
|
cfg := tuning.Get()
|
|
rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder
|
|
gain := int(math.Floor(rawGain))
|
|
hm.TownRestHealRemainder = rawGain - float64(gain)
|
|
if gain <= 0 {
|
|
return
|
|
}
|
|
hm.Hero.HP += gain
|
|
if hm.Hero.HP > hm.Hero.MaxHP {
|
|
hm.Hero.HP = hm.Hero.MaxHP
|
|
}
|
|
}
|
|
|
|
// AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest).
|
|
func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error {
|
|
if graph == nil || townID == 0 {
|
|
return fmt.Errorf("invalid town")
|
|
}
|
|
if _, ok := graph.Towns[townID]; !ok {
|
|
return fmt.Errorf("unknown town")
|
|
}
|
|
hm.Road = nil
|
|
hm.WaypointIndex = 0
|
|
hm.WaypointFraction = 0
|
|
hm.DestinationTownID = townID
|
|
hm.spawnAtRoadStart = false
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
hm.TownRestHealRemainder = 0
|
|
hm.Excursion = model.ExcursionSession{}
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestHealRemainder = 0
|
|
hm.clearNPCWalk()
|
|
t := graph.Towns[townID]
|
|
hm.CurrentX = t.WorldX
|
|
hm.CurrentY = t.WorldY
|
|
hm.EnterTown(now, graph)
|
|
return nil
|
|
}
|
|
|
|
// AdminStartRoadsideRest forces the hero into roadside rest on the current road segment.
|
|
func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
|
|
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
|
|
return false
|
|
}
|
|
if hm.State == model.StateFighting {
|
|
return false
|
|
}
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.beginRoadsideRest(now)
|
|
return true
|
|
}
|
|
|
|
// AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road.
|
|
// Cooldown and random roll are bypassed; the hero must not already be in an excursion.
|
|
func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
|
|
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
|
|
return false
|
|
}
|
|
if hm.State == model.StateFighting {
|
|
return false
|
|
}
|
|
if hm.State != model.StateWalking {
|
|
return false
|
|
}
|
|
if hm.Excursion.Active() {
|
|
return false
|
|
}
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return false
|
|
}
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.beginExcursion(now)
|
|
return true
|
|
}
|
|
|
|
// AdminStopExcursion skips the remaining forest/wild leg and starts the return leg toward the road
|
|
// (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 {
|
|
if !hm.Excursion.Active() {
|
|
return false
|
|
}
|
|
if hm.Excursion.Kind == model.ExcursionKindTown {
|
|
return false
|
|
}
|
|
if hm.State == model.StateFighting {
|
|
return false
|
|
}
|
|
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline {
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestUntil = time.Time{}
|
|
hm.RestHealRemainder = 0
|
|
hm.State = model.StateWalking
|
|
hm.Hero.State = model.StateWalking
|
|
hm.enterAdventureReturnToRoad()
|
|
hm.refreshSpeed(now)
|
|
return true
|
|
}
|
|
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)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
|
|
func (hm *HeroMovement) AdminStopRest(now time.Time) bool {
|
|
if hm.State != model.StateResting {
|
|
return false
|
|
}
|
|
if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline {
|
|
return false
|
|
}
|
|
if hm.Excursion.Active() {
|
|
hm.endExcursion(now)
|
|
}
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestUntil = time.Time{}
|
|
hm.RestHealRemainder = 0
|
|
hm.State = model.StateWalking
|
|
hm.Hero.State = model.StateWalking
|
|
hm.refreshSpeed(now)
|
|
return true
|
|
}
|
|
|
|
// AdminStartRest forces a resting period (same duration model as town rest).
|
|
func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
|
|
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
|
|
return false
|
|
}
|
|
if hm.State == model.StateFighting {
|
|
return false
|
|
}
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.TownNPCQueue = nil
|
|
hm.NextTownNPCRollAt = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
hm.TownRestHealRemainder = 0
|
|
hm.clearNPCWalk()
|
|
hm.clearTownCenterWalk()
|
|
hm.TownPlazaHealActive = false
|
|
hm.TownLeaveAt = time.Time{}
|
|
hm.TownLastNPCLingerUntil = time.Time{}
|
|
if graph != nil && hm.CurrentTownID == 0 {
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
}
|
|
hm.State = model.StateResting
|
|
hm.Hero.State = model.StateResting
|
|
hm.ActiveRestKind = model.RestKindTown
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
return true
|
|
}
|
|
|
|
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return 0, 1
|
|
}
|
|
idx := hm.WaypointIndex
|
|
if idx >= len(hm.Road.Waypoints)-1 {
|
|
idx = len(hm.Road.Waypoints) - 2
|
|
}
|
|
if idx < 0 {
|
|
return 0, 1
|
|
}
|
|
from := hm.Road.Waypoints[idx]
|
|
to := hm.Road.Waypoints[idx+1]
|
|
dx := to.X - from.X
|
|
dy := to.Y - from.Y
|
|
L := math.Hypot(dx, dy)
|
|
if L < 1e-6 {
|
|
return 0, 1
|
|
}
|
|
return -dy / L, dx / L
|
|
}
|
|
|
|
// roadForwardUnit is the normalized tangent along the road toward the next waypoint.
|
|
func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return 1, 0
|
|
}
|
|
idx := hm.WaypointIndex
|
|
if idx >= len(hm.Road.Waypoints)-1 {
|
|
idx = len(hm.Road.Waypoints) - 2
|
|
}
|
|
if idx < 0 {
|
|
return 1, 0
|
|
}
|
|
from := hm.Road.Waypoints[idx]
|
|
to := hm.Road.Waypoints[idx+1]
|
|
dx := to.X - from.X
|
|
dy := to.Y - from.Y
|
|
L := math.Hypot(dx, dy)
|
|
if L < 1e-6 {
|
|
return 1, 0
|
|
}
|
|
return dx / L, dy / L
|
|
}
|
|
|
|
func (hm *HeroMovement) excursionUsesAttractors() bool {
|
|
return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone && hm.Excursion.Kind != model.ExcursionKindTown
|
|
}
|
|
|
|
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) {
|
|
if hm.excursionUsesAttractors() {
|
|
return 0, 0
|
|
}
|
|
exc := &hm.Excursion
|
|
if exc.Active() {
|
|
perpX, perpY := hm.roadPerpendicularUnit()
|
|
depth := exc.DepthWorldUnits
|
|
var t float64
|
|
switch exc.Phase {
|
|
case model.ExcursionOut:
|
|
outMs := float64(exc.OutUntil.Sub(exc.StartedAt).Milliseconds())
|
|
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(exc.ReturnUntil.Sub(exc.WildUntil).Milliseconds())
|
|
if retMs > 0 {
|
|
elapsed := float64(now.Sub(exc.WildUntil).Milliseconds())
|
|
t = 1.0 - smoothstep(clamp01(elapsed/retMs))
|
|
}
|
|
}
|
|
d := depth * t
|
|
return perpX * d, perpY * d
|
|
}
|
|
|
|
return 0, 0
|
|
}
|
|
|
|
// WanderingMerchantCost matches REST encounter / npc alms pricing.
|
|
func WanderingMerchantCost(level int) int64 {
|
|
cfg := tuning.Get()
|
|
return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel
|
|
}
|
|
|
|
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
|
|
func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
|
|
cfg := tuning.Get()
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
if rand.Float64() >= cfg.EncounterActivityBase {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
monsterW := cfg.MonsterEncounterWeightBase
|
|
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
|
|
total := monsterW + merchantW
|
|
r := rand.Float64() * total
|
|
if r < monsterW {
|
|
e := PickEnemyForHero(hm.Hero)
|
|
return true, e, true
|
|
}
|
|
return false, model.Enemy{}, true
|
|
}
|
|
|
|
// 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
|
|
clearLegacyTownNPCState(hm)
|
|
hm.TownRestHealRemainder = 0
|
|
hm.Excursion = model.ExcursionSession{}
|
|
hm.sentTownTourWireSig = ""
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestHealRemainder = 0
|
|
hm.clearTownCenterWalk()
|
|
|
|
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
|
|
}
|
|
|
|
hm.State = model.StateInTown
|
|
hm.Hero.State = model.StateInTown
|
|
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) {
|
|
clearLegacyTownNPCState(hm)
|
|
hm.TownRestHealRemainder = 0
|
|
hm.RestUntil = time.Time{}
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestHealRemainder = 0
|
|
hm.Excursion = model.ExcursionSession{}
|
|
hm.sentTownTourWireSig = ""
|
|
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)
|
|
// Project CurrentX/Y onto the outbound road polyline. The normal town flow walks the hero
|
|
// to the plaza first; forcing waypoint 0 caused a visible teleport away from that spot.
|
|
hm.assignRoad(graph, false)
|
|
hm.refreshSpeed(now)
|
|
}
|
|
|
|
func randomTownNPCDelay() time.Duration {
|
|
cfg := tuning.Get()
|
|
minDelay := time.Duration(cfg.TownNPCRollMinMs) * time.Millisecond
|
|
maxDelay := time.Duration(cfg.TownNPCRollMaxMs) * time.Millisecond
|
|
rangeMs := (maxDelay - minDelay).Milliseconds()
|
|
if rangeMs < 0 {
|
|
rangeMs = 0
|
|
}
|
|
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
|
|
}
|
|
|
|
// townNPCStandPoint is a spot near the NPC along the hero's approach (from → npc), not on the NPC tile.
|
|
func townNPCStandPoint(npcX, npcY, fromX, fromY, standoff float64) (sx, sy float64) {
|
|
if standoff <= 0 {
|
|
standoff = tuning.DefaultValues().TownNPCStandoffWorld
|
|
}
|
|
dx := fromX - npcX
|
|
dy := fromY - npcY
|
|
ln := math.Hypot(dx, dy)
|
|
if ln < 1e-4 {
|
|
s := standoff * 0.7071067811865476
|
|
return npcX + s, npcY + s
|
|
}
|
|
ux, uy := dx/ln, dy/ln
|
|
const gap = 0.05
|
|
step := standoff
|
|
if step > ln-gap {
|
|
step = ln - gap
|
|
}
|
|
if step < 0.12 {
|
|
step = math.Min(0.12, math.Max(0.06, ln*0.42))
|
|
}
|
|
return npcX + ux*step, npcY + uy*step
|
|
}
|
|
|
|
// clearNPCWalk resets the walk-to-NPC sub-state.
|
|
func (hm *HeroMovement) clearNPCWalk() {
|
|
hm.TownNPCWalkTargetID = 0
|
|
hm.TownNPCWalkToX = 0
|
|
hm.TownNPCWalkToY = 0
|
|
}
|
|
|
|
func (hm *HeroMovement) clearTownCenterWalk() {
|
|
hm.TownCenterWalkActive = false
|
|
hm.TownCenterWalkToX = 0
|
|
hm.TownCenterWalkToY = 0
|
|
}
|
|
|
|
// StartFighting pauses movement for combat.
|
|
func (hm *HeroMovement) StartFighting() {
|
|
hm.State = model.StateFighting
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
}
|
|
|
|
// ResumWalking resumes movement after combat.
|
|
func (hm *HeroMovement) ResumeWalking(now time.Time) {
|
|
hm.State = model.StateWalking
|
|
hm.LastMoveTick = now
|
|
hm.refreshSpeed(now)
|
|
}
|
|
|
|
// Die sets the movement state to dead.
|
|
func (hm *HeroMovement) Die() {
|
|
hm.State = model.StateDead
|
|
}
|
|
|
|
// canSimulateMovement is true when the hero may advance along roads, towns, rests, and encounters.
|
|
// Dead heroes must not run the movement FSM or AdvanceTick.
|
|
func (hm *HeroMovement) canSimulateMovement() bool {
|
|
if hm == nil || hm.Hero == nil {
|
|
return false
|
|
}
|
|
if hm.State == model.StateDead {
|
|
return false
|
|
}
|
|
if hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// skipMovementSimulation returns true if the hero must not run movement this step.
|
|
// When the model is dead but FSM is not yet StateDead, aligns with Die().
|
|
func (hm *HeroMovement) skipMovementSimulation() bool {
|
|
if hm == nil {
|
|
return true
|
|
}
|
|
if hm.canSimulateMovement() {
|
|
return false
|
|
}
|
|
if hm.Hero != nil && (hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead) {
|
|
hm.Die()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// worldPositionAt returns hero world (x,y) matching SyncToHero / hero_move (spine + display offset).
|
|
func (hm *HeroMovement) worldPositionAt(now time.Time) (x, y float64) {
|
|
if hm == nil || hm.Hero == nil {
|
|
return 0, 0
|
|
}
|
|
ox, oy := hm.displayOffset(now)
|
|
return hm.CurrentX + ox, hm.CurrentY + oy
|
|
}
|
|
|
|
// SyncToHero writes movement state back to the hero model for persistence.
|
|
// Position uses the same world coordinates as hero_move / position_sync (road spine + display offset).
|
|
func (hm *HeroMovement) SyncToHero() {
|
|
now := time.Now()
|
|
x, y := hm.worldPositionAt(now)
|
|
hm.Hero.PositionX = x
|
|
hm.Hero.PositionY = y
|
|
hm.Hero.State = hm.State
|
|
if hm.CurrentTownID != 0 {
|
|
id := hm.CurrentTownID
|
|
hm.Hero.CurrentTownID = &id
|
|
} else {
|
|
hm.Hero.CurrentTownID = nil
|
|
}
|
|
if hm.DestinationTownID != 0 {
|
|
id := hm.DestinationTownID
|
|
hm.Hero.DestinationTownID = &id
|
|
} else {
|
|
hm.Hero.DestinationTownID = nil
|
|
}
|
|
hm.Hero.MoveState = string(hm.State)
|
|
hm.Hero.RestKind = model.RestKindNone
|
|
if hm.State == model.StateResting {
|
|
if hm.ActiveRestKind != model.RestKindNone {
|
|
hm.Hero.RestKind = hm.ActiveRestKind
|
|
} else {
|
|
hm.Hero.RestKind = model.RestKindTown
|
|
}
|
|
}
|
|
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.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()
|
|
}
|
|
|
|
// TownPausePersistDue reports whether excursion/rest state should be persisted.
|
|
// Returns the current signature for use when marking persistence.
|
|
func (hm *HeroMovement) TownPausePersistDue() (townPausePersistSignature, bool) {
|
|
sig := hm.townPausePersistSignature()
|
|
if sig == hm.lastTownPausePersistSignature {
|
|
return sig, false
|
|
}
|
|
return sig, true
|
|
}
|
|
|
|
// MarkTownPausePersisted stores the latest persisted signature.
|
|
func (hm *HeroMovement) MarkTownPausePersisted(sig townPausePersistSignature) {
|
|
hm.lastTownPausePersistSignature = sig
|
|
}
|
|
|
|
func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
|
|
var sig townPausePersistSignature
|
|
if hm.State == model.StateResting {
|
|
rk := hm.ActiveRestKind
|
|
if rk == model.RestKindNone {
|
|
rk = model.RestKindTown
|
|
}
|
|
sig.RestKind = rk
|
|
sig.RestUntil = hm.RestUntil
|
|
}
|
|
|
|
if hm.Excursion.Active() {
|
|
s := hm.Excursion
|
|
sig.ExcursionKind = s.Kind
|
|
sig.ExcursionPhase = s.Phase
|
|
sig.ExcursionStartedAt = s.StartedAt
|
|
sig.ExcursionOutUntil = s.OutUntil
|
|
sig.ExcursionWildUntil = s.WildUntil
|
|
sig.ExcursionReturnUntil = s.ReturnUntil
|
|
sig.ExcursionDepthWorldUnits = s.DepthWorldUnits
|
|
sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint
|
|
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 {
|
|
sig.InTown = true
|
|
sig.InTownNextRoll = hm.NextTownNPCRollAt
|
|
sig.InTownLeave = hm.TownLeaveAt
|
|
sig.InTownVisitStarted = hm.TownVisitStartedAt
|
|
sig.InTownVisitLogs = hm.TownVisitLogsEmitted
|
|
sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID
|
|
sig.InTownNPCWalkToX = hm.TownNPCWalkToX
|
|
sig.InTownNPCWalkToY = hm.TownNPCWalkToY
|
|
sig.InTownPlazaHeal = hm.TownPlazaHealActive
|
|
sig.InTownCenterWalkActive = hm.TownCenterWalkActive
|
|
sig.InTownCenterWalkToX = hm.TownCenterWalkToX
|
|
sig.InTownCenterWalkToY = hm.TownCenterWalkToY
|
|
sig.InTownNPCQueueLen = len(hm.TownNPCQueue)
|
|
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
|
|
sig.InTownVisitName = hm.TownVisitNPCName
|
|
sig.InTownVisitType = hm.TownVisitNPCType
|
|
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil
|
|
}
|
|
return sig
|
|
}
|
|
|
|
func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
|
|
var p *model.TownPausePersisted
|
|
|
|
switch hm.State {
|
|
case model.StateResting:
|
|
rk := model.RestKindTown
|
|
if hm.ActiveRestKind != model.RestKindNone {
|
|
rk = hm.ActiveRestKind
|
|
}
|
|
if rk == model.RestKindTown && hm.RestUntil.IsZero() {
|
|
break
|
|
}
|
|
p = &model.TownPausePersisted{
|
|
RestKind: rk,
|
|
TownRestHealRemainder: hm.TownRestHealRemainder,
|
|
RestHealRemainder: hm.RestHealRemainder,
|
|
}
|
|
if !hm.RestUntil.IsZero() {
|
|
t := hm.RestUntil
|
|
p.RestUntil = &t
|
|
}
|
|
case model.StateInTown:
|
|
p = &model.TownPausePersisted{
|
|
TownVisitNPCName: hm.TownVisitNPCName,
|
|
TownVisitNPCType: hm.TownVisitNPCType,
|
|
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
|
|
NPCWalkTargetID: hm.TownNPCWalkTargetID,
|
|
NPCWalkToX: hm.TownNPCWalkToX,
|
|
NPCWalkToY: hm.TownNPCWalkToY,
|
|
}
|
|
if len(hm.TownNPCQueue) > 0 {
|
|
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
|
|
}
|
|
if !hm.NextTownNPCRollAt.IsZero() {
|
|
t := hm.NextTownNPCRollAt
|
|
p.NextTownNPCRollAt = &t
|
|
}
|
|
if !hm.TownLeaveAt.IsZero() {
|
|
t := hm.TownLeaveAt
|
|
p.TownLeaveAt = &t
|
|
}
|
|
if !hm.TownVisitStartedAt.IsZero() {
|
|
t := hm.TownVisitStartedAt
|
|
p.TownVisitStartedAt = &t
|
|
}
|
|
if !hm.TownLastNPCLingerUntil.IsZero() {
|
|
t := hm.TownLastNPCLingerUntil
|
|
p.TownLastNPCLingerUntil = &t
|
|
}
|
|
if hm.TownPlazaHealActive {
|
|
p.TownPlazaHealActive = true
|
|
}
|
|
if hm.TownCenterWalkActive {
|
|
p.CenterWalkActive = true
|
|
p.CenterWalkToX = hm.TownCenterWalkToX
|
|
p.CenterWalkToY = hm.TownCenterWalkToY
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
Kind: string(s.Kind),
|
|
Phase: string(s.Phase),
|
|
DepthWorldUnits: s.DepthWorldUnits,
|
|
RoadFreezeWaypoint: s.RoadFreezeWaypoint,
|
|
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() {
|
|
t := s.StartedAt
|
|
ep.StartedAt = &t
|
|
}
|
|
if !s.OutUntil.IsZero() {
|
|
t := s.OutUntil
|
|
ep.OutUntil = &t
|
|
}
|
|
if !s.WildUntil.IsZero() {
|
|
t := s.WildUntil
|
|
ep.WildUntil = &t
|
|
}
|
|
if !s.ReturnUntil.IsZero() {
|
|
t := s.ReturnUntil
|
|
ep.ReturnUntil = &t
|
|
}
|
|
if !s.AdventureEndsAt.IsZero() {
|
|
t := s.AdventureEndsAt
|
|
ep.AdventureEndsAt = &t
|
|
}
|
|
if !s.WanderNextAt.IsZero() {
|
|
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
|
|
}
|
|
|
|
func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) {
|
|
blob := hero.TownPause
|
|
switch hero.State {
|
|
case model.StateResting:
|
|
if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) {
|
|
if blob.RestUntil != nil && !blob.RestUntil.IsZero() {
|
|
hm.RestUntil = *blob.RestUntil
|
|
}
|
|
hm.ActiveRestKind = blob.RestKind
|
|
hm.TownRestHealRemainder = blob.TownRestHealRemainder
|
|
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)
|
|
}
|
|
case model.StateInTown:
|
|
if blob == nil {
|
|
return
|
|
}
|
|
if len(blob.NPCQueue) > 0 {
|
|
hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...)
|
|
}
|
|
if blob.NextTownNPCRollAt != nil {
|
|
hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt
|
|
}
|
|
if blob.TownLeaveAt != nil {
|
|
hm.TownLeaveAt = *blob.TownLeaveAt
|
|
}
|
|
hm.TownVisitNPCName = blob.TownVisitNPCName
|
|
hm.TownVisitNPCType = blob.TownVisitNPCType
|
|
if blob.TownVisitStartedAt != nil {
|
|
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
|
|
}
|
|
if blob.TownLastNPCLingerUntil != nil {
|
|
hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil
|
|
}
|
|
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
|
|
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
|
|
hm.TownNPCWalkToX = blob.NPCWalkToX
|
|
hm.TownNPCWalkToY = blob.NPCWalkToY
|
|
hm.TownPlazaHealActive = blob.TownPlazaHealActive
|
|
hm.TownCenterWalkToX = blob.CenterWalkToX
|
|
hm.TownCenterWalkToY = blob.CenterWalkToY
|
|
hm.TownCenterWalkActive = blob.CenterWalkActive
|
|
if !hm.TownCenterWalkActive && blob.CenterWalkStart != nil && !blob.CenterWalkStart.IsZero() {
|
|
hm.TownCenterWalkActive = true
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// 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)
|
|
if ep.StartedAt != nil {
|
|
hm.Excursion.StartedAt = *ep.StartedAt
|
|
}
|
|
if ep.OutUntil != nil {
|
|
hm.Excursion.OutUntil = *ep.OutUntil
|
|
}
|
|
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
|
|
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
|
|
}
|
|
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).
|
|
func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload {
|
|
tx, ty := hm.TargetPoint()
|
|
ox, oy := hm.displayOffset(now)
|
|
return model.HeroMovePayload{
|
|
X: hm.CurrentX + ox,
|
|
Y: hm.CurrentY + oy,
|
|
TargetX: tx + ox,
|
|
TargetY: ty + oy,
|
|
Speed: hm.Speed,
|
|
Heading: hm.Heading(),
|
|
}
|
|
}
|
|
|
|
// RoutePayload builds the route_assigned WS payload.
|
|
func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload {
|
|
if hm.Road == nil {
|
|
return nil
|
|
}
|
|
waypoints := make([]model.PointXY, len(hm.Road.Waypoints))
|
|
for i, p := range hm.Road.Waypoints {
|
|
waypoints[i] = model.PointXY{X: p.X, Y: p.Y}
|
|
}
|
|
return &model.RouteAssignedPayload{
|
|
RoadID: hm.Road.ID,
|
|
Waypoints: waypoints,
|
|
DestinationTownID: hm.DestinationTownID,
|
|
Speed: hm.Speed,
|
|
}
|
|
}
|
|
|
|
// PositionSyncPayload builds the position_sync WS payload.
|
|
func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload {
|
|
ox, oy := hm.displayOffset(now)
|
|
return model.PositionSyncPayload{
|
|
X: hm.CurrentX + ox,
|
|
Y: hm.CurrentY + oy,
|
|
WaypointIndex: hm.WaypointIndex,
|
|
WaypointFraction: hm.WaypointFraction,
|
|
State: string(hm.State),
|
|
}
|
|
}
|
|
|
|
// randomRestDuration returns a random duration between TownRestMin and TownRestMax.
|
|
func randomRestDuration() time.Duration {
|
|
cfg := tuning.Get()
|
|
minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond
|
|
maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond
|
|
rangeMs := (maxDur - minDur).Milliseconds()
|
|
if rangeMs < 0 {
|
|
rangeMs = 0
|
|
}
|
|
return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
|
|
}
|
|
|
|
// EncounterStarter starts or resolves a random encounter while walking (engine: combat;
|
|
// offline: synchronous SimulateOneFight via callback).
|
|
type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)
|
|
|
|
// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
|
|
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)
|
|
|
|
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
|
|
if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock {
|
|
return
|
|
}
|
|
logInterval := townNPCLogInterval()
|
|
for hm.TownVisitLogsEmitted < townNPCVisitLogLines {
|
|
deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval)
|
|
if now.Before(deadline) {
|
|
break
|
|
}
|
|
lineIdx := hm.TownVisitLogsEmitted
|
|
log(heroID, model.AdventureLogLine{
|
|
Event: &model.AdventureLogEvent{
|
|
Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx),
|
|
},
|
|
})
|
|
hm.TownVisitLogsEmitted++
|
|
}
|
|
}
|
|
|
|
// --- 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) excursionWildness(now time.Time) float64 {
|
|
if hm.Excursion.Phase == model.ExcursionWild {
|
|
return 1
|
|
}
|
|
if hm.Excursion.Phase == model.ExcursionReturn {
|
|
retMs := float64(hm.Excursion.ReturnUntil.Sub(hm.Excursion.WildUntil).Milliseconds())
|
|
var t float64
|
|
if retMs > 0 {
|
|
elapsed := float64(now.Sub(hm.Excursion.WildUntil).Milliseconds())
|
|
t = 1.0 - smoothstep(clamp01(elapsed/retMs))
|
|
} else {
|
|
t = 1.0
|
|
}
|
|
cfg := tuning.Get()
|
|
minWild := cfg.AdventureReturnWildnessMin
|
|
if minWild < 0 {
|
|
minWild = 0
|
|
}
|
|
if minWild > 1 {
|
|
minWild = 1
|
|
}
|
|
if t < minWild {
|
|
t = minWild
|
|
}
|
|
return t
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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()
|
|
depth := cfg.AdventureDepthWorldUnits
|
|
if depth <= 0 {
|
|
depth = tuning.DefaultValues().AdventureDepthWorldUnits
|
|
}
|
|
minDur := cfg.AdventureDurationMinMs
|
|
maxDur := cfg.AdventureDurationMaxMs
|
|
if minDur <= 0 {
|
|
minDur = tuning.DefaultValues().AdventureDurationMinMs
|
|
}
|
|
if maxDur <= 0 {
|
|
maxDur = tuning.DefaultValues().AdventureDurationMaxMs
|
|
}
|
|
adventureEnds := now.Add(randomDurationBetweenMs(minDur, maxDur))
|
|
|
|
hm.Excursion = model.ExcursionSession{
|
|
Kind: model.ExcursionKindAdventure,
|
|
Phase: model.ExcursionOut,
|
|
StartedAt: now,
|
|
DepthWorldUnits: depth,
|
|
RoadFreezeWaypoint: hm.WaypointIndex,
|
|
RoadFreezeFraction: hm.WaypointFraction,
|
|
StartX: hm.CurrentX,
|
|
StartY: hm.CurrentY,
|
|
AdventureEndsAt: adventureEnds,
|
|
}
|
|
hm.pickExcursionForestAttractor(depth)
|
|
}
|
|
|
|
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
|
|
|
|
depth := cfg.RoadsideRestDepthWorldUnits
|
|
if depth <= 0 {
|
|
depth = 12.0
|
|
}
|
|
restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
|
|
|
|
hm.Excursion = model.ExcursionSession{
|
|
Kind: model.ExcursionKindRoadside,
|
|
Phase: model.ExcursionOut,
|
|
StartedAt: now,
|
|
DepthWorldUnits: depth,
|
|
RoadFreezeWaypoint: hm.WaypointIndex,
|
|
RoadFreezeFraction: hm.WaypointFraction,
|
|
StartX: hm.CurrentX,
|
|
StartY: hm.CurrentY,
|
|
}
|
|
hm.pickExcursionForestAttractor(depth)
|
|
// 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)
|
|
}
|
|
|
|
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, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
|
|
cfg := tuning.Get()
|
|
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
|
|
if now.Sub(hm.LastEncounterAt) < cooldown {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
if rand.Float64() >= cfg.EncounterActivityBase {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
wildness := hm.excursionWildness(now)
|
|
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness
|
|
merchantW := cfg.MerchantEncounterWeightBase
|
|
total := monsterW + merchantW
|
|
r := rand.Float64() * total
|
|
if r < monsterW {
|
|
e := PickEnemyForHero(hm.Hero)
|
|
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.
|
|
//
|
|
// sender may be nil to suppress all WebSocket payloads (offline ticks).
|
|
// 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.
|
|
// townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up.
|
|
func ProcessSingleHeroMovementTick(
|
|
heroID int64,
|
|
hm *HeroMovement,
|
|
graph *RoadGraph,
|
|
now time.Time,
|
|
sender MessageSender,
|
|
onEncounter EncounterStarter,
|
|
onMerchantEncounter MerchantEncounterHook,
|
|
adventureLog AdventureLogWriter,
|
|
persistAfterTownEnter AfterTownEnterPersist,
|
|
townTourOffline TownTourOfflineAtNPC,
|
|
) {
|
|
if graph == nil {
|
|
return
|
|
}
|
|
|
|
if hm.skipMovementSimulation() {
|
|
return
|
|
}
|
|
|
|
switch hm.State {
|
|
case model.StateFighting, model.StateDead:
|
|
return
|
|
|
|
case model.StateResting:
|
|
dt := now.Sub(hm.LastMoveTick).Seconds()
|
|
if dt <= 0 {
|
|
dt = movementTickRate().Seconds()
|
|
}
|
|
hm.LastMoveTick = now
|
|
|
|
switch hm.ActiveRestKind {
|
|
case model.RestKindRoadside:
|
|
prevPhase := hm.Excursion.Phase
|
|
hm.refreshSpeed(now)
|
|
switch hm.Excursion.Phase {
|
|
case model.ExcursionOut:
|
|
if hm.stepTowardAttractor(now, dt) {
|
|
hm.Excursion.Phase = model.ExcursionWild
|
|
}
|
|
case model.ExcursionWild:
|
|
hm.applyRestHealTick(dt)
|
|
if adventureLog != nil {
|
|
if hm.RoadsideThoughtNextAt.IsZero() {
|
|
hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second)
|
|
}
|
|
if !now.Before(hm.RoadsideThoughtNextAt) {
|
|
if n := len(model.RoadsideSlugs); n > 0 {
|
|
adventureLog(heroID, model.AdventureLogLine{
|
|
Event: &model.AdventureLogEvent{
|
|
Code: model.RoadsidePhraseKey(model.RoadsideSlugs[rand.Intn(n)]),
|
|
},
|
|
})
|
|
}
|
|
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()
|
|
}
|
|
case model.ExcursionReturn:
|
|
if hm.stepTowardAttractor(now, dt) {
|
|
hm.endExcursion(now)
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
hm.RestUntil = time.Time{}
|
|
hm.RestHealRemainder = 0
|
|
hm.RoadsideThoughtNextAt = time.Time{}
|
|
hm.State = model.StateWalking
|
|
hm.Hero.State = model.StateWalking
|
|
hm.refreshSpeed(now)
|
|
}
|
|
}
|
|
if sender != nil && hm.Excursion.Phase != prevPhase {
|
|
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
|
|
}
|
|
hm.SyncToHero()
|
|
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)
|
|
cfg := tuning.Get()
|
|
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
|
|
if hpFrac >= cfg.AdventureRestTargetHp {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case model.StateInTown:
|
|
if hm.Excursion.Kind == model.ExcursionKindTown {
|
|
processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
|
|
return
|
|
}
|
|
// Legacy in-town row without town excursion: force exit.
|
|
if graph != 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
|
|
|
|
case model.StateWalking:
|
|
cfg := tuning.Get()
|
|
hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
|
|
if hadNoRoad {
|
|
hm.Road = nil
|
|
hm.pickDestination(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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout.
|
|
if !hm.WanderingMerchantDeadline.IsZero() {
|
|
if !now.Before(hm.WanderingMerchantDeadline) {
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"})
|
|
}
|
|
} else {
|
|
hm.LastMoveTick = now
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
}
|
|
if hm.Hero != nil {
|
|
hm.SyncToHero()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- Active adventure excursion (attractor movement while walking) ---
|
|
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
|
|
hm.refreshSpeed(now)
|
|
|
|
if hm.Excursion.Phase == model.ExcursionOut {
|
|
if hm.stepTowardAttractor(now, dtAdv) {
|
|
hm.Excursion.Phase = model.ExcursionWild
|
|
hm.adventureScheduleWanderRetarget(now)
|
|
hm.adventurePickWanderAttractor()
|
|
}
|
|
}
|
|
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() {
|
|
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))
|
|
}
|
|
hm.LastMoveTick = now
|
|
return
|
|
}
|
|
if onEncounter != nil || onMerchantEncounter != nil {
|
|
monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
|
|
if hit {
|
|
if monster && onEncounter != nil {
|
|
hm.LastEncounterAt = now
|
|
onEncounter(hm, &enemy, now)
|
|
hm.LastMoveTick = 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", NPCNameKey: model.WanderingMerchantNPCKey,
|
|
Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost,
|
|
})
|
|
}
|
|
if onMerchantEncounter != nil {
|
|
onMerchantEncounter(hm, now, cost)
|
|
}
|
|
hm.LastMoveTick = now
|
|
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
|
|
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 {
|
|
hm.EnterTown(now, graph)
|
|
|
|
if sender != nil {
|
|
town := graph.Towns[hm.CurrentTownID]
|
|
if town != nil {
|
|
npcInfos := graph.TownNPCInfos(hm.CurrentTownID)
|
|
buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID)
|
|
var restMs int64
|
|
if hm.State == model.StateResting {
|
|
restMs = hm.RestUntil.Sub(now).Milliseconds()
|
|
}
|
|
sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{
|
|
TownID: town.ID,
|
|
TownName: town.Name,
|
|
TownNameKey: town.NameKey,
|
|
Biome: town.Biome,
|
|
NPCs: npcInfos,
|
|
Buildings: buildingInfos,
|
|
RestDurationMs: restMs,
|
|
})
|
|
}
|
|
}
|
|
|
|
hm.SyncToHero()
|
|
if persistAfterTownEnter != nil {
|
|
persistAfterTownEnter(hm.Hero)
|
|
}
|
|
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, graph)
|
|
if hit {
|
|
if monster {
|
|
if onEncounter != nil {
|
|
hm.LastEncounterAt = now
|
|
onEncounter(hm, &enemy, now)
|
|
return
|
|
}
|
|
} else {
|
|
cost := WanderingMerchantCost(hm.Hero.Level)
|
|
if sender != nil || onMerchantEncounter != nil {
|
|
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",
|
|
NPCNameKey: model.WanderingMerchantNPCKey,
|
|
Role: "alms",
|
|
DialogueKey: model.WanderingMerchantDialogueKey,
|
|
Cost: cost,
|
|
})
|
|
}
|
|
if onMerchantEncounter != nil {
|
|
onMerchantEncounter(hm, now, cost)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
hm.SyncToHero()
|
|
}
|
|
}
|