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
+1976 -487

No files matched your search

+29 -4
View File
@@ -340,8 +340,9 @@ The player state remains campaign-level and survives location changes.
## Save and Load Schema ## Save and Load Schema
Authoritative persistence uses one strict, versioned schema. `schemaVersion` is Authoritative persistence currently uses strict schema version `5`.
required and unsupported versions are rejected rather than migrated. A `schemaVersion` is required and unsupported versions are rejected rather than
migrated. A
single-player save is a separate versioned envelope containing `networkState` single-player save is a separate versioned envelope containing `networkState`
(the authoritative `GameState` JSON) and `localState` (the client-local location (the authoritative `GameState` JSON) and `localState` (the client-local location
map). Lockstep sends that authoritative document only in `initial_state`; later map). Lockstep sends that authoritative document only in `initial_state`; later
@@ -351,6 +352,9 @@ messages contain numbered command bundles.
- Locations are keyed by location ID. - Locations are keyed by location ID.
- Each location stores NPC and interactive-object state in ID-keyed objects. - Each location stores NPC and interactive-object state in ID-keyed objects.
- Pending interaction targets and character references use stable IDs. - Pending interaction targets and character references use stable IDs.
- Character navigation persistence includes waypoints, actual previous-tick
movement, blocked/retry state, the blocker ID, and the NPC cooldown sequence
and cursor.
- Immutable content such as meshes, initial taxi configuration, and object - Immutable content such as meshes, initial taxi configuration, and object
definitions is reconstructed from campaign configuration. definitions is reconstructed from campaign configuration.
- Configuration is loaded first and mutable saved state is overlaid in place. - Configuration is loaded first and mutable saved state is overlaid in place.
@@ -478,12 +482,33 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
auxiliary-state synchronization without making `Game` a server friend. auxiliary-state synchronization without making `Game` a server friend.
- Empty player maps are valid. Player-specific interactions remain inactive until - Empty player maps are valid. Player-specific interactions remain inactive until
a player entity is added. a player entity is added.
- Pathfinding priority queues use cell index as a stable secondary key. - Pathfinding uses integer cardinal/diagonal costs and compares priority,
accumulated cost, then cell index.
- Gameplay randomness must be seeded and serialized; the `random_device` battle - Gameplay randomness must be seeded and serialized; the `random_device` battle
bot is disabled for multiplayer sessions. bot is disabled for multiplayer sessions.
- A lockstep compatibility fingerprint must match before the client accepts the - A lockstep compatibility fingerprint must match before the client accepts the
initial state. initial state.
### Dynamic Navigation Occupancy
`LocationGameState` is the authoritative boundary for character-aware planning.
It collects enabled, living, stationary players and NPCs in stable ID order and
passes their positions and collision radii to `LocationNetworkState` and
`PathFinder`. The requester is excluded; moving characters are transparent to
planning. “Moving” here means actual previous-tick displacement
(`movedLastTick`), not merely owning an active path, so visually stalled
characters remain planning obstacles. Route planning uses full avoidance radii;
the movement guard uses smaller contact radii.
Every character movement step is guarded against stable-ID-ordered reservations
initialized from tick-start geometry. Accepted steps update their reservation
before later characters advance, preventing two movers from ending a tick
overlapped. Blocked/retry counters, cooldown sequence/cursor, and blocker ID are
serialized character state. Head-on players and bots cancel their commands;
NPCs retain their targets and consume the next deterministic, ID-shuffled
cooldown before replanning. Static navigation topology changes route through
the same centralized replanning path.
## Main Files ## Main Files
- `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence. - `src/GameState.h` / `src/GameState.cpp` — aggregate state and top-level persistence.
@@ -502,7 +527,7 @@ cmake --build proj-windows/build --config Debug --target ShadowOverBishkekDemo -
broadcasting, buffering, and replica advancement. broadcasting, buffering, and replica advancement.
- `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring. - `src/Game.h` / `src/Game.cpp` — application host, campaign lifecycle, and runtime wiring.
- `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing, - `src/Location.h` / `src/Location.cpp` — loaded location runtime, command routing,
interactions, collisions, scripts, and presentation-event consumption. interactions, scripts, and presentation-event consumption.
- `src/Character.h` / `src/Character.cpp` — passive character presentation resolving - `src/Character.h` / `src/Character.cpp` — passive character presentation resolving
immutable `CharacterState` snapshots by stable ID. immutable `CharacterState` snapshots by stable ID.
- `src/CharacterState.h` / `src/CharacterState.cpp` — deterministic character - `src/CharacterState.h` / `src/CharacterState.cpp` — deterministic character
+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) 5. [Approaching Unreachable Destinations](#5-approaching-unreachable-destinations)
6. [Dynamic Obstacles](#6-dynamic-obstacles) 6. [Dynamic Obstacles](#6-dynamic-obstacles)
7. [Path Following](#7-path-following) 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) 9. [Dynamic Replanning](#9-dynamic-replanning)
10. [Key Constants Reference](#10-key-constants-reference) 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). **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. **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 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. 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). - While processing cells, it tracks `bestIndex` — the visited cell with the
2. If empty, retry `findPath` without dynamic obstacles (an NPC blocking a doorway is ignored). smallest squared grid distance to the end cell. Equal distances use the
3. If still empty (destination genuinely unreachable), run **nearest-reachable A\***. lower linear cell index.
**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.
- If A\* exhausts all reachable space without finding `end`, it reconstructs and returns a path to `bestIndex`. - 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. - 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. 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:** **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:** **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 not the character currently planning the path (`self`).
- It is alive and enabled. - 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. - It did not physically move during the previous simulation tick, unless it
- Its position lies within `kDynamicObstacleInfluenceDist = 6 m` of the direct line segment from `start` to `end` (distant characters do not affect the search). 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). **Obstacle radius:** the character's full `collisionRadius`, inflated by the
navigation map's `agentRadius` and one cell half-diagonal for conservative
**Fallback when dynamic obstacles block the only path:** rasterization. There is no obstacle-dropping fallback; an occupied destination
resolves to a nearby reachable cell.
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.
--- ---
## 7. Path Following ## 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`. 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. 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. 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. 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: 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
1. Compute the overlap: `penetration = (collisionRadius_A + collisionRadius_B) - distance(A, B)`. may issue another command. NPCs retain `requestedWalkTarget` and retry later.
2. If `penetration > 0`, compute a push direction (A-to-B normal) and a push magnitude of `penetration / 2` per character. Characters following in the same direction simply wait for spacing and do not
3. Compute candidate new positions `newA` and `newB`. accumulate stuck ticks. A rejected movement step uses the standing animation.
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.
--- ---
## 9. Dynamic Replanning ## 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**. Example NPC tuning:
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.
**`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 | | `cellSize` | `PathFinder` config | 0.4 m | Grid cell size |
| `agentRadius` | `PathFinder` config | 0.45 m | Character half-width for grid erosion | | `agentRadius` | `PathFinder` config | 0.45 m | Character half-width for grid erosion |
| `objectPadding` | `PathFinder` config | 0.25 m | Extra clearance around obstacles | | `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 | | `movementContactRadius` | `CharacterState` / NPC config | 0.32 m | Smaller movement-guard radius |
| `TARGET_REPLAN_THRESHOLD` | `Character.h` | 0.25 m | Deduplication threshold in `setTarget` | | `kWalkThreshold` | `CharacterState.h` | 0.05 m | Distance below which a waypoint is considered reached |
| `kDynamicObstacleInfluenceDist` | `Location.cpp` | 6.0 m | Max distance from path for a character to become an obstacle | | `TARGET_REPLAN_THRESHOLD` | `CharacterState.cpp` | 0.25 m | Deduplication threshold in `setTarget` |
| `kDynamicObstacleRadiusFraction` | `Location.cpp` | 0.6× | Fraction of collision radius used for dynamic obstacle footprint | | `BLOCKED_REPLAN_TICKS` | `CharacterState.cpp` | 3 ticks | Blocked steps before stopping |
| `kNudgeDist` | `Location.cpp` | 1.2 m | Distance an NPC steps aside when yielding | | default retry sequence | `CharacterState.cpp` | 2–11 ticks | Ten ID-shuffled NPC retry cooldowns |
| `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 |
| `NPC_TALK_DISTANCE` | `Location.cpp` | 1.35 m | Distance at which walking-to-NPC interaction fires | | `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.
+17 -7
View File
@@ -75,15 +75,15 @@ When `NETWORK` is defined:
The WebSocket client is created only when `battle_zone_multiplayer` is selected The WebSocket client is created only when `battle_zone_multiplayer` is selected
and is destroyed when that campaign is unloaded. The single-player and and is destroyed when that campaign is unloaded. The single-player and
multiplayer Battle Zone entries share location content and the start-state path, multiplayer Battle Zone entries share location content but use separate
so the state can be regenerated through the single-player entry. At startup, the start-state files. At startup, the standalone server loads the multiplayer
standalone server loads the authoritative half of that shared save and rewrites start state; each remote client restores the received authoritative state and
its campaign ID to `battle_zone_multiplayer`; each remote client restores only its own local presentation state. The multiplayer campaign currently declares the
the local presentation half. The multiplayer campaign currently declares the
pre-existing `player` and `player2` state entries as connection pre-existing `player` and `player2` state entries as connection
slots; the random test-bot controller is disabled. slots; the random test-bot controller is disabled.
The campaign's `networkCompatibility` token is the fingerprint; release builds The campaign's `networkCompatibility` token is the fingerprint (currently
must bump it whenever deterministic content changes. `battle-zone-lockstep-v5`); release builds must bump it whenever deterministic
content changes.
If DNS resolution, TCP connection, the WebSocket handshake, or the active socket If DNS resolution, TCP connection, the WebSocket handshake, or the active socket
fails, the client unloads the remote campaign and returns to the campaign fails, the client unloads the remote campaign and returns to the campaign
@@ -180,6 +180,16 @@ submission, and contiguous tick consumption.
## Current Limitations ## Current Limitations
- Paths are calculated independently on every lockstep participant from the
same ordered commands and tick-start occupancy. Integer A* costs, stable
character IDs, serialized previous-tick movement and blocked/retry state, and deterministic
nearest-reachable ties keep route selection aligned.
- Moving characters are omitted from A* but remain solid to the authoritative
movement guard. After three blocked ticks, players/bots cancel movement while
NPCs stop and consume the next serialized cooldown from their deterministic
retry sequence before replanning around the blocker.
- Opposing traffic can wait in a one-character-wide corridor because the
current policy does not reserve future space-time cells.
- Only preconfigured player slots can connect; dynamic join/spawn commands are - Only preconfigured player slots can connect; dynamic join/spawn commands are
not implemented. not implemented.
- There is no authentication or reconnect/replay history. - There is no authentication or reconnect/replay history.
+1 -1
View File
@@ -40,7 +40,7 @@
"quests": "resources/config/quests_battle_zone.json", "quests": "resources/config/quests_battle_zone.json",
"tutorial": false, "tutorial": false,
"networkPlayerSlots": ["player", "player2"], "networkPlayerSlots": ["player", "player2"],
"networkCompatibility": "battle-zone-lockstep-v2", "networkCompatibility": "battle-zone-lockstep-v5",
"loadingScreen": "resources/w/ui/screen_loading.json" "loadingScreen": "resources/w/ui/screen_loading.json"
} }
] ]
@@ -5,6 +5,7 @@
"npcs": "resources/config/npcs_battle_zone.json", "npcs": "resources/config/npcs_battle_zone.json",
"dialogues": "resources/dialogue/battle_zone_dialogues.json", "dialogues": "resources/dialogue/battle_zone_dialogues.json",
"navigation": [ "navigation": [
"resources/navigation/battle_zone001.txt",
"resources/navigation/dorm0_large.json" "resources/navigation/dorm0_large.json"
], ],
"script": "resources/start_battle_zone.lua", "script": "resources/start_battle_zone.lua",
+136 -1
View File
@@ -14,7 +14,7 @@
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim", "animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim", "animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim", "animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.0, "positionX": -5.6,
"positionY": 0.0, "positionY": 0.0,
"positionZ": -12.0, "positionZ": -12.0,
"walkSpeed": 1.5, "walkSpeed": 1.5,
@@ -26,6 +26,141 @@
"hp": 35, "hp": 35,
"visibleRange" : 6.5, "visibleRange" : 6.5,
"canAttack": true "canAttack": true
},
{
"id": "ghost_02x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -12.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_03x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -12.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_04x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -12.5,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_05x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -5.6,
"positionY": 0.0,
"positionZ": -13.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
},
{
"id": "ghost_06x",
"name": "Тень",
"meshTextures": {
"Shadow_NPC_Rigged": "resources/w/shadow/Shadow_BaseColor.png"
},
"animationIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationWalkPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionIdlePath": "resources/w/shadow/shadow_idle003.anim",
"animationActionAttackPath": "resources/w/shadow/shadow_attack003.anim",
"animationStandToActionPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToStandPath": "resources/w/shadow/shadow_idle003.anim",
"animationActionToDeathPath": "resources/w/shadow/shadow_die003.anim",
"animationDeathIdlePath": "resources/w/shadow/shadow_dead003.anim",
"positionX": -6.4,
"positionY": 0.0,
"positionZ": -13.0,
"walkSpeed": 1.5,
"rotationSpeed": 8.0,
"modelScale": 1.0,
"modelCorrectionRotX": -90.0,
"modelCorrectionRotY": 180.0,
"modelCorrectionRotZ": 0.0,
"hp": 35,
"visibleRange" : 6.5,
"canAttack": true
} }
] ]
} }
+341 -18
View File
@@ -121,7 +121,326 @@
"scale": 1.0 "scale": 1.0
} }
}, },
"npcs": {}, "npcs": {
"ghost_01x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-12.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-12.0
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-12.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-12.0
]
},
"ghost_02x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-12.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-12.0
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-12.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-12.0
]
},
"ghost_03x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-12.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-12.5
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-12.5
]
},
"ghost_04x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-12.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-12.5
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-12.5
]
},
"ghost_05x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-13.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-13.0
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-13.0
]
},
"ghost_06x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-13.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-13.0
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-13.0
]
}
},
"scriptGlobals": {}, "scriptGlobals": {},
"state": { "state": {
"activeNavigationIndex": 0, "activeNavigationIndex": 0,
@@ -171,24 +490,28 @@
0.0 0.0
], ],
"hp": 200.0, "hp": 200.0,
"pathWaypoints": [], "pathWaypoints": [[
-6.4,
0.0,
13.0
]],
"position": [ "position": [
-6.4,
0.0, 0.0,
0.0, 13.0
0.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.4,
0.0, 0.0,
0.0, 13.0
0.0
], ],
"showWeapon": true, "showWeapon": true,
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.0, "visibleRange": 6.0,
"walkTarget": [ "walkTarget": [
-6.4,
0.0, 0.0,
0.0, 13.0
0.0
] ]
}, },
"player": { "player": {
@@ -198,7 +521,7 @@
"battle_state": 0, "battle_state": 0,
"currentState": 0, "currentState": 0,
"currentWaypointIndex": 0, "currentWaypointIndex": 0,
"enabled": false, "enabled": true,
"faceTargetId": "", "faceTargetId": "",
"facingAngle": 0.0, "facingAngle": 0.0,
"homeDriftCheckTimer": 5.0, "homeDriftCheckTimer": 5.0,
@@ -210,28 +533,28 @@
"hp": 200.0, "hp": 200.0,
"pathWaypoints": [ "pathWaypoints": [
[ [
-6.025001525878906, -5.8,
0.0, 0.0,
12.775001525878906 12.8
] ]
], ],
"position": [ "position": [
-6.0, -5.8,
0.0, 0.0,
12.800000190734863 12.8
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.0, -5.8,
0.0, 0.0,
12.800000190734863 12.8
], ],
"showWeapon": true, "showWeapon": true,
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.0, "visibleRange": 6.0,
"walkTarget": [ "walkTarget": [
-6.025001525878906, -5.8,
0.0, 0.0,
12.775001525878906 12.8
] ]
} }
}, },
@@ -239,7 +562,7 @@
"quests": [] "quests": []
}, },
"savedAt": "2026-11-05 17:43", "savedAt": "2026-11-05 17:43",
"schemaVersion": 2, "schemaVersion": 5,
"taxiIsCalled": false, "taxiIsCalled": false,
"tutorialJournalScreenOpened": false, "tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false, "tutorialNeedOpenTaxiScreen": false,
@@ -121,7 +121,326 @@
"scale": 1.0 "scale": 1.0
} }
}, },
"npcs": {}, "npcs": {
"ghost_01x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-12.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
3,
10,
5,
6,
8,
7,
9,
4,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-12.0
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-12.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-12.0
]
},
"ghost_02x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-12.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
11,
3,
10,
2,
9,
5,
4,
7,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-12.0
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-12.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-12.0
]
},
"ghost_03x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-12.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
2,
4,
8,
3,
6,
10,
9,
7,
5,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-12.5
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-12.5
]
},
"ghost_04x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-12.5
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
3,
4,
7,
10,
5,
11,
9,
2,
8,
6
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-12.5
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-12.5
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-12.5
]
},
"ghost_05x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-5.599999904632568,
0.0,
-13.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
8,
7,
2,
9,
4,
10,
6,
5,
3,
11
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-5.599999904632568,
0.0,
-13.0
],
"requestedWalkTarget": [
-5.599999904632568,
0.0,
-13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-5.599999904632568,
0.0,
-13.0
]
},
"ghost_06x": {
"animationFrame": 0.0,
"attackTargetId": "",
"attack_cooldown": 0.0,
"battle_state": 0,
"currentState": 0,
"currentWaypointIndex": 0,
"enabled": true,
"faceTargetId": "",
"facingAngle": 0.0,
"homeDriftCheckTimer": 5.0,
"homePosition": [
-6.400000095367432,
0.0,
-13.0
],
"hp": 35.0,
"movedLastTick": false,
"movementBlockedTicks": 0,
"navigationRetryCooldownIndex": 0,
"navigationRetryCooldownTicks": [
7,
11,
5,
4,
3,
10,
9,
8,
6,
2
],
"navigationRetryTicks": 0,
"pathWaypoints": [],
"position": [
-6.400000095367432,
0.0,
-13.0
],
"requestedWalkTarget": [
-6.400000095367432,
0.0,
-13.0
],
"showWeapon": true,
"targetFacingAngle": 0.0,
"visibleRange": 6.5,
"walkTarget": [
-6.400000095367432,
0.0,
-13.0
]
}
},
"scriptGlobals": {}, "scriptGlobals": {},
"state": { "state": {
"activeNavigationIndex": 0, "activeNavigationIndex": 0,
@@ -171,24 +490,28 @@
0.0 0.0
], ],
"hp": 200.0, "hp": 200.0,
"pathWaypoints": [], "pathWaypoints": [[
-6.8,
0.0,
13.0
]],
"position": [ "position": [
-6.8,
0.0, 0.0,
0.0, 13.0
0.0
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.8,
0.0, 0.0,
0.0, 13.0
0.0
], ],
"showWeapon": true, "showWeapon": true,
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.0, "visibleRange": 6.0,
"walkTarget": [ "walkTarget": [
-6.8,
0.0, 0.0,
0.0, 13.0
0.0
] ]
}, },
"player": { "player": {
@@ -210,28 +533,28 @@
"hp": 200.0, "hp": 200.0,
"pathWaypoints": [ "pathWaypoints": [
[ [
-6.025001525878906, -5.4,
0.0, 0.0,
12.775001525878906 12.8
] ]
], ],
"position": [ "position": [
-6.0, -5.4,
0.0, 0.0,
12.800000190734863 12.8
], ],
"requestedWalkTarget": [ "requestedWalkTarget": [
-6.0, -5.4,
0.0, 0.0,
12.800000190734863 12.8
], ],
"showWeapon": true, "showWeapon": true,
"targetFacingAngle": 0.0, "targetFacingAngle": 0.0,
"visibleRange": 6.0, "visibleRange": 6.0,
"walkTarget": [ "walkTarget": [
-6.025001525878906, -5.4,
0.0, 0.0,
12.775001525878906 12.8
] ]
} }
}, },
@@ -239,7 +562,7 @@
"quests": [] "quests": []
}, },
"savedAt": "2026-11-05 17:43", "savedAt": "2026-11-05 17:43",
"schemaVersion": 2, "schemaVersion": 5,
"taxiIsCalled": false, "taxiIsCalled": false,
"tutorialJournalScreenOpened": false, "tutorialJournalScreenOpened": false,
"tutorialNeedOpenTaxiScreen": false, "tutorialNeedOpenTaxiScreen": false,
+8
View File
@@ -125,6 +125,14 @@ endif()
include(CTest) include(CTest)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(
GameServerPathFinderTests tests/PathFinderTests.cpp)
target_link_libraries(GameServerPathFinderTests PRIVATE game_server_core)
source_group(
"Server\\Tests" FILES tests/PathFinderTests.cpp)
add_test(
NAME GameServer.PathFinder
COMMAND GameServerPathFinderTests)
add_executable( add_executable(
GameServerStateTests tests/LocationGameStateTests.cpp) GameServerStateTests tests/LocationGameStateTests.cpp)
target_link_libraries(GameServerStateTests PRIVATE game_server_core) target_link_libraries(GameServerStateTests PRIVATE game_server_core)
+53
View File
@@ -276,5 +276,58 @@ int main()
return fail("Enabled player acquired an autonomous attack target"); return fail("Enabled player acquired an autonomous attack target");
} }
FRG::CharacterState guardedMover;
guardedMover.configureAsPlayer();
guardedMover.enabled = true;
guardedMover.position = Eigen::Vector3f::Zero();
guardedMover.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
guardedMover.requestedWalkTarget = guardedMover.walkTarget;
guardedMover.pathWaypoints = {guardedMover.walkTarget};
const std::vector<FRG::CharacterMovementObstacle> movementObstacles{
{"mover", Eigen::Vector3f::Zero(), 0.45f},
{"blocker", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f}};
FRG::CharacterUpdateContext guardedContext;
guardedContext.characterId = "mover";
guardedContext.movementObstacles = &movementObstacles;
FRG::CharacterUpdateEvents blockedEvents;
for (int i = 0; i < 3; ++i)
blockedEvents = guardedMover.update(100, guardedContext);
if (!guardedMover.position.isZero() ||
blockedEvents.forceNavigationReplan ||
!guardedMover.pathWaypoints.empty()) {
return fail("Movement guard did not cancel the blocked player action");
}
nlohmann::json guardedJson;
guardedMover.save(guardedJson);
FRG::CharacterState restoredGuardedMover;
restoredGuardedMover.load(guardedJson);
if (restoredGuardedMover.movementBlockedTicks !=
guardedMover.movementBlockedTicks ||
restoredGuardedMover.navigationRetryTicks !=
guardedMover.navigationRetryTicks) {
return fail("Movement blockage state did not round trip");
}
FRG::LocationGameState occupancyState;
auto stationary = std::make_shared<FRG::CharacterState>();
stationary->enabled = true;
stationary->hp = 10.f;
stationary->position = Eigen::Vector3f(4.f, 0.f, 0.f);
auto moving = std::make_shared<FRG::CharacterState>();
moving->enabled = true;
moving->hp = 10.f;
moving->pathWaypoints = {Eigen::Vector3f(8.f, 0.f, 0.f)};
moving->movedLastTick = true;
FRG::PlayerStateMap occupancyPlayers{
{"stationary", stationary}, {"moving", moving}};
if (occupancyState.collectPathObstacles(
"requester", occupancyPlayers).size() != 1 ||
occupancyState.collectPathObstacles(
"requester", occupancyPlayers, "moving").size() != 2 ||
!occupancyState.collectPathObstacles(
"stationary", occupancyPlayers).empty()) {
return fail("Stationary obstacle collection policy is incorrect");
}
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
+269
View File
@@ -0,0 +1,269 @@
#include "CharacterState.h"
#include "LocationGameState.h"
#include "navigation/PathFinder.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
namespace {
int fail(const char* message)
{
std::cout << "PathFinderTests: " << message << std::endl;
return EXIT_FAILURE;
}
std::filesystem::path writeGrid(
const std::string& name, int width, int depth, bool corridor)
{
const auto path = std::filesystem::temp_directory_path() / name;
std::ofstream output(path);
output << "cellSize 1\nagentRadius 0.1\nfloorY 0\n"
<< "minX 0\nminZ 0\ngridWidth " << width
<< "\ngridDepth " << depth << "\n";
for (int z = 0; z < depth; ++z) {
for (int x = 0; x < width; ++x)
output << (!corridor || z == depth / 2 ? '1' : '0');
output << '\n';
}
return path;
}
bool samePath(
const std::vector<Eigen::Vector3f>& lhs,
const std::vector<Eigen::Vector3f>& rhs)
{
if (lhs.size() != rhs.size()) return false;
for (size_t i = 0; i < lhs.size(); ++i)
if (lhs[i] != rhs[i]) return false;
return true;
}
float segmentDistanceSquared(
const Eigen::Vector3f& point,
const Eigen::Vector3f& start,
const Eigen::Vector3f& end)
{
const Eigen::Vector2f p(point.x(), point.z());
const Eigen::Vector2f a(start.x(), start.z());
const Eigen::Vector2f segment(end.x() - start.x(), end.z() - start.z());
const float lengthSquared = segment.squaredNorm();
const float t = lengthSquared > 1e-8f
? (std::max)(0.f, (std::min)(1.f, (p - a).dot(segment) / lengthSquared))
: 0.f;
return (p - (a + segment * t)).squaredNorm();
}
void runBenchmark(FRG::PathFinder& pathFinder, int queries)
{
std::vector<FRG::PathFinder::DynamicObstacle> obstacles;
for (int i = 0; i < queries; ++i) {
const float x = 4.5f + static_cast<float>((i * 7) % 70);
const float z = 4.5f + static_cast<float>((i * 13) % 70);
obstacles.push_back({Eigen::Vector3f(x, 0.f, z), 0.25f});
}
const auto start = std::chrono::steady_clock::now();
size_t waypointCount = 0;
for (int i = 0; i < queries; ++i) {
const Eigen::Vector3f from(
1.5f, 0.f, 1.5f + static_cast<float>(i % 30));
const Eigen::Vector3f to(
78.5f, 0.f, 78.5f - static_cast<float>(i % 30));
waypointCount +=
pathFinder.findPathToNearest(from, to, obstacles).size();
}
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
std::cout << "PathFinder benchmark " << queries << " characters/queries: "
<< elapsed.count() << " ms, " << waypointCount
<< " waypoints" << std::endl;
}
} // namespace
int main(int argc, char** argv)
{
const bool benchmark = argc > 1 && std::string(argv[1]) == "--benchmark";
const auto openGrid = writeGrid("frg_pathfinder_open.txt", 80, 80, false);
FRG::PathFinder pathFinder;
pathFinder.build(openGrid.string());
if (!pathFinder.isReady()) return fail("Open test grid did not load");
const FRG::PathFinder::DynamicObstacle first{
Eigen::Vector3f(8.5f, 0.f, 8.5f), 0.75f};
const FRG::PathFinder::DynamicObstacle second{
Eigen::Vector3f(12.5f, 0.f, 10.5f), 0.75f};
const Eigen::Vector3f start(2.5f, 0.f, 8.5f);
const Eigen::Vector3f end(18.5f, 0.f, 8.5f);
const auto ordered = pathFinder.findPathToNearest(
start, end, {first, second});
const auto reversed = pathFinder.findPathToNearest(
start, end, {second, first});
if (ordered.empty() || !samePath(ordered, reversed))
return fail("Obstacle insertion order changed the route");
Eigen::Vector3f previous = start;
for (const Eigen::Vector3f& point : ordered) {
if (segmentDistanceSquared(
first.position, previous, point) < 0.85f * 0.85f)
return fail("Smoothed path crossed a dynamic obstacle");
previous = point;
}
const auto occupiedDestination =
pathFinder.findPathToNearest(start, first.position, {first});
if (occupiedDestination.empty())
return fail("Occupied destination did not produce a nearest route");
const Eigen::Vector3f occupiedEnd = occupiedDestination.back();
const Eigen::Vector2f occupiedDelta(
occupiedEnd.x() - first.position.x(),
occupiedEnd.z() - first.position.z());
if (occupiedDelta.squaredNorm() < 0.85f * 0.85f)
return fail("Occupied destination ended inside the obstacle");
const auto corridorGrid =
writeGrid("frg_pathfinder_corridor.txt", 20, 3, true);
FRG::PathFinder corridor;
corridor.build(corridorGrid.string());
const FRG::PathFinder::DynamicObstacle blocker{
Eigen::Vector3f(10.5f, 0.f, 1.5f), 0.45f};
const auto nearest = corridor.findPathToNearest(
Eigen::Vector3f(1.5f, 0.f, 1.5f),
Eigen::Vector3f(18.5f, 0.f, 1.5f), {blocker});
if (nearest.empty() || nearest.back().x() >= blocker.position.x())
return fail("Nearest fallback ignored a dynamic blocker");
FRG::CharacterState guardedMover;
guardedMover.configureAsPlayer();
guardedMover.enabled = true;
guardedMover.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
guardedMover.requestedWalkTarget = guardedMover.walkTarget;
guardedMover.pathWaypoints = {guardedMover.walkTarget};
const std::vector<FRG::CharacterMovementObstacle> movementObstacles{
{"mover", Eigen::Vector3f::Zero(), 0.45f},
{"blocker", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f}};
FRG::CharacterUpdateContext guardedContext;
guardedContext.characterId = "mover";
guardedContext.movementObstacles = &movementObstacles;
FRG::CharacterUpdateEvents blockedEvents;
for (int i = 0; i < 3; ++i)
blockedEvents = guardedMover.update(100, guardedContext);
if (!guardedMover.position.isZero() ||
blockedEvents.forceNavigationReplan ||
!guardedMover.pathWaypoints.empty() ||
guardedMover.requestedWalkTarget != guardedMover.position ||
guardedMover.currentState != FRG::AnimationState::STAND)
return fail("Blocked player action was not cancelled");
FRG::CharacterState retryingNpc;
retryingNpc.enabled = true;
retryingNpc.configureNavigationRetryCooldowns(
"retrying_npc", {2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
FRG::CharacterState otherNpc;
otherNpc.configureNavigationRetryCooldowns(
"other_npc", {2, 3, 4, 5, 6, 7, 8, 9, 10, 11});
if (otherNpc.navigationRetryCooldownTicks ==
retryingNpc.navigationRetryCooldownTicks)
return fail("Stable NPC IDs produced the same retry sequence");
const int firstCooldown =
retryingNpc.navigationRetryCooldownTicks.front();
retryingNpc.walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
retryingNpc.requestedWalkTarget = retryingNpc.walkTarget;
retryingNpc.pathWaypoints = {retryingNpc.walkTarget};
const std::vector<FRG::CharacterMovementObstacle> movingBlocker{
{"b", Eigen::Vector3f(0.8f, 0.f, 0.f), 0.45f, true,
Eigen::Vector3f(-1.f, 0.f, 0.f)}};
FRG::CharacterUpdateContext retryContext;
retryContext.characterId = "retrying_npc";
retryContext.movementObstacles = &movingBlocker;
FRG::CharacterUpdateEvents retryEvents;
for (int i = 0; i < 3; ++i)
retryEvents = retryingNpc.update(100, retryContext);
if (retryEvents.forceNavigationReplan ||
!retryingNpc.pathWaypoints.empty() ||
retryingNpc.navigationRetryTicks != firstCooldown ||
retryingNpc.requestedWalkTarget != Eigen::Vector3f(2.f, 0.f, 0.f))
return fail("Blocked NPC did not stop with a deterministic cooldown");
for (int i = 0; i < firstCooldown; ++i)
retryEvents = retryingNpc.update(100, retryContext);
if (!retryEvents.forceNavigationReplan ||
retryEvents.blockingCharacterId != "b")
return fail("Blocked NPC did not retry after its cooldown");
retryingNpc.movedLastTick = true;
nlohmann::json blockedState;
retryingNpc.save(blockedState);
FRG::CharacterState restoredMover;
restoredMover.load(blockedState);
if (restoredMover.movementBlockedTicks !=
retryingNpc.movementBlockedTicks ||
restoredMover.navigationRetryTicks !=
retryingNpc.navigationRetryTicks ||
restoredMover.navigationRetryCooldownTicks !=
retryingNpc.navigationRetryCooldownTicks ||
restoredMover.navigationRetryCooldownIndex !=
retryingNpc.navigationRetryCooldownIndex ||
!restoredMover.movedLastTick)
return fail("Blocked movement state did not round trip");
FRG::LocationGameState occupancyState;
auto stationary = std::make_shared<FRG::CharacterState>();
stationary->enabled = true;
stationary->hp = 10.f;
auto moving = std::make_shared<FRG::CharacterState>();
moving->enabled = true;
moving->hp = 10.f;
moving->pathWaypoints = {Eigen::Vector3f(4.f, 0.f, 0.f)};
moving->movedLastTick = true;
FRG::PlayerStateMap occupancyPlayers{
{"stationary", stationary}, {"moving", moving}};
if (occupancyState.collectPathObstacles(
"requester", occupancyPlayers).size() != 1 ||
occupancyState.collectPathObstacles(
"requester", occupancyPlayers, "moving").size() != 2 ||
!occupancyState.collectPathObstacles(
"stationary", occupancyPlayers).empty())
return fail("Stationary occupancy policy is incorrect");
moving->movedLastTick = false;
if (occupancyState.collectPathObstacles(
"requester", occupancyPlayers).size() != 2)
return fail("Physically stalled character was omitted from planning");
FRG::LocationGameState approachingState;
auto left = std::make_shared<FRG::CharacterState>();
left->configureAsPlayer();
left->enabled = true;
left->position = Eigen::Vector3f(0.f, 0.f, 0.f);
left->walkTarget = Eigen::Vector3f(2.f, 0.f, 0.f);
left->requestedWalkTarget = left->walkTarget;
left->pathWaypoints = {left->walkTarget};
auto right = std::make_shared<FRG::CharacterState>();
right->configureAsPlayer();
right->enabled = true;
right->position = Eigen::Vector3f(1.4f, 0.f, 0.f);
right->walkTarget = Eigen::Vector3f(-1.f, 0.f, 0.f);
right->requestedWalkTarget = right->walkTarget;
right->pathWaypoints = {right->walkTarget};
FRG::PlayerStateMap approachingPlayers{{"a", left}, {"b", right}};
for (int i = 0; i < 5; ++i)
approachingState.update(100, approachingPlayers, false);
const float separation = (right->position - left->position).norm();
if (separation + 1e-5f <
left->movementContactRadius + right->movementContactRadius ||
!left->pathWaypoints.empty() || !right->pathWaypoints.empty())
return fail("Colliding players did not stop without overlap");
if (benchmark) {
runBenchmark(pathFinder, 100);
runBenchmark(pathFinder, 1000);
}
std::filesystem::remove(openGrid);
std::filesystem::remove(corridorGrid);
return EXIT_SUCCESS;
}
+57 -12
View File
@@ -41,6 +41,15 @@ int main()
nlohmann::json initialState; nlohmann::json initialState;
authoritative.save(initialState); authoritative.save(initialState);
initialState["players"]["player2"] = initialState["players"]["player"]; initialState["players"]["player2"] = initialState["players"]["player"];
FRG::LocationGameState testLocation;
auto hostileNpc = std::make_shared<FRG::CharacterState>();
hostileNpc->enabled = true;
hostileNpc->hp = 200.f;
testLocation.npcs.emplace("hostile_npc", std::move(hostileNpc));
nlohmann::json testLocationState;
testLocation.save(testLocationState);
initialState["currentLocationId"] = "test_location";
initialState["locations"]["test_location"] = std::move(testLocationState);
authoritative.load(initialState); authoritative.load(initialState);
FRG::WebsocketGameServer server( FRG::WebsocketGameServer server(
authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep", authoritative, {"player", "player2"}, "lockstep-v2:test_lockstep",
@@ -94,19 +103,55 @@ int main()
client->submit(FRG::SetCharacterDestinationCommand{ client->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"}); {}, "spoofed", Eigen::Vector3f(1.234f, 0.f, 2.345f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(20)); secondClient->submit(FRG::SetCharacterDestinationCommand{
{}, "spoofed", Eigen::Vector3f(-1.234f, 0.f, 3.456f), "ignored"});
std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update(); server.update();
server.update(); server.update();
require( server.update();
waitFor([&]() { const bool destinationsConverged = waitFor([&]() {
client->update(0); client->update(0);
return client->snapshot()->revision > 1; secondClient->update(0);
}), const auto first = client->snapshot();
"client did not consume finalized lockstep ticks"); const auto second = secondClient->snapshot();
const auto host = authoritative.snapshot();
return first->players.at("player").state.requestedWalkTarget.isApprox(
Eigen::Vector3f(1.23f, 0.f, 2.35f)) &&
second->players.at("player2").state.requestedWalkTarget.isApprox(
Eigen::Vector3f(-1.23f, 0.f, 3.46f)) &&
first->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget) &&
second->players.at("player").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) &&
host->players.at("player").state.requestedWalkTarget.isApprox(
first->players.at("player").state.requestedWalkTarget) &&
host->players.at("player2").state.requestedWalkTarget.isApprox(
second->players.at("player2").state.requestedWalkTarget);
});
if (!destinationsConverged) {
const auto first = client->snapshot();
const auto second = secondClient->snapshot();
const auto host = authoritative.snapshot();
std::cerr << "destination revisions " << first->revision << ", "
<< second->revision << ", " << host->revision << "\n"
<< "player targets "
<< first->players.at("player").state.requestedWalkTarget.transpose()
<< " / "
<< host->players.at("player").state.requestedWalkTarget.transpose()
<< "\nplayer2 targets "
<< second->players.at("player2").state.requestedWalkTarget.transpose()
<< " / "
<< host->players.at("player2").state.requestedWalkTarget.transpose()
<< std::endl;
}
require(
destinationsConverged,
"simultaneous destinations did not converge across lockstep replicas");
client->submit(FRG::SetCharacterTargetCommand{ client->submit(FRG::SetCharacterTargetCommand{
"stale_location", "spoofed_character", "hostile_npc"}); "stale_location", "spoofed_character", "hostile_npc"});
std::this_thread::sleep_for(std::chrono::milliseconds(20)); std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update();
server.update(); server.update();
server.update(); server.update();
require( require(
@@ -124,7 +169,8 @@ int main()
client->submit(FRG::ClearCharacterAttackTargetCommand{ client->submit(FRG::ClearCharacterAttackTargetCommand{
"stale_location", "spoofed_character"}); "stale_location", "spoofed_character"});
std::this_thread::sleep_for(std::chrono::milliseconds(20)); std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update();
server.update(); server.update();
server.update(); server.update();
require( require(
@@ -139,7 +185,8 @@ int main()
client->submit(FRG::TryCharacterAttackComboCommand{ client->submit(FRG::TryCharacterAttackComboCommand{
"stale_location", "spoofed_character"}); "stale_location", "spoofed_character"});
std::this_thread::sleep_for(std::chrono::milliseconds(20)); std::this_thread::sleep_for(std::chrono::milliseconds(50));
server.update();
server.update(); server.update();
server.update(); server.update();
require( require(
@@ -147,9 +194,7 @@ int main()
client->update(0); client->update(0);
secondClient->update(0); secondClient->update(0);
return client->snapshot()->revision == return client->snapshot()->revision ==
secondClient->snapshot()->revision && secondClient->snapshot()->revision;
client->snapshot()->revision ==
authoritative.snapshot()->revision;
}), }),
"attack combo command was not rebroadcast in lockstep"); "attack combo command was not rebroadcast in lockstep");
+166 -10
View File
@@ -6,10 +6,22 @@
namespace FRG { namespace FRG {
namespace { namespace {
// Tick counts are authoritative and therefore independent of render rate.
constexpr float ATTACK_COOLDOWN_TIME = 1.6f; constexpr float ATTACK_COOLDOWN_TIME = 1.6f;
constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f; constexpr float HOME_DRIFT_CHECK_INTERVAL = 5.0f;
constexpr float TARGET_REPLAN_THRESHOLD = 0.25f; constexpr float TARGET_REPLAN_THRESHOLD = 0.25f;
constexpr float PI = 3.14159265358979323846f; constexpr float PI = 3.14159265358979323846f;
constexpr int BLOCKED_REPLAN_TICKS = 3;
std::uint32_t stableIdHash(const std::string& value)
{
std::uint32_t hash = 2166136261u;
for (const unsigned char byte : value) {
hash ^= byte;
hash *= 16777619u;
}
return hash;
}
} }
void CharacterState::configureAsPlayer() { void CharacterState::configureAsPlayer() {
@@ -23,6 +35,36 @@ void CharacterState::configureAsPlayer() {
isPlayer = true; isPlayer = true;
} }
void CharacterState::configureNavigationRetryCooldowns(
const std::string& entityId, std::vector<int> cooldownTicks)
{
if (cooldownTicks.empty()) {
cooldownTicks = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
}
for (int& ticks : cooldownTicks)
ticks = (std::max)(1, (std::min)(600, ticks));
std::uint32_t state = stableIdHash(entityId);
for (std::size_t i = cooldownTicks.size(); i > 1; --i) {
state = state * 1664525u + 1013904223u;
const std::size_t other = state % i;
std::swap(cooldownTicks[i - 1], cooldownTicks[other]);
}
navigationRetryCooldownTicks = std::move(cooldownTicks);
navigationRetryCooldownIndex = 0;
}
void CharacterState::scheduleNextNavigationRetry()
{
if (navigationRetryCooldownTicks.empty())
configureNavigationRetryCooldowns(npcName);
navigationRetryTicks = navigationRetryCooldownTicks[
navigationRetryCooldownIndex % navigationRetryCooldownTicks.size()];
navigationRetryCooldownIndex =
(navigationRetryCooldownIndex + 1) %
navigationRetryCooldownTicks.size();
}
bool CharacterState::isMoving() const { bool CharacterState::isMoving() const {
Eigen::Vector3f toTarget = walkTarget - position; Eigen::Vector3f toTarget = walkTarget - position;
toTarget.y() = 0.f; toTarget.y() = 0.f;
@@ -42,13 +84,17 @@ void CharacterState::stopInPlace() {
pathWaypoints.clear(); pathWaypoints.clear();
currentWaypointIndex = 0; currentWaypointIndex = 0;
onArrivedCallbackName.clear(); onArrivedCallbackName.clear();
movementBlockedTicks = 0;
navigationRetryTicks = 0;
blockingCharacterId.clear();
} }
void CharacterState::setTarget( void CharacterState::setTarget(
const Eigen::Vector3f& target, const Eigen::Vector3f& target,
std::string callbackName, std::string callbackName,
const LocationNetworkState& location, const LocationNetworkState& location,
const std::string& entityId) const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
{ {
const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z()); const Eigen::Vector3f normalizedTarget(target.x(), 0.f, target.z());
const bool sameRequestedTarget = const bool sameRequestedTarget =
@@ -66,9 +112,13 @@ void CharacterState::setTarget(
requestedWalkTarget = normalizedTarget; requestedWalkTarget = normalizedTarget;
onArrivedCallbackName = std::move(callbackName); onArrivedCallbackName = std::move(callbackName);
movementBlockedTicks = 0;
navigationRetryTicks = 0;
blockingCharacterId.clear();
pathWaypoints = location.navigationMaps.empty() pathWaypoints = location.navigationMaps.empty()
? std::vector<Eigen::Vector3f>{} ? std::vector<Eigen::Vector3f>{}
: location.planPathExceptMyself(position, normalizedTarget, entityId); : location.planPathExceptMyself(
position, normalizedTarget, entityId, dynamicObstacles);
currentWaypointIndex = 0; currentWaypointIndex = 0;
if (!pathWaypoints.empty()) { if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f; for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
@@ -77,26 +127,32 @@ void CharacterState::setTarget(
} }
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z()); walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear(); if (!isPlayer) scheduleNextNavigationRetry();
} }
void CharacterState::forceReplan( void CharacterState::forceReplan(
const LocationNetworkState& location, const std::string& entityId) const LocationNetworkState& location,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles)
{ {
const Eigen::Vector3f normalizedTarget( const Eigen::Vector3f normalizedTarget(
requestedWalkTarget.x(), 0.f, requestedWalkTarget.z()); requestedWalkTarget.x(), 0.f, requestedWalkTarget.z());
pathWaypoints = location.navigationMaps.empty() pathWaypoints = location.navigationMaps.empty()
? std::vector<Eigen::Vector3f>{} ? std::vector<Eigen::Vector3f>{}
: location.planPathExceptMyself(position, normalizedTarget, entityId); : location.planPathExceptMyself(
position, normalizedTarget, entityId, dynamicObstacles);
currentWaypointIndex = 0; currentWaypointIndex = 0;
movementBlockedTicks = 0;
navigationRetryTicks = 0;
if (!pathWaypoints.empty()) { if (!pathWaypoints.empty()) {
for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f; for (Eigen::Vector3f& waypoint : pathWaypoints) waypoint.y() = 0.f;
walkTarget = pathWaypoints.back(); walkTarget = pathWaypoints.back();
blockingCharacterId.clear();
return; return;
} }
walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z()); walkTarget = Eigen::Vector3f(position.x(), 0.f, position.z());
onArrivedCallbackName.clear(); if (!isPlayer) scheduleNextNavigationRetry();
} }
void CharacterState::stopAndRotateToTarget(std::string characterId) void CharacterState::stopAndRotateToTarget(std::string characterId)
@@ -146,7 +202,9 @@ CharacterUpdateEvents CharacterState::update(
std::int64_t deltaMs, const CharacterUpdateContext& context) std::int64_t deltaMs, const CharacterUpdateContext& context)
{ {
CharacterUpdateEvents events; CharacterUpdateEvents events;
movedLastTick = false;
if (!enabled) return events; if (!enabled) return events;
bool navigationPausedThisTick = false;
const CharacterObservation* attackTarget = context.attackTarget; const CharacterObservation* attackTarget = context.attackTarget;
const CharacterObservation* faceTarget = context.faceTarget; const CharacterObservation* faceTarget = context.faceTarget;
@@ -185,12 +243,75 @@ CharacterUpdateEvents CharacterState::update(
if (distance > kWalkThreshold) { if (distance > kWalkThreshold) {
const Eigen::Vector3f direction = toTarget / distance; const Eigen::Vector3f direction = toTarget / distance;
const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f; const float moveAmount = walkSpeed * static_cast<float>(deltaMs) / 1000.f;
position = moveAmount >= distance ? activeTarget : position + direction * moveAmount; Eigen::Vector3f candidate =
position.y() = 0.f; moveAmount >= distance ? activeTarget : position + direction * moveAmount;
candidate.y() = 0.f;
const CharacterMovementObstacle* blocker = nullptr;
if (context.movementObstacles) {
for (const CharacterMovementObstacle& obstacle :
*context.movementObstacles) {
if (obstacle.id == context.characterId) continue;
Eigen::Vector3f startDelta = position - obstacle.position;
Eigen::Vector3f endDelta = candidate - obstacle.position;
startDelta.y() = 0.f;
endDelta.y() = 0.f;
const float minimumDistance =
movementContactRadius + obstacle.radius;
const float minimumDistanceSq =
minimumDistance * minimumDistance;
const Eigen::Vector3f segment = candidate - position;
const float segmentLengthSq =
segment.x() * segment.x() + segment.z() * segment.z();
float t = 0.f;
if (segmentLengthSq > 1e-8f) {
t = -(startDelta.x() * segment.x() +
startDelta.z() * segment.z()) / segmentLengthSq;
t = (std::max)(0.f, (std::min)(1.f, t));
}
Eigen::Vector3f closest = startDelta + segment * t;
closest.y() = 0.f;
const bool movingCloser =
endDelta.squaredNorm() + 1e-6f < startDelta.squaredNorm();
if (movingCloser &&
closest.squaredNorm() < minimumDistanceSq) {
blocker = &obstacle;
break;
}
}
}
if (blocker) {
blockingCharacterId = blocker->id;
currentState = AnimationState::STAND;
const bool sameDirection =
blocker->moving &&
blocker->movementDirection.dot(direction) > 0.5f;
if (sameDirection) {
movementBlockedTicks = 0;
} else if (++movementBlockedTicks >= BLOCKED_REPLAN_TICKS) {
if (isPlayer) {
stopInPlace();
} else {
pathWaypoints.clear();
currentWaypointIndex = 0;
walkTarget =
Eigen::Vector3f(position.x(), 0.f, position.z());
movementBlockedTicks = 0;
scheduleNextNavigationRetry();
navigationPausedThisTick = true;
}
}
} else {
movedLastTick =
(candidate - position).squaredNorm() > 1e-8f;
position = candidate;
movementBlockedTicks = 0;
blockingCharacterId.clear();
}
targetFacingAngle = std::atan2(direction.x(), -direction.z()); targetFacingAngle = std::atan2(direction.x(), -direction.z());
currentState = AnimationState::WALK; if (!blocker) currentState = AnimationState::WALK;
} else { } else {
currentState = AnimationState::STAND; currentState = AnimationState::STAND;
const bool completedPath = !pathWaypoints.empty();
const bool hasNextWaypoint = const bool hasNextWaypoint =
!pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size(); !pathWaypoints.empty() && currentWaypointIndex + 1 < pathWaypoints.size();
if (hasNextWaypoint) { if (hasNextWaypoint) {
@@ -198,7 +319,12 @@ CharacterUpdateEvents CharacterState::update(
} else { } else {
pathWaypoints.clear(); pathWaypoints.clear();
currentWaypointIndex = 0; currentWaypointIndex = 0;
if (!onArrivedCallbackName.empty()) { if (completedPath &&
(requestedWalkTarget - position).norm() >
TARGET_REPLAN_THRESHOLD &&
!isPlayer)
scheduleNextNavigationRetry();
if (completedPath && !onArrivedCallbackName.empty()) {
events.arrivedCallbackName = onArrivedCallbackName; events.arrivedCallbackName = onArrivedCallbackName;
onArrivedCallbackName.clear(); onArrivedCallbackName.clear();
} }
@@ -217,6 +343,18 @@ CharacterUpdateEvents CharacterState::update(
} }
} }
if (!isPlayer && !navigationPausedThisTick && pathWaypoints.empty() &&
(requestedWalkTarget - position).norm() > TARGET_REPLAN_THRESHOLD &&
!events.navigationRequested) {
if (navigationRetryTicks > 0) --navigationRetryTicks;
if (navigationRetryTicks == 0) {
events.navigationRequested = true;
events.forceNavigationReplan = true;
events.navigationTarget = requestedWalkTarget;
events.blockingCharacterId = blockingCharacterId;
}
}
if (hp <= 0.f) { if (hp <= 0.f) {
if (currentState != AnimationState::ACTION_TO_DEATH && if (currentState != AnimationState::ACTION_TO_DEATH &&
currentState != AnimationState::DEATH_IDLE) { currentState != AnimationState::DEATH_IDLE) {
@@ -350,6 +488,13 @@ void CharacterState::save(nlohmann::json& out) const
out["pathWaypoints"] = std::move(wpArr); out["pathWaypoints"] = std::move(wpArr);
out["currentWaypointIndex"] = currentWaypointIndex; out["currentWaypointIndex"] = currentWaypointIndex;
out["homeDriftCheckTimer"] = homeDriftCheckTimer; out["homeDriftCheckTimer"] = homeDriftCheckTimer;
out["movementBlockedTicks"] = movementBlockedTicks;
out["navigationRetryTicks"] = navigationRetryTicks;
out["movedLastTick"] = movedLastTick;
out["navigationRetryCooldownTicks"] = navigationRetryCooldownTicks;
out["navigationRetryCooldownIndex"] = navigationRetryCooldownIndex;
if (!blockingCharacterId.empty())
out["blockingCharacterId"] = blockingCharacterId;
out["animationFrame"] = animationFrame; out["animationFrame"] = animationFrame;
out["visibleRange"] = visibleRange; out["visibleRange"] = visibleRange;
if (!onArrivedCallbackName.empty()) if (!onArrivedCallbackName.empty())
@@ -387,6 +532,17 @@ void CharacterState::load(const nlohmann::json& in)
} }
currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex); currentWaypointIndex = in.value("currentWaypointIndex", currentWaypointIndex);
homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer); homeDriftCheckTimer = in.value("homeDriftCheckTimer", homeDriftCheckTimer);
movementBlockedTicks = in.value("movementBlockedTicks", movementBlockedTicks);
navigationRetryTicks = in.value("navigationRetryTicks", navigationRetryTicks);
movedLastTick = in.value("movedLastTick", movedLastTick);
navigationRetryCooldownTicks = in.value(
"navigationRetryCooldownTicks", navigationRetryCooldownTicks);
navigationRetryCooldownIndex = in.value(
"navigationRetryCooldownIndex", navigationRetryCooldownIndex);
if (!navigationRetryCooldownTicks.empty())
navigationRetryCooldownIndex %= navigationRetryCooldownTicks.size();
blockingCharacterId =
in.value("blockingCharacterId", std::string());
animationFrame = in.value("animationFrame", animationFrame); animationFrame = in.value("animationFrame", animationFrame);
onArrivedCallbackName = in.value("onArrivedCallbackName", std::string()); onArrivedCallbackName = in.value("onArrivedCallbackName", std::string());
} }
+34 -2
View File
@@ -9,6 +9,7 @@
#include <Eigen/Geometry> #include <Eigen/Geometry>
#include "external/nlohmann/json.hpp" #include "external/nlohmann/json.hpp"
#include "ISaveable.h" #include "ISaveable.h"
#include "navigation/PathFinder.h"
namespace FRG { namespace FRG {
@@ -110,8 +111,12 @@ public:
float modelScale = 0.12f; float modelScale = 0.12f;
Eigen::Quaternionf modelCorrectionRotation = Eigen::Quaternionf::Identity(); Eigen::Quaternionf modelCorrectionRotation = Eigen::Quaternionf::Identity();
bool useGpuSkinning = true; bool useGpuSkinning = true;
// Full clearance used while planning around stationary characters.
float collisionRadius = 0.45f; float collisionRadius = 0.45f;
// Smaller physical contact threshold used by the movement guard.
float movementContactRadius = 0.32f;
float interactionRadius = 0.0f; float interactionRadius = 0.0f;
bool movedLastTick = false;
// --- Pathfinding data (serializable parts) --- // --- Pathfinding data (serializable parts) ---
Eigen::Vector3f walkTarget = Eigen::Vector3f::Zero(); Eigen::Vector3f walkTarget = Eigen::Vector3f::Zero();
@@ -121,6 +126,11 @@ public:
// Internal drift-check timer (serializable so it survives save/load). // Internal drift-check timer (serializable so it survives save/load).
float homeDriftCheckTimer = 5.0f; float homeDriftCheckTimer = 5.0f;
int movementBlockedTicks = 0;
int navigationRetryTicks = 0;
std::string blockingCharacterId;
std::vector<int> navigationRetryCooldownTicks;
std::size_t navigationRetryCooldownIndex = 0;
// Global Lua function called when the character reaches the final waypoint. // Global Lua function called when the character reaches the final waypoint.
// The name is serialized, so no runtime callback needs to be rehydrated. // The name is serialized, so no runtime callback needs to be rehydrated.
@@ -131,6 +141,9 @@ public:
// --- Pure-state methods --- // --- Pure-state methods ---
void configureAsPlayer(); void configureAsPlayer();
void configureNavigationRetryCooldowns(
const std::string& entityId,
std::vector<int> cooldownTicks = {});
bool isMoving() const; bool isMoving() const;
const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; } const Eigen::Vector3f& getRequestedWalkTarget() const { return requestedWalkTarget; }
Eigen::Vector3f getCurrentNavigationTarget() const; Eigen::Vector3f getCurrentNavigationTarget() const;
@@ -138,9 +151,13 @@ public:
void setTarget(const Eigen::Vector3f& target, void setTarget(const Eigen::Vector3f& target,
std::string callbackName, std::string callbackName,
const LocationNetworkState& location, const LocationNetworkState& location,
const std::string& entityId); const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
void forceReplan(const LocationNetworkState& location, void forceReplan(const LocationNetworkState& location,
const std::string& entityId); const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>&
dynamicObstacles = {});
void stopAndRotateToTarget(std::string characterId); void stopAndRotateToTarget(std::string characterId);
void resetPlayerAfterDeath(); void resetPlayerAfterDeath();
void tryAttackCombo(); void tryAttackCombo();
@@ -151,6 +168,9 @@ public:
// --- Serialisation (runtime mutable fields only; creation info is reloaded from config) --- // --- Serialisation (runtime mutable fields only; creation info is reloaded from config) ---
void save(nlohmann::json& out) const override; void save(nlohmann::json& out) const override;
void load(const nlohmann::json& in) override; void load(const nlohmann::json& in) override;
private:
void scheduleNextNavigationRetry();
}; };
using PlayerStateMap = using PlayerStateMap =
@@ -161,16 +181,28 @@ struct CharacterObservation {
float hp = 0.f; float hp = 0.f;
}; };
struct CharacterMovementObstacle {
std::string id;
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float radius = 0.f;
bool moving = false;
Eigen::Vector3f movementDirection = Eigen::Vector3f::Zero();
};
struct CharacterUpdateContext { struct CharacterUpdateContext {
std::string characterId;
const CharacterObservation* attackTarget = nullptr; const CharacterObservation* attackTarget = nullptr;
const CharacterObservation* faceTarget = nullptr; const CharacterObservation* faceTarget = nullptr;
const std::vector<CharacterMovementObstacle>* movementObstacles = nullptr;
}; };
struct CharacterUpdateEvents { struct CharacterUpdateEvents {
bool attackImpact = false; bool attackImpact = false;
bool deathAnimationComplete = false; bool deathAnimationComplete = false;
bool navigationRequested = false; bool navigationRequested = false;
bool forceNavigationReplan = false;
Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero(); Eigen::Vector3f navigationTarget = Eigen::Vector3f::Zero();
std::string blockingCharacterId;
std::string arrivedCallbackName; std::string arrivedCallbackName;
std::string attackTargetId; std::string attackTargetId;
Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero(); Eigen::Vector3f attackDirection = Eigen::Vector3f::Zero();
+1 -1
View File
@@ -680,7 +680,7 @@ namespace FRG
// Developer workflow: uncomment to (re)generate the campaign's start-state JSON // Developer workflow: uncomment to (re)generate the campaign's start-state JSON
// from this freshly-rebuilt state after changing gameplay/content JSON or Lua. // from this freshly-rebuilt state after changing gameplay/content JSON or Lua.
// Re-comment before shipping. // Re-comment before shipping.
//saveInitialState(); saveInitialState();
} }
+1 -1
View File
@@ -33,7 +33,7 @@ struct StoredChatMessage {
}; };
struct GameState : public ISaveable { struct GameState : public ISaveable {
static constexpr int kSchemaVersion = 2; static constexpr int kSchemaVersion = 5;
// --- World --- // --- World ---
// Campaign being played (see CampaignRegistry). // Campaign being played (see CampaignRegistry).
+105 -1
View File
@@ -126,6 +126,58 @@ const CharacterState* LocationGameState::findCharacter(
return nullptr; return nullptr;
} }
std::vector<PathFinder::DynamicObstacle> LocationGameState::collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
const std::string& forcedObstacleId) const
{
std::vector<std::pair<std::string, const CharacterState*>> characters;
characters.reserve(players.size() + npcs.size());
for (const auto& [id, character] : players)
if (character) characters.emplace_back(id, character.get());
for (const auto& [id, character] : npcs)
if (character) characters.emplace_back(id, character.get());
std::sort(characters.begin(), characters.end(),
[](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; });
std::vector<PathFinder::DynamicObstacle> obstacles;
obstacles.reserve(characters.size());
for (const auto& [id, character] : characters) {
if (id == requesterId || !character->enabled || character->hp <= 0.f)
continue;
if (character->movedLastTick && id != forcedObstacleId) continue;
obstacles.push_back({character->position, character->collisionRadius});
}
return obstacles;
}
bool LocationGameState::setCharacterDestination(
const std::string& id,
const Eigen::Vector3f& target,
std::string callbackName,
PlayerStateMap& players,
const std::string& forcedObstacleId)
{
CharacterState* character = findCharacter(id, players);
if (!character || !location) return false;
character->setTarget(
target, std::move(callbackName), *location, id,
collectPathObstacles(id, players, forcedObstacleId));
return true;
}
bool LocationGameState::replanCharacter(
const std::string& id,
PlayerStateMap& players,
const std::string& forcedObstacleId)
{
CharacterState* character = findCharacter(id, players);
if (!character || !location) return false;
character->forceReplan(
*location, id, collectPathObstacles(id, players, forcedObstacleId));
return true;
}
void LocationGameState::startTaxi() void LocationGameState::startTaxi()
{ {
location->taxiCar.waypoints = location->taxiCarWaypoints; location->taxiCar.waypoints = location->taxiCarWaypoints;
@@ -330,18 +382,70 @@ LocationSimulationEvents LocationGameState::update(
if (toHome.norm() > CharacterState::kWalkThreshold && if (toHome.norm() > CharacterState::kWalkThreshold &&
(npc.requestedWalkTarget - homeTarget).norm() > (npc.requestedWalkTarget - homeTarget).norm() >
CharacterState::kWalkThreshold) { CharacterState::kWalkThreshold) {
npc.setTarget(homeTarget, {}, *location, id); setCharacterDestination(id, homeTarget, {}, players);
} }
} }
} }
std::vector<CharacterMovementObstacle> movementObstacles;
movementObstacles.reserve(playerIds.size() + npcIds.size());
auto addMovementObstacle =
[&movementObstacles](const std::string& id,
const CharacterState& character) {
Eigen::Vector3f direction =
character.getCurrentNavigationTarget() - character.position;
direction.y() = 0.f;
const float length = direction.norm();
if (length > CharacterState::kWalkThreshold) direction /= length;
else direction = Eigen::Vector3f::Zero();
movementObstacles.push_back({
id,
character.position,
character.movementContactRadius,
character.movedLastTick,
direction});
};
for (const std::string& id : playerIds) {
const CharacterState& character = *players.at(id);
if (character.enabled && character.hp > 0.f)
addMovementObstacle(id, character);
}
for (const std::string& id : npcIds) {
const CharacterState& character = *npcs.at(id);
if (character.enabled && character.hp > 0.f)
addMovementObstacle(id, character);
}
// One global ID order also makes blocker selection deterministic.
std::sort(
movementObstacles.begin(), movementObstacles.end(),
[](const CharacterMovementObstacle& lhs,
const CharacterMovementObstacle& rhs) { return lhs.id < rhs.id; });
auto advance = [&](const std::string& id, CharacterState& state) { auto advance = [&](const std::string& id, CharacterState& state) {
CharacterUpdateContext context; CharacterUpdateContext context;
context.characterId = id;
context.movementObstacles = &movementObstacles;
auto attack = observations.find(state.attackTargetId); auto attack = observations.find(state.attackTargetId);
if (attack != observations.end()) context.attackTarget = &attack->second; if (attack != observations.end()) context.attackTarget = &attack->second;
auto face = observations.find(state.faceTargetId); auto face = observations.find(state.faceTargetId);
if (face != observations.end()) context.faceTarget = &face->second; if (face != observations.end()) context.faceTarget = &face->second;
CharacterUpdateEvents events = state.update(deltaMs, context); CharacterUpdateEvents events = state.update(deltaMs, context);
auto reservation = std::lower_bound(
movementObstacles.begin(), movementObstacles.end(), id,
[](const CharacterMovementObstacle& obstacle,
const std::string& value) { return obstacle.id < value; });
if (reservation != movementObstacles.end() && reservation->id == id) {
reservation->position = state.position;
reservation->moving = state.movedLastTick;
Eigen::Vector3f direction =
state.getCurrentNavigationTarget() - state.position;
direction.y() = 0.f;
const float length = direction.norm();
if (length > CharacterState::kWalkThreshold)
reservation->movementDirection = direction / length;
else
reservation->movementDirection = Eigen::Vector3f::Zero();
}
if (!events.arrivedCallbackName.empty() || events.attackImpact || if (!events.arrivedCallbackName.empty() || events.attackImpact ||
events.deathAnimationComplete || events.navigationRequested) { events.deathAnimationComplete || events.navigationRequested) {
result.characters.push_back({id, std::move(events)}); result.characters.push_back({id, std::move(events)});
+14
View File
@@ -64,6 +64,20 @@ struct LocationGameState {
CharacterState* findCharacter(const std::string& id, PlayerStateMap& players); CharacterState* findCharacter(const std::string& id, PlayerStateMap& players);
const CharacterState* findCharacter( const CharacterState* findCharacter(
const std::string& id, const PlayerStateMap& players) const; const std::string& id, const PlayerStateMap& players) const;
std::vector<PathFinder::DynamicObstacle> collectPathObstacles(
const std::string& requesterId,
const PlayerStateMap& players,
const std::string& forcedObstacleId = {}) const;
bool setCharacterDestination(
const std::string& id,
const Eigen::Vector3f& target,
std::string callbackName,
PlayerStateMap& players,
const std::string& forcedObstacleId = {});
bool replanCharacter(
const std::string& id,
PlayerStateMap& players,
const std::string& forcedObstacleId = {});
void startTaxi(); void startTaxi();
void resetTaxi(); void resetTaxi();
LocationSimulationEvents update( LocationSimulationEvents update(
+12 -2
View File
@@ -122,9 +122,19 @@ void loadSinglePlayerPersistence(
} }
std::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const // Entity ownership stays above this navigation-only layer.
std::vector<Eigen::Vector3f> LocationNetworkState::planPathExceptMyself(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles) const
{ {
return navigationMaps[activeNavigationIndex].findPathToNearest(start, end, {}); (void)entityId;
if (activeNavigationIndex < 0 ||
activeNavigationIndex >= static_cast<int>(navigationMaps.size()))
return {};
return navigationMaps[activeNavigationIndex].findPathToNearest(
start, end, dynamicObstacles);
} }
void LocationNetworkState::initNavigation( void LocationNetworkState::initNavigation(
+5 -1
View File
@@ -41,7 +41,11 @@ struct LocationNetworkState : public ISaveable {
//This value is populated from the JSON configs and should not be changed during the regular gameplay. //This value is populated from the JSON configs and should not be changed during the regular gameplay.
//It might be changed only in the editor mode during development. //It might be changed only in the editor mode during development.
std::vector<PathFinder> navigationMaps; std::vector<PathFinder> navigationMaps;
std::vector<Eigen::Vector3f> planPathExceptMyself(const Eigen::Vector3f& start, const Eigen::Vector3f& end, const std::string& entityId) const; std::vector<Eigen::Vector3f> planPathExceptMyself(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end,
const std::string& entityId,
const std::vector<PathFinder::DynamicObstacle>& dynamicObstacles = {}) const;
void initNavigation( void initNavigation(
const std::vector<std::string>& paths, const std::vector<std::string>& paths,
const std::string& zipFile = std::string()); const std::string& zipFile = std::string());
+33
View File
@@ -183,7 +183,12 @@ CharacterState NpcData::toCharacterState() const
state.visibleRange = visibleRange; state.visibleRange = visibleRange;
state.canAttack = canAttack; state.canAttack = canAttack;
state.enabled = enabled; state.enabled = enabled;
state.collisionRadius = navigationAvoidanceRadius;
state.movementContactRadius =
(std::min)(movementContactRadius, navigationAvoidanceRadius);
state.interactionRadius = interactionRadius; state.interactionRadius = interactionRadius;
state.configureNavigationRetryCooldowns(
id, navigationRetryCooldownTicks);
Eigen::Quaternionf correction = Eigen::Quaternionf::Identity(); Eigen::Quaternionf correction = Eigen::Quaternionf::Identity();
if (modelCorrectionRotX != 0.f) if (modelCorrectionRotX != 0.f)
@@ -342,6 +347,34 @@ LocationDefinition LocationContentLoader::load(
item.value("visibleRange", VISIBLE_RANGE); item.value("visibleRange", VISIBLE_RANGE);
data.canAttack = item.value("canAttack", false); data.canAttack = item.value("canAttack", false);
data.enabled = item.value("enabled", true); data.enabled = item.value("enabled", true);
data.navigationAvoidanceRadius = item.value(
"navigationAvoidanceRadius",
item.value("collisionRadius", 0.45f));
data.navigationAvoidanceRadius =
(std::max)(0.05f, data.navigationAvoidanceRadius);
data.movementContactRadius = item.value(
"movementContactRadius",
data.navigationAvoidanceRadius * 0.72f);
data.movementContactRadius = (std::max)(
0.05f, (std::min)(
data.movementContactRadius,
data.navigationAvoidanceRadius));
const char* cooldownKey =
item.contains("navigationRetryCooldownTicks")
? "navigationRetryCooldownTicks"
: "navigationRetryCooldownMs";
if (item.contains(cooldownKey) &&
item.at(cooldownKey).is_array()) {
for (const auto& value : item.at(cooldownKey)) {
if (!value.is_number_integer()) continue;
int ticks = value.get<int>();
if (std::string(cooldownKey) ==
"navigationRetryCooldownMs") {
ticks = (std::max)(1, (ticks + 99) / 100);
}
data.navigationRetryCooldownTicks.push_back(ticks);
}
}
result.npcs.push_back(std::move(data)); result.npcs.push_back(std::move(data));
} }
} }
+3
View File
@@ -72,7 +72,10 @@ struct NpcData {
float visibleRange = VISIBLE_RANGE; float visibleRange = VISIBLE_RANGE;
bool canAttack = false; bool canAttack = false;
bool enabled = true; bool enabled = true;
float navigationAvoidanceRadius = 0.45f;
float movementContactRadius = 0.32f;
float interactionRadius = 0.f; float interactionRadius = 0.f;
std::vector<int> navigationRetryCooldownTicks;
CharacterState toCharacterState() const; CharacterState toCharacterState() const;
}; };
+25 -24
View File
@@ -323,9 +323,9 @@ void InternalGameServer::submit(const GameCommand& command)
LocationNetworkState& locationState = *location->second.location; LocationNetworkState& locationState = *location->second.location;
locationState.activeNavigationIndex = value.index; locationState.activeNavigationIndex = value.index;
for (const auto& [id, player] : state_.players) for (const auto& [id, player] : state_.players)
if (player) player->forceReplan(locationState, id); if (player) location->second.replanCharacter(id, state_.players);
for (const auto& [id, npc] : location->second.npcs) for (const auto& [id, npc] : location->second.npcs)
if (npc) npc->forceReplan(locationState, id); if (npc) location->second.replanCharacter(id, state_.players);
} }
} else if constexpr (std::is_same_v<T, SetNavigationAreaAvailableCommand>) { } else if constexpr (std::is_same_v<T, SetNavigationAreaAvailableCommand>) {
auto location = state_.locations.find(value.locationId); auto location = state_.locations.find(value.locationId);
@@ -336,6 +336,10 @@ void InternalGameServer::submit(const GameCommand& command)
index < static_cast<int>(locationState.navigationMaps.size())) { index < static_cast<int>(locationState.navigationMaps.size())) {
locationState.navigationMaps[index].setAreaAvailable( locationState.navigationMaps[index].setAreaAvailable(
value.areaName, value.available); value.areaName, value.available);
for (const auto& [id, player] : state_.players)
if (player) location->second.replanCharacter(id, state_.players);
for (const auto& [id, npc] : location->second.npcs)
if (npc) location->second.replanCharacter(id, state_.players);
} }
} }
} else if constexpr (std::is_same_v<T, AddNavigationObstacleCommand>) { } else if constexpr (std::is_same_v<T, AddNavigationObstacleCommand>) {
@@ -349,6 +353,10 @@ void InternalGameServer::submit(const GameCommand& command)
polygon.name = value.name; polygon.name = value.name;
polygon.polygon = value.polygon; polygon.polygon = value.polygon;
locationState.navigationMaps[index].addObstaclePolygon(polygon); locationState.navigationMaps[index].addObstaclePolygon(polygon);
for (const auto& [id, player] : state_.players)
if (player) location->second.replanCharacter(id, state_.players);
for (const auto& [id, npc] : location->second.npcs)
if (npc) location->second.replanCharacter(id, state_.players);
} }
} }
} else if constexpr (std::is_same_v<T, AddInventoryItemCommand>) { } else if constexpr (std::is_same_v<T, AddInventoryItemCommand>) {
@@ -424,14 +432,10 @@ void InternalGameServer::submit(const GameCommand& command)
if (character) character->attackTargetId.clear(); if (character) character->attackTargetId.clear();
} else if constexpr (std::is_same_v<T, SetCharacterDestinationCommand>) { } else if constexpr (std::is_same_v<T, SetCharacterDestinationCommand>) {
auto location = state_.locations.find(value.locationId); auto location = state_.locations.find(value.locationId);
CharacterState* character = if (location != state_.locations.end())
state_.findCharacter(value.locationId, value.characterId); location->second.setCharacterDestination(
if (character && location != state_.locations.end() && value.characterId, value.target, value.callbackName,
location->second.location) { state_.players);
character->setTarget(
value.target, value.callbackName,
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, StopCharacterCommand>) { } else if constexpr (std::is_same_v<T, StopCharacterCommand>) {
CharacterState* character = CharacterState* character =
state_.findCharacter(value.locationId, value.characterId); state_.findCharacter(value.locationId, value.characterId);
@@ -443,13 +447,9 @@ void InternalGameServer::submit(const GameCommand& command)
character->stopAndRotateToTarget(value.targetId); character->stopAndRotateToTarget(value.targetId);
} else if constexpr (std::is_same_v<T, ReplanCharacterCommand>) { } else if constexpr (std::is_same_v<T, ReplanCharacterCommand>) {
auto location = state_.locations.find(value.locationId); auto location = state_.locations.find(value.locationId);
CharacterState* character = if (location != state_.locations.end())
state_.findCharacter(value.locationId, value.characterId); location->second.replanCharacter(
if (character && location != state_.locations.end() && value.characterId, state_.players);
location->second.location) {
character->forceReplan(
*location->second.location, value.characterId);
}
} else if constexpr (std::is_same_v<T, ResetCharacterAfterDeathCommand>) { } else if constexpr (std::is_same_v<T, ResetCharacterAfterDeathCommand>) {
CharacterState* character = CharacterState* character =
state_.findCharacter(value.locationId, value.characterId); state_.findCharacter(value.locationId, value.characterId);
@@ -518,13 +518,14 @@ void InternalGameServer::update(std::int64_t deltaMs)
for (const CharacterSimulationEvent& event : for (const CharacterSimulationEvent& event :
pendingSimulationEvents_.characters) { pendingSimulationEvents_.characters) {
if (!event.events.navigationRequested) continue; if (!event.events.navigationRequested) continue;
CharacterState* character = state_.findCharacter( if (event.events.forceNavigationReplan)
state_.currentLocationName, event.characterId); stored->second.replanCharacter(
if (character && stored->second.location) { event.characterId, state_.players,
character->setTarget( event.events.blockingCharacterId);
event.events.navigationTarget, {}, else
*stored->second.location, event.characterId); stored->second.setCharacterDestination(
} event.characterId, event.events.navigationTarget, {},
state_.players);
} }
publishMutation(); publishMutation();
} }
+200 -319
View File
@@ -14,15 +14,6 @@ namespace FRG {
using json = nlohmann::json; using json = nlohmann::json;
namespace { namespace {
const float INF_COST = (std::numeric_limits<float>::max)();
float distanceCells(const PathFinder::Cell& a, const PathFinder::Cell& b)
{
const float dx = static_cast<float>(a.x - b.x);
const float dz = static_cast<float>(a.z - b.z);
return std::sqrt(dx * dx + dz * dz);
}
std::vector<Eigen::Vector2f> readPolygon(const json& item) std::vector<Eigen::Vector2f> readPolygon(const json& item)
{ {
std::vector<Eigen::Vector2f> polygon; std::vector<Eigen::Vector2f> polygon;
@@ -127,397 +118,287 @@ void PathFinder::build(const std::string& configPath,
std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start, std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end) const const Eigen::Vector3f& end) const
{ {
if (!ready || walkable.empty()) { const std::lock_guard<std::mutex> lock(*queryMutex);
return {}; return runSearch(start, end, 0, false);
}
Cell startCell;
Cell endCell;
if (!findNearestWalkableCell(start, startCell, walkable) ||
!findNearestWalkableCell(end, endCell, walkable)) {
return {};
}
if (startCell.x == endCell.x && startCell.z == endCell.z) {
return { cellCenter(endCell) };
}
struct QueueNode {
int index = 0;
float priority = 0.0f;
bool operator<(const QueueNode& other) const
{
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
};
const int cellCount = gridWidth * gridDepth;
std::vector<float> cost(static_cast<size_t>(cellCount), INF_COST);
std::vector<int> cameFrom(static_cast<size_t>(cellCount), -1);
std::priority_queue<QueueNode> open;
const int startIndex = indexOf(startCell);
const int endIndex = indexOf(endCell);
cost[static_cast<size_t>(startIndex)] = 0.0f;
open.push({ startIndex, 0.0f });
static const int offsets[8][2] = {
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }
};
while (!open.empty()) {
const QueueNode current = open.top();
open.pop();
if (current.index == endIndex) {
break;
}
const Cell currentCell{ current.index % gridWidth, current.index / gridWidth };
for (const auto& offset : offsets) {
Cell next{ currentCell.x + offset[0], currentCell.z + offset[1] };
if (!isCellWalkable(next, walkable)) {
continue;
}
const bool diagonal = offset[0] != 0 && offset[1] != 0;
if (diagonal) {
Cell horizontal{ currentCell.x + offset[0], currentCell.z };
Cell vertical{ currentCell.x, currentCell.z + offset[1] };
if (!isCellWalkable(horizontal, walkable) || !isCellWalkable(vertical, walkable)) {
continue;
}
}
const int nextIndex = indexOf(next);
const float stepCost = diagonal ? 1.41421356f : 1.0f;
const float newCost = cost[static_cast<size_t>(current.index)] + stepCost;
if (newCost >= cost[static_cast<size_t>(nextIndex)]) {
continue;
}
cost[static_cast<size_t>(nextIndex)] = newCost;
cameFrom[static_cast<size_t>(nextIndex)] = current.index;
const float priority = newCost + distanceCells(next, endCell);
open.push({ nextIndex, priority });
}
}
if (cameFrom[static_cast<size_t>(endIndex)] == -1) {
return {};
}
std::vector<Cell> cells;
for (int current = endIndex; current != -1; current = cameFrom[static_cast<size_t>(current)]) {
cells.push_back({ current % gridWidth, current / gridWidth });
if (current == startIndex) {
break;
}
}
std::reverse(cells.begin(), cells.end());
cells = smoothCells(cells, walkable);
std::vector<Eigen::Vector3f> path;
path.reserve(cells.size());
for (const Cell& cell : cells) {
path.push_back(cellCenter(cell));
}
if (!path.empty() && (path.front() - Eigen::Vector3f(start.x(), floorY, start.z())).norm() < cellSize * 0.75f) {
path.erase(path.begin());
}
if (!path.empty()) {
Cell requestedEndCell;
if (worldToCell(end, requestedEndCell) &&
requestedEndCell.x == endCell.x &&
requestedEndCell.z == endCell.z) {
path.back() = Eigen::Vector3f(end.x(), floorY, end.z());
}
}
return path;
} }
std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start, std::vector<Eigen::Vector3f> PathFinder::findPath(const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const const std::vector<DynamicObstacle>& dynamicObstacles) const
{ {
if (!ready || walkable.empty()) { const std::lock_guard<std::mutex> lock(*queryMutex);
return {}; return runSearch(start, end, buildDynamicOverlay(dynamicObstacles), false);
}
std::uint32_t PathFinder::buildDynamicOverlay(
const std::vector<DynamicObstacle>& dynamicObstacles) const
{
const size_t cellCount = walkable.size();
if (dynamicBlockedGeneration.size() != cellCount) {
dynamicBlockedGeneration.assign(cellCount, 0);
dynamicGeneration = 0;
}
if (++dynamicGeneration == 0) {
std::fill(dynamicBlockedGeneration.begin(), dynamicBlockedGeneration.end(), 0);
dynamicGeneration = 1;
} }
std::vector<unsigned char> walkableGrid = walkable;
if (!dynamicObstacles.empty()) {
for (const DynamicObstacle& obstacle : dynamicObstacles) { for (const DynamicObstacle& obstacle : dynamicObstacles) {
const float radius = obstacle.radius + agentRadius; // Include the cell half-diagonal so a segment between adjacent
if (radius <= 0.0f) continue; // unblocked cell centres cannot geometrically cut through the circle.
const float radius =
const float minWorldX = obstacle.position.x() - radius; obstacle.radius + agentRadius + cellSize * 0.70710678f;
const float maxWorldX = obstacle.position.x() + radius; if (radius <= 0.f) continue;
const float minWorldZ = obstacle.position.z() - radius; const int minCellX = (std::max)(
const float maxWorldZ = obstacle.position.z() + radius; 0, static_cast<int>(std::floor((obstacle.position.x() - radius - minX) / cellSize)));
const int maxCellX = (std::min)(
const int minCellX = (std::max)(0, static_cast<int>(std::floor((minWorldX - minX) / cellSize))); gridWidth - 1, static_cast<int>(std::floor((obstacle.position.x() + radius - minX) / cellSize)));
const int maxCellX = (std::min)(gridWidth - 1, static_cast<int>(std::floor((maxWorldX - minX) / cellSize))); const int minCellZ = (std::max)(
const int minCellZ = (std::max)(0, static_cast<int>(std::floor((minWorldZ - minZ) / cellSize))); 0, static_cast<int>(std::floor((obstacle.position.z() - radius - minZ) / cellSize)));
const int maxCellZ = (std::min)(gridDepth - 1, static_cast<int>(std::floor((maxWorldZ - minZ) / cellSize))); const int maxCellZ = (std::min)(
gridDepth - 1, static_cast<int>(std::floor((obstacle.position.z() + radius - minZ) / cellSize)));
const float radiusSq = radius * radius; const float radiusSq = radius * radius;
for (int z = minCellZ; z <= maxCellZ; ++z) { for (int z = minCellZ; z <= maxCellZ; ++z) {
for (int x = minCellX; x <= maxCellX; ++x) { for (int x = minCellX; x <= maxCellX; ++x) {
const Cell cell{ x, z }; const Cell cell{x, z};
const Eigen::Vector3f center = cellCenter(cell); const Eigen::Vector3f center = cellCenter(cell);
const float dx = center.x() - obstacle.position.x(); const float dx = center.x() - obstacle.position.x();
const float dz = center.z() - obstacle.position.z(); const float dz = center.z() - obstacle.position.z();
if (dx * dx + dz * dz <= radiusSq) { if (dx * dx + dz * dz <= radiusSq)
const int idx = indexOf(cell); dynamicBlockedGeneration[static_cast<size_t>(indexOf(cell))] =
if (idx >= 0 && idx < static_cast<int>(walkableGrid.size())) { dynamicGeneration;
walkableGrid[static_cast<size_t>(idx)] = 0;
} }
} }
} }
} return dynamicGeneration;
}
}
Cell startCell;
Cell endCell;
if (!findNearestWalkableCell(start, startCell, walkableGrid) ||
!findNearestWalkableCell(end, endCell, walkableGrid)) {
return {};
}
if (startCell.x == endCell.x && startCell.z == endCell.z) {
return { cellCenter(endCell) };
}
struct QueueNode {
int index = 0;
float priority = 0.0f;
bool operator<(const QueueNode& other) const
{
if (priority != other.priority) return priority > other.priority;
return index > other.index;
}
};
const int cellCount = gridWidth * gridDepth;
std::vector<float> cost(static_cast<size_t>(cellCount), INF_COST);
std::vector<int> cameFrom(static_cast<size_t>(cellCount), -1);
std::priority_queue<QueueNode> open;
const int startIndex = indexOf(startCell);
const int endIndex = indexOf(endCell);
cost[static_cast<size_t>(startIndex)] = 0.0f;
open.push({ startIndex, 0.0f });
static const int offsets[8][2] = {
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }
};
while (!open.empty()) {
const QueueNode current = open.top();
open.pop();
if (current.index == endIndex) {
break;
}
const Cell currentCell{ current.index % gridWidth, current.index / gridWidth };
for (const auto& offset : offsets) {
Cell next{ currentCell.x + offset[0], currentCell.z + offset[1] };
if (!isCellWalkable(next, walkableGrid)) {
continue;
}
const bool diagonal = offset[0] != 0 && offset[1] != 0;
if (diagonal) {
Cell horizontal{ currentCell.x + offset[0], currentCell.z };
Cell vertical{ currentCell.x, currentCell.z + offset[1] };
if (!isCellWalkable(horizontal, walkableGrid) || !isCellWalkable(vertical, walkableGrid)) {
continue;
}
}
const int nextIndex = indexOf(next);
const float stepCost = diagonal ? 1.41421356f : 1.0f;
const float newCost = cost[static_cast<size_t>(current.index)] + stepCost;
if (newCost >= cost[static_cast<size_t>(nextIndex)]) {
continue;
}
cost[static_cast<size_t>(nextIndex)] = newCost;
cameFrom[static_cast<size_t>(nextIndex)] = current.index;
const float priority = newCost + distanceCells(next, endCell);
open.push({ nextIndex, priority });
}
}
if (cameFrom[static_cast<size_t>(endIndex)] == -1) {
return {};
}
std::vector<Cell> cells;
for (int current = endIndex; current != -1; current = cameFrom[static_cast<size_t>(current)]) {
cells.push_back({ current % gridWidth, current / gridWidth });
if (current == startIndex) {
break;
}
}
std::reverse(cells.begin(), cells.end());
cells = smoothCells(cells, walkableGrid);
std::vector<Eigen::Vector3f> path;
path.reserve(cells.size());
for (const Cell& cell : cells) {
path.push_back(cellCenter(cell));
}
if (!path.empty() && (path.front() - Eigen::Vector3f(start.x(), floorY, start.z())).norm() < cellSize * 0.75f) {
path.erase(path.begin());
}
if (!path.empty()) {
Cell requestedEndCell;
if (worldToCell(end, requestedEndCell) &&
requestedEndCell.x == endCell.x &&
requestedEndCell.z == endCell.z) {
path.back() = Eigen::Vector3f(end.x(), floorY, end.z());
}
}
return path;
} }
std::vector<Eigen::Vector3f> PathFinder::findNearestReachableImpl( bool PathFinder::isCellWalkableOverlay(
const Cell& cell, std::uint32_t generation) const
{
if (!isCellWalkable(cell)) return false;
return generation == 0 ||
dynamicBlockedGeneration[static_cast<size_t>(indexOf(cell))] != generation;
}
bool PathFinder::findNearestWalkableCellOverlay(
const Eigen::Vector3f& point, Cell& out, std::uint32_t generation) const
{
Cell origin;
if (!worldToCell(point, origin)) {
origin.x = (std::max)(0, (std::min)(
gridWidth - 1, static_cast<int>(std::floor((point.x() - minX) / cellSize))));
origin.z = (std::max)(0, (std::min)(
gridDepth - 1, static_cast<int>(std::floor((point.z() - minZ) / cellSize))));
}
if (isCellWalkableOverlay(origin, generation)) {
out = origin;
return true;
}
const int maxRadius =
(std::max)(4, static_cast<int>(std::ceil(8.0f / cellSize)));
for (int radius = 1; radius <= maxRadius; ++radius) {
for (int z = origin.z - radius; z <= origin.z + radius; ++z) {
for (int x = origin.x - radius; x <= origin.x + radius; ++x) {
if (std::abs(x - origin.x) != radius &&
std::abs(z - origin.z) != radius) continue;
const Cell candidate{x, z};
if (isCellWalkableOverlay(candidate, generation)) {
out = candidate;
return true;
}
}
}
}
return false;
}
bool PathFinder::hasLineOfSightOverlay(
const Cell& from, const Cell& to, std::uint32_t generation) const
{
int x = from.x;
int z = from.z;
const int dx = std::abs(to.x - from.x);
const int dz = std::abs(to.z - from.z);
const int sx = from.x < to.x ? 1 : -1;
const int sz = from.z < to.z ? 1 : -1;
int error = dx - dz;
while (true) {
if (!isCellWalkableOverlay({x, z}, generation)) return false;
if (x == to.x && z == to.z) return true;
const int twiceError = error * 2;
const int oldX = x;
const int oldZ = z;
if (twiceError > -dz) {
error -= dz;
x += sx;
}
if (twiceError < dx) {
error += dx;
z += sz;
}
if (x != oldX && z != oldZ &&
(!isCellWalkableOverlay({x, oldZ}, generation) ||
!isCellWalkableOverlay({oldX, z}, generation))) {
return false;
}
}
}
std::vector<PathFinder::Cell> PathFinder::smoothCellsOverlay(
const std::vector<Cell>& cells, std::uint32_t generation) const
{
if (cells.size() <= 2) return cells;
std::vector<Cell> result;
result.push_back(cells.front());
size_t anchor = 0;
while (anchor + 1 < cells.size()) {
size_t next = cells.size() - 1;
while (next > anchor + 1 &&
!hasLineOfSightOverlay(cells[anchor], cells[next], generation)) {
--next;
}
result.push_back(cells[next]);
anchor = next;
}
return result;
}
std::vector<Eigen::Vector3f> PathFinder::runSearch(
const Eigen::Vector3f& start, const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<unsigned char>& walkableGrid) const std::uint32_t obstacleGeneration,
bool nearestReachable) const
{ {
if (!ready || walkableGrid.empty()) return {}; if (!ready || walkable.empty()) return {};
Cell startCell; Cell startCell;
Cell endCell; Cell endCell;
if (!findNearestWalkableCell(start, startCell, walkableGrid)) return {}; if (!findNearestWalkableCellOverlay(start, startCell, obstacleGeneration) ||
if (!findNearestWalkableCell(end, endCell, walkableGrid)) return {}; !findNearestWalkableCellOverlay(end, endCell, obstacleGeneration)) return {};
if (startCell.x == endCell.x && startCell.z == endCell.z)
return {cellCenter(endCell)};
const size_t cellCount = walkable.size();
if (searchCost.size() != cellCount) {
searchCost.resize(cellCount);
searchCameFrom.resize(cellCount);
searchVisitedGeneration.assign(cellCount, 0);
searchGeneration = 0;
}
if (++searchGeneration == 0) {
std::fill(searchVisitedGeneration.begin(), searchVisitedGeneration.end(), 0);
searchGeneration = 1;
}
const auto heuristic = [](const Cell& a, const Cell& b) {
const int dx = std::abs(a.x - b.x);
const int dz = std::abs(a.z - b.z);
return 14 * (std::min)(dx, dz) + 10 * (std::max(dx, dz) - (std::min)(dx, dz));
};
struct QueueNode { struct QueueNode {
int index = 0; int index;
float priority = 0.0f; int priority;
int cost;
bool operator<(const QueueNode& other) const { bool operator<(const QueueNode& other) const {
if (priority != other.priority) return priority > other.priority; if (priority != other.priority) return priority > other.priority;
if (cost != other.cost) return cost > other.cost;
return index > other.index; return index > other.index;
} }
}; };
const int cellCount = gridWidth * gridDepth;
std::vector<float> cost(static_cast<size_t>(cellCount), INF_COST);
std::vector<int> cameFrom(static_cast<size_t>(cellCount), -1);
std::priority_queue<QueueNode> open; std::priority_queue<QueueNode> open;
const int startIndex = indexOf(startCell); const int startIndex = indexOf(startCell);
const int endIndex = indexOf(endCell); const int endIndex = indexOf(endCell);
searchVisitedGeneration[static_cast<size_t>(startIndex)] = searchGeneration;
cost[static_cast<size_t>(startIndex)] = 0.f; searchCost[static_cast<size_t>(startIndex)] = 0;
open.push({ startIndex, 0.f }); searchCameFrom[static_cast<size_t>(startIndex)] = -1;
open.push({startIndex, heuristic(startCell, endCell), 0});
int bestIndex = startIndex; int bestIndex = startIndex;
float bestDist = distanceCells(startCell, endCell); int bestDistanceSq =
(startCell.x - endCell.x) * (startCell.x - endCell.x) +
(startCell.z - endCell.z) * (startCell.z - endCell.z);
static const int offsets[8][2] = { static const int offsets[8][2] = {
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 }
}; };
while (!open.empty()) { while (!open.empty()) {
const QueueNode current = open.top(); const QueueNode current = open.top();
open.pop(); open.pop();
const size_t currentOffset = static_cast<size_t>(current.index);
if (searchVisitedGeneration[currentOffset] != searchGeneration ||
searchCost[currentOffset] != current.cost) continue;
if (current.index == endIndex) { if (current.index == endIndex) {
bestIndex = endIndex; bestIndex = endIndex;
break; break;
} }
if (cost[static_cast<size_t>(current.index)] < current.priority - distanceCells({ current.index % gridWidth, current.index / gridWidth }, endCell)) {
continue; // stale entry
}
const Cell currentCell{ current.index % gridWidth, current.index / gridWidth }; const Cell currentCell{ current.index % gridWidth, current.index / gridWidth };
const float d = distanceCells(currentCell, endCell); const int dx = currentCell.x - endCell.x;
if (d < bestDist) { const int dz = currentCell.z - endCell.z;
bestDist = d; const int distanceSq = dx * dx + dz * dz;
if (distanceSq < bestDistanceSq ||
(distanceSq == bestDistanceSq && current.index < bestIndex)) {
bestDistanceSq = distanceSq;
bestIndex = current.index; bestIndex = current.index;
} }
for (const auto& offset : offsets) { for (const auto& offset : offsets) {
Cell next{ currentCell.x + offset[0], currentCell.z + offset[1] }; Cell next{ currentCell.x + offset[0], currentCell.z + offset[1] };
if (!isCellWalkable(next, walkableGrid)) continue; if (!isCellWalkableOverlay(next, obstacleGeneration)) continue;
const bool diagonal = offset[0] != 0 && offset[1] != 0; const bool diagonal = offset[0] != 0 && offset[1] != 0;
if (diagonal) { if (diagonal) {
Cell h{ currentCell.x + offset[0], currentCell.z }; Cell h{ currentCell.x + offset[0], currentCell.z };
Cell v{ currentCell.x, currentCell.z + offset[1] }; Cell v{ currentCell.x, currentCell.z + offset[1] };
if (!isCellWalkable(h, walkableGrid) || !isCellWalkable(v, walkableGrid)) continue; if (!isCellWalkableOverlay(h, obstacleGeneration) ||
!isCellWalkableOverlay(v, obstacleGeneration)) continue;
} }
const int nextIndex = indexOf(next); const int nextIndex = indexOf(next);
const float stepCost = diagonal ? 1.41421356f : 1.0f; const size_t nextOffset = static_cast<size_t>(nextIndex);
const float newCost = cost[static_cast<size_t>(current.index)] + stepCost; const int newCost = current.cost + (diagonal ? 14 : 10);
if (newCost >= cost[static_cast<size_t>(nextIndex)]) continue; if (searchVisitedGeneration[nextOffset] == searchGeneration &&
newCost >= searchCost[nextOffset]) continue;
cost[static_cast<size_t>(nextIndex)] = newCost; searchVisitedGeneration[nextOffset] = searchGeneration;
cameFrom[static_cast<size_t>(nextIndex)] = current.index; searchCost[nextOffset] = newCost;
open.push({ nextIndex, newCost + distanceCells(next, endCell) }); searchCameFrom[nextOffset] = current.index;
open.push({nextIndex, newCost + heuristic(next, endCell), newCost});
} }
} }
if (!nearestReachable && bestIndex != endIndex) return {};
if (bestIndex == startIndex) return {}; if (bestIndex == startIndex) return {};
std::vector<Cell> cells; std::vector<Cell> cells;
for (int cur = bestIndex; cur != -1; cur = cameFrom[static_cast<size_t>(cur)]) { for (int cur = bestIndex; cur != -1;
cur = searchCameFrom[static_cast<size_t>(cur)]) {
cells.push_back({ cur % gridWidth, cur / gridWidth }); cells.push_back({ cur % gridWidth, cur / gridWidth });
if (cur == startIndex) break; if (cur == startIndex) break;
} }
std::reverse(cells.begin(), cells.end()); std::reverse(cells.begin(), cells.end());
cells = smoothCells(cells, walkableGrid); cells = smoothCellsOverlay(cells, obstacleGeneration);
std::vector<Eigen::Vector3f> path; std::vector<Eigen::Vector3f> path;
path.reserve(cells.size()); path.reserve(cells.size());
for (const Cell& cell : cells) path.push_back(cellCenter(cell)); for (const Cell& cell : cells) path.push_back(cellCenter(cell));
if (!path.empty() &&
if (!path.empty() && (path.front() - Eigen::Vector3f(start.x(), floorY, start.z())).norm() < cellSize * 0.75f) (path.front() - Eigen::Vector3f(start.x(), floorY, start.z())).norm() <
cellSize * 0.75f)
path.erase(path.begin()); path.erase(path.begin());
if (bestIndex == endIndex && !path.empty()) {
Cell requestedEndCell;
if (worldToCell(end, requestedEndCell) &&
requestedEndCell.x == endCell.x && requestedEndCell.z == endCell.z) {
path.back() = Eigen::Vector3f(end.x(), floorY, end.z());
}
}
return path; return path;
} }
std::vector<Eigen::Vector3f> PathFinder::findPathToNearest( std::vector<Eigen::Vector3f> PathFinder::findPathToNearest(
const Eigen::Vector3f& start, const Eigen::Vector3f& end) const const Eigen::Vector3f& start, const Eigen::Vector3f& end) const
{ {
auto path = findPath(start, end); const std::lock_guard<std::mutex> lock(*queryMutex);
if (!path.empty()) return path; return runSearch(start, end, 0, true);
return findNearestReachableImpl(start, end, walkable);
} }
std::vector<Eigen::Vector3f> PathFinder::findPathToNearest( std::vector<Eigen::Vector3f> PathFinder::findPathToNearest(
const Eigen::Vector3f& start, const Eigen::Vector3f& end, const Eigen::Vector3f& start, const Eigen::Vector3f& end,
const std::vector<DynamicObstacle>& dynamicObstacles) const const std::vector<DynamicObstacle>& dynamicObstacles) const
{ {
auto path = findPath(start, end, dynamicObstacles); const std::lock_guard<std::mutex> lock(*queryMutex);
if (!path.empty()) return path; return runSearch(
path = findPath(start, end); start, end, buildDynamicOverlay(dynamicObstacles), true);
if (!path.empty()) return path;
return findNearestReachableImpl(start, end, walkable);
} }
bool PathFinder::setAreaAvailable(const std::string& areaName, bool available) bool PathFinder::setAreaAvailable(const std::string& areaName, bool available)
+27 -4
View File
@@ -1,6 +1,9 @@
#pragma once #pragma once
#include <Eigen/Core> #include <Eigen/Core>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -79,6 +82,17 @@ private:
std::vector<unsigned char> walkable; std::vector<unsigned char> walkable;
std::vector<NavigationArea> areas; std::vector<NavigationArea> areas;
// Reused by synchronous authoritative path queries. Generation stamps avoid
// copying/clearing the complete map for every dynamic-obstacle search.
mutable std::vector<std::uint32_t> dynamicBlockedGeneration;
mutable std::uint32_t dynamicGeneration = 0;
mutable std::vector<int> searchCost;
mutable std::vector<int> searchCameFrom;
mutable std::vector<std::uint32_t> searchVisitedGeneration;
mutable std::uint32_t searchGeneration = 0;
mutable std::shared_ptr<std::mutex> queryMutex =
std::make_shared<std::mutex>();
void loadConfig(const std::string& configPath, const std::string& zipPath); void loadConfig(const std::string& configPath, const std::string& zipPath);
bool loadGrid(const std::string& path, const std::string& zipPath); bool loadGrid(const std::string& path, const std::string& zipPath);
void resetGridBounds(); void resetGridBounds();
@@ -103,11 +117,20 @@ private:
bool hasLineOfSight(const Cell& from, const Cell& to, const std::vector<unsigned char>& walkableGrid) const; bool hasLineOfSight(const Cell& from, const Cell& to, const std::vector<unsigned char>& walkableGrid) const;
std::vector<Cell> smoothCells(const std::vector<Cell>& cells, const std::vector<unsigned char>& walkableGrid) const; std::vector<Cell> smoothCells(const std::vector<Cell>& cells, const std::vector<unsigned char>& walkableGrid) const;
// A* that runs to exhaustion and returns a path to the reachable cell std::uint32_t buildDynamicOverlay(
// geometrically closest to end when end itself is unreachable. const std::vector<DynamicObstacle>& dynamicObstacles) const;
std::vector<Eigen::Vector3f> findNearestReachableImpl(const Eigen::Vector3f& start, bool isCellWalkableOverlay(const Cell& cell, std::uint32_t generation) const;
bool findNearestWalkableCellOverlay(
const Eigen::Vector3f& point, Cell& out, std::uint32_t generation) const;
bool hasLineOfSightOverlay(
const Cell& from, const Cell& to, std::uint32_t generation) const;
std::vector<Cell> smoothCellsOverlay(
const std::vector<Cell>& cells, std::uint32_t generation) const;
std::vector<Eigen::Vector3f> runSearch(
const Eigen::Vector3f& start,
const Eigen::Vector3f& end, const Eigen::Vector3f& end,
const std::vector<unsigned char>& walkableGrid) const; std::uint32_t obstacleGeneration,
bool nearestReachable) const;
}; };
} // namespace FRG } // namespace FRG