diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index bf7c6d4..113cf68 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -912,6 +912,30 @@ func randomTownNPCDelay() time.Duration { 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 @@ -1610,6 +1634,18 @@ func ProcessSingleHeroMovementTick( switch hm.ActiveRestKind { case model.RestKindRoadside: + // For roadside rest, ensure Wild→Return always gets a fresh return + // deadline so the hero walks back to the road smoothly (prevents + // advanceExcursionPhases from skipping the return phase on time jumps). + if hm.Excursion.Phase == model.ExcursionWild && !now.Before(hm.Excursion.WildUntil) { + hm.Excursion.Phase = model.ExcursionReturn + speed := hm.Speed + if speed < 0.1 { + speed = 0.1 + } + hm.Excursion.WildUntil = now + hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second))) + } excursionEnded := hm.advanceExcursionPhases(now) if hm.Excursion.Phase == model.ExcursionWild { hm.applyRestHealTick(dt) @@ -1691,19 +1727,26 @@ func ProcessSingleHeroMovementTick( // --- 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. + // Arrived at stand point (near 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 + standX := hm.TownNPCWalkToX + standY := 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, + WorldX: standX, WorldY: standY, + }) + // Snap client interpolation to the NPC tile (visit message alone left the + // hero short of the last hero_move segment). + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: hm.CurrentX, TargetY: hm.CurrentY, + Speed: 0, Heading: 0, }) } hm.TownVisitNPCName = npc.Name @@ -1797,8 +1840,13 @@ func ProcessSingleHeroMovementTick( npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY } } - dx := npcWX - hm.CurrentX - dy := npcWY - hm.CurrentY + standoff := cfg.TownNPCStandoffWorld + if standoff <= 0 { + standoff = tuning.DefaultValues().TownNPCStandoffWorld + } + toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) + dx := toX - hm.CurrentX + dy := toY - hm.CurrentY dist := math.Sqrt(dx*dx + dy*dy) walkSpeed := cfg.TownNPCWalkSpeed if walkSpeed <= 0 { @@ -1812,15 +1860,15 @@ func ProcessSingleHeroMovementTick( hm.TownNPCWalkTargetID = npcID hm.TownNPCWalkFromX = hm.CurrentX hm.TownNPCWalkFromY = hm.CurrentY - hm.TownNPCWalkToX = npcWX - hm.TownNPCWalkToY = npcWY + hm.TownNPCWalkToX = toX + hm.TownNPCWalkToY = toY hm.TownNPCWalkStart = now hm.TownNPCWalkArrive = now.Add(walkDur) if sender != nil { heading := math.Atan2(dy, dx) sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, - TargetX: npcWX, TargetY: npcWY, + TargetX: toX, TargetY: toY, Speed: walkSpeed, Heading: heading, }) } diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index d9ad12c..5d47461 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -145,7 +145,8 @@ type TownEnterPayload struct { RestDurationMs int64 `json:"restDurationMs"` } -// TownNPCVisitPayload is sent when the hero arrives at an NPC (quest/shop/healer) during a town stay. +// TownNPCVisitPayload is sent when the hero finishes walking to an NPC visit (quest/shop/healer). +// WorldX/WorldY are the hero's stand position (near the NPC), not the NPC tile center. type TownNPCVisitPayload struct { NPCID int64 `json:"npcId"` Name string `json:"name"` diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 4a3f28c..079e94a 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -29,6 +29,8 @@ type Values struct { TownNPCPauseMs int64 `json:"townNpcPauseMs"` TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` + // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). + TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"` MerchantCostBase int64 `json:"merchantCostBase"` @@ -216,6 +218,7 @@ func DefaultValues() Values { TownNPCPauseMs: 30_000, TownNPCLogIntervalMs: 5_000, TownNPCWalkSpeed: 3.0, + TownNPCStandoffWorld: 0.65, WanderingMerchantPromptTimeoutMs: 15_000, MerchantCostBase: 20, MerchantCostPerLevel: 5, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cba665a..8fabb13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -335,6 +335,8 @@ export function App() { // NPC interaction state (server-driven via town_enter) const [nearestNPC, setNearestNPC] = useState(null); const [npcInteractionDismissed, setNpcInteractionDismissed] = useState(null); + /** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */ + const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState(null); // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); @@ -642,6 +644,8 @@ export function App() { setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); addLogEntry(`Entered ${p.townName}`); setNearestNPC(null); + setNpcVisitAwaitingProximity(null); + setSelectedNPC(null); setNpcInteractionDismissed(null); }, @@ -650,22 +654,21 @@ export function App() { }, onTownNPCVisit: (p) => { - const role = - p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel; - setToast({ message: `${role}: ${p.name}`, color: '#c9a227' }); - setNearestNPC({ + setNearestNPC(null); + setNpcInteractionDismissed(null); + setNpcVisitAwaitingProximity({ id: p.npcId, name: p.name, type: p.type as NPCData['type'], worldX: p.worldX ?? 0, worldY: p.worldY ?? 0, }); - setNpcInteractionDismissed(null); }, onTownExit: () => { setCurrentTown(null); setNearestNPC(null); + setNpcVisitAwaitingProximity(null); }, onNPCEncounter: (p) => { @@ -761,6 +764,43 @@ export function App() { }; }, []); + // Open trader / quest / healer panel only after the hero sprite has reached the NPC (not on town_enter). + useEffect(() => { + if (!npcVisitAwaitingProximity) return; + const pending = npcVisitAwaitingProximity; + const proximityR = 0.55; + const proximityR2 = proximityR * proximityR; + const timeoutMs = 5000; + const started = performance.now(); + let raf = 0; + + const step = () => { + const eng = engineRef.current; + let closeEnough = false; + if (eng) { + const { x, y } = eng.getHeroDisplayWorldPosition(); + const dx = x - pending.worldX; + const dy = y - pending.worldY; + closeEnough = dx * dx + dy * dy <= proximityR2; + } + if (closeEnough || performance.now() - started > timeoutMs) { + const role = + pending.type === 'merchant' + ? tr.shopLabel + : pending.type === 'healer' + ? tr.healerLabel + : tr.questLabel; + setToast({ message: `${role}: ${pending.name}`, color: '#c9a227' }); + setNearestNPC(pending); + setNpcVisitAwaitingProximity(null); + return; + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [npcVisitAwaitingProximity, tr]); + // Restore per-hero buff button cooldowns useEffect(() => { const id = gameState.hero?.id; diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index c93b95e..d3ba895 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -163,6 +163,11 @@ export class GameEngine { this._allNPCs = npcs; } + /** Interpolated hero position in world space (for UI proximity checks). */ + getHeroDisplayWorldPosition(): { x: number; y: number } { + return { x: this._heroDisplayX, y: this._heroDisplayY }; + } + /** Update the list of nearby heroes for shared-world rendering. */ setNearbyHeroes(heroes: NearbyHeroData[]): void { this._nearbyHeroes = heroes; @@ -778,11 +783,11 @@ export class GameEngine { ); const rk = state.hero.restKind?.toLowerCase() ?? ''; - const wild = - state.hero.excursionPhase?.toLowerCase() === 'wild'; + const excPhase = state.hero.excursionPhase?.toLowerCase() ?? ''; + const offRoad = excPhase === 'wild' || excPhase === 'return'; const showRestCamp = state.phase === GamePhase.Resting && - wild && + offRoad && (rk === 'roadside' || rk === 'adventure_inline'); if (showRestCamp) { this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now); diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 2ce36eb..f89ce33 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -507,6 +507,7 @@ export interface TownEnterPayload { restDurationMs?: number; } +/** worldX/Y = hero stand point near the NPC (server), not the NPC sprite center. */ export interface TownNPCVisitPayload { npcId: number; name: string;