fix town npc moves

master
Denis Ranneft 1 month ago
parent 0a72101c8a
commit b11b9bc437

@ -912,6 +912,30 @@ func randomTownNPCDelay() time.Duration {
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond 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. // clearNPCWalk resets the walk-to-NPC sub-state.
func (hm *HeroMovement) clearNPCWalk() { func (hm *HeroMovement) clearNPCWalk() {
hm.TownNPCWalkTargetID = 0 hm.TownNPCWalkTargetID = 0
@ -1610,6 +1634,18 @@ func ProcessSingleHeroMovementTick(
switch hm.ActiveRestKind { switch hm.ActiveRestKind {
case model.RestKindRoadside: 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) excursionEnded := hm.advanceExcursionPhases(now)
if hm.Excursion.Phase == model.ExcursionWild { if hm.Excursion.Phase == model.ExcursionWild {
hm.applyRestHealTick(dt) hm.applyRestHealTick(dt)
@ -1691,19 +1727,26 @@ func ProcessSingleHeroMovementTick(
// --- Sub-state: hero is walking toward an NPC inside the town --- // --- Sub-state: hero is walking toward an NPC inside the town ---
if hm.TownNPCWalkTargetID != 0 { if hm.TownNPCWalkTargetID != 0 {
if !now.Before(hm.TownNPCWalkArrive) { 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.CurrentX = hm.TownNPCWalkToX
hm.CurrentY = hm.TownNPCWalkToY hm.CurrentY = hm.TownNPCWalkToY
npcID := hm.TownNPCWalkTargetID npcID := hm.TownNPCWalkTargetID
npcWX := hm.TownNPCWalkToX standX := hm.TownNPCWalkToX
npcWY := hm.TownNPCWalkToY standY := hm.TownNPCWalkToY
hm.clearNPCWalk() hm.clearNPCWalk()
if npc, ok := graph.NPCByID[npcID]; ok { if npc, ok := graph.NPCByID[npcID]; ok {
if sender != nil { if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, 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 hm.TownVisitNPCName = npc.Name
@ -1797,8 +1840,13 @@ func ProcessSingleHeroMovementTick(
npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY
} }
} }
dx := npcWX - hm.CurrentX standoff := cfg.TownNPCStandoffWorld
dy := npcWY - hm.CurrentY 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) dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 { if walkSpeed <= 0 {
@ -1812,15 +1860,15 @@ func ProcessSingleHeroMovementTick(
hm.TownNPCWalkTargetID = npcID hm.TownNPCWalkTargetID = npcID
hm.TownNPCWalkFromX = hm.CurrentX hm.TownNPCWalkFromX = hm.CurrentX
hm.TownNPCWalkFromY = hm.CurrentY hm.TownNPCWalkFromY = hm.CurrentY
hm.TownNPCWalkToX = npcWX hm.TownNPCWalkToX = toX
hm.TownNPCWalkToY = npcWY hm.TownNPCWalkToY = toY
hm.TownNPCWalkStart = now hm.TownNPCWalkStart = now
hm.TownNPCWalkArrive = now.Add(walkDur) hm.TownNPCWalkArrive = now.Add(walkDur)
if sender != nil { if sender != nil {
heading := math.Atan2(dy, dx) heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY, X: hm.CurrentX, Y: hm.CurrentY,
TargetX: npcWX, TargetY: npcWY, TargetX: toX, TargetY: toY,
Speed: walkSpeed, Heading: heading, Speed: walkSpeed, Heading: heading,
}) })
} }

@ -145,7 +145,8 @@ type TownEnterPayload struct {
RestDurationMs int64 `json:"restDurationMs"` 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 { type TownNPCVisitPayload struct {
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
Name string `json:"name"` Name string `json:"name"`

@ -29,6 +29,8 @@ type Values struct {
TownNPCPauseMs int64 `json:"townNpcPauseMs"` TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` 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"` WanderingMerchantPromptTimeoutMs int64 `json:"wanderingMerchantPromptTimeoutMs"`
MerchantCostBase int64 `json:"merchantCostBase"` MerchantCostBase int64 `json:"merchantCostBase"`
@ -216,6 +218,7 @@ func DefaultValues() Values {
TownNPCPauseMs: 30_000, TownNPCPauseMs: 30_000,
TownNPCLogIntervalMs: 5_000, TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0, TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
WanderingMerchantPromptTimeoutMs: 15_000, WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 20, MerchantCostBase: 20,
MerchantCostPerLevel: 5, MerchantCostPerLevel: 5,

@ -335,6 +335,8 @@ export function App() {
// NPC interaction state (server-driven via town_enter) // NPC interaction state (server-driven via town_enter)
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null); const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null); const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
/** Server signaled a town NPC visit; UI waits until the hero display reaches the NPC. */
const [npcVisitAwaitingProximity, setNpcVisitAwaitingProximity] = useState<NPCData | null>(null);
// Wandering NPC encounter state // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
@ -642,6 +644,8 @@ export function App() {
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`); addLogEntry(`Entered ${p.townName}`);
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
setSelectedNPC(null);
setNpcInteractionDismissed(null); setNpcInteractionDismissed(null);
}, },
@ -650,22 +654,21 @@ export function App() {
}, },
onTownNPCVisit: (p) => { onTownNPCVisit: (p) => {
const role = setNearestNPC(null);
p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel; setNpcInteractionDismissed(null);
setToast({ message: `${role}: ${p.name}`, color: '#c9a227' }); setNpcVisitAwaitingProximity({
setNearestNPC({
id: p.npcId, id: p.npcId,
name: p.name, name: p.name,
type: p.type as NPCData['type'], type: p.type as NPCData['type'],
worldX: p.worldX ?? 0, worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0, worldY: p.worldY ?? 0,
}); });
setNpcInteractionDismissed(null);
}, },
onTownExit: () => { onTownExit: () => {
setCurrentTown(null); setCurrentTown(null);
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null);
}, },
onNPCEncounter: (p) => { 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 // Restore per-hero buff button cooldowns
useEffect(() => { useEffect(() => {
const id = gameState.hero?.id; const id = gameState.hero?.id;

@ -163,6 +163,11 @@ export class GameEngine {
this._allNPCs = npcs; 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. */ /** Update the list of nearby heroes for shared-world rendering. */
setNearbyHeroes(heroes: NearbyHeroData[]): void { setNearbyHeroes(heroes: NearbyHeroData[]): void {
this._nearbyHeroes = heroes; this._nearbyHeroes = heroes;
@ -778,11 +783,11 @@ export class GameEngine {
); );
const rk = state.hero.restKind?.toLowerCase() ?? ''; const rk = state.hero.restKind?.toLowerCase() ?? '';
const wild = const excPhase = state.hero.excursionPhase?.toLowerCase() ?? '';
state.hero.excursionPhase?.toLowerCase() === 'wild'; const offRoad = excPhase === 'wild' || excPhase === 'return';
const showRestCamp = const showRestCamp =
state.phase === GamePhase.Resting && state.phase === GamePhase.Resting &&
wild && offRoad &&
(rk === 'roadside' || rk === 'adventure_inline'); (rk === 'roadside' || rk === 'adventure_inline');
if (showRestCamp) { if (showRestCamp) {
this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now); this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now);

@ -507,6 +507,7 @@ export interface TownEnterPayload {
restDurationMs?: number; restDurationMs?: number;
} }
/** worldX/Y = hero stand point near the NPC (server), not the NPC sprite center. */
export interface TownNPCVisitPayload { export interface TownNPCVisitPayload {
npcId: number; npcId: number;
name: string; name: string;

Loading…
Cancel
Save