Working about pathfinding

This commit is contained in:
Vladislav Khorev committed 2026-11-08 17:24:15 +06:00
1 parent 6a5adb1c79
commit c6d98bd72a
25 files changed
+1997 -508

No files matched your search

+98 -62
View File
@@ -13,7 +13,7 @@ This document describes the grid-based pathfinding used for the player and all N
5. [Approaching Unreachable Destinations](#5-approaching-unreachable-destinations)
6. [Dynamic Obstacles](#6-dynamic-obstacles)
7. [Path Following](#7-path-following)
8. [Character Collision Resolution](#8-character-collision-resolution)
8. [Character Movement Guard](#8-character-movement-guard)
9. [Dynamic Replanning](#9-dynamic-replanning)
10. [Key Constants Reference](#10-key-constants-reference)
@@ -103,9 +103,11 @@ Navigation areas can be toggled at runtime via `PathFinder::setAreaAvailable()`,
**Neighbor connectivity:** 8-directional (cardinal + diagonal). Diagonal moves are blocked if either of the two adjacent cardinal cells is unwalkable (no corner-cutting).
**Step costs:** `1.0` for cardinal, `√2 ≈ 1.414` for diagonal.
**Step costs:** integer `10` for cardinal and `14` for diagonal.
**Heuristic:** Euclidean distance in cell units to the end cell.
**Heuristic:** octile distance using the same `10`/`14` scale. Queue entries
compare priority, accumulated cost, then linear cell index; stale entries are
rejected. This avoids floating-point route-order differences.
**Start/end snapping:** If the exact cell for `start` or `end` is not walkable, `findNearestWalkableCell` expands a square ring outward (up to radius 8 m) to find the nearest walkable cell. This makes clicking slightly outside the nav mesh still produce a valid path.
@@ -130,7 +132,9 @@ while anchor is not the last cell:
anchor = next
```
Line-of-sight is checked by stepping along the segment in increments of `cellSize / 2` and verifying that each sampled cell is walkable. The result is a minimal set of waypoints connected by straight, obstacle-free segments.
Line-of-sight uses deterministic integer grid traversal and applies the same
no-corner-cutting rule as A*. The result is a minimal set of waypoints connected
by straight, obstacle-free segments.
---
@@ -138,22 +142,20 @@ Line-of-sight is checked by stepping along the segment in increments of `cellSiz
When a player clicks on a point in a disconnected region (e.g., across a thin wall), the original `findPath` returns an empty path and the character does not move. This is surprising — a click on a solid wall sensibly moves the character to the nearest reachable point, but a click into an inaccessible room does nothing.
**`findPathToNearest`** fixes this with a three-step cascade:
**`findPathToNearest`** runs one A\* search against the requested static and
dynamic occupancy. If the destination is unreachable, the same search returns
the reachable cell geometrically closest to it. It never retries without
dynamic obstacles.
1. Try `findPath` with dynamic obstacles (stationary characters are avoided).
2. If empty, retry `findPath` without dynamic obstacles (an NPC blocking a doorway is ignored).
3. If still empty (destination genuinely unreachable), run **nearest-reachable A\***.
**Nearest-reachable A\***, implemented in `findNearestReachableImpl`:
- Runs the identical A\* loop against the static walkable grid.
- While processing cells, tracks `bestIndex` — the already-visited cell with the smallest Euclidean distance (in cell units) to the end cell.
- While processing cells, it tracks `bestIndex` — the visited cell with the
smallest squared grid distance to the end cell. Equal distances use the
lower linear cell index.
- If A\* exhausts all reachable space without finding `end`, it reconstructs and returns a path to `bestIndex`.
- If `bestIndex` is still the start cell (character is completely isolated), an empty path is returned and the character stays put.
The net effect: clicking anywhere in the world always moves the character as close as possible to the target, matching the behaviour of clicking on a solid wall.
`findPathToNearest` replaces the direct `findPath` call in `Location::setupNavigation`'s path planner lambda, so it applies equally to the player and all NPCs.
`LocationGameState` applies this behavior to players and NPCs.
---
@@ -163,7 +165,10 @@ When a path is planned, other characters can temporarily mark cells as blocked t
**How it works:**
In `Location::setupNavigation`, every character is given a `PathPlanner` closure. Before calling `findPath`, the closure builds a list of `PathFinder::DynamicObstacle` entries (position + radius) representing nearby characters. `findPath` copies the static walkable grid, stamps zeros in circles around each obstacle, then runs A\* on the modified copy. The static grid is never mutated.
`LocationGameState::collectPathObstacles` creates a stable-ID-sorted snapshot
from authoritative players and NPCs. `PathFinder` stamps circles into a
generation-tagged overlay and reuses A\* buffers; it does not copy or mutate the
static walkability grid per query.
**Which characters become obstacles:**
@@ -171,74 +176,101 @@ A character is added as a dynamic obstacle only when **all** of these are true:
- It is not the character currently planning the path (`self`).
- It is alive and enabled.
- **It is not moving** — a moving character is transparent to pathfinding, so it does not block narrow corridors that it is actively passing through.
- Its position lies within `kDynamicObstacleInfluenceDist = 6 m` of the direct line segment from `start` to `end` (distant characters do not affect the search).
- It did not physically move during the previous simulation tick, unless it
just blocked this requester and is promoted for a forced replan. An active
path alone does not make a stalled character transparent.
**Obstacle radius:** `character.collisionRadius × 0.6`. Using 60 % of the physical collision radius makes path planning less conservative; physical separation at full radius is still enforced by collision resolution (§8).
**Fallback when dynamic obstacles block the only path:**
If step 1 of `findPathToNearest` (with dynamic obstacles) returns empty, step 2 retries without any dynamic obstacles. This handles the common case of an NPC standing in a doorway: the player paths through the NPC's position, and the nudge logic (§8) pushes the NPC aside as the player passes.
**Obstacle radius:** the character's full `collisionRadius`, inflated by the
navigation map's `agentRadius` and one cell half-diagonal for conservative
rasterization. There is no obstacle-dropping fallback; an occupied destination
resolves to a nearby reachable cell.
---
## 7. Path Following
`Character::setTarget(destination, onArrived)` sets a new walk target. It calls the path planner to generate a waypoint list. The result is stored in `pathWaypoints`; the final destination is also stored in `walkTarget` and `requestedWalkTarget`.
`CharacterState::setTarget(destination, onArrived)` sets a new walk target. It
calls the centralized location planner to generate a waypoint list. The result
is stored in `pathWaypoints`; the reachable endpoint is stored in `walkTarget`
and the original command destination in `requestedWalkTarget`.
Each frame in `Character::update`:
Each fixed simulation tick in `CharacterState::update`:
1. **Active target** — if `pathWaypoints` is non-empty, the character moves toward `pathWaypoints[currentWaypointIndex]`; otherwise it moves toward `walkTarget`.
2. **Movement** — the character advances along the XZ direction at `walkSpeed` m/s and rotates smoothly toward the movement direction at `rotationSpeed` rad/s.
3. **Waypoint advance** — when the character is within `WALK_THRESHOLD = 0.05 m` of the current waypoint, it advances to the next one. When the last waypoint is reached, `pathWaypoints` is cleared and the optional `onArrived` callback is fired.
4. **State machine** — the animation state switches between `STAND` and `WALK` based on whether the character is moving.
`Character::isMoving()` returns `true` if `pathWaypoints` is non-empty or the distance to `walkTarget` exceeds `WALK_THRESHOLD`. This is used by dynamic obstacle filtering and collision nudging.
`CharacterState::isMoving()` reports whether navigation remains active.
`movedLastTick` separately records actual displacement and controls dynamic
obstacle filtering.
**Stopping in place:** `Character::stopInPlace()` sets `walkTarget` and `requestedWalkTarget` to the current position and clears `pathWaypoints`. It is called when an external force (collision resolution) displaces a stationary player so that the player does not walk back to their previous target position.
**Stopping in place:** `CharacterState::stopInPlace()` sets `walkTarget` and
`requestedWalkTarget` to the current position, clears the path and callback, and
resets blockage state. It is used to cancel player/bot movement after a
persistent contact and by other explicit stop actions.
---
## 8. Character Collision Resolution
## 8. Character Movement Guard
Pathfinding alone does not prevent two characters from occupying the same space — it only steers paths around stationary characters. Physical separation is handled separately each frame by `Location::resolveCharacterCollisions`.
Pathfinding steers around stationary characters. A second authoritative guard
checks each proposed movement segment against stable-ID-ordered reservations
initialized from tick-start circles. An accepted step updates that character's
reservation before later characters advance. A step that approaches or sweeps
through another circle is rejected rather than pushing either character.
**Algorithm** (3 iterations per frame):
Characters that moved during the previous tick are transparent during planning
but remain solid to the movement guard. Planning uses the full
`collisionRadius` (default 0.45 m); contact checks use the smaller
`movementContactRadius` (default 0.32 m). NPC JSON can override these as
`navigationAvoidanceRadius` and `movementContactRadius`. This tolerance avoids
false bumps from grid discretization while still preventing stacking.
For every pair `(A, B)` of living, enabled characters:
1. Compute the overlap: `penetration = (collisionRadius_A + collisionRadius_B) - distance(A, B)`.
2. If `penetration > 0`, compute a push direction (A-to-B normal) and a push magnitude of `penetration / 2` per character.
3. Compute candidate new positions `newA` and `newB`.
4. Validate against the navigation grid (`PathFinder::isWalkable`). If a pushed position is unwalkable, only the other character is moved.
5. **Player stays put:** if the player was not moving (`!isMoving()`) before the push, `stopInPlace()` is called after the push so the player does not walk back to the old target.
6. **NPC yielding:** if one character was moving and the other was standing, `nudgeCharacterAside` is called on the standing character.
**`nudgeCharacterAside(standing, awayFrom)`:**
Gives the standing NPC a short walk target so it steps out of the way:
1. Compute the direction from `awayFrom` to the NPC's current position.
2. Try four candidate targets at distance `1.2 m` in directions: straight away, +90°, −90°, 180°.
3. Use the first candidate that is walkable (per `PathFinder::isWalkable`).
4. Call `standing->setTarget(candidate)` — the NPC takes a small step aside, then stands at the new spot.
5. The player is never nudged; combat NPCs can be nudged, but their attack AI immediately overrides the yield target on the next tick.
When moving characters remain blocked head-on or while crossing for three
ticks, both stop. Player and bot destinations are cancelled; the user or bot
may issue another command. NPCs retain `requestedWalkTarget` and retry later.
Characters following in the same direction simply wait for spacing and do not
accumulate stuck ticks. A rejected movement step uses the standing animation.
---
## 9. Dynamic Replanning
When characters move they can displace each other or enter each other's planned paths. `Location::updateDynamicReplans` handles this:
Rejected non-following movement increments serialized `movementBlockedTicks`.
After three blocked ticks an NPC stops and schedules a retry. When its cooldown
expires, `InternalGameServer` promotes the blocker to a hard obstacle and
replans to `requestedWalkTarget`.
**Every frame:**
Each NPC has `navigationRetryCooldownTicks`, normally ten distinct values.
`LocationDefinition` deterministically shuffles the sequence from the stable NPC
ID, and every retry consumes the next value. The sequence and cursor are
serialized, preventing synchronized retry loops while preserving lockstep.
NPC JSON can override it with `navigationRetryCooldownTicks` or
`navigationRetryCooldownMs`.
1. Measure how much each character moved since the last frame. Characters that moved more than `kMovedEps = 0.05 m` are collected as **movers**.
2. For each mover, find other characters that are currently walking. If the mover's position is within `kReplanTriggerDist = 1.8 m` of the segment `[walker.position → walker.nextWaypoint]`, trigger a replan for the walker via `forceReplan()`.
3. A per-character cooldown of `kReplanCooldownMs = 500 ms` prevents the same character from replanning more often than twice per second.
Example NPC tuning:
**`Character::forceReplan()`** re-runs the path planner from the character's current position to its stored `requestedWalkTarget`, updating `pathWaypoints` in place. If the replanned path is empty, the character stops at its current position.
```json
{
"navigationAvoidanceRadius": 0.45,
"movementContactRadius": 0.32,
"navigationRetryCooldownMs": [
200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100
]
}
```
The relatively generous trigger distance (1.8 m vs the old 1.1 m) and cooldown (500 ms vs 300 ms) prevent micro-jitter: small position corrections from collision resolution no longer spam replanning events.
The configured values are clamped to positive tick counts and shuffled
deterministically per NPC ID. Supplying different ten-value sets gives explicit
control for unusually dense locations.
Navigation map switches, area availability changes, and added static obstacle
polygons also force centralized replanning.
Opposing traffic in a one-character-wide corridor can wait until one route or
blocker changes; this policy intentionally does not implement space-time
cooperative reservations.
---
@@ -249,12 +281,16 @@ The relatively generous trigger distance (1.8 m vs the old 1.1 m) and cooldown (
| `cellSize` | `PathFinder` config | 0.4 m | Grid cell size |
| `agentRadius` | `PathFinder` config | 0.45 m | Character half-width for grid erosion |
| `objectPadding` | `PathFinder` config | 0.25 m | Extra clearance around obstacles |
| `WALK_THRESHOLD` | `Character.h` | 0.05 m | Distance below which a waypoint is considered reached |
| `TARGET_REPLAN_THRESHOLD` | `Character.h` | 0.25 m | Deduplication threshold in `setTarget` |
| `kDynamicObstacleInfluenceDist` | `Location.cpp` | 6.0 m | Max distance from path for a character to become an obstacle |
| `kDynamicObstacleRadiusFraction` | `Location.cpp` | 0.6× | Fraction of collision radius used for dynamic obstacle footprint |
| `kNudgeDist` | `Location.cpp` | 1.2 m | Distance an NPC steps aside when yielding |
| `kReplanTriggerDist` | `Location.cpp` | 1.8 m | Mover must be this close to a walker's path to trigger replan |
| `kReplanCooldownMs` | `Location.cpp` | 500 ms | Minimum interval between replans for any one character |
| `movementContactRadius` | `CharacterState` / NPC config | 0.32 m | Smaller movement-guard radius |
| `kWalkThreshold` | `CharacterState.h` | 0.05 m | Distance below which a waypoint is considered reached |
| `TARGET_REPLAN_THRESHOLD` | `CharacterState.cpp` | 0.25 m | Deduplication threshold in `setTarget` |
| `BLOCKED_REPLAN_TICKS` | `CharacterState.cpp` | 3 ticks | Blocked steps before stopping |
| default retry sequence | `CharacterState.cpp` | 2–11 ticks | Ten ID-shuffled NPC retry cooldowns |
| `NPC_TALK_DISTANCE` | `Location.cpp` | 1.35 m | Distance at which walking-to-NPC interaction fires |
| `kIterations` (collision) | `Location.cpp` | 3 | Push-apart iterations per frame |
### Reference benchmark
`GameServerPathFinderTests --benchmark` is non-gating. On the development
Windows Release build with an 80×80 grid, the current implementation completed
100 obstacle-aware queries in 15 ms and 1000 queries in 176 ms. Treat these as
comparative measurements rather than cross-machine budgets.