diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index ddebe74..bf7c6d4 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -70,6 +70,15 @@ type HeroMovement struct { TownVisitStartedAt time.Time 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 time.Time @@ -615,6 +624,7 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim 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 @@ -715,6 +725,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 + hm.clearNPCWalk() if graph != nil && hm.CurrentTownID == 0 { 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.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 + hm.clearNPCWalk() ids := graph.TownNPCIDs(destID) if len(ids) == 0 { @@ -879,6 +891,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} + hm.clearNPCWalk() hm.State = model.StateWalking hm.Hero.State = model.StateWalking // 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 } +// 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. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting @@ -1019,6 +1043,19 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { TownVisitNPCName: hm.TownVisitNPCName, TownVisitNPCType: hm.TownVisitNPCType, 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 { 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.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). @@ -1638,8 +1686,75 @@ func ProcessSingleHeroMovementTick( case model.StateInTown: cfg := tuning.Get() - // Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time. 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. if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { hm.TownVisitNPCName = "" @@ -1649,7 +1764,7 @@ func ProcessSingleHeroMovementTick( } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) - if len(hm.TownNPCQueue) == 0 { + if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 { if hm.TownLeaveAt.IsZero() { hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond) } @@ -1676,26 +1791,39 @@ func ProcessSingleHeroMovementTick( npcID := hm.TownNPCQueue[0] hm.TownNPCQueue = hm.TownNPCQueue[1:] if npc, ok := graph.NPCByID[npcID]; ok { + npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID) + if !posOk { + if town := graph.Towns[hm.CurrentTownID]; town != nil { + npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY + } + } + 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 { - sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ - NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, + heading := math.Atan2(dy, dx) + 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)) } else { diff --git a/backend/internal/game/road_graph.go b/backend/internal/game/road_graph.go index e30a8d0..35b0823 100644 --- a/backend/internal/game/road_graph.go +++ b/backend/internal/game/road_graph.go @@ -32,6 +32,8 @@ type TownNPC struct { Name string Type string BuildingID *int64 + OffsetX float64 + OffsetY float64 } // TownBuilding is a building placed in a town (from town_buildings table). @@ -89,7 +91,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) return nil, fmt.Errorf("iterate towns: %w", err) } - npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id FROM npcs ORDER BY town_id, id`) + npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`) if err != nil { return nil, fmt.Errorf("load npcs: %w", err) } @@ -97,7 +99,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) for npcRows.Next() { var n TownNPC var townID int64 - if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID); err != nil { + if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } g.NPCByID[n.ID] = n @@ -180,21 +182,40 @@ func (g *RoadGraph) TownBuildingInfos(townID int64) []model.TownBuildingInfo { return infos } -// TownNPCInfos returns NPC payloads for the given town, including building IDs. +// TownNPCInfos returns NPC payloads for the given town, including building IDs and world positions. func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo { + town := g.Towns[townID] npcs := g.TownNPCs[townID] infos := make([]model.TownNPCInfo, 0, len(npcs)) for _, n := range npcs { - infos = append(infos, model.TownNPCInfo{ + info := model.TownNPCInfo{ ID: n.ID, Name: n.Name, Type: n.Type, BuildingID: n.BuildingID, - }) + } + if town != nil { + info.WorldX = town.WorldX + n.OffsetX + info.WorldY = town.WorldY + n.OffsetY + } + infos = append(infos, info) } return infos } +// NPCWorldPos returns the absolute world position of an NPC using its town center + offset. +func (g *RoadGraph) NPCWorldPos(npcID, townID int64) (worldX, worldY float64, ok bool) { + npc, found := g.NPCByID[npcID] + if !found { + return 0, 0, false + } + town := g.Towns[townID] + if town == nil { + return 0, 0, false + } + return town.WorldX + npc.OffsetX, town.WorldY + npc.OffsetY, true +} + // TownNPCIDs returns NPC ids for a town in stable DB order (for visit queues). func (g *RoadGraph) TownNPCIDs(townID int64) []int64 { list := g.TownNPCs[townID] diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go index c592f68..1c863ce 100644 --- a/backend/internal/model/town_pause.go +++ b/backend/internal/model/town_pause.go @@ -19,6 +19,15 @@ type TownPausePersisted struct { TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"` TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"` + // Walk-to-NPC: hero is mid-walk toward an NPC inside the town. + NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"` + NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"` + NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"` + NPCWalkToX float64 `json:"npcWalkToX,omitempty"` + NPCWalkToY float64 `json:"npcWalkToY,omitempty"` + NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"` + NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"` + // Excursion (mini-adventure) session persisted for reconnect / offline resume. Excursion *ExcursionPersisted `json:"excursion,omitempty"` } diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index ac3f5ef..d9ad12c 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -116,10 +116,12 @@ type HeroRevivedPayload struct { // TownNPCInfo describes an NPC in a town (town_enter payload). type TownNPCInfo struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - BuildingID *int64 `json:"buildingId,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + BuildingID *int64 `json:"buildingId,omitempty"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` } // TownBuildingInfo describes a building in a town (town_enter payload). @@ -143,12 +145,14 @@ type TownEnterPayload struct { RestDurationMs int64 `json:"restDurationMs"` } -// TownNPCVisitPayload is sent when the hero approaches an NPC (quest/shop/healer) during a town stay. +// TownNPCVisitPayload is sent when the hero arrives at an NPC (quest/shop/healer) during a town stay. type TownNPCVisitPayload struct { - NPCID int64 `json:"npcId"` - Name string `json:"name"` - Type string `json:"type"` - TownID int64 `json:"townId"` + NPCID int64 `json:"npcId"` + Name string `json:"name"` + Type string `json:"type"` + TownID int64 `json:"townId"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` } // AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log. diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index b96b212..4a3f28c 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -26,8 +26,9 @@ type Values struct { TownNPCRollMinMs int64 `json:"townNpcRollMinMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"` - TownNPCPauseMs int64 `json:"townNpcPauseMs"` - TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` + TownNPCPauseMs int64 `json:"townNpcPauseMs"` + TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` + TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"` MerchantCostBase int64 `json:"merchantCostBase"` @@ -214,6 +215,7 @@ func DefaultValues() Values { TownNPCRetryMs: 450, TownNPCPauseMs: 30_000, TownNPCLogIntervalMs: 5_000, + TownNPCWalkSpeed: 3.0, WanderingMerchantPromptTimeoutMs: 15_000, MerchantCostBase: 20, MerchantCostPerLevel: 5, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9372bc7..09cc24e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -641,18 +641,8 @@ export function App() { setCurrentTown(town); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); addLogEntry(`Entered ${p.townName}`); - const npcs = p.npcs ?? []; - if (npcs.length > 0) { - const firstNPC = npcs[0]!; - setNearestNPC({ - id: firstNPC.id, - name: firstNPC.name, - type: firstNPC.type as NPCData['type'], - worldX: 0, - worldY: 0, - }); - setNpcInteractionDismissed(null); - } + setNearestNPC(null); + setNpcInteractionDismissed(null); }, onAdventureLogLine: (p) => { @@ -667,8 +657,8 @@ export function App() { id: p.npcId, name: p.name, type: p.type as NPCData['type'], - worldX: 0, - worldY: 0, + worldX: p.worldX ?? 0, + worldY: p.worldY ?? 0, }); setNpcInteractionDismissed(null); }, diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 3e39d87..2ce36eb 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -494,7 +494,7 @@ export interface TownEnterPayload { townId: number; townName: string; biome?: string; - npcs?: Array<{ id: number; name: string; type: string; buildingId?: number }>; + npcs?: Array<{ id: number; name: string; type: string; buildingId?: number; worldX: number; worldY: number }>; buildings?: Array<{ id: number; buildingType: string; @@ -512,6 +512,8 @@ export interface TownNPCVisitPayload { name: string; type: string; townId: number; + worldX: number; + worldY: number; } /** Server-persisted adventure log line (e.g. town NPC visit narration). */