|
|
|
@ -70,6 +70,15 @@ type HeroMovement struct {
|
|
|
|
TownVisitStartedAt time.Time
|
|
|
|
TownVisitStartedAt time.Time
|
|
|
|
TownVisitLogsEmitted int
|
|
|
|
TownVisitLogsEmitted int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires.
|
|
|
|
|
|
|
|
TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking)
|
|
|
|
|
|
|
|
TownNPCWalkFromX float64
|
|
|
|
|
|
|
|
TownNPCWalkFromY float64
|
|
|
|
|
|
|
|
TownNPCWalkToX float64
|
|
|
|
|
|
|
|
TownNPCWalkToY float64
|
|
|
|
|
|
|
|
TownNPCWalkStart time.Time // when walk began
|
|
|
|
|
|
|
|
TownNPCWalkArrive time.Time // when hero reaches NPC
|
|
|
|
|
|
|
|
|
|
|
|
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
|
|
|
|
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
|
|
|
|
TownLeaveAt time.Time
|
|
|
|
TownLeaveAt time.Time
|
|
|
|
|
|
|
|
|
|
|
|
@ -615,6 +624,7 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
t := graph.Towns[townID]
|
|
|
|
t := graph.Towns[townID]
|
|
|
|
hm.CurrentX = t.WorldX
|
|
|
|
hm.CurrentX = t.WorldX
|
|
|
|
hm.CurrentY = t.WorldY
|
|
|
|
hm.CurrentY = t.WorldY
|
|
|
|
@ -715,6 +725,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
|
|
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
|
|
hm.TownVisitStartedAt = time.Time{}
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
hm.TownRestHealRemainder = 0
|
|
|
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
if graph != nil && hm.CurrentTownID == 0 {
|
|
|
|
if graph != nil && hm.CurrentTownID == 0 {
|
|
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
|
|
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -846,6 +857,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
|
|
|
|
|
|
|
ids := graph.TownNPCIDs(destID)
|
|
|
|
ids := graph.TownNPCIDs(destID)
|
|
|
|
if len(ids) == 0 {
|
|
|
|
if len(ids) == 0 {
|
|
|
|
@ -879,6 +891,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.ActiveRestKind = model.RestKindNone
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
hm.RestHealRemainder = 0
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
hm.Excursion = model.ExcursionSession{}
|
|
|
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
hm.State = model.StateWalking
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
hm.Hero.State = model.StateWalking
|
|
|
|
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
|
|
|
|
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
|
|
|
|
@ -899,6 +912,17 @@ func randomTownNPCDelay() time.Duration {
|
|
|
|
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
|
|
|
|
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// clearNPCWalk resets the walk-to-NPC sub-state.
|
|
|
|
|
|
|
|
func (hm *HeroMovement) clearNPCWalk() {
|
|
|
|
|
|
|
|
hm.TownNPCWalkTargetID = 0
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromX = 0
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromY = 0
|
|
|
|
|
|
|
|
hm.TownNPCWalkToX = 0
|
|
|
|
|
|
|
|
hm.TownNPCWalkToY = 0
|
|
|
|
|
|
|
|
hm.TownNPCWalkStart = time.Time{}
|
|
|
|
|
|
|
|
hm.TownNPCWalkArrive = time.Time{}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// StartFighting pauses movement for combat.
|
|
|
|
// StartFighting pauses movement for combat.
|
|
|
|
func (hm *HeroMovement) StartFighting() {
|
|
|
|
func (hm *HeroMovement) StartFighting() {
|
|
|
|
hm.State = model.StateFighting
|
|
|
|
hm.State = model.StateFighting
|
|
|
|
@ -1019,6 +1043,19 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
|
|
|
|
TownVisitNPCName: hm.TownVisitNPCName,
|
|
|
|
TownVisitNPCName: hm.TownVisitNPCName,
|
|
|
|
TownVisitNPCType: hm.TownVisitNPCType,
|
|
|
|
TownVisitNPCType: hm.TownVisitNPCType,
|
|
|
|
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
|
|
|
|
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
|
|
|
|
|
|
|
|
NPCWalkTargetID: hm.TownNPCWalkTargetID,
|
|
|
|
|
|
|
|
NPCWalkFromX: hm.TownNPCWalkFromX,
|
|
|
|
|
|
|
|
NPCWalkFromY: hm.TownNPCWalkFromY,
|
|
|
|
|
|
|
|
NPCWalkToX: hm.TownNPCWalkToX,
|
|
|
|
|
|
|
|
NPCWalkToY: hm.TownNPCWalkToY,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if !hm.TownNPCWalkStart.IsZero() {
|
|
|
|
|
|
|
|
t := hm.TownNPCWalkStart
|
|
|
|
|
|
|
|
p.NPCWalkStart = &t
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if !hm.TownNPCWalkArrive.IsZero() {
|
|
|
|
|
|
|
|
t := hm.TownNPCWalkArrive
|
|
|
|
|
|
|
|
p.NPCWalkArrive = &t
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(hm.TownNPCQueue) > 0 {
|
|
|
|
if len(hm.TownNPCQueue) > 0 {
|
|
|
|
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
|
|
|
|
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
|
|
|
|
@ -1111,6 +1148,17 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
|
|
|
|
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
|
|
|
|
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
|
|
|
|
}
|
|
|
|
}
|
|
|
|
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
|
|
|
|
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
|
|
|
|
|
|
|
|
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromX = blob.NPCWalkFromX
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromY = blob.NPCWalkFromY
|
|
|
|
|
|
|
|
hm.TownNPCWalkToX = blob.NPCWalkToX
|
|
|
|
|
|
|
|
hm.TownNPCWalkToY = blob.NPCWalkToY
|
|
|
|
|
|
|
|
if blob.NPCWalkStart != nil {
|
|
|
|
|
|
|
|
hm.TownNPCWalkStart = *blob.NPCWalkStart
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if blob.NPCWalkArrive != nil {
|
|
|
|
|
|
|
|
hm.TownNPCWalkArrive = *blob.NPCWalkArrive
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Restore excursion session from blob (may exist alongside any hero state).
|
|
|
|
// Restore excursion session from blob (may exist alongside any hero state).
|
|
|
|
@ -1638,8 +1686,75 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
|
|
|
|
|
|
|
|
case model.StateInTown:
|
|
|
|
case model.StateInTown:
|
|
|
|
cfg := tuning.Get()
|
|
|
|
cfg := tuning.Get()
|
|
|
|
// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
|
|
|
|
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
hm.LastMoveTick = now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// --- Sub-state: hero is walking toward an NPC inside the town ---
|
|
|
|
|
|
|
|
if hm.TownNPCWalkTargetID != 0 {
|
|
|
|
|
|
|
|
if !now.Before(hm.TownNPCWalkArrive) {
|
|
|
|
|
|
|
|
// Arrived at NPC — snap position and fire the visit event.
|
|
|
|
|
|
|
|
hm.CurrentX = hm.TownNPCWalkToX
|
|
|
|
|
|
|
|
hm.CurrentY = hm.TownNPCWalkToY
|
|
|
|
|
|
|
|
npcID := hm.TownNPCWalkTargetID
|
|
|
|
|
|
|
|
npcWX := hm.TownNPCWalkToX
|
|
|
|
|
|
|
|
npcWY := hm.TownNPCWalkToY
|
|
|
|
|
|
|
|
hm.clearNPCWalk()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
|
|
WorldX: npcWX, WorldY: npcWY,
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
hm.TownVisitNPCName = npc.Name
|
|
|
|
|
|
|
|
hm.TownVisitNPCType = npc.Type
|
|
|
|
|
|
|
|
hm.TownVisitStartedAt = now
|
|
|
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
|
|
|
if npc.Type == "merchant" {
|
|
|
|
|
|
|
|
share := cfg.MerchantTownAutoSellShare
|
|
|
|
|
|
|
|
if share <= 0 || share > 1 {
|
|
|
|
|
|
|
|
share = tuning.DefaultValues().MerchantTownAutoSellShare
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
|
|
|
|
|
|
|
|
if soldItems > 0 && adventureLog != nil {
|
|
|
|
|
|
|
|
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// Still walking — interpolate position.
|
|
|
|
|
|
|
|
totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds()
|
|
|
|
|
|
|
|
if totalMs <= 0 {
|
|
|
|
|
|
|
|
totalMs = 1
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
elapsed := now.Sub(hm.TownNPCWalkStart).Milliseconds()
|
|
|
|
|
|
|
|
t := float64(elapsed) / float64(totalMs)
|
|
|
|
|
|
|
|
if t > 1 {
|
|
|
|
|
|
|
|
t = 1
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
hm.CurrentX = hm.TownNPCWalkFromX + (hm.TownNPCWalkToX-hm.TownNPCWalkFromX)*t
|
|
|
|
|
|
|
|
hm.CurrentY = hm.TownNPCWalkFromY + (hm.TownNPCWalkToY-hm.TownNPCWalkFromY)*t
|
|
|
|
|
|
|
|
if sender != nil {
|
|
|
|
|
|
|
|
walkSpeed := cfg.TownNPCWalkSpeed
|
|
|
|
|
|
|
|
if walkSpeed <= 0 {
|
|
|
|
|
|
|
|
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
dx := hm.TownNPCWalkToX - hm.CurrentX
|
|
|
|
|
|
|
|
dy := hm.TownNPCWalkToY - hm.CurrentY
|
|
|
|
|
|
|
|
heading := math.Atan2(dy, dx)
|
|
|
|
|
|
|
|
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
|
|
|
|
|
|
|
|
X: hm.CurrentX, Y: hm.CurrentY,
|
|
|
|
|
|
|
|
TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
|
|
|
|
|
|
|
|
Speed: walkSpeed, Heading: heading,
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
hm.SyncToHero()
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NPC visit pause ended: clear visit log state before the next roll.
|
|
|
|
// NPC visit pause ended: clear visit log state before the next roll.
|
|
|
|
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
|
|
|
|
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
|
|
|
|
hm.TownVisitNPCName = ""
|
|
|
|
hm.TownVisitNPCName = ""
|
|
|
|
@ -1649,7 +1764,7 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
}
|
|
|
|
}
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
|
|
|
|
|
|
|
if len(hm.TownNPCQueue) == 0 {
|
|
|
|
if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 {
|
|
|
|
if hm.TownLeaveAt.IsZero() {
|
|
|
|
if hm.TownLeaveAt.IsZero() {
|
|
|
|
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
|
|
|
|
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@ -1676,26 +1791,39 @@ func ProcessSingleHeroMovementTick(
|
|
|
|
npcID := hm.TownNPCQueue[0]
|
|
|
|
npcID := hm.TownNPCQueue[0]
|
|
|
|
hm.TownNPCQueue = hm.TownNPCQueue[1:]
|
|
|
|
hm.TownNPCQueue = hm.TownNPCQueue[1:]
|
|
|
|
if npc, ok := graph.NPCByID[npcID]; ok {
|
|
|
|
if npc, ok := graph.NPCByID[npcID]; ok {
|
|
|
|
|
|
|
|
npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID)
|
|
|
|
|
|
|
|
if !posOk {
|
|
|
|
|
|
|
|
if town := graph.Towns[hm.CurrentTownID]; town != nil {
|
|
|
|
|
|
|
|
npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
dx := npcWX - hm.CurrentX
|
|
|
|
|
|
|
|
dy := npcWY - hm.CurrentY
|
|
|
|
|
|
|
|
dist := math.Sqrt(dx*dx + dy*dy)
|
|
|
|
|
|
|
|
walkSpeed := cfg.TownNPCWalkSpeed
|
|
|
|
|
|
|
|
if walkSpeed <= 0 {
|
|
|
|
|
|
|
|
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const minWalkMs = 300
|
|
|
|
|
|
|
|
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
|
|
|
|
|
|
|
|
if walkDur < minWalkMs*time.Millisecond {
|
|
|
|
|
|
|
|
walkDur = minWalkMs * time.Millisecond
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
hm.TownNPCWalkTargetID = npcID
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromX = hm.CurrentX
|
|
|
|
|
|
|
|
hm.TownNPCWalkFromY = hm.CurrentY
|
|
|
|
|
|
|
|
hm.TownNPCWalkToX = npcWX
|
|
|
|
|
|
|
|
hm.TownNPCWalkToY = npcWY
|
|
|
|
|
|
|
|
hm.TownNPCWalkStart = now
|
|
|
|
|
|
|
|
hm.TownNPCWalkArrive = now.Add(walkDur)
|
|
|
|
if sender != nil {
|
|
|
|
if sender != nil {
|
|
|
|
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
|
|
|
|
heading := math.Atan2(dy, dx)
|
|
|
|
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
|
|
|
|
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
|
|
|
|
|
|
|
|
X: hm.CurrentX, Y: hm.CurrentY,
|
|
|
|
|
|
|
|
TargetX: npcWX, TargetY: npcWY,
|
|
|
|
|
|
|
|
Speed: walkSpeed, Heading: heading,
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
hm.TownVisitNPCName = npc.Name
|
|
|
|
|
|
|
|
hm.TownVisitNPCType = npc.Type
|
|
|
|
|
|
|
|
hm.TownVisitStartedAt = now
|
|
|
|
|
|
|
|
hm.TownVisitLogsEmitted = 0
|
|
|
|
|
|
|
|
if npc.Type == "merchant" {
|
|
|
|
|
|
|
|
share := cfg.MerchantTownAutoSellShare
|
|
|
|
|
|
|
|
if share <= 0 || share > 1 {
|
|
|
|
|
|
|
|
share = tuning.DefaultValues().MerchantTownAutoSellShare
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
|
|
|
|
|
|
|
|
if soldItems > 0 && adventureLog != nil {
|
|
|
|
|
|
|
|
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
|