Working about pathfinding
This commit is contained in:
1 parent
6a5adb1c79
commit
c6d98bd72a
25 files changed
+1997
-508
No files matched your search
+98
-62
@@ -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.
|
||||
Reference in new issue
Block a user