From 9590420f5f7595021771c216f3129827a0591b37 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 7 May 2026 12:23:50 +0200 Subject: Fix unit stopping when destination is occupied by another unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a unit arrived exactly MOVE_SPEED away from its destination, it would move there via moveToward rather than snapping. On the next tick, its own cell appeared blocked in the moveMap, triggering a repath to an adjacent cell — causing infinite oscillation. Fix by excluding the unit's own current cell from the blocked check before repathing. Also adds repath-to-adjacent logic so units that arrive while the destination is genuinely occupied by someone else settle next to it instead of stopping mid-journey. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 ++- integration_test.go | 37 +++++++++++++++++++++++++++++++++++++ physics/move.go | 49 +++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7a24235..07f4bf7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ ## Bugs -- [ ] stops moving if someone settles at destination +- [x] stops moving if someone settles at destination +- [ ] spawner spawns 2 units instead of 1 ## Dependencies diff --git a/integration_test.go b/integration_test.go index 23d106b..7d687fb 100644 --- a/integration_test.go +++ b/integration_test.go @@ -162,3 +162,40 @@ func TestTwoEntitiesMovingToSameCellDontOverlap(t *testing.T) { assert.NotEqual(t, game.Position[unitA], game.Position[unitB], "Entity A and B should NOT overlap: A at %s, B at %s", game.Position[unitA], game.Position[unitB]) } + +func TestUnitDoesNotStopWhenDestinationOccupied(t *testing.T) { + // Unit B is far away, unit A is close. Both ordered to the same cell. + // A arrives first and settles. B should repath to an adjacent free cell + // rather than stopping mid-journey. + game := &Game{} + game.CurrentAction = Selecting + unitImg := NewFilledCircleImage(100, color.White) + + target := physics.Point{X: 500, Y: 0} + + unitA := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 400, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + unitB := game.Append(NewEntityBuilder(). + WithPosition(physics.Point{X: 0, Y: 0}). + WithSolid(unitImg).WithSelectable("round", selection.Unit). + WithMove().WithOrder().Build()) + + game.UpdateSimulation(Input{EscapePressed: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 50, Y: -50}, LeftMouseDown: true}) + game.UpdateSimulation(Input{Cursor: physics.Point{X: 450, Y: 50}, LeftMouseUp: true}) + game.UpdateSimulation(Input{Cursor: target, RightMouseUp: true}) + + // Run long enough for both units to settle (B travels ~5 cells) + for range 100 { + game.UpdateSimulation(Input{}) + } + + assert.False(t, game.Move[unitA].IsActive, "unit A should have stopped") + assert.False(t, game.Move[unitB].IsActive, "unit B should have stopped near target, not mid-journey") + assert.NotEqual(t, game.Position[unitA], game.Position[unitB], "units should not overlap") + // B should be adjacent to target, not stuck far away + assert.LessOrEqual(t, physics.Distance(game.Position[unitB], target), 150.0, + "unit B should be adjacent to target, got %s", game.Position[unitB]) +} diff --git a/physics/move.go b/physics/move.go index 78b97b7..d59fd05 100644 --- a/physics/move.go +++ b/physics/move.go @@ -144,6 +144,20 @@ func (s *PathSearch) search() (Path, bool) { return Path{}, false } +func findNearestFreeAdjacent(from Point, blocked Point, moveMap MoveMap) (Point, bool) { + var free []Point + for _, p := range AdjacentPoints(blocked) { + if !moveMap.Blocked[p] { + free = append(free, p) + } + } + if len(free) == 0 { + return Point{}, false + } + dest, dist := Closest(from, free, moveMap) + return dest, dist != math.MaxInt +} + func NewMove(origin Point, destination Point, moveMap MoveMap) Move { current := Point{ X: origin.X / 100 * 100, @@ -192,12 +206,24 @@ func UpdateMoveSystem(moveMap MoveMap, masks []ecs.Mask, moves []Move, positions *position = moveToward(*position, next) continue } - // About to snap to next cell — check it's still free - if moveMap.Blocked[next] { + // About to snap to next cell — check it's still free (exclude own cell) + currentCell := Point{X: position.X / 100 * 100, Y: position.Y / 100 * 100} + if moveMap.Blocked[next] && next != currentCell { slog.Debug("cell blocked on arrival, repathing", slog.String("next", next.String())) - path, ok := SearchPath(*position, move.Destination, moveMap) + destination := move.Destination + if moveMap.Blocked[destination] { + newDest, ok := findNearestFreeAdjacent(*position, destination, moveMap) + if !ok { + slog.Info("no accessible cell near destination", slog.String("destination", destination.String())) + move.IsActive = false + continue + } + destination = newDest + move.Destination = newDest + } + path, ok := SearchPath(*position, destination, moveMap) if !ok { - slog.Info("no path found", slog.String("destination", move.Destination.String())) + slog.Info("no path found", slog.String("destination", destination.String())) move.IsActive = false continue } @@ -219,9 +245,20 @@ func UpdateMoveSystem(moveMap MoveMap, masks []ecs.Mask, moves []Move, positions continue } // Here maybe the destination has to change - path, ok := SearchPath(next, move.Destination, moveMap) + destination := move.Destination + if moveMap.Blocked[destination] && destination != next { + newDest, ok := findNearestFreeAdjacent(next, destination, moveMap) + if !ok { + slog.Info("no accessible cell near destination", slog.String("destination", destination.String())) + move.IsActive = false + continue + } + destination = newDest + move.Destination = newDest + } + path, ok := SearchPath(next, destination, moveMap) if !ok { - slog.Info("no path found", slog.String("destination", move.Destination.String())) + slog.Info("no path found", slog.String("destination", destination.String())) move.IsActive = false continue } -- cgit v1.3