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.
1290 lines
39 KiB
Go
1290 lines
39 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
"time"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
)
|
|
|
|
const (
|
|
// BaseMoveSpeed is the hero's base movement speed in world-units per second.
|
|
BaseMoveSpeed = 2.0
|
|
|
|
// MovementTickRate is how often the movement system updates (2 Hz).
|
|
MovementTickRate = 500 * time.Millisecond
|
|
|
|
// PositionSyncRate is how often the server sends a full position_sync (drift correction).
|
|
PositionSyncRate = 10 * time.Second
|
|
|
|
// EncounterCooldownBase is the minimum gap between road encounters (monster or merchant).
|
|
EncounterCooldownBase = 12 * time.Second
|
|
|
|
// WanderingMerchantPromptTimeout is how long the hero stays stopped for the wandering merchant dialog (online).
|
|
WanderingMerchantPromptTimeout = 15 * time.Second
|
|
|
|
// EncounterActivityBase scales per-tick chance to roll an encounter after cooldown.
|
|
// Effective activity is higher deep off-road (see rollRoadEncounter).
|
|
EncounterActivityBase = 0.035
|
|
|
|
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion.
|
|
StartAdventurePerTick = 0.000030
|
|
|
|
// AdventureDurationMin/Max bound how long an off-road excursion lasts.
|
|
AdventureDurationMin = 15 * time.Minute
|
|
AdventureDurationMax = 20 * time.Minute
|
|
|
|
// AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness.
|
|
AdventureMaxLateral = 20.0
|
|
|
|
// AdventureWildernessRampFraction is the share of excursion time spent easing off the road at the start
|
|
// and easing back at the end. The middle (1 - 2*ramp) stays at full lateral offset so the hero
|
|
// visibly walks beside the road for most of a long excursion.
|
|
AdventureWildernessRampFraction = 0.12
|
|
|
|
// LowHPThreshold: below this HP fraction (of MaxHP) the hero seeks a short roadside rest.
|
|
LowHPThreshold = 0.35
|
|
// RoadsideRestExitHP: leave roadside rest when HP reaches this fraction of MaxHP (or max duration).
|
|
RoadsideRestExitHP = 0.92
|
|
// RoadsideRestDurationMin/Max cap how long a roadside rest can last (hero may leave earlier if healed).
|
|
RoadsideRestDurationMin = 40 * time.Second
|
|
RoadsideRestDurationMax = 100 * time.Second
|
|
// RoadsideRestLateral is perpendicular offset from the road while resting (smaller than adventure).
|
|
RoadsideRestLateral = 1.15
|
|
// RoadsideRestHPPerSecond is MaxHP fraction restored per second while roadside resting (0.1%).
|
|
RoadsideRestHPPerSecond = 0.001
|
|
|
|
// RoadsideRestThoughtMinInterval / MaxInterval between adventure log lines while resting.
|
|
RoadsideRestThoughtMinInterval = 4 * time.Second
|
|
RoadsideRestThoughtMaxInterval = 11 * time.Second
|
|
|
|
// TownRestMin is the minimum rest duration when arriving at a town.
|
|
TownRestMin = 5 * 60 * time.Second
|
|
|
|
// TownRestMax is the maximum rest duration when arriving at a town.
|
|
TownRestMax = 20 * 60 * time.Second
|
|
|
|
// TownArrivalRadius is how close the hero must be to the final waypoint
|
|
// to be considered "arrived" at the town.
|
|
TownArrivalRadius = 0.5
|
|
|
|
// Town NPC visits: high chance each attempt to approach the next NPC; queue clears on LeaveTown.
|
|
townNPCVisitChance = 0.78
|
|
townNPCRollMin = 800 * time.Millisecond
|
|
townNPCRollMax = 2600 * time.Millisecond
|
|
townNPCRetryAfterMiss = 450 * time.Millisecond
|
|
// TownNPCVisitTownPause is how long the hero stays in town after the last NPC (whole town) before leaving.
|
|
TownNPCVisitTownPause = 30 * time.Second
|
|
// TownNPCVisitLogInterval is how often a line is written to the adventure log during a visit.
|
|
TownNPCVisitLogInterval = 5 * time.Second
|
|
// townNPCVisitLogLines is how many log lines to emit per NPC (every TownNPCVisitLogInterval).
|
|
townNPCVisitLogLines = 6
|
|
)
|
|
|
|
// TownNPCVisitNarrationBlock is the minimum gap before visiting the next town NPC (first line through last line).
|
|
var TownNPCVisitNarrationBlock = TownNPCVisitLogInterval * (townNPCVisitLogLines - 1)
|
|
|
|
// AdventureLogWriter persists or pushes one adventure log line for a hero (optional).
|
|
type AdventureLogWriter func(heroID int64, message string)
|
|
|
|
// 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
|
|
TownVisitNPCType string
|
|
TownVisitStartedAt time.Time
|
|
TownVisitLogsEmitted int
|
|
|
|
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
|
|
TownLeaveAt time.Time
|
|
|
|
// Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends.
|
|
AdventureStartAt time.Time
|
|
AdventureEndAt time.Time
|
|
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
|
|
|
|
// Roadside rest (low HP): step off the road and recover HP; not persisted.
|
|
RoadsideRestEndAt time.Time
|
|
RoadsideRestSide int // +1 / -1 perpendicular; 0 = not resting
|
|
RoadsideRestNextLog time.Time
|
|
|
|
// WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only).
|
|
WanderingMerchantDeadline 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// If resting/in_town, set a short rest timer so they leave soon.
|
|
if hero.State == model.StateResting || hero.State == model.StateInTown {
|
|
hm.State = model.StateResting
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
return hm
|
|
}
|
|
|
|
// Walking state: assign a road if we don't have a destination.
|
|
if hm.DestinationTownID == 0 {
|
|
hm.pickDestination(graph)
|
|
}
|
|
hm.assignRoad(graph)
|
|
if hm.Road == nil {
|
|
hm.pickDestination(graph)
|
|
hm.assignRoad(graph)
|
|
}
|
|
hm.State = model.StateWalking
|
|
|
|
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
|
|
}
|
|
|
|
// pickDestination selects the next town the hero should walk toward.
|
|
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
|
|
func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
|
|
if hm.CurrentTownID == 0 {
|
|
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
|
|
}
|
|
|
|
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.
|
|
func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
|
|
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 {
|
|
// 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.TownLeaveAt = shift(hm.TownLeaveAt)
|
|
hm.AdventureStartAt = shift(hm.AdventureStartAt)
|
|
hm.AdventureEndAt = shift(hm.AdventureEndAt)
|
|
hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt)
|
|
hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog)
|
|
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
|
|
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 = 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.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
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
|
|
|
|
if distThisTick >= remaining {
|
|
// 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.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.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) adventureActive(now time.Time) bool {
|
|
return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt)
|
|
}
|
|
|
|
func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
|
|
if hm.AdventureEndAt.IsZero() {
|
|
return
|
|
}
|
|
if now.Before(hm.AdventureEndAt) {
|
|
return
|
|
}
|
|
hm.AdventureStartAt = time.Time{}
|
|
hm.AdventureEndAt = time.Time{}
|
|
hm.AdventureSide = 0
|
|
}
|
|
|
|
func (hm *HeroMovement) roadsideRestInProgress() bool {
|
|
return !hm.RoadsideRestEndAt.IsZero()
|
|
}
|
|
|
|
func (hm *HeroMovement) endRoadsideRest() {
|
|
hm.RoadsideRestEndAt = time.Time{}
|
|
hm.RoadsideRestSide = 0
|
|
hm.RoadsideRestNextLog = time.Time{}
|
|
}
|
|
|
|
func (hm *HeroMovement) applyRoadsideRestHeal(dt float64) {
|
|
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
|
|
return
|
|
}
|
|
gain := int(math.Ceil(float64(hm.Hero.MaxHP) * RoadsideRestHPPerSecond * dt))
|
|
if gain < 1 {
|
|
gain = 1
|
|
}
|
|
hm.Hero.HP += gain
|
|
if hm.Hero.HP > hm.Hero.MaxHP {
|
|
hm.Hero.HP = hm.Hero.MaxHP
|
|
}
|
|
}
|
|
|
|
// tryStartRoadsideRest pulls the hero off the road when HP is low; cancels an active adventure.
|
|
func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) {
|
|
if hm.roadsideRestInProgress() {
|
|
return
|
|
}
|
|
if hm.Hero == nil || hm.Hero.MaxHP <= 0 {
|
|
return
|
|
}
|
|
if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > LowHPThreshold {
|
|
return
|
|
}
|
|
hm.AdventureStartAt = time.Time{}
|
|
hm.AdventureEndAt = time.Time{}
|
|
hm.AdventureSide = 0
|
|
spanNs := (RoadsideRestDurationMax - RoadsideRestDurationMin).Nanoseconds()
|
|
if spanNs < 1 {
|
|
spanNs = 1
|
|
}
|
|
hm.RoadsideRestEndAt = now.Add(RoadsideRestDurationMin + time.Duration(rand.Int63n(spanNs+1)))
|
|
if rand.Float64() < 0.5 {
|
|
hm.RoadsideRestSide = 1
|
|
} else {
|
|
hm.RoadsideRestSide = -1
|
|
}
|
|
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
|
|
}
|
|
|
|
func randomRoadsideRestThoughtDelay() time.Duration {
|
|
span := RoadsideRestThoughtMaxInterval - RoadsideRestThoughtMinInterval
|
|
if span < 0 {
|
|
span = 0
|
|
}
|
|
return RoadsideRestThoughtMinInterval + time.Duration(rand.Int63n(int64(span)+1))
|
|
}
|
|
|
|
// emitRoadsideRestThoughts appends occasional journal lines while the hero rests off the road.
|
|
func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
|
|
if log == nil || !hm.roadsideRestInProgress() {
|
|
return
|
|
}
|
|
if hm.RoadsideRestNextLog.IsZero() {
|
|
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
|
|
return
|
|
}
|
|
if now.Before(hm.RoadsideRestNextLog) {
|
|
return
|
|
}
|
|
log(heroID, randomRoadsideRestThought())
|
|
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
|
|
}
|
|
|
|
// tryStartAdventure begins a timed off-road excursion with small probability.
|
|
func (hm *HeroMovement) tryStartAdventure(now time.Time) {
|
|
if hm.adventureActive(now) {
|
|
return
|
|
}
|
|
if rand.Float64() >= StartAdventurePerTick {
|
|
return
|
|
}
|
|
hm.AdventureStartAt = now
|
|
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
|
|
if spanNs < 1 {
|
|
spanNs = 1
|
|
}
|
|
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
|
|
if rand.Float64() < 0.5 {
|
|
hm.AdventureSide = 1
|
|
} else {
|
|
hm.AdventureSide = -1
|
|
}
|
|
}
|
|
|
|
// StartAdventureForced starts an off-road adventure immediately (admin).
|
|
func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
|
|
if hm.Hero == nil || hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
|
|
return false
|
|
}
|
|
if hm.State != model.StateWalking {
|
|
return false
|
|
}
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return false
|
|
}
|
|
if hm.adventureActive(now) {
|
|
return true
|
|
}
|
|
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
|
|
if spanNs < 1 {
|
|
spanNs = 1
|
|
}
|
|
hm.AdventureStartAt = now
|
|
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
|
|
if rand.Float64() < 0.5 {
|
|
hm.AdventureSide = 1
|
|
} else {
|
|
hm.AdventureSide = -1
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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.AdventureStartAt = time.Time{}
|
|
hm.AdventureEndAt = time.Time{}
|
|
hm.AdventureSide = 0
|
|
hm.endRoadsideRest()
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
t := graph.Towns[townID]
|
|
hm.CurrentX = t.WorldX
|
|
hm.CurrentY = t.WorldY
|
|
hm.EnterTown(now, graph)
|
|
return nil
|
|
}
|
|
|
|
// 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.endRoadsideRest()
|
|
hm.AdventureStartAt = time.Time{}
|
|
hm.AdventureEndAt = time.Time{}
|
|
hm.AdventureSide = 0
|
|
hm.WanderingMerchantDeadline = time.Time{}
|
|
hm.TownNPCQueue = nil
|
|
hm.NextTownNPCRollAt = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
if graph != nil && hm.CurrentTownID == 0 {
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
}
|
|
hm.State = model.StateResting
|
|
hm.Hero.State = model.StateResting
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
return true
|
|
}
|
|
|
|
// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back.
|
|
// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.)
|
|
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
|
|
if !hm.adventureActive(now) {
|
|
return 0
|
|
}
|
|
total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds()
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
elapsed := now.Sub(hm.AdventureStartAt).Seconds()
|
|
p := elapsed / total
|
|
if p < 0 {
|
|
p = 0
|
|
} else if p > 1 {
|
|
p = 1
|
|
}
|
|
r := AdventureWildernessRampFraction
|
|
if r < 1e-6 {
|
|
r = 1e-6
|
|
}
|
|
if r > 0.49 {
|
|
r = 0.49
|
|
}
|
|
if p < r {
|
|
return p / r
|
|
}
|
|
if p > 1-r {
|
|
return (1 - p) / r
|
|
}
|
|
return 1
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
|
|
if hm.roadsideRestInProgress() {
|
|
if hm.RoadsideRestSide == 0 {
|
|
return 0, 0
|
|
}
|
|
px, py := hm.roadPerpendicularUnit()
|
|
mag := float64(hm.RoadsideRestSide) * RoadsideRestLateral
|
|
return px * mag, py * mag
|
|
}
|
|
w := hm.wildernessFactor(now)
|
|
if w <= 0 || hm.AdventureSide == 0 {
|
|
return 0, 0
|
|
}
|
|
px, py := hm.roadPerpendicularUnit()
|
|
mag := float64(hm.AdventureSide) * AdventureMaxLateral * w
|
|
return px * mag, py * mag
|
|
}
|
|
|
|
// WanderingMerchantCost matches REST encounter / npc alms pricing.
|
|
func WanderingMerchantCost(level int) int64 {
|
|
return int64(20 + level*5)
|
|
}
|
|
|
|
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
|
|
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
w := hm.wildernessFactor(now)
|
|
// More encounter checks on the road; still ramps up further from the road.
|
|
activity := EncounterActivityBase * (0.62 + 0.38*w)
|
|
if rand.Float64() >= activity {
|
|
return false, model.Enemy{}, false
|
|
}
|
|
// On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters.
|
|
monsterW := 0.62 + 0.18*w*w
|
|
merchantW := 0.04 + 0.10*(1-w)*(1-w)
|
|
total := monsterW + merchantW
|
|
r := rand.Float64() * total
|
|
if r < monsterW {
|
|
e := PickEnemyForLevel(hm.Hero.Level)
|
|
return true, e, true
|
|
}
|
|
return false, model.Enemy{}, true
|
|
}
|
|
|
|
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
|
|
// are NPCs, otherwise a short resting state (StateResting).
|
|
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
|
|
destID := hm.DestinationTownID
|
|
hm.CurrentTownID = destID
|
|
hm.DestinationTownID = 0
|
|
hm.Road = nil
|
|
hm.TownNPCQueue = nil
|
|
hm.NextTownNPCRollAt = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
hm.TownLeaveAt = time.Time{}
|
|
hm.AdventureStartAt = time.Time{}
|
|
hm.AdventureEndAt = time.Time{}
|
|
hm.AdventureSide = 0
|
|
hm.endRoadsideRest()
|
|
|
|
ids := graph.TownNPCIDs(destID)
|
|
if len(ids) == 0 {
|
|
hm.State = model.StateResting
|
|
hm.Hero.State = model.StateResting
|
|
hm.RestUntil = now.Add(randomRestDuration())
|
|
return
|
|
}
|
|
|
|
q := make([]int64, len(ids))
|
|
copy(q, ids)
|
|
rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
|
|
hm.TownNPCQueue = q
|
|
hm.State = model.StateInTown
|
|
hm.Hero.State = model.StateInTown
|
|
hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
|
|
}
|
|
|
|
// LeaveTown transitions the hero from town to walking, picking a new destination.
|
|
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
|
|
hm.TownNPCQueue = nil
|
|
hm.NextTownNPCRollAt = time.Time{}
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
hm.TownLeaveAt = time.Time{}
|
|
hm.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)
|
|
hm.refreshSpeed(now)
|
|
}
|
|
|
|
func randomTownNPCDelay() time.Duration {
|
|
rangeMs := (townNPCRollMax - townNPCRollMin).Milliseconds()
|
|
return townNPCRollMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
|
|
}
|
|
|
|
// StartFighting pauses movement for combat.
|
|
func (hm *HeroMovement) StartFighting() {
|
|
hm.State = model.StateFighting
|
|
hm.endRoadsideRest()
|
|
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
|
|
hm.endRoadsideRest()
|
|
}
|
|
|
|
// SyncToHero writes movement state back to the hero model for persistence.
|
|
func (hm *HeroMovement) SyncToHero() {
|
|
hm.Hero.PositionX = hm.CurrentX
|
|
hm.Hero.PositionY = hm.CurrentY
|
|
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)
|
|
}
|
|
|
|
// 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 {
|
|
rangeMs := (TownRestMax - TownRestMin).Milliseconds()
|
|
return TownRestMin + 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)
|
|
|
|
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
|
|
if log == nil || hm.TownVisitStartedAt.IsZero() {
|
|
return
|
|
}
|
|
for hm.TownVisitLogsEmitted < townNPCVisitLogLines {
|
|
deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * TownNPCVisitLogInterval)
|
|
if now.Before(deadline) {
|
|
break
|
|
}
|
|
msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted)
|
|
if msg != "" {
|
|
log(heroID, msg)
|
|
}
|
|
hm.TownVisitLogsEmitted++
|
|
}
|
|
}
|
|
|
|
func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
|
|
if lineIndex < 0 || lineIndex >= townNPCVisitLogLines {
|
|
return ""
|
|
}
|
|
switch npcType {
|
|
case "merchant":
|
|
switch lineIndex {
|
|
case 0:
|
|
return fmt.Sprintf("You stop at %s's stall.", npcName)
|
|
case 1:
|
|
return "Crates, pouches, and price tags blur together as you browse."
|
|
case 2:
|
|
return fmt.Sprintf("%s points out a few curious trinkets.", npcName)
|
|
case 3:
|
|
return "You weigh a potion against your coin purse."
|
|
case 4:
|
|
return "A short haggle ends in a reluctant nod."
|
|
case 5:
|
|
return fmt.Sprintf("You thank %s and step back from the counter.", npcName)
|
|
}
|
|
case "healer":
|
|
switch lineIndex {
|
|
case 0:
|
|
return fmt.Sprintf("You seek out %s.", npcName)
|
|
case 1:
|
|
return "The healer examines your wounds with a calm eye."
|
|
case 2:
|
|
return "Herbs steam gently; bandages are laid out in neat rows."
|
|
case 3:
|
|
return "A slow warmth spreads as salves are applied."
|
|
case 4:
|
|
return "You rest a moment on a bench, breathing easier."
|
|
case 5:
|
|
return fmt.Sprintf("You nod to %s and return to the street.", npcName)
|
|
}
|
|
case "quest_giver":
|
|
switch lineIndex {
|
|
case 0:
|
|
return fmt.Sprintf("You speak with %s about the road ahead.", npcName)
|
|
case 1:
|
|
return "Rumors of trouble and slim rewards fill the air."
|
|
case 2:
|
|
return "A worn map is smoothed flat between you."
|
|
case 3:
|
|
return "You mark targets and deadlines in your mind."
|
|
case 4:
|
|
return fmt.Sprintf("%s hints at better pay for the bold.", npcName)
|
|
case 5:
|
|
return "You part with a clearer picture of what must be done."
|
|
}
|
|
default:
|
|
switch lineIndex {
|
|
case 0:
|
|
return fmt.Sprintf("You spend time with %s.", npcName)
|
|
case 1:
|
|
return "Conversation drifts from weather to the wider world."
|
|
case 2:
|
|
return "A few practical details stick in your memory."
|
|
case 3:
|
|
return "You listen more than you speak."
|
|
case 4:
|
|
return "Promises and coin change hands—or almost do."
|
|
case 5:
|
|
return fmt.Sprintf("You say farewell to %s.", npcName)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
|
|
// It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate
|
|
// 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),
|
|
// and roadside rest emits occasional thoughts.
|
|
func ProcessSingleHeroMovementTick(
|
|
heroID int64,
|
|
hm *HeroMovement,
|
|
graph *RoadGraph,
|
|
now time.Time,
|
|
sender MessageSender,
|
|
onEncounter EncounterStarter,
|
|
onMerchantEncounter MerchantEncounterHook,
|
|
adventureLog AdventureLogWriter,
|
|
) {
|
|
if graph == nil {
|
|
return
|
|
}
|
|
|
|
switch hm.State {
|
|
case model.StateFighting, model.StateDead:
|
|
return
|
|
|
|
case model.StateResting:
|
|
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
|
|
hm.LastMoveTick = 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:
|
|
// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
|
|
hm.LastMoveTick = now
|
|
// NPC visit pause ended: clear visit log state before the next roll.
|
|
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
|
|
hm.TownVisitNPCName = ""
|
|
hm.TownVisitNPCType = ""
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
hm.TownVisitLogsEmitted = 0
|
|
}
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
if len(hm.TownNPCQueue) == 0 {
|
|
if hm.TownLeaveAt.IsZero() {
|
|
hm.TownLeaveAt = now.Add(TownNPCVisitTownPause)
|
|
}
|
|
if now.Before(hm.TownLeaveAt) {
|
|
return
|
|
}
|
|
hm.TownLeaveAt = time.Time{}
|
|
hm.LeaveTown(graph, now)
|
|
hm.SyncToHero()
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
|
|
if route := hm.RoutePayload(); route != nil {
|
|
sender.SendToHero(heroID, "route_assigned", route)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if now.Before(hm.NextTownNPCRollAt) {
|
|
return
|
|
}
|
|
if rand.Float64() < townNPCVisitChance {
|
|
npcID := hm.TownNPCQueue[0]
|
|
hm.TownNPCQueue = hm.TownNPCQueue[1:]
|
|
if npc, ok := graph.NPCByID[npcID]; ok {
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
|
|
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
|
|
})
|
|
}
|
|
hm.TownVisitNPCName = npc.Name
|
|
hm.TownVisitNPCType = npc.Type
|
|
hm.TownVisitStartedAt = now
|
|
hm.TownVisitLogsEmitted = 0
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
}
|
|
hm.NextTownNPCRollAt = now.Add(TownNPCVisitNarrationBlock)
|
|
} else {
|
|
hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss)
|
|
}
|
|
|
|
case model.StateWalking:
|
|
hm.expireAdventureIfNeeded(now)
|
|
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
|
|
hm.Road = nil
|
|
hm.pickDestination(graph)
|
|
hm.assignRoad(graph)
|
|
}
|
|
|
|
// 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.Hero.PositionX = hm.CurrentX
|
|
hm.Hero.PositionY = hm.CurrentY
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if hm.roadsideRestInProgress() {
|
|
dt := now.Sub(hm.LastMoveTick).Seconds()
|
|
if dt <= 0 {
|
|
dt = MovementTickRate.Seconds()
|
|
}
|
|
hm.LastMoveTick = now
|
|
hm.applyRoadsideRestHeal(dt)
|
|
emitRoadsideRestThoughts(heroID, hm, now, adventureLog)
|
|
timeUp := !now.Before(hm.RoadsideRestEndAt)
|
|
hpOk := hm.Hero != nil && hm.Hero.MaxHP > 0 &&
|
|
float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) >= RoadsideRestExitHP
|
|
if timeUp || hpOk {
|
|
hm.endRoadsideRest()
|
|
} else {
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
}
|
|
hm.Hero.PositionX = hm.CurrentX
|
|
hm.Hero.PositionY = hm.CurrentY
|
|
return
|
|
}
|
|
}
|
|
|
|
hm.tryStartRoadsideRest(now)
|
|
if hm.roadsideRestInProgress() {
|
|
hm.LastMoveTick = now
|
|
emitRoadsideRestThoughts(heroID, hm, now, adventureLog)
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
}
|
|
hm.Hero.PositionX = hm.CurrentX
|
|
hm.Hero.PositionY = hm.CurrentY
|
|
return
|
|
}
|
|
|
|
hm.tryStartAdventure(now)
|
|
reachedTown := hm.AdvanceTick(now, graph)
|
|
|
|
if reachedTown {
|
|
hm.EnterTown(now, graph)
|
|
|
|
if sender != nil {
|
|
town := graph.Towns[hm.CurrentTownID]
|
|
if town != nil {
|
|
npcInfos := make([]model.TownNPCInfo, 0, len(graph.TownNPCs[hm.CurrentTownID]))
|
|
for _, n := range graph.TownNPCs[hm.CurrentTownID] {
|
|
npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type})
|
|
}
|
|
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,
|
|
Biome: town.Biome,
|
|
NPCs: npcInfos,
|
|
RestDurationMs: restMs,
|
|
})
|
|
}
|
|
}
|
|
|
|
hm.SyncToHero()
|
|
return
|
|
}
|
|
|
|
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
|
|
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
|
|
monster, enemy, hit := hm.rollRoadEncounter(now)
|
|
if hit {
|
|
if monster {
|
|
if onEncounter != nil {
|
|
hm.LastEncounterAt = now
|
|
onEncounter(hm, &enemy, now)
|
|
return
|
|
}
|
|
// No monster handler — skip consuming the roll (extremely rare).
|
|
} else {
|
|
cost := WanderingMerchantCost(hm.Hero.Level)
|
|
if sender != nil || onMerchantEncounter != nil {
|
|
hm.LastEncounterAt = now
|
|
if sender != nil {
|
|
hm.WanderingMerchantDeadline = now.Add(WanderingMerchantPromptTimeout)
|
|
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
|
|
NPCID: 0,
|
|
NPCName: "Wandering Merchant",
|
|
Role: "alms",
|
|
Cost: cost,
|
|
})
|
|
}
|
|
if onMerchantEncounter != nil {
|
|
onMerchantEncounter(hm, now, cost)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if sender != nil {
|
|
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
|
|
}
|
|
|
|
hm.Hero.PositionX = hm.CurrentX
|
|
hm.Hero.PositionY = hm.CurrentY
|
|
}
|
|
}
|