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
}
// 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,
})
}

@ -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"`

@ -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,

@ -335,6 +335,8 @@ export function App() {
// NPC interaction state (server-driven via town_enter)
const [nearestNPC, setNearestNPC] = useState<NPCData | 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
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(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;

@ -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);

@ -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;

Loading…
Cancel
Save