297 lines
13 KiB
Markdown
297 lines
13 KiB
Markdown
# Pathfinding System
|
||
|
||
This document describes the grid-based pathfinding used for the player and all NPCs, including the collision avoidance and movement quality improvements.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Grid Representation](#1-grid-representation)
|
||
2. [Building the Walkable Grid](#2-building-the-walkable-grid)
|
||
3. [A\* Path Search](#3-a-path-search)
|
||
4. [Path Smoothing](#4-path-smoothing)
|
||
5. [Approaching Unreachable Destinations](#5-approaching-unreachable-destinations)
|
||
6. [Dynamic Obstacles](#6-dynamic-obstacles)
|
||
7. [Path Following](#7-path-following)
|
||
8. [Character Movement Guard](#8-character-movement-guard)
|
||
9. [Dynamic Replanning](#9-dynamic-replanning)
|
||
10. [Key Constants Reference](#10-key-constants-reference)
|
||
|
||
---
|
||
|
||
## 1. Grid Representation
|
||
|
||
The world is divided into a uniform 2D grid in the XZ plane (Y is ignored during pathfinding; all characters walk on a flat floor at `floorY`).
|
||
|
||
Each cell is either **walkable** (`1`) or **blocked** (`0`). The grid is stored as a flat `std::vector<unsigned char>` indexed by `z * gridWidth + x`.
|
||
|
||
**Parameters** (all configurable in the JSON config file):
|
||
|
||
| Parameter | Default | Description |
|
||
|---|---|---|
|
||
| `cellSize` | 0.4 m | Width and depth of one cell |
|
||
| `agentRadius` | 0.45 m | Half-width of a character — used to erode free space |
|
||
| `objectPadding` | 0.25 m | Extra clearance added around obstacle polygons |
|
||
| `boundaryPadding` | 0.0 m | Inward erosion from the edges of navigation areas |
|
||
| `floorY` | 0.0 | Y coordinate placed on every path waypoint |
|
||
|
||
**Grid bounds** are computed from the union of all navigation area polygons plus a padding margin of `cellSize * 2 + agentRadius + objectPadding` on every side.
|
||
|
||
**Cell coordinate conversion:**
|
||
|
||
```
|
||
cell.x = floor((worldX - minX) / cellSize)
|
||
cell.z = floor((worldZ - minZ) / cellSize)
|
||
|
||
cellCenter.x = minX + (cell.x + 0.5) * cellSize
|
||
cellCenter.z = minZ + (cell.z + 0.5) * cellSize
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Building the Walkable Grid
|
||
|
||
The grid can be loaded in two ways.
|
||
|
||
### 2a. Pre-computed grid (`.txt` file)
|
||
|
||
A plain-text file with a small header followed by rows of `1`/`0` characters:
|
||
|
||
```
|
||
cellSize 0.4
|
||
agentRadius 0.45
|
||
floorY 0.0
|
||
...
|
||
minX -5.0
|
||
minZ -5.0
|
||
gridWidth 50
|
||
gridDepth 50
|
||
11111111...
|
||
10000001...
|
||
```
|
||
|
||
This format is generated by `PathFinder::saveGrid()` after building from polygons and can be loaded much faster than recomputing from geometry.
|
||
|
||
### 2b. Polygon-based config (`.json` file)
|
||
|
||
The JSON file lists **navigation areas** (convex or concave walkable regions) and **obstacle polygons** (impassable zones within those regions):
|
||
|
||
```json
|
||
{
|
||
"cellSize": 0.4,
|
||
"areas": [
|
||
{ "name": "main_room", "available": true, "polygon": [[x,z], ...] }
|
||
],
|
||
"obstacles": [
|
||
{ "name": "table", "polygon": [[x,z], ...] }
|
||
]
|
||
}
|
||
```
|
||
|
||
**Build steps:**
|
||
|
||
1. **Mark available areas walkable** — every cell whose center lies inside any `available` navigation area polygon gets `walkable = 1`. If `boundaryPadding > 0`, cells too close to the outer edge of the area are left blocked.
|
||
2. **Mark obstacle polygons blocked** — cells whose center lies inside an obstacle polygon, or within `agentRadius + objectPadding` of its edges, are set to `0`.
|
||
|
||
Navigation areas can be toggled at runtime via `PathFinder::setAreaAvailable()`, which rebuilds the entire grid. This is used to open or close doors, gated areas, etc.
|
||
|
||
---
|
||
|
||
## 3. A\* Path Search
|
||
|
||
`PathFinder::findPath(start, end)` runs a standard A\* on the walkable grid.
|
||
|
||
**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:** integer `10` for cardinal and `14` for diagonal.
|
||
|
||
**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.
|
||
|
||
**Path reconstruction:** After A\* completes, the cell chain is walked via `cameFrom[]` from `end` back to `start`, reversed, then smoothed (see §4).
|
||
|
||
**First-waypoint trimming:** If the first waypoint is within `cellSize × 0.75` of `start`, it is dropped (the character is already close enough).
|
||
|
||
**Last-waypoint precision:** If the requested `end` maps to the same cell as the snapped end cell, the last waypoint is replaced with the exact `end` world position rather than the cell centre.
|
||
|
||
---
|
||
|
||
## 4. Path Smoothing
|
||
|
||
Raw A\* paths follow the grid diagonals and produce staircase-shaped routes. A **string-pulling** (line-of-sight) pass compresses them:
|
||
|
||
```
|
||
anchor = path[0]
|
||
result = [anchor]
|
||
while anchor is not the last cell:
|
||
find the furthest cell 'next' from anchor with unobstructed line of sight
|
||
result.append(next)
|
||
anchor = next
|
||
```
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 5. Approaching Unreachable Destinations
|
||
|
||
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`** 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.
|
||
|
||
- 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.
|
||
|
||
`LocationGameState` applies this behavior to players and NPCs.
|
||
|
||
---
|
||
|
||
## 6. Dynamic Obstacles
|
||
|
||
When a path is planned, other characters can temporarily mark cells as blocked to make the character walk around them rather than through them.
|
||
|
||
**How it works:**
|
||
|
||
`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:**
|
||
|
||
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 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:** 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
|
||
|
||
`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 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.
|
||
|
||
`CharacterState::isMoving()` reports whether navigation remains active.
|
||
`movedLastTick` separately records actual displacement and controls dynamic
|
||
obstacle filtering.
|
||
|
||
**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 Movement Guard
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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
|
||
|
||
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`.
|
||
|
||
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`.
|
||
|
||
Example NPC tuning:
|
||
|
||
```json
|
||
{
|
||
"navigationAvoidanceRadius": 0.45,
|
||
"movementContactRadius": 0.32,
|
||
"navigationRetryCooldownMs": [
|
||
200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100
|
||
]
|
||
}
|
||
```
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 10. Key Constants Reference
|
||
|
||
| Constant | Location | Value | Description |
|
||
|---|---|---|---|
|
||
| `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 |
|
||
| `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 |
|
||
|
||
### 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.
|