From 13c7e65515330fd99d6379b1fac7da376f8761b8 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 13:51:17 +0300 Subject: [PATCH] fix movement --- backend/internal/game/movement.go | 9 ++++--- backend/internal/game/rest_test.go | 39 ++++++++++++++++++++++++++++++ frontend/src/game/engine.ts | 34 +++----------------------- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index abdac3d..3dccdcc 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -403,8 +403,8 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) { // assignRoad finds and configures the road from CurrentTownID to DestinationTownID. // If no road exists (hero is mid-road), it finds the nearest town and routes from there. -// startAtFirstWaypoint: place hero at jittered waypoint 0 (departure town) without nearest-point snap — -// use when leaving town so an off-road NPC position does not snap to an arbitrary polyline point. +// startAtFirstWaypoint: force hero to jittered waypoint 0 (departure town). Otherwise +// snapProgressToNearestPointOnRoad projects CurrentX/Y onto the polyline (used after LeaveTown). func (hm *HeroMovement) assignRoad(graph *RoadGraph, startAtFirstWaypoint bool) { road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID) if road == nil { @@ -962,8 +962,9 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { // Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick. hm.LastMoveTick = now hm.pickDestination(graph) - // Start exactly at the road origin (current town); snap from an NPC-tile would jump to the wrong spine point. - hm.assignRoad(graph, true) + // Project CurrentX/Y onto the outbound road polyline. The normal town flow walks the hero + // to the plaza first; forcing waypoint 0 caused a visible teleport away from that spot. + hm.assignRoad(graph, false) hm.refreshSpeed(now) } diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go index a367310..1404ee8 100644 --- a/backend/internal/game/rest_test.go +++ b/backend/internal/game/rest_test.go @@ -1,6 +1,7 @@ package game import ( + "math" "testing" "time" @@ -33,6 +34,44 @@ func testGraph() *RoadGraph { } } +// TestLeaveTown_ProjectsPlazaOntoRoad ensures we do not snap the hero to waypoint 0 when leaving +// town from a plaza offset from the graph origin (regression: assignRoad(..., true) teleported). +func TestLeaveTown_ProjectsPlazaOntoRoad(t *testing.T) { + graph := testGraph() + town1 := int64(1) + hero := &model.Hero{ + ID: 1, State: model.StateInTown, HP: 10, MaxHP: 10, Level: 1, + CurrentTownID: &town1, + PositionX: 2, + PositionY: 1, + } + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + if hm.State != model.StateInTown { + t.Fatalf("expected StateInTown from NewHeroMovement, got %v", hm.State) + } + hm.CurrentTownID = 1 + hm.CurrentX = 2 + hm.CurrentY = 1 + hm.Road = nil + + hm.LeaveTown(graph, now) + + if hm.State != model.StateWalking { + t.Fatalf("expected StateWalking after LeaveTown, got %v", hm.State) + } + if hm.Road == nil { + t.Fatal("expected Road assigned after LeaveTown") + } + // Waypoint 0 is (0,0); (2,1) should project closer to the outbound polyline than to the origin. + dFromPlaza := math.Hypot(hm.CurrentX-2, hm.CurrentY-1) + dFromOrigin := math.Hypot(2, 1) + if dFromPlaza >= dFromOrigin-0.05 { + t.Fatalf("expected position projected toward plaza (2,1), got (%g,%g) distPlaza=%g distOrigin=%g", + hm.CurrentX, hm.CurrentY, dFromPlaza, dFromOrigin) + } +} + func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero { townID := int64(1) destID := int64(2) diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index aedf958..7b2095c 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -371,9 +371,6 @@ export class GameEngine { } } - const newX = hero.position.x || 0; - const newY = hero.position.y || 0; - const activity = hero.serverActivityState?.toLowerCase(); const isDead = hero.hp <= 0 || activity === 'dead'; @@ -405,16 +402,9 @@ export class GameEngine { } } - { - const tdx = newX - this._targetPositionX; - const tdy = newY - this._targetPositionY; - if ( - tdx * tdx + tdy * tdy > - POSITION_DRIFT_SNAP_THRESHOLD * POSITION_DRIFT_SNAP_THRESHOLD - ) { - this._snapHeroWorldPositionTo(newX, newY); - } - } + // Display position: follow hero_move interpolation and applyPositionSync only. + // Do not snap here on hero_state vs last move — server position includes + // excursion/rest offsets and ordering with hero_move caused visible teleports. this._notifyStateChange(); } @@ -923,24 +913,6 @@ export class GameEngine { // ---- Private: Helpers ---- - /** - * Snap render/interpolation state and camera to a world position (teleport, town arrival, etc.). - */ - private _snapHeroWorldPositionTo(x: number, y: number): void { - this._heroDisplayX = x; - this._heroDisplayY = y; - this._prevPositionX = x; - this._prevPositionY = y; - this._targetPositionX = x; - this._targetPositionY = y; - this._moveTargetX = x; - this._moveTargetY = y; - this._lastMoveUpdateTime = performance.now(); - const heroScreen = worldToScreen(x, y); - this.camera.setTarget(heroScreen.x, heroScreen.y); - this.camera.snapToTarget(); - } - private _notifyStateChange(): void { this._onStateChange?.(this._gameState); }