From ec3c0ee4fedbeb047442c74a8b151adfc0aed146 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:09:01 -0400 Subject: [PATCH 01/45] NS-23: Add implementation plan and E1.M1 snapshot for path-follow kickoff --- .../modules/E1_M1_InputAndMovementRuntime.md | 1 + docs/plans/NS-23-implementation-plan.md | 98 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 docs/plans/NS-23-implementation-plan.md diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index 5fd2062..1286d74 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -34,6 +34,7 @@ Contract readiness is tracked in the [module dependency register](module_depende ## Implementation snapshot - **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). +- **In progress:** **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow baseline while keeping **`MoveCommand`** / **`GET`** authority ([NS-23](../../plans/NS-23-implementation-plan.md)). - **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md new file mode 100644 index 0000000..ce159be --- /dev/null +++ b/docs/plans/NS-23-implementation-plan.md @@ -0,0 +1,98 @@ +# NS-23 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NS-23 | +| **Title** | E1.M1: Client path-follow baseline (navigation + visible locomotion) | +| **Jira** | [NS-23](https://neon-sprawl.atlassian.net/browse/NS-23) | +| **Parent context** | [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) · [NS-1 — Epic 1](https://neon-sprawl.atlassian.net/browse/NS-1) · Slice 1 — Movement and position sync | +| **Decomposition** | [E1.M1 — InputAndMovementRuntime](../decomposition/modules/E1_M1_InputAndMovementRuntime.md) | + +**Delivery:** Ship **`docs/plans/NS-23-implementation-plan.md`** on the **same branch / PR** as the NS-23 implementation (plan + code together), matching the NS-19 pattern. + +## Goal, scope, and out-of-scope + +**Goal:** Deliver **visible** click-to-move: the avatar **follows a path** toward the clicked point using a **navigation mesh** baseline (obstacle-aware), not only steering at a goal or snapping on server ack alone. Aligns with E1.M1: path-follow baseline under server authority. + +**In scope** + +- **Godot:** `NavigationRegion3D` (baked mesh) over the prototype walkable floor in `main.tscn`, respecting existing static obstacles; **`NavigationAgent3D`** (on player or child node) driving **`CharacterBody3D`** motion (`move_and_slide` toward next path position — follow Godot 4.x recommended pattern). +- **Pick integration:** Reuse **`ground_pick.gd`** (`walkable` group, upward normal); clicked point becomes **`NavigationAgent3D.target_position`** (after any needed Y / agent height adjustment consistent with current capsule). +- **Server authority (unchanged contract):** One **`MoveCommand`** per successful pick with the **same world target** as today ([NS-16](NS-16-implementation-plan.md)); server **v1 snap** + **[NS-19](NS-19-implementation-plan.md)** validation unchanged. + +**Out of scope (per Jira)** + +- Full prediction/reconciliation, MMO-grade netcode, server-side nav mesh validation. +- Replacing **`InteractionRequest`** / range rules. + +## Locked — client nav vs server authority (this slice) + +| Topic | Decision | +|--------|----------| +| **When to POST** | **Once per click**, immediately with the **picked destination** (same JSON as today). No waypoint streaming to the server in this story. | +| **Server truth** | Still **snap to target** on **200** after validation; **`GET /position`** after POST remains the reconciliation read (existing `PositionAuthorityClient` flow). | +| **Straight-line vs path** | NS-19 validates **displacement** from **current authoritative position** to **target** (horizontal + vertical limits). It does **not** check path clearance through geometry; **client nav** is responsible for **visible** obstacle avoidance. | +| **After successful move** | **Do not** teleport the avatar on the verifying **`GET`** when the player is expected to walk: apply the authoritative position as a **navigation goal** and let the agent complete the path; **initial boot** `sync_from_server` remains a **hard snap** so spawn matches the server. | +| **POST 400** | Existing behavior: emit **`move_rejected`**, show NS-19 UX; **do not** start (or clear) a nav path to that target. | +| **Distance limits** | Default **`MaxHorizontalStep`** (e.g. 18 m) remains compatible with **single-command** long clicks across the prototype; if a future scene needs shorter steps, chain commands or tune limits in a later story. | + +## Acceptance criteria checklist + +- [ ] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). +- [ ] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). +- [ ] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. + +## Technical approach + +1. **`main.tscn` / navigation** + - Add **`NavigationRegion3D`** covering walkable floor; include obstacle **`StaticBody3D`** geometry in baking as required by Godot 4 so paths wrap obstacles. + - Bake after layout changes; document any **editor step** in `client/README.md` if bake is not fully reproducible from scene alone. + +2. **Player / agent** + - Attach **`NavigationAgent3D`**; replace NS-14-style **direct steering toward `_goal`** with **path following** (velocity toward `get_next_path_position()` or equivalent, `move_and_slide`). + - Keep **`snap_to_server`** for **boot** and any **hard reconcile** case you explicitly document (default: boot only). + +3. **Wiring (`main.gd` / `PositionAuthorityClient`)** + - On **`target_chosen`**: call **`submit_move_target`** as today (POST → GET). + - Distinguish **authoritative position from boot** vs **after successful move** so **`main.gd`** snaps on boot but sets **nav target** (no snap) after verify when implementing path-follow. + - Optional small API: second signal, enum on existing signal, or `sync_from_server`-only snap path — pick the smallest change that stays readable per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md). + +4. **Documentation** + - **`client/README.md`:** Update movement section: navmesh path-follow + server authority one-liner. + - **`server/README.md`:** One short subsection or bullet: server validates **end target** and **step**; **client** nav is **presentational** for obstacle following in this slice. + +## Files to add (expected) + +| Path | Purpose | +|------|---------| +| None required by name | Prefer extending existing `player.gd` / scene unless a dedicated `player_navigation.gd` reduces `main.gd` bloat. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scenes/main.tscn` | `NavigationRegion3D`, bake source meshes, agent node setup. | +| `client/scripts/player.gd` | Path-follow via `NavigationAgent3D`; clarify header (NS-14 → NS-23 evolution). | +| `client/scripts/main.gd` | Boot snap vs post-move nav goal wiring. | +| `client/scripts/position_authority_client.gd` | If needed: signal shape or phase so main can snap only on boot. | +| `client/README.md` | Manual check steps for path-follow + server. | +| `server/README.md` | Client nav vs authority note (AC). | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Implementation snapshot when NS-23 ships. | + +## Tests + +| Action | What to cover | +|--------|----------------| +| **Manual (required)** | Server running; click around obstacles — path goes around; click NS-19 reject targets — same rejection behavior as before; cold start — player matches server position. | +| **Automated** | None required for this story unless an existing headless hook can assert nav baking (unlikely); prefer manual for Godot nav. | + +## Open questions / risks + +- **Agent radius / height** vs capsule: mis-tuning causes clipping or “stuck”; match **`CollisionShape3D`** to agent parameters. +- **Frame ordering:** `NavigationServer` sync — follow Godot docs (`await get_tree().physics_frame` if required before first path query). + +## PR / review + +Cross-check [E1.M1](../decomposition/modules/E1_M1_InputAndMovementRuntime.md), [client README](../../client/README.md), and [server README](../../server/README.md). Confirm NS-19 props in `main.tscn` still behave after nav region changes. From 1f4e3c49f640a6692d2cd2567806c5c422838758 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:14:37 -0400 Subject: [PATCH 02/45] NS-23: Resolve agent tuning and nav sync risks in implementation plan --- docs/plans/NS-23-implementation-plan.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md index ce159be..447141b 100644 --- a/docs/plans/NS-23-implementation-plan.md +++ b/docs/plans/NS-23-implementation-plan.md @@ -63,6 +63,27 @@ - **`client/README.md`:** Update movement section: navmesh path-follow + server authority one-liner. - **`server/README.md`:** One short subsection or bullet: server validates **end target** and **step**; **client** nav is **presentational** for obstacle following in this slice. +## Resolved — agent shape vs capsule (was risk) + +Prototype player collider in [`main.tscn`](../../client/scenes/main.tscn): **`CapsuleShape3D_player`** — **`radius = 0.4`**, **`height = 1.0`**. + +| Setting | Locked choice | +|--------|----------------| +| **`NavigationAgent3D.radius` / `height`** | Match the **physics** capsule (**0.4** / **1.0**) so nav clearance matches what `move_and_slide` actually sweeps. | +| **Navmesh bake agent** | In **`NavigationRegion3D`** bake settings, use the **same** radius/height (or Godot’s equivalent **agent** parameters) so baked polygons match runtime pathfinding. | +| **Corner clipping vs “stuck”** | If the mesh clips into box corners, **increase** agent radius slightly and **rebake** (wider corridor on the mesh) before shrinking physics. Do not silently shrink the capsule without an explicit design change. | +| **Vertical alignment** | Keep **`path_height_offset`** (or equivalent) at **0** unless the capsule foot position and floor snap misalign; the pick already supplies floor **`Y`**. | +| **Arrival** | Align **`target_desired_distance`** (and related agent tolerances) with existing player **`ARRIVE_EPS`** (~**0.35** m) so stop distance matches prior steering feel unless QA says otherwise. | + +## Resolved — navigation sync / frame order (was risk) + +| Topic | Locked choice | +|--------|----------------| +| **Where motion runs** | Path queries (`get_next_path_position`, velocity toward next point) and **`move_and_slide`** run only in **`Player._physics_process`**, not in `_process` or UI thread. | +| **When `target_position` is set** | `main.gd` (or signal handler) may set the agent target in response to **`target_chosen`** / authority callbacks; the **first** consumable segment appears after the navigation map updates. | +| **First frame after new target** | If **`NavigationAgent3D.is_navigation_finished()`** or a zero-length first step appears on the **same** frame as `target_position` assignment, **defer** one tick: `await get_tree().physics_frame` **once** after setting target **or** simply rely on **next** `_physics_process` (preferred — avoid await in hot path). **Do not** poll path from `_input` without deferral. | +| **Map readiness** | After adding **`NavigationRegion3D`**, ensure the region is **baked** and enabled before F5; startup **`sync_from_server`** snap does not need a path. | + ## Files to add (expected) | Path | Purpose | @@ -90,8 +111,7 @@ ## Open questions / risks -- **Agent radius / height** vs capsule: mis-tuning causes clipping or “stuck”; match **`CollisionShape3D`** to agent parameters. -- **Frame ordering:** `NavigationServer` sync — follow Godot docs (`await get_tree().physics_frame` if required before first path query). +None — agent/bake alignment and physics-frame path consumption are locked in **Resolved — agent shape vs capsule** and **Resolved — navigation sync / frame order** above. Reopen only if the scene scale or avatar collider changes materially. ## PR / review From 0ac059fa3b411f22a122e9a4e8441e61cae339e2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:22:51 -0400 Subject: [PATCH 03/45] NS-23: NavigationAgent3D path-follow with server move authority --- client/README.md | 25 ++++---- client/scenes/main.tscn | 62 +++++++++++++------ client/scripts/main.gd | 13 +++- client/scripts/player.gd | 40 +++++++----- client/scripts/position_authority_client.gd | 11 ++-- .../modules/E1_M1_InputAndMovementRuntime.md | 3 +- docs/plans/NS-16-implementation-plan.md | 2 +- docs/plans/NS-23-implementation-plan.md | 6 +- server/README.md | 2 + 9 files changed, 104 insertions(+), 60 deletions(-) diff --git a/client/README.md b/client/README.md index e8f576f..6b1f6a6 100644 --- a/client/README.md +++ b/client/README.md @@ -9,20 +9,21 @@ Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible) Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** child nodes and scripts; put picking, server sync, and similar features in **dedicated scripts** (typically under `scripts/`) and connect via **signals** or small APIs. Use **Autoloads** only when several scenes truly need the same global. Full guidance for contributors and tooling: [`.cursor/rules/godot-client-script-organization.md`](../.cursor/rules/godot-client-script-organization.md). -## Authoritative movement (NS-16) +## Authoritative movement (NS-16, NS-23) -With the game server running ([`server/README.md`](../server/README.md)), the client sends a **`MoveCommand`** over HTTP and **snaps** the avatar to the server’s **`PositionState`** after a follow-up **`GET`**. +With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NS-16/19); the client **walks** along a **baked navigation mesh** (`NavigationRegion3D` + **`NavigationAgent3D`** on the player) toward that verified position instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. -- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify), thin `scripts/main.gd` wiring. +- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). +- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`** so **`bake_navigation_mesh()`** (called from `main.gd` after one **`process_frame`**) picks up floor, NS-19 props, and the **`Obstacle`** cube. After moving floor or obstacles, open the scene in the editor and **Bake NavigationMesh** on the region if you need to refresh cached mesh data. - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). -- On startup, **`sync_from_server()`** runs once so the capsule matches the server before you click. -### Manual check (NS-16) +### Manual check (NS-16 + NS-23) 1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`). 2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`). -3. Open the client in Godot and run the main scene (**F5**). The player should jump to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo). -4. **Left-click** the floor: the client POSTs the target, then GETs position; the avatar **snaps** to the authoritative coordinates (no client-side walk to the click). +3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo). +4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** around **`Obstacle`** toward the authoritative target. NS-19 reject clicks still show the reject label and do **not** start a path. +5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged. If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes. @@ -39,16 +40,16 @@ The main scene includes a **prototype terminal** at the map center (same world * 2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane). 3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere. -## Movement prototype (NS-14, legacy local steering) +## Movement prototype (NS-14 → NS-23) -The **`CharacterBody3D`** still uses horizontal steering + **`move_and_slide()`** in **`player.gd`**, but **NS-16** drives placement via **`snap_to_server()`** after each successful sync, so you will mostly see **snapping**, not walking to the click. Obstacle sliding from NS-14 applies only if local goals diverge from server snaps in future work. +**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion (NS-23). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). - The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. -### Manual check (NS-14 remnants) +### Manual check (remnants) -1. Run the main scene (**F5**) **with server running** for NS-16 behavior above. -2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for NS-16. +1. Run the main scene (**F5**) **with server running** for movement behavior above. +2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for the demo path. ### Clicks still ignored? diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 6d31397..b62c246 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -72,6 +72,17 @@ albedo_color = Color(0.92, 0.42, 0.12, 1) [sub_resource type="StandardMaterial3D" id="Mat_ns19_farpad"] albedo_color = Color(0.32, 0.38, 0.55, 1) +[sub_resource type="NavigationMesh" id="NavigationMesh_district"] +geometry_parsed_geometry_type = 2 +geometry_collision_mask = 4294967295 +geometry_source_geometry_mode = 0 +cell_size = 0.15 +cell_height = 0.15 +agent_radius = 0.4 +agent_height = 1.0 +agent_max_climb = 0.35 +border_size = 0.15 + [node name="Main" type="Node3D" unique_id=1358372723] script = ExtResource("1_main") @@ -86,21 +97,24 @@ transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.5 current = true fov = 50.0 -[node name="Floor" type="StaticBody3D" parent="World" unique_id=1800743112 groups=["walkable"]] +[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World" unique_id=8880001] +navigation_mesh = SubResource("NavigationMesh_district") + +[node name="Floor" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1800743112 groups=["walkable"]] collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor" unique_id=152652175] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) mesh = SubResource("BoxMesh_floor") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor" unique_id=409532142] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Floor" unique_id=409532142] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) shape = SubResource("BoxShape3D_floor") -[node name="PrototypeTerminal" type="StaticBody3D" parent="World" unique_id=1700001] +[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PrototypeTerminal" unique_id=1700002] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] mesh = SubResource("BoxMesh_terminal") surface_material_override/0 = SubResource("Mat_terminal") @@ -117,61 +131,61 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) mesh = SubResource("BoxMesh_marker") surface_material_override/0 = SubResource("Mat_marker_base") -[node name="NS19BumpA" type="StaticBody3D" parent="World" unique_id=1900001 groups=["walkable"]] +[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.4, 0.075, -2.6) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpA" unique_id=1900002] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900002] mesh = SubResource("BoxMesh_ns19_bump_a") surface_material_override/0 = SubResource("Mat_ns19_bump") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpA" unique_id=1900003] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] shape = SubResource("BoxShape3D_ns19_bump_a") -[node name="NS19BumpB" type="StaticBody3D" parent="World" unique_id=1900011 groups=["walkable"]] +[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpB" unique_id=1900012] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900012] mesh = SubResource("BoxMesh_ns19_bump_b") surface_material_override/0 = SubResource("Mat_ns19_bump") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpB" unique_id=1900013] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] shape = SubResource("BoxShape3D_ns19_bump_b") -[node name="NS19RejectPedestal" type="StaticBody3D" parent="World" unique_id=1900021 groups=["walkable"]] +[node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19RejectPedestal" unique_id=1900022] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900022] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) mesh = SubResource("BoxMesh_ns19_pedestal") surface_material_override/0 = SubResource("Mat_ns19_pedestal") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19RejectPedestal" unique_id=1900023] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900023] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) shape = SubResource("BoxShape3D_ns19_pedestal") -[node name="NS19FarPad" type="StaticBody3D" parent="World" unique_id=1900031 groups=["walkable"]] +[node name="NS19FarPad" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900031 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0.06, 9) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19FarPad" unique_id=1900032] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900032] mesh = SubResource("BoxMesh_ns19_farpad") surface_material_override/0 = SubResource("Mat_ns19_farpad") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19FarPad" unique_id=1900033] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900033] shape = SubResource("BoxShape3D_ns19_farpad") -[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763] +[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) collision_layer = 1 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle" unique_id=750473628] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) mesh = SubResource("BoxMesh_obstacle") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle" unique_id=1344302688] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Obstacle" unique_id=1344302688] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) shape = SubResource("BoxShape3D_obstacle") @@ -184,6 +198,14 @@ script = ExtResource("2_player") [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] shape = SubResource("CapsuleShape3D_player") +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="Player" unique_id=8880002] +avoidance_enabled = false +path_desired_distance = 0.35 +target_desired_distance = 0.35 +path_height_offset = 0.0 +radius = 0.4 +height = 1.0 + [node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=2027034386] mesh = SubResource("CapsuleMesh_player") diff --git a/client/scripts/main.gd b/client/scripts/main.gd index d218071..92537e9 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -2,6 +2,7 @@ extends Node3D ## NS-16: composes ground pick + server authority; see `ground_pick.gd` / ## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes). +## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. const MOVE_REJECT_MSG_SECONDS: float = 4.0 @@ -9,6 +10,7 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0 var _move_reject_msg_token: int = 0 @onready var _camera: Camera3D = $World/Camera3D +@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $Player @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @@ -17,6 +19,8 @@ var _move_reject_msg_token: int = 0 func _ready() -> void: + await get_tree().process_frame + _nav_region.bake_navigation_mesh() _ground_pick.set("fallback_camera", _camera) _ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen")) _authority.connect( @@ -32,11 +36,16 @@ func _on_target_chosen(world: Vector3) -> void: _authority.call("submit_move_target", world) -func _on_authoritative_position(world: Vector3) -> void: - _player.snap_to_server(world) +func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void: + if apply_as_snap: + _player.snap_to_server(world) + else: + _player.set_authoritative_nav_goal(world) func _on_move_rejected(reason_code: String) -> void: + # Rejected POST never reached VERIFY_GET, so no new nav target was applied; do not clear an + # in-flight path from a prior successful move. push_warning("Move rejected: %s" % reason_code) _move_reject_msg_token += 1 var token: int = _move_reject_msg_token diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 48edee7..f2b5e2d 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,37 +1,47 @@ extends CharacterBody3D -## NS-14: click-to-move using horizontal steering + move_and_slide (not NavigationAgent3D). -## Obstacles are handled by physics sliding, not pathfinding — see client README. +## NS-23: `NavigationAgent3D` path-follow for click-to-move visuals. Server owns `PositionState` / +## `MoveCommand` (NS-16, NS-19); boot snap vs post-verify nav goal from `main.gd`. const MOVE_SPEED: float = 5.0 -const ARRIVE_EPS: float = 0.35 -var _goal: Vector3 +@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D func _ready() -> void: - _goal = global_position + _nav_agent.target_position = global_position -func set_move_goal(world_pos: Vector3) -> void: - _goal = world_pos - _goal.y = global_position.y +## After successful move POST + GET: walk to server target without teleporting. +func set_authoritative_nav_goal(world_pos: Vector3) -> void: + _nav_agent.target_position = world_pos -## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight -## the sync. +## NS-19 rejection or cancel pending path without moving the body. +func clear_nav_goal() -> void: + velocity = Vector3.ZERO + _nav_agent.target_position = global_position + + +## Boot (and any hard reconcile): teleport to server position and reset agent. func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO - _goal = world_pos + _nav_agent.target_position = world_pos func _physics_process(_delta: float) -> void: - var to_goal: Vector3 = _goal - global_position - to_goal.y = 0.0 - if to_goal.length() <= ARRIVE_EPS: + if _nav_agent.is_navigation_finished(): + velocity = Vector3.ZERO + move_and_slide() + return + + var next_path_position: Vector3 = _nav_agent.get_next_path_position() + var to_next: Vector3 = next_path_position - global_position + to_next.y = 0.0 + if to_next.length_squared() < 0.0001: velocity = Vector3.ZERO else: - velocity = to_goal.normalized() * MOVE_SPEED + velocity = to_next.normalized() * MOVE_SPEED velocity.y = 0.0 move_and_slide() diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 3f0df7a..a8b832a 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -2,9 +2,10 @@ extends Node ## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the ## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`. +## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport). ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) -signal authoritative_position_received(world: Vector3) +signal authoritative_position_received(world: Vector3, apply_as_snap: bool) signal move_rejected(reason_code: String) enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET } @@ -79,7 +80,7 @@ func _on_request_completed( Phase.BOOT_GET: _busy = false if response_code == 200: - _emit_position_from_response(text) + _emit_position_from_response(text, true) Phase.POST_MOVE: if response_code == 400: _emit_move_rejection_if_present(text) @@ -96,7 +97,7 @@ func _on_request_completed( Phase.VERIFY_GET: _busy = false if response_code == 200: - _emit_position_from_response(text) + _emit_position_from_response(text, false) func _emit_move_rejection_if_present(json_text: String) -> void: @@ -110,7 +111,7 @@ func _emit_move_rejection_if_present(json_text: String) -> void: move_rejected.emit("unknown") -func _emit_position_from_response(json_text: String) -> void: +func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void: var parsed: Variant = JSON.parse_string(json_text) if parsed == null: return @@ -124,4 +125,4 @@ func _emit_position_from_response(json_text: String) -> void: var x: float = float(pos.get("x", 0.0)) var y: float = float(pos.get("y", 0.0)) var z: float = float(pos.get("z", 0.0)) - authoritative_position_received.emit(Vector3(x, y, z)) + authoritative_position_received.emit(Vector3(x, y, z), apply_as_snap) diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index 1286d74..0bd2c11 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -33,8 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende ## Implementation snapshot -- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). -- **In progress:** **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow baseline while keeping **`MoveCommand`** / **`GET`** authority ([NS-23](../../plans/NS-23-implementation-plan.md)). +- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow for click-to-move visuals while server authority unchanged ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). - **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). diff --git a/docs/plans/NS-16-implementation-plan.md b/docs/plans/NS-16-implementation-plan.md index 8327234..dabe1e5 100644 --- a/docs/plans/NS-16-implementation-plan.md +++ b/docs/plans/NS-16-implementation-plan.md @@ -54,7 +54,7 @@ 5. **Godot client** — **Split scripts by concern** (required repo policy: [godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md)); do **not** grow a monolithic `main.gd` with pick + HTTP + wiring. - **`ground_pick.gd`** on a new **`Node3D`** child (e.g. `GroundPick`): owns walkable **ray pick** (logic moved out of NS-14 `main.gd`). Exposes **`target_chosen(Vector3)`** when the user clicks valid ground. **`main.gd`** sets **`fallback_camera`** (or equivalent) in `_ready`. - - **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3)`** so **`main`** only snaps the player. + - **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3, bool)`** (NS-23: second arg = boot snap vs nav goal) so **`main`** snaps on boot and path-follows after move verify. - **`main.gd`:** Thin composer only—connect signals, call **`sync_from_server()`** on run, delegate pick and HTTP to the two child scripts. - Align **client README** with NS-14 note: server authority path replaces pure local steering for this demo. diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md index 447141b..c057803 100644 --- a/docs/plans/NS-23-implementation-plan.md +++ b/docs/plans/NS-23-implementation-plan.md @@ -40,9 +40,9 @@ ## Acceptance criteria checklist -- [ ] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). -- [ ] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). -- [ ] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. +- [x] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). +- [x] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). +- [x] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. ## Technical approach diff --git a/server/README.md b/server/README.md index d62b873..a1da7a0 100644 --- a/server/README.md +++ b/server/README.md @@ -63,6 +63,8 @@ Unknown player ids return **404**. Full zone sync / replication is still out of **`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply. +**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**—path-follow around static geometry. The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. + **NS-19 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation. | Config key | Meaning | Default (see `appsettings.json`) | From f49c8b88d21f77a37c34316b14fc841c308d13b0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:29:58 -0400 Subject: [PATCH 04/45] NS-23: Fix nav movement (sync bake, map guard, path/fallback steering) --- client/README.md | 2 +- client/scripts/main.gd | 6 +++++- client/scripts/player.gd | 33 +++++++++++++++++++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/client/README.md b/client/README.md index 6b1f6a6..c349f57 100644 --- a/client/README.md +++ b/client/README.md @@ -14,7 +14,7 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NS-16/19); the client **walks** along a **baked navigation mesh** (`NavigationRegion3D` + **`NavigationAgent3D`** on the player) toward that verified position instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). -- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`** so **`bake_navigation_mesh()`** (called from `main.gd` after one **`process_frame`**) picks up floor, NS-19 props, and the **`Obstacle`** cube. After moving floor or obstacles, open the scene in the editor and **Bake NavigationMesh** on the region if you need to refresh cached mesh data. +- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` calls **`bake_navigation_mesh(false)`** (main-thread bake) after one **`process_frame`**, then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. After moving floor or obstacles, re-bake in the editor if needed. - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). ### Manual check (NS-16 + NS-23) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 92537e9..e7b9bf2 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -20,7 +20,11 @@ var _move_reject_msg_token: int = 0 func _ready() -> void: await get_tree().process_frame - _nav_region.bake_navigation_mesh() + # Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map + # exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents). + _nav_region.bake_navigation_mesh(false) + await get_tree().physics_frame + await get_tree().physics_frame _ground_pick.set("fallback_camera", _camera) _ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen")) _authority.connect( diff --git a/client/scripts/player.gd b/client/scripts/player.gd index f2b5e2d..d323ebb 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -4,43 +4,64 @@ extends CharacterBody3D ## `MoveCommand` (NS-16, NS-19); boot snap vs post-verify nav goal from `main.gd`. const MOVE_SPEED: float = 5.0 +## If the nav mesh is missing or the agent gives up, steer on XZ toward `target_position`. +const FALLBACK_ARRIVE_EPS: float = 0.4 @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D func _ready() -> void: - _nav_agent.target_position = global_position + _nav_agent.set_target_position(global_position) ## After successful move POST + GET: walk to server target without teleporting. func set_authoritative_nav_goal(world_pos: Vector3) -> void: - _nav_agent.target_position = world_pos + _nav_agent.set_target_position(world_pos) ## NS-19 rejection or cancel pending path without moving the body. func clear_nav_goal() -> void: velocity = Vector3.ZERO - _nav_agent.target_position = global_position + _nav_agent.set_target_position(global_position) ## Boot (and any hard reconcile): teleport to server position and reset agent. func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO - _nav_agent.target_position = world_pos + _nav_agent.set_target_position(world_pos) func _physics_process(_delta: float) -> void: - if _nav_agent.is_navigation_finished(): + var nav_map: RID = _nav_agent.get_navigation_map() + if NavigationServer3D.map_get_iteration_id(nav_map) == 0: velocity = Vector3.ZERO move_and_slide() return + if _nav_agent.is_navigation_finished(): + var target: Vector3 = _nav_agent.target_position + var flat_target := Vector3(target.x, global_position.y, target.z) + var to_target: Vector3 = flat_target - global_position + to_target.y = 0.0 + if to_target.length_squared() > FALLBACK_ARRIVE_EPS * FALLBACK_ARRIVE_EPS: + velocity = to_target.normalized() * MOVE_SPEED + velocity.y = 0.0 + else: + velocity = Vector3.ZERO + move_and_slide() + return var next_path_position: Vector3 = _nav_agent.get_next_path_position() var to_next: Vector3 = next_path_position - global_position to_next.y = 0.0 if to_next.length_squared() < 0.0001: - velocity = Vector3.ZERO + var to_final: Vector3 = _nav_agent.target_position - global_position + to_final.y = 0.0 + if to_final.length_squared() > 0.0001: + velocity = to_final.normalized() * MOVE_SPEED + velocity.y = 0.0 + else: + velocity = Vector3.ZERO else: velocity = to_next.normalized() * MOVE_SPEED velocity.y = 0.0 From 510422d8a295f01e172f3bbdeea434c6ace1e567 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:33:28 -0400 Subject: [PATCH 05/45] NS-23: Drive motion from stored server goal; ignore agent finished flag --- client/scripts/player.gd | 72 ++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index d323ebb..cb3d47d 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,11 +1,15 @@ extends CharacterBody3D -## NS-23: `NavigationAgent3D` path-follow for click-to-move visuals. Server owns `PositionState` / -## `MoveCommand` (NS-16, NS-19); boot snap vs post-verify nav goal from `main.gd`. +## NS-23: Nav agent suggests waypoints; arrival uses stored server goal only (not agent “finished”). +## `is_navigation_finished()` / internal target can clear early while we are still far from goal. const MOVE_SPEED: float = 5.0 -## If the nav mesh is missing or the agent gives up, steer on XZ toward `target_position`. -const FALLBACK_ARRIVE_EPS: float = 0.4 +const ARRIVE_EPS: float = 0.35 +## Outside this XZ distance to server goal, use nav corners; inside, steer straight to goal. +const DIRECT_APPROACH_RADIUS: float = 0.85 + +var _has_walk_goal: bool = false +var _auth_walk_goal: Vector3 = Vector3.ZERO @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -16,12 +20,15 @@ func _ready() -> void: ## After successful move POST + GET: walk to server target without teleporting. func set_authoritative_nav_goal(world_pos: Vector3) -> void: + _auth_walk_goal = world_pos + _has_walk_goal = true _nav_agent.set_target_position(world_pos) ## NS-19 rejection or cancel pending path without moving the body. func clear_nav_goal() -> void: velocity = Vector3.ZERO + _has_walk_goal = false _nav_agent.set_target_position(global_position) @@ -29,40 +36,47 @@ func clear_nav_goal() -> void: func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO + _has_walk_goal = false _nav_agent.set_target_position(world_pos) func _physics_process(_delta: float) -> void: - var nav_map: RID = _nav_agent.get_navigation_map() - if NavigationServer3D.map_get_iteration_id(nav_map) == 0: + if not _has_walk_goal: velocity = Vector3.ZERO move_and_slide() return - if _nav_agent.is_navigation_finished(): - var target: Vector3 = _nav_agent.target_position - var flat_target := Vector3(target.x, global_position.y, target.z) - var to_target: Vector3 = flat_target - global_position - to_target.y = 0.0 - if to_target.length_squared() > FALLBACK_ARRIVE_EPS * FALLBACK_ARRIVE_EPS: - velocity = to_target.normalized() * MOVE_SPEED - velocity.y = 0.0 - else: - velocity = Vector3.ZERO + + var to_goal: Vector3 = Vector3( + _auth_walk_goal.x - global_position.x, 0.0, _auth_walk_goal.z - global_position.z + ) + var dist_goal: float = to_goal.length() + if dist_goal <= ARRIVE_EPS: + velocity = Vector3.ZERO + _has_walk_goal = false + _nav_agent.set_target_position(global_position) move_and_slide() return - var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next: Vector3 = next_path_position - global_position - to_next.y = 0.0 - if to_next.length_squared() < 0.0001: - var to_final: Vector3 = _nav_agent.target_position - global_position - to_final.y = 0.0 - if to_final.length_squared() > 0.0001: - velocity = to_final.normalized() * MOVE_SPEED - velocity.y = 0.0 - else: - velocity = Vector3.ZERO - else: - velocity = to_next.normalized() * MOVE_SPEED + var nav_map: RID = _nav_agent.get_navigation_map() + if NavigationServer3D.map_get_iteration_id(nav_map) == 0: + velocity = to_goal.normalized() * MOVE_SPEED velocity.y = 0.0 + move_and_slide() + return + + var dir: Vector3 + if dist_goal <= DIRECT_APPROACH_RADIUS: + dir = to_goal.normalized() + else: + var next_path_position: Vector3 = _nav_agent.get_next_path_position() + var to_next: Vector3 = Vector3( + next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z + ) + if to_next.length_squared() > 0.0025: + dir = to_next.normalized() + else: + dir = to_goal.normalized() + + velocity = dir * MOVE_SPEED + velocity.y = 0.0 move_and_slide() From 6d00070c00ae335bfea091068068a279de339334 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:36:43 -0400 Subject: [PATCH 06/45] NS-23: Climb NS-19 bumps with 3D steer + vertical arrival + floor snap --- client/scenes/main.tscn | 1 + client/scripts/player.gd | 42 +++++++++++++++++++++++++--------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index b62c246..bab6f8d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -193,6 +193,7 @@ shape = SubResource("BoxShape3D_obstacle") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 collision_mask = 1 +floor_snap_length = 0.22 script = ExtResource("2_player") [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] diff --git a/client/scripts/player.gd b/client/scripts/player.gd index cb3d47d..8173a4b 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -2,9 +2,14 @@ extends CharacterBody3D ## NS-23: Nav agent suggests waypoints; arrival uses stored server goal only (not agent “finished”). ## `is_navigation_finished()` / internal target can clear early while we are still far from goal. +## NS-19 bumps: allow limited Y in `velocity` so `move_and_slide` can step up/down shallow hits. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 +## Must match NS-19 shallow bumps (~0.15 m rise); don’t clear goal while still below pick Y. +const VERT_ARRIVE_EPS: float = 0.16 +const MAX_CLIMB_SPEED: float = 2.6 +const MAX_DESCENT_SPEED: float = 2.2 ## Outside this XZ distance to server goal, use nav corners; inside, steer straight to goal. const DIRECT_APPROACH_RADIUS: float = 0.85 @@ -40,17 +45,26 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) +func _steer_toward_world_point(point: Vector3) -> void: + var delta: Vector3 = point - global_position + if delta.length_squared() < 1e-8: + velocity = Vector3.ZERO + return + var desired: Vector3 = delta.normalized() * MOVE_SPEED + desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) + velocity = desired + + func _physics_process(_delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO move_and_slide() return - var to_goal: Vector3 = Vector3( - _auth_walk_goal.x - global_position.x, 0.0, _auth_walk_goal.z - global_position.z - ) - var dist_goal: float = to_goal.length() - if dist_goal <= ARRIVE_EPS: + var full_to_goal: Vector3 = _auth_walk_goal - global_position + var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() + var vert_err: float = absf(full_to_goal.y) + if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS: velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) @@ -59,24 +73,20 @@ func _physics_process(_delta: float) -> void: var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: - velocity = to_goal.normalized() * MOVE_SPEED - velocity.y = 0.0 + _steer_toward_world_point(_auth_walk_goal) move_and_slide() return - var dir: Vector3 - if dist_goal <= DIRECT_APPROACH_RADIUS: - dir = to_goal.normalized() + if horiz_dist <= DIRECT_APPROACH_RADIUS: + _steer_toward_world_point(_auth_walk_goal) else: var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next: Vector3 = Vector3( + var to_next_h: Vector3 = Vector3( next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z ) - if to_next.length_squared() > 0.0025: - dir = to_next.normalized() + if to_next_h.length_squared() > 0.0025: + _steer_toward_world_point(next_path_position) else: - dir = to_goal.normalized() + _steer_toward_world_point(_auth_walk_goal) - velocity = dir * MOVE_SPEED - velocity.y = 0.0 move_and_slide() From 6fd49a2e1bd40af82d26e2518acf78de64574117 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:39:30 -0400 Subject: [PATCH 07/45] NS-23: Auto-step onto NS-19 bumps via CharacterBody3D.test_move --- client/scripts/player.gd | 58 ++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 8173a4b..295836c 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,17 +1,19 @@ extends CharacterBody3D ## NS-23: Nav agent suggests waypoints; arrival uses stored server goal only (not agent “finished”). -## `is_navigation_finished()` / internal target can clear early while we are still far from goal. -## NS-19 bumps: allow limited Y in `velocity` so `move_and_slide` can step up/down shallow hits. +## NS-19 bumps: thin box colliders need **auto-step** (`test_move` up+forward); Y steering alone +## is not enough for vertical faces. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 -## Must match NS-19 shallow bumps (~0.15 m rise); don’t clear goal while still below pick Y. const VERT_ARRIVE_EPS: float = 0.16 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 -## Outside this XZ distance to server goal, use nav corners; inside, steer straight to goal. const DIRECT_APPROACH_RADIUS: float = 0.85 +## NS-19 bumps ≤ ~0.15 m; probe slightly above for capsule + nav tolerance. +const STEP_MAX_HEIGHT: float = 0.32 +const STEP_PROBE_FWD: float = 0.38 +const STUCK_HORIZ_SPEED: float = 0.28 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -23,21 +25,18 @@ func _ready() -> void: _nav_agent.set_target_position(global_position) -## After successful move POST + GET: walk to server target without teleporting. func set_authoritative_nav_goal(world_pos: Vector3) -> void: _auth_walk_goal = world_pos _has_walk_goal = true _nav_agent.set_target_position(world_pos) -## NS-19 rejection or cancel pending path without moving the body. func clear_nav_goal() -> void: velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) -## Boot (and any hard reconcile): teleport to server position and reset agent. func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO @@ -55,7 +54,33 @@ func _steer_toward_world_point(point: Vector3) -> void: velocity = desired -func _physics_process(_delta: float) -> void: +## Godot `test_move`: returns **true** if the motion would hit something (blocked). +func _try_autostep(delta: float) -> bool: + var to_goal_h: Vector3 = Vector3( + _auth_walk_goal.x - global_position.x, 0.0, _auth_walk_goal.z - global_position.z + ) + if to_goal_h.length_squared() < 0.0225: + return false + var dir_h: Vector3 = to_goal_h.normalized() + var fwd_len: float = maxf(MOVE_SPEED * delta, 0.1) + var motion_fwd: Vector3 = dir_h * minf(fwd_len, STEP_PROBE_FWD) + var motion_up: Vector3 = Vector3(0.0, STEP_MAX_HEIGHT, 0.0) + var xf: Transform3D = global_transform + if not test_move(xf, motion_fwd): + return false + if test_move(xf, motion_up): + return false + var xf_up := Transform3D(xf.basis, xf.origin + motion_up) + if test_move(xf_up, motion_fwd): + return false + global_position += motion_up + motion_fwd + velocity.x = dir_h.x * MOVE_SPEED + velocity.z = dir_h.z * MOVE_SPEED + velocity.y = 0.0 + return true + + +func _physics_process(delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO move_and_slide() @@ -75,6 +100,7 @@ func _physics_process(_delta: float) -> void: if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _steer_toward_world_point(_auth_walk_goal) move_and_slide() + _maybe_autostep_after_slide(delta, full_to_goal, horiz_dist) return if horiz_dist <= DIRECT_APPROACH_RADIUS: @@ -90,3 +116,19 @@ func _physics_process(_delta: float) -> void: _steer_toward_world_point(_auth_walk_goal) move_and_slide() + _maybe_autostep_after_slide(delta, full_to_goal, horiz_dist) + + +func _maybe_autostep_after_slide( + delta: float, full_to_goal: Vector3, horiz_dist: float +) -> void: + if not _has_walk_goal: + return + if not is_on_floor() and not is_on_wall(): + return + var hspd: float = Vector2(velocity.x, velocity.z).length() + var want_step: bool = hspd < STUCK_HORIZ_SPEED + if not want_step and is_on_wall(): + want_step = full_to_goal.y > 0.03 and horiz_dist > 0.12 + if want_step and _try_autostep(delta): + move_and_slide() From b007c7fe58ae2c73ae997d3a5764fb6a2bd12b26 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:42:50 -0400 Subject: [PATCH 08/45] NS-23: Autostep down+forward to walk off bump edges --- client/scripts/player.gd | 41 ++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 295836c..32a507e 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,8 +1,8 @@ extends CharacterBody3D ## NS-23: Nav agent suggests waypoints; arrival uses stored server goal only (not agent “finished”). -## NS-19 bumps: thin box colliders need **auto-step** (`test_move` up+forward); Y steering alone -## is not enough for vertical faces. +## NS-19 bumps: **autostep** via `test_move` — up+forward to mount, down+forward to leave (pure down +## hits the bump volume you’re standing on). const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -10,10 +10,11 @@ const VERT_ARRIVE_EPS: float = 0.16 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 -## NS-19 bumps ≤ ~0.15 m; probe slightly above for capsule + nav tolerance. const STEP_MAX_HEIGHT: float = 0.32 const STEP_PROBE_FWD: float = 0.38 const STUCK_HORIZ_SPEED: float = 0.28 +## Drop tries when leaving a bump (combined with forward over the lip). +const LEDGE_DROP_STEPS: PackedFloat32Array = PackedFloat32Array([0.08, 0.12, 0.18, 0.24, 0.32]) var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -55,19 +56,27 @@ func _steer_toward_world_point(point: Vector3) -> void: ## Godot `test_move`: returns **true** if the motion would hit something (blocked). -func _try_autostep(delta: float) -> bool: +func _try_autostep(delta: float, full_to_goal: Vector3) -> bool: var to_goal_h: Vector3 = Vector3( - _auth_walk_goal.x - global_position.x, 0.0, _auth_walk_goal.z - global_position.z + full_to_goal.x, 0.0, full_to_goal.z ) if to_goal_h.length_squared() < 0.0225: return false var dir_h: Vector3 = to_goal_h.normalized() var fwd_len: float = maxf(MOVE_SPEED * delta, 0.1) var motion_fwd: Vector3 = dir_h * minf(fwd_len, STEP_PROBE_FWD) - var motion_up: Vector3 = Vector3(0.0, STEP_MAX_HEIGHT, 0.0) var xf: Transform3D = global_transform if not test_move(xf, motion_fwd): return false + if full_to_goal.y > 0.02: + return _autostep_up(xf, motion_fwd, dir_h) + if full_to_goal.y < -0.02: + return _autostep_down(xf, motion_fwd, dir_h) + return false + + +func _autostep_up(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: + var motion_up: Vector3 = Vector3(0.0, STEP_MAX_HEIGHT, 0.0) if test_move(xf, motion_up): return false var xf_up := Transform3D(xf.basis, xf.origin + motion_up) @@ -80,6 +89,20 @@ func _try_autostep(delta: float) -> bool: return true +func _autostep_down(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: + for i: int in range(LEDGE_DROP_STEPS.size()): + var drop: float = LEDGE_DROP_STEPS[i] + var motion_off: Vector3 = motion_fwd + Vector3(0.0, -drop, 0.0) + if test_move(xf, motion_off): + continue + global_position += motion_off + velocity.x = dir_h.x * MOVE_SPEED + velocity.z = dir_h.z * MOVE_SPEED + velocity.y = -minf(MAX_DESCENT_SPEED, 1.2) + return true + return false + + func _physics_process(delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO @@ -129,6 +152,8 @@ func _maybe_autostep_after_slide( var hspd: float = Vector2(velocity.x, velocity.z).length() var want_step: bool = hspd < STUCK_HORIZ_SPEED if not want_step and is_on_wall(): - want_step = full_to_goal.y > 0.03 and horiz_dist > 0.12 - if want_step and _try_autostep(delta): + want_step = horiz_dist > 0.12 and ( + full_to_goal.y > 0.03 or full_to_goal.y < -0.03 + ) + if want_step and _try_autostep(delta, full_to_goal): move_and_slide() From 394e2a71fbbca948ec0dfa71832a6b6944ffedae Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:44:07 -0400 Subject: [PATCH 09/45] NS-23: Fix const LEDGE_DROP_STEPS (Godot const expression rule) --- client/scripts/player.gd | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 32a507e..6663b06 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -14,7 +14,7 @@ const STEP_MAX_HEIGHT: float = 0.32 const STEP_PROBE_FWD: float = 0.38 const STUCK_HORIZ_SPEED: float = 0.28 ## Drop tries when leaving a bump (combined with forward over the lip). -const LEDGE_DROP_STEPS: PackedFloat32Array = PackedFloat32Array([0.08, 0.12, 0.18, 0.24, 0.32]) +const LEDGE_DROP_STEPS = [0.08, 0.12, 0.18, 0.24, 0.32] var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -90,8 +90,7 @@ func _autostep_up(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: func _autostep_down(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: - for i: int in range(LEDGE_DROP_STEPS.size()): - var drop: float = LEDGE_DROP_STEPS[i] + for drop: float in LEDGE_DROP_STEPS: var motion_off: Vector3 = motion_fwd + Vector3(0.0, -drop, 0.0) if test_move(xf, motion_off): continue From 0e79250f1a08dcd7e56a9170ebea7b91d7f2bb1c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:48:51 -0400 Subject: [PATCH 10/45] NS-23: NS-19 bump convex ramps; drop autostep - Add four invisible ConvexPolygonShape3D ramp wedges per bump (walkable) so move_and_slide can climb/descend without test_move ledge hacks. - Remove _try_autostep / _maybe_autostep_after_slide and LEDGE_DROP_STEPS; they mis-detected geometry and fought CharacterBody3D slide. - Fix duplicate subresource unique_id on BumpA RampWest and BumpB RampSouth. --- client/scenes/main.tscn | 72 +++++++++++++++++++++++++++++++++++++ client/scripts/player.gd | 78 ++-------------------------------------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index bab6f8d..15b85e4 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -51,6 +51,30 @@ size = Vector3(0.85, 0.12, 0.85) [sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b"] size = Vector3(0.85, 0.12, 0.85) +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_s"] +points = PackedVector3Array(-0.5, -0.075, -0.65, 0.5, -0.075, -0.65, 0.5, -0.075, -0.5, -0.5, -0.075, -0.5, 0.5, 0.075, -0.5, -0.5, 0.075, -0.5) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_n"] +points = PackedVector3Array(-0.5, -0.075, 0.5, 0.5, -0.075, 0.5, 0.5, -0.075, 0.65, -0.5, -0.075, 0.65, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_e"] +points = PackedVector3Array(0.5, -0.075, -0.5, 0.65, -0.075, -0.5, 0.65, -0.075, 0.5, 0.5, -0.075, 0.5, 0.5, 0.075, 0.5, 0.5, 0.075, -0.5) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_w"] +points = PackedVector3Array(-0.65, -0.075, -0.5, -0.5, -0.075, -0.5, -0.5, -0.075, 0.5, -0.65, -0.075, 0.5, -0.5, 0.075, 0.5, -0.5, 0.075, -0.5) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_s"] +points = PackedVector3Array(-0.425, -0.06, -0.575, 0.425, -0.06, -0.575, 0.425, -0.06, -0.425, -0.425, -0.06, -0.425, 0.425, 0.06, -0.425, -0.425, 0.06, -0.425) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_n"] +points = PackedVector3Array(-0.425, -0.06, 0.425, 0.425, -0.06, 0.425, 0.425, -0.06, 0.575, -0.425, -0.06, 0.575, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_e"] +points = PackedVector3Array(0.425, -0.06, -0.425, 0.575, -0.06, -0.425, 0.575, -0.06, 0.425, 0.425, -0.06, 0.425, 0.425, 0.06, 0.425, 0.425, 0.06, -0.425) + +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_w"] +points = PackedVector3Array(-0.575, -0.06, -0.425, -0.425, -0.06, -0.425, -0.425, -0.06, 0.425, -0.575, -0.06, 0.425, -0.425, 0.06, 0.425, -0.425, 0.06, -0.425) + [sub_resource type="BoxMesh" id="BoxMesh_ns19_pedestal"] size = Vector3(1.5, 2.5, 1.5) @@ -142,6 +166,30 @@ surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] shape = SubResource("BoxShape3D_ns19_bump_a") +[node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900004 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampSouth" unique_id=1900005] +shape = SubResource("Convex_ns19a_ramp_s") + +[node name="RampNorth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900006 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampNorth" unique_id=1900007] +shape = SubResource("Convex_ns19a_ramp_n") + +[node name="RampEast" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900008 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampEast" unique_id=1900009] +shape = SubResource("Convex_ns19a_ramp_e") + +[node name="RampWest" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900010 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampWest" unique_id=1910014] +shape = SubResource("Convex_ns19a_ramp_w") + [node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) collision_layer = 1 @@ -153,6 +201,30 @@ surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] shape = SubResource("BoxShape3D_ns19_bump_b") +[node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1911004 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampSouth" unique_id=1911005] +shape = SubResource("Convex_ns19b_ramp_s") + +[node name="RampNorth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900016 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampNorth" unique_id=1900017] +shape = SubResource("Convex_ns19b_ramp_n") + +[node name="RampEast" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900018 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampEast" unique_id=1900019] +shape = SubResource("Convex_ns19b_ramp_e") + +[node name="RampWest" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900020 groups=["walkable"]] +collision_layer = 1 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampWest" unique_id=1900024] +shape = SubResource("Convex_ns19b_ramp_w") + [node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) collision_layer = 1 diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 6663b06..413c00e 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,8 +1,7 @@ extends CharacterBody3D -## NS-23: Nav agent suggests waypoints; arrival uses stored server goal only (not agent “finished”). -## NS-19 bumps: **autostep** via `test_move` — up+forward to mount, down+forward to leave (pure down -## hits the bump volume you’re standing on). +## NS-23: Nav waypoints + stored server goal for arrival. +## NS-19: invisible convex **ramps** on bumps; no `test_move` autostep (bad vs `move_and_slide`). const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -10,11 +9,6 @@ const VERT_ARRIVE_EPS: float = 0.16 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 -const STEP_MAX_HEIGHT: float = 0.32 -const STEP_PROBE_FWD: float = 0.38 -const STUCK_HORIZ_SPEED: float = 0.28 -## Drop tries when leaving a bump (combined with forward over the lip). -const LEDGE_DROP_STEPS = [0.08, 0.12, 0.18, 0.24, 0.32] var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -55,54 +49,7 @@ func _steer_toward_world_point(point: Vector3) -> void: velocity = desired -## Godot `test_move`: returns **true** if the motion would hit something (blocked). -func _try_autostep(delta: float, full_to_goal: Vector3) -> bool: - var to_goal_h: Vector3 = Vector3( - full_to_goal.x, 0.0, full_to_goal.z - ) - if to_goal_h.length_squared() < 0.0225: - return false - var dir_h: Vector3 = to_goal_h.normalized() - var fwd_len: float = maxf(MOVE_SPEED * delta, 0.1) - var motion_fwd: Vector3 = dir_h * minf(fwd_len, STEP_PROBE_FWD) - var xf: Transform3D = global_transform - if not test_move(xf, motion_fwd): - return false - if full_to_goal.y > 0.02: - return _autostep_up(xf, motion_fwd, dir_h) - if full_to_goal.y < -0.02: - return _autostep_down(xf, motion_fwd, dir_h) - return false - - -func _autostep_up(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: - var motion_up: Vector3 = Vector3(0.0, STEP_MAX_HEIGHT, 0.0) - if test_move(xf, motion_up): - return false - var xf_up := Transform3D(xf.basis, xf.origin + motion_up) - if test_move(xf_up, motion_fwd): - return false - global_position += motion_up + motion_fwd - velocity.x = dir_h.x * MOVE_SPEED - velocity.z = dir_h.z * MOVE_SPEED - velocity.y = 0.0 - return true - - -func _autostep_down(xf: Transform3D, motion_fwd: Vector3, dir_h: Vector3) -> bool: - for drop: float in LEDGE_DROP_STEPS: - var motion_off: Vector3 = motion_fwd + Vector3(0.0, -drop, 0.0) - if test_move(xf, motion_off): - continue - global_position += motion_off - velocity.x = dir_h.x * MOVE_SPEED - velocity.z = dir_h.z * MOVE_SPEED - velocity.y = -minf(MAX_DESCENT_SPEED, 1.2) - return true - return false - - -func _physics_process(delta: float) -> void: +func _physics_process(_delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO move_and_slide() @@ -122,7 +69,6 @@ func _physics_process(delta: float) -> void: if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _steer_toward_world_point(_auth_walk_goal) move_and_slide() - _maybe_autostep_after_slide(delta, full_to_goal, horiz_dist) return if horiz_dist <= DIRECT_APPROACH_RADIUS: @@ -138,21 +84,3 @@ func _physics_process(delta: float) -> void: _steer_toward_world_point(_auth_walk_goal) move_and_slide() - _maybe_autostep_after_slide(delta, full_to_goal, horiz_dist) - - -func _maybe_autostep_after_slide( - delta: float, full_to_goal: Vector3, horiz_dist: float -) -> void: - if not _has_walk_goal: - return - if not is_on_floor() and not is_on_wall(): - return - var hspd: float = Vector2(velocity.x, velocity.z).length() - var want_step: bool = hspd < STUCK_HORIZ_SPEED - if not want_step and is_on_wall(): - want_step = horiz_dist > 0.12 and ( - full_to_goal.y > 0.03 or full_to_goal.y < -0.03 - ) - if want_step and _try_autostep(delta, full_to_goal): - move_and_slide() From 38abf6d5d234b8fe33a669301fa4391814d5a960 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:51:32 -0400 Subject: [PATCH 11/45] NS-23: NS-19 bump collision top-cap only (fix ramp snag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full bump box shared vertical faces with convex ramp wedges, doubling collision and snagging CharacterBody3D on and off. Replace with thin top BoxShape3D; ramps own all side contact. Raise Player floor_max_angle to 50° and floor_snap_length slightly for cleaner ramp floor snapping. --- client/scenes/main.tscn | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 15b85e4..9c9fce8 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -45,12 +45,18 @@ size = Vector3(1, 0.15, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a"] size = Vector3(1, 0.15, 1) +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a_top"] +size = Vector3(1, 0.06, 1) + [sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_b"] size = Vector3(0.85, 0.12, 0.85) [sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b"] size = Vector3(0.85, 0.12, 0.85) +[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b_top"] +size = Vector3(0.85, 0.06, 0.85) + [sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_s"] points = PackedVector3Array(-0.5, -0.075, -0.65, 0.5, -0.075, -0.65, 0.5, -0.075, -0.5, -0.5, -0.075, -0.5, 0.5, 0.075, -0.5, -0.5, 0.075, -0.5) @@ -164,7 +170,8 @@ mesh = SubResource("BoxMesh_ns19_bump_a") surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] -shape = SubResource("BoxShape3D_ns19_bump_a") +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.045, 0) +shape = SubResource("BoxShape3D_ns19_bump_a_top") [node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900004 groups=["walkable"]] collision_layer = 1 @@ -199,7 +206,8 @@ mesh = SubResource("BoxMesh_ns19_bump_b") surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] -shape = SubResource("BoxShape3D_ns19_bump_b") +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0) +shape = SubResource("BoxShape3D_ns19_bump_b_top") [node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1911004 groups=["walkable"]] collision_layer = 1 @@ -265,7 +273,8 @@ shape = SubResource("BoxShape3D_obstacle") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 collision_mask = 1 -floor_snap_length = 0.22 +floor_max_angle = 0.872665 +floor_snap_length = 0.28 script = ExtResource("2_player") [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] From f6baf3bf72ccb863239b48efe171cb1f03753d9d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:55:15 -0400 Subject: [PATCH 12/45] NS-23: NS-19 bump single frustum collider (fix departure edges) Replace top slab + four ramp wedges with one ConvexPolygonShape3D frustum per bump so plateau and slopes are one manifold (no collider seams at table edges when walking off). Add CharacterBody3D safe_margin to reduce edge contact with physics. --- client/scenes/main.tscn | 93 +++------------------------------------- client/scripts/player.gd | 2 +- 2 files changed, 8 insertions(+), 87 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 9c9fce8..a954d9e 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -42,44 +42,14 @@ albedo_color = Color(0.15, 0.4, 1, 1) [sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_a"] size = Vector3(1, 0.15, 1) -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a"] -size = Vector3(1, 0.15, 1) - -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a_top"] -size = Vector3(1, 0.06, 1) - [sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_b"] size = Vector3(0.85, 0.12, 0.85) -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b"] -size = Vector3(0.85, 0.12, 0.85) +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_frustum"] +points = PackedVector3Array(-0.65, -0.075, -0.65, 0.65, -0.075, -0.65, 0.65, -0.075, 0.65, -0.65, -0.075, 0.65, -0.5, 0.075, -0.5, 0.5, 0.075, -0.5, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b_top"] -size = Vector3(0.85, 0.06, 0.85) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_s"] -points = PackedVector3Array(-0.5, -0.075, -0.65, 0.5, -0.075, -0.65, 0.5, -0.075, -0.5, -0.5, -0.075, -0.5, 0.5, 0.075, -0.5, -0.5, 0.075, -0.5) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_n"] -points = PackedVector3Array(-0.5, -0.075, 0.5, 0.5, -0.075, 0.5, 0.5, -0.075, 0.65, -0.5, -0.075, 0.65, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_e"] -points = PackedVector3Array(0.5, -0.075, -0.5, 0.65, -0.075, -0.5, 0.65, -0.075, 0.5, 0.5, -0.075, 0.5, 0.5, 0.075, 0.5, 0.5, 0.075, -0.5) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_ramp_w"] -points = PackedVector3Array(-0.65, -0.075, -0.5, -0.5, -0.075, -0.5, -0.5, -0.075, 0.5, -0.65, -0.075, 0.5, -0.5, 0.075, 0.5, -0.5, 0.075, -0.5) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_s"] -points = PackedVector3Array(-0.425, -0.06, -0.575, 0.425, -0.06, -0.575, 0.425, -0.06, -0.425, -0.425, -0.06, -0.425, 0.425, 0.06, -0.425, -0.425, 0.06, -0.425) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_n"] -points = PackedVector3Array(-0.425, -0.06, 0.425, 0.425, -0.06, 0.425, 0.425, -0.06, 0.575, -0.425, -0.06, 0.575, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_e"] -points = PackedVector3Array(0.425, -0.06, -0.425, 0.575, -0.06, -0.425, 0.575, -0.06, 0.425, 0.425, -0.06, 0.425, 0.425, 0.06, 0.425, 0.425, 0.06, -0.425) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_ramp_w"] -points = PackedVector3Array(-0.575, -0.06, -0.425, -0.425, -0.06, -0.425, -0.425, -0.06, 0.425, -0.575, -0.06, 0.425, -0.425, 0.06, 0.425, -0.425, 0.06, -0.425) +[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_frustum"] +points = PackedVector3Array(-0.575, -0.06, -0.575, 0.575, -0.06, -0.575, 0.575, -0.06, 0.575, -0.575, -0.06, 0.575, -0.425, 0.06, -0.425, 0.425, 0.06, -0.425, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425) [sub_resource type="BoxMesh" id="BoxMesh_ns19_pedestal"] size = Vector3(1.5, 2.5, 1.5) @@ -170,32 +140,7 @@ mesh = SubResource("BoxMesh_ns19_bump_a") surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.045, 0) -shape = SubResource("BoxShape3D_ns19_bump_a_top") - -[node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900004 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampSouth" unique_id=1900005] -shape = SubResource("Convex_ns19a_ramp_s") - -[node name="RampNorth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900006 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampNorth" unique_id=1900007] -shape = SubResource("Convex_ns19a_ramp_n") - -[node name="RampEast" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900008 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampEast" unique_id=1900009] -shape = SubResource("Convex_ns19a_ramp_e") - -[node name="RampWest" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900010 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA/RampWest" unique_id=1910014] -shape = SubResource("Convex_ns19a_ramp_w") +shape = SubResource("Convex_ns19a_frustum") [node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) @@ -206,32 +151,7 @@ mesh = SubResource("BoxMesh_ns19_bump_b") surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.03, 0) -shape = SubResource("BoxShape3D_ns19_bump_b_top") - -[node name="RampSouth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1911004 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampSouth" unique_id=1911005] -shape = SubResource("Convex_ns19b_ramp_s") - -[node name="RampNorth" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900016 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampNorth" unique_id=1900017] -shape = SubResource("Convex_ns19b_ramp_n") - -[node name="RampEast" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900018 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampEast" unique_id=1900019] -shape = SubResource("Convex_ns19b_ramp_e") - -[node name="RampWest" type="StaticBody3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900020 groups=["walkable"]] -collision_layer = 1 - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB/RampWest" unique_id=1900024] -shape = SubResource("Convex_ns19b_ramp_w") +shape = SubResource("Convex_ns19b_frustum") [node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) @@ -275,6 +195,7 @@ collision_layer = 2 collision_mask = 1 floor_max_angle = 0.872665 floor_snap_length = 0.28 +safe_margin = 0.045 script = ExtResource("2_player") [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 413c00e..a79476a 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,7 +1,7 @@ extends CharacterBody3D ## NS-23: Nav waypoints + stored server goal for arrival. -## NS-19: invisible convex **ramps** on bumps; no `test_move` autostep (bad vs `move_and_slide`). +## NS-19: bump collision is one convex frustum (plateau + slopes); no autostep. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 From 7ffcf54d80aa330c2677b2e9c002d775d7cb4efa Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 00:58:10 -0400 Subject: [PATCH 13/45] NS-23: horizontal nav steering so bump-top departure works Path waypoints from the plateau often sit on the slope almost under the player (same xz, lower y). 3D steer + y clamp gave almost no horizontal velocity, so standing on top then clicking off looked stuck. Steer in xz at MOVE_SPEED and let move_and_slide follow floor/slope; keep 3D only for vertical climb when xz is aligned. --- client/scripts/player.gd | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index a79476a..05caaea 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -40,13 +40,25 @@ func snap_to_server(world_pos: Vector3) -> void: func _steer_toward_world_point(point: Vector3) -> void: - var delta: Vector3 = point - global_position - if delta.length_squared() < 1e-8: - velocity = Vector3.ZERO + var pos := global_position + var delta_h: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) + # Full 3D toward a waypoint that is mostly *below* (same xz, on the slope) while standing on + # the plateau yields almost no horizontal speed, so you never reach the rim. Nav in xz + floor + # slide handles ramps and departure from bump tops. + if delta_h.length_squared() > 1e-8: + velocity = delta_h.normalized() * MOVE_SPEED return - var desired: Vector3 = delta.normalized() * MOVE_SPEED - desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) - velocity = desired + var dy: float = point.y - pos.y + if dy > 0.08: + var delta: Vector3 = point - pos + if delta.length_squared() < 1e-8: + velocity = Vector3.ZERO + return + var desired: Vector3 = delta.normalized() * MOVE_SPEED + desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) + velocity = desired + else: + velocity = Vector3.ZERO func _physics_process(_delta: float) -> void: From 4b9e30c624eeb7624799b1326c2d033c16254172 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:01:22 -0400 Subject: [PATCH 14/45] NS-23: fix false arrival when leaving bump tops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERT_ARRIVE_EPS (0.16) was larger than the bump rise (~0.12–0.15), so a floor click beside the plateau often satisfied arrival (horiz + vert) on the first frame and cleared the walk goal before any motion. Tighten vertical arrival to 0.055. Add a steer fallback when the target shares xz but is lower (plateau vs floor underfoot) so we still pick a horizontal escape toward the rim. --- client/scripts/player.gd | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 05caaea..d921752 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -5,7 +5,9 @@ extends CharacterBody3D const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 -const VERT_ARRIVE_EPS: float = 0.16 +## Must be **below** NS-19 bump rise (~0.12–0.15) or we "arrive" while still on the plateau when the +## goal is on the floor beside the bump (small horiz offset + ~0.15 m vertical). +const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 @@ -57,6 +59,12 @@ func _steer_toward_world_point(point: Vector3) -> void: var desired: Vector3 = delta.normalized() * MOVE_SPEED desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) velocity = desired + elif dy < -0.04: + # Goal is lower and almost same xz (e.g. floor under the plateau): still need rim escape. + var escape_h := Vector3(-global_transform.basis.z.x, 0.0, -global_transform.basis.z.z) + if escape_h.length_squared() < 1e-8: + escape_h = Vector3(1.0, 0.0, 0.0) + velocity = escape_h.normalized() * MOVE_SPEED else: velocity = Vector3.ZERO From 725bef11332c73a22647337c6f31108318902723 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:03:51 -0400 Subject: [PATCH 15/45] NS-23: allow ground pick on bump slopes (fix stuck departure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIN_WALKABLE_NORMAL_DOT_UP 0.82 is stricter than NS-19 frustum slopes (~45° / dot 0.71 on Bump A, ~39° / 0.78 on B). Clicks off the plateau often hit the slope first; the pick was dropped with no MoveCommand. Lower to 0.64 so ramps register; vertical walls remain ~0. --- client/scripts/ground_pick.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd index 7bef4c8..3dee890 100644 --- a/client/scripts/ground_pick.gd +++ b/client/scripts/ground_pick.gd @@ -1,14 +1,14 @@ extends Node3D ## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. -## NS-19: ignores hits on **steep** surfaces (e.g. pedestal **walls**) so players cannot chain small -## vertical steps up a vertical face; only **floor-like** normals (high dot with UP) count. +## NS-19: reject vertical faces (pedestal). Bump slopes ~39–45° (dot UP ~0.71–0.78). +## 0.82 rejected slope-first picks when leaving plateau; use 0.64. Walls stay ~0. ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal target_chosen(world: Vector3) ## Minimum `hit_normal.dot(Vector3.UP)` to accept a pick (0 = vertical wall, 1 = flat floor). -const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.82 +const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.64 ## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` ## in `_ready`. From 05d7916cc729e351a63c9bf3db7d92b4440f04d3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:07:02 -0400 Subject: [PATCH 16/45] =?UTF-8?q?NS-23:=20fix=20bump=20departure=20?= =?UTF-8?q?=E2=80=94=20stepped=20pick=20+=20goal-only=20steer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground pick: step the ray through steep walkable hits (0.09 < dot UP < 0.64) so a view that grazes bump sides still resolves to flat floor beyond; walls (dot ~0) stop the chain. Player: drop NavigationAgent3D waypoint following; steer only toward the server goal in xz so plateau paths are not dominated by vertical nav segments. move_and_slide handles slopes and obstacles. --- client/scripts/ground_pick.gd | 69 ++++++++++++++++++++++++----------- client/scripts/player.gd | 23 ++---------- 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd index 3dee890..3322ed7 100644 --- a/client/scripts/ground_pick.gd +++ b/client/scripts/ground_pick.gd @@ -1,8 +1,8 @@ extends Node3D ## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. -## NS-19: reject vertical faces (pedestal). Bump slopes ~39–45° (dot UP ~0.71–0.78). -## 0.82 rejected slope-first picks when leaving plateau; use 0.64. Walls stay ~0. +## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance +## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau. ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal target_chosen(world: Vector3) @@ -10,6 +10,15 @@ signal target_chosen(world: Vector3) ## Minimum `hit_normal.dot(Vector3.UP)` to accept a pick (0 = vertical wall, 1 = flat floor). const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.64 +## Nudge past a steep walkable triangle so the ray can hit ground behind (meters along view ray). +const STEEP_PICK_THROUGH: float = 0.14 + +## Max ray segments (steep walkable → advance) before giving up. +const MAX_PICK_SEGMENTS: int = 24 + +## Below this dot, treat as wall — do not step through (pedestal sides stay non-pickable). +const WALL_NORMAL_DOT_CUTOFF: float = 0.09 + ## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd` ## in `_ready`. var fallback_camera: Camera3D @@ -29,27 +38,45 @@ func _try_pick(screen_pos: Vector2) -> void: if cam == null: return - var origin: Vector3 = cam.project_ray_origin(screen_pos) var ray_dir: Vector3 = cam.project_ray_normal(screen_pos) - var to: Vector3 = origin + ray_dir * 2000.0 - var query := PhysicsRayQueryParameters3D.create(origin, to) - query.collision_mask = 1 + if ray_dir.length_squared() < 1e-12: + return + ray_dir = ray_dir.normalized() + var from: Vector3 = cam.project_ray_origin(screen_pos) var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var hit: Dictionary = space.intersect_ray(query) - if hit.is_empty(): - return - var collider: Variant = hit.get("collider") - if not _collider_is_walkable(collider): - return - var hit_normal: Variant = hit.get("normal", Vector3.ZERO) - if hit_normal is not Vector3: - return - var nrm: Vector3 = (hit_normal as Vector3).normalized() - if nrm.dot(Vector3.UP) < MIN_WALKABLE_NORMAL_DOT_UP: - return - var hit_pos: Variant = hit.get("position") - if hit_pos is Vector3: - target_chosen.emit(hit_pos as Vector3) + const RAY_LEN: float = 2000.0 + var chosen: Vector3 = Vector3.ZERO + var have_chosen: bool = false + + for _i in range(MAX_PICK_SEGMENTS): + var to: Vector3 = from + ray_dir * RAY_LEN + var query := PhysicsRayQueryParameters3D.create(from, to) + query.collision_mask = 1 + var hit: Dictionary = space.intersect_ray(query) + if hit.is_empty(): + break + if not _collider_is_walkable(hit.get("collider")): + break + var hit_normal: Variant = hit.get("normal", Vector3.ZERO) + if hit_normal is not Vector3: + break + var nrm: Vector3 = (hit_normal as Vector3).normalized() + var ndot: float = nrm.dot(Vector3.UP) + if ndot >= MIN_WALKABLE_NORMAL_DOT_UP: + var pos_var: Variant = hit.get("position") + if pos_var is Vector3: + chosen = pos_var as Vector3 + have_chosen = true + break + if ndot <= WALL_NORMAL_DOT_CUTOFF: + break + var hp_var: Variant = hit.get("position") + if hp_var is not Vector3: + break + from = (hp_var as Vector3) + ray_dir * STEEP_PICK_THROUGH + + if have_chosen: + target_chosen.emit(chosen) func _collider_is_walkable(collider: Variant) -> bool: diff --git a/client/scripts/player.gd b/client/scripts/player.gd index d921752..30b687d 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -10,7 +10,6 @@ const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 -const DIRECT_APPROACH_RADIUS: float = 0.85 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -85,22 +84,8 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - var nav_map: RID = _nav_agent.get_navigation_map() - if NavigationServer3D.map_get_iteration_id(nav_map) == 0: - _steer_toward_world_point(_auth_walk_goal) - move_and_slide() - return - - if horiz_dist <= DIRECT_APPROACH_RADIUS: - _steer_toward_world_point(_auth_walk_goal) - else: - var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next_h: Vector3 = Vector3( - next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z - ) - if to_next_h.length_squared() > 0.0025: - _steer_toward_world_point(next_path_position) - else: - _steer_toward_world_point(_auth_walk_goal) - + # Do not follow NavigationAgent3D waypoints: from the bump plateau the first segment often + # drops straight down the mesh, and the agent map can disagree with presentation. Steer only + # toward the server goal in xz; move_and_slide + floor handles slopes and obstacles. + _steer_toward_world_point(_auth_walk_goal) move_and_slide() From a5ec8df4a73cfb38038483144b4a6956b6ec24fc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:10:16 -0400 Subject: [PATCH 17/45] NS-23: terminal collision, nav around obstacle, agent under region - Add BoxShape3D to PrototypeTerminal (layer 1, walkable) so the player cannot pass through; matches terminal mesh size. - Bake navigation from collision layer 1 only so the player (layer 2) is not merged into the mesh; reparent Player under NavigationRegion3D so NavigationAgent3D resolves a valid map. - Restore nav waypoint following for long horizontal legs; keep horizontal steering toward next/goal so bump departure behavior stays sane. --- client/scenes/main.tscn | 19 +++++++++++++------ client/scripts/main.gd | 2 +- client/scripts/player.gd | 26 ++++++++++++++++++++++---- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index a954d9e..395914c 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -33,6 +33,9 @@ size = Vector3(0.9, 1, 0.4) [sub_resource type="StandardMaterial3D" id="Mat_terminal"] albedo_color = Color(0.28, 0.38, 0.45, 1) +[sub_resource type="BoxShape3D" id="BoxShape3D_terminal"] +size = Vector3(0.9, 1, 0.4) + [sub_resource type="BoxMesh" id="BoxMesh_marker"] size = Vector3(0.22, 0.35, 0.22) @@ -74,7 +77,7 @@ albedo_color = Color(0.32, 0.38, 0.55, 1) [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_parsed_geometry_type = 2 -geometry_collision_mask = 4294967295 +geometry_collision_mask = 1 geometry_source_geometry_mode = 0 cell_size = 0.15 cell_height = 0.15 @@ -111,13 +114,17 @@ mesh = SubResource("BoxMesh_floor") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) shape = SubResource("BoxShape3D_floor") -[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001] +[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +collision_layer = 1 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] mesh = SubResource("BoxMesh_terminal") surface_material_override/0 = SubResource("Mat_terminal") +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006] +shape = SubResource("BoxShape3D_terminal") + [node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003] script = ExtResource("6_rad") @@ -189,7 +196,7 @@ mesh = SubResource("BoxMesh_obstacle") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) shape = SubResource("BoxShape3D_obstacle") -[node name="Player" type="CharacterBody3D" parent="." unique_id=352931696] +[node name="Player" type="CharacterBody3D" parent="World/NavigationRegion3D" unique_id=352931696] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 collision_mask = 1 @@ -198,10 +205,10 @@ floor_snap_length = 0.28 safe_margin = 0.045 script = ExtResource("2_player") -[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Player" unique_id=1695755590] shape = SubResource("CapsuleShape3D_player") -[node name="NavigationAgent3D" type="NavigationAgent3D" parent="Player" unique_id=8880002] +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="World/NavigationRegion3D/Player" unique_id=8880002] avoidance_enabled = false path_desired_distance = 0.35 target_desired_distance = 0.35 @@ -209,7 +216,7 @@ path_height_offset = 0.0 radius = 0.4 height = 1.0 -[node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=2027034386] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Player" unique_id=2027034386] mesh = SubResource("CapsuleMesh_player") [node name="GroundPick" type="Node3D" parent="." unique_id=2500001] diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e7b9bf2..6c8aaf1 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -11,7 +11,7 @@ var _move_reject_msg_token: int = 0 @onready var _camera: Camera3D = $World/Camera3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D -@onready var _player: CharacterBody3D = $Player +@onready var _player: CharacterBody3D = $World/NavigationRegion3D/Player @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 30b687d..0f90763 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -10,6 +10,7 @@ const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 +const DIRECT_APPROACH_RADIUS: float = 0.85 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -84,8 +85,25 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - # Do not follow NavigationAgent3D waypoints: from the bump plateau the first segment often - # drops straight down the mesh, and the agent map can disagree with presentation. Steer only - # toward the server goal in xz; move_and_slide + floor handles slopes and obstacles. - _steer_toward_world_point(_auth_walk_goal) + # Waypoints route around obstacles; steering stays horizontal-only (see _steer_toward_world_point) + # so bump plateaus do not get a mostly-vertical first segment. If next waypoint aligns under the + # player (small xz delta), fall back to steering toward the goal xz. + var nav_map: RID = _nav_agent.get_navigation_map() + if NavigationServer3D.map_get_iteration_id(nav_map) == 0: + _steer_toward_world_point(_auth_walk_goal) + move_and_slide() + return + + if horiz_dist <= DIRECT_APPROACH_RADIUS: + _steer_toward_world_point(_auth_walk_goal) + else: + var next_path_position: Vector3 = _nav_agent.get_next_path_position() + var to_next_h: Vector3 = Vector3( + next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z + ) + if to_next_h.length_squared() > 0.0025: + _steer_toward_world_point(next_path_position) + else: + _steer_toward_world_point(_auth_walk_goal) + move_and_slide() From db1fe0f46b1fee14d7ec7ebfbdacde2f2b4e1347 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:12:11 -0400 Subject: [PATCH 18/45] NS-23: skip nav waypoints on NS-19 bump plateaus only Restore obstacle routing via waypoints elsewhere; on bump tops nav often prefers a downward segment and breaks departure. Detect plateau with is_on_floor + flat normal + ray down into group ns19_bump; then steer only toward authoritative goal xz. Slopes keep waypoint pathing. --- client/scenes/main.tscn | 4 ++-- client/scripts/player.gd | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 395914c..2d8b4f5 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -138,7 +138,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) mesh = SubResource("BoxMesh_marker") surface_material_override/0 = SubResource("Mat_marker_base") -[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable"]] +[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable", "ns19_bump"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.4, 0.075, -2.6) collision_layer = 1 @@ -149,7 +149,7 @@ surface_material_override/0 = SubResource("Mat_ns19_bump") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] shape = SubResource("Convex_ns19a_frustum") -[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]] +[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable", "ns19_bump"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) collision_layer = 1 diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 0f90763..5184f80 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,7 +1,7 @@ extends CharacterBody3D ## NS-23: Nav waypoints + stored server goal for arrival. -## NS-19: bump collision is one convex frustum (plateau + slopes); no autostep. +## NS-19: convex frustum bumps; `ns19_bump` group — on flat plateau skip nav waypoints (departure). const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -11,6 +11,8 @@ const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 +## Floor normal must be this “flat” to count as bump plateau (slopes use nav waypoints). +const NS19_BUMP_TOP_NORMAL_DOT: float = 0.992 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -41,6 +43,33 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) +func _on_ns19_bump_plateau() -> bool: + if not is_on_floor(): + return false + if get_floor_normal().dot(Vector3.UP) < NS19_BUMP_TOP_NORMAL_DOT: + return false + return _raycast_down_hits_group("ns19_bump", 2.5) + + +func _raycast_down_hits_group(group_name: String, cast_len: float) -> bool: + var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state + var from: Vector3 = global_position + var to: Vector3 = from + Vector3(0.0, -cast_len, 0.0) + var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) + q.collision_mask = 1 + q.exclude = [get_rid()] + var hit: Dictionary = space.intersect_ray(q) + if hit.is_empty(): + return false + var col: Variant = hit.get("collider") + var n: Node = col as Node + while n: + if n.is_in_group(group_name): + return true + n = n.get_parent() + return false + + func _steer_toward_world_point(point: Vector3) -> void: var pos := global_position var delta_h: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) @@ -94,6 +123,14 @@ func _physics_process(_delta: float) -> void: move_and_slide() return + # Nav waypoints on the plateau often point down the slope first; goal-only + horizontal steer + # matches the NS-19 departure fix. Slopes stay on waypoints so obstacle routing still applies + # when crossing the district. + if _on_ns19_bump_plateau(): + _steer_toward_world_point(_auth_walk_goal) + move_and_slide() + return + if horiz_dist <= DIRECT_APPROACH_RADIUS: _steer_toward_world_point(_auth_walk_goal) else: From 66d7438a595c84a92006d2da88731d3ef902c0fa Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:14:19 -0400 Subject: [PATCH 19/45] NS-23: broaden NS-19 bump nav bypass for departure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plateau-only (strict flat normal + is_on_floor) missed rim poses and convex top edges. Skip waypoints when a down ray from body or feet offset hits ns19_bump and either the goal is lower than the body origin (typical floor click off bump) or we are on a flatter floor normal (~15°). Keeps waypoint routing on bump slopes when climbing past obstacles unless the move is clearly downward. --- client/scripts/player.gd | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 5184f80..a085809 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,7 +1,7 @@ extends CharacterBody3D ## NS-23: Nav waypoints + stored server goal for arrival. -## NS-19: convex frustum bumps; `ns19_bump` group — on flat plateau skip nav waypoints (departure). +## NS-19: `ns19_bump` — skip nav waypoints when leaving (goal lower) or on flatish bump top. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -11,8 +11,11 @@ const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 -## Floor normal must be this “flat” to count as bump plateau (slopes use nav waypoints). -const NS19_BUMP_TOP_NORMAL_DOT: float = 0.992 +## Plateau / near-rim: floor normal within ~15° of up still counts (0.992 missed convex edges). +const NS19_BUMP_FLAT_NORMAL_DOT: float = 0.964 +const NS19_RAY_DOWN: float = 3.0 +## Second ray origin offset toward feet so a tall capsule still hits bump underfoot. +const NS19_FEET_RAY_Y_OFFSET: float = -0.45 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -43,18 +46,23 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) -func _on_ns19_bump_plateau() -> bool: - if not is_on_floor(): +func _should_skip_nav_for_ns19_bump() -> bool: + var under_bump: bool = _ns19_bump_under_ray(global_position) + if not under_bump: + under_bump = _ns19_bump_under_ray(global_position + Vector3(0.0, NS19_FEET_RAY_Y_OFFSET, 0.0)) + if not under_bump: return false - if get_floor_normal().dot(Vector3.UP) < NS19_BUMP_TOP_NORMAL_DOT: - return false - return _raycast_down_hits_group("ns19_bump", 2.5) + # Waypoints pick “down the mesh” first; horizontal steer to goal fixes departure. + if _auth_walk_goal.y < global_position.y - 0.02: + return true + if is_on_floor() and get_floor_normal().dot(Vector3.UP) >= NS19_BUMP_FLAT_NORMAL_DOT: + return true + return false -func _raycast_down_hits_group(group_name: String, cast_len: float) -> bool: +func _ns19_bump_under_ray(from: Vector3) -> bool: var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var from: Vector3 = global_position - var to: Vector3 = from + Vector3(0.0, -cast_len, 0.0) + var to: Vector3 = from + Vector3(0.0, -NS19_RAY_DOWN, 0.0) var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) q.collision_mask = 1 q.exclude = [get_rid()] @@ -64,7 +72,7 @@ func _raycast_down_hits_group(group_name: String, cast_len: float) -> bool: var col: Variant = hit.get("collider") var n: Node = col as Node while n: - if n.is_in_group(group_name): + if n.is_in_group("ns19_bump"): return true n = n.get_parent() return false @@ -123,10 +131,8 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - # Nav waypoints on the plateau often point down the slope first; goal-only + horizontal steer - # matches the NS-19 departure fix. Slopes stay on waypoints so obstacle routing still applies - # when crossing the district. - if _on_ns19_bump_plateau(): + # On bump + (goal lower or flat top): skip waypoints so we do not follow vertical “drop” legs. + if _should_skip_nav_for_ns19_bump(): _steer_toward_world_point(_auth_walk_goal) move_and_slide() return From 35d16d15db48c650b92a71eb0889cb10f621ec42 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:16:07 -0400 Subject: [PATCH 20/45] NS-23: harden bump departure (always skip nav on bump, steer dy<0) - If any of three down-rays (body + two lower origins) hits ns19_bump, skip NavigationAgent waypoints unconditionally on that footprint. - Steering: any goal lower than origin with same xz (dy < 0) uses rim escape; dy in (-0.04, 0) previously zeroed velocity and could hang. --- client/scripts/player.gd | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index a085809..cc09f8a 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,7 +1,7 @@ extends CharacterBody3D ## NS-23: Nav waypoints + stored server goal for arrival. -## NS-19: `ns19_bump` — skip nav waypoints when leaving (goal lower) or on flatish bump top. +## NS-19: any underfoot ray into `ns19_bump` → skip nav (waypoints); bumps are small vs obstacles. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -11,11 +11,9 @@ const VERT_ARRIVE_EPS: float = 0.055 const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 -## Plateau / near-rim: floor normal within ~15° of up still counts (0.992 missed convex edges). -const NS19_BUMP_FLAT_NORMAL_DOT: float = 0.964 const NS19_RAY_DOWN: float = 3.0 -## Second ray origin offset toward feet so a tall capsule still hits bump underfoot. -const NS19_FEET_RAY_Y_OFFSET: float = -0.45 +## Downward ray origins (Y offset from body origin) so we still detect bump under a tall capsule. +const NS19_UNDERFOOT_RAY_Y: Array[float] = [0.0, -0.45, -0.82] var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -47,16 +45,9 @@ func snap_to_server(world_pos: Vector3) -> void: func _should_skip_nav_for_ns19_bump() -> bool: - var under_bump: bool = _ns19_bump_under_ray(global_position) - if not under_bump: - under_bump = _ns19_bump_under_ray(global_position + Vector3(0.0, NS19_FEET_RAY_Y_OFFSET, 0.0)) - if not under_bump: - return false - # Waypoints pick “down the mesh” first; horizontal steer to goal fixes departure. - if _auth_walk_goal.y < global_position.y - 0.02: - return true - if is_on_floor() and get_floor_normal().dot(Vector3.UP) >= NS19_BUMP_FLAT_NORMAL_DOT: - return true + for y_off: float in NS19_UNDERFOOT_RAY_Y: + if _ns19_bump_under_ray(global_position + Vector3(0.0, y_off, 0.0)): + return true return false @@ -96,8 +87,8 @@ func _steer_toward_world_point(point: Vector3) -> void: var desired: Vector3 = delta.normalized() * MOVE_SPEED desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) velocity = desired - elif dy < -0.04: - # Goal is lower and almost same xz (e.g. floor under the plateau): still need rim escape. + elif dy < 0.0: + # Same xz, goal slightly lower (e.g. -0.03): old threshold left velocity at zero (“hang”). var escape_h := Vector3(-global_transform.basis.z.x, 0.0, -global_transform.basis.z.z) if escape_h.length_squared() < 1e-8: escape_h = Vector3(1.0, 0.0, 0.0) @@ -131,7 +122,7 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - # On bump + (goal lower or flat top): skip waypoints so we do not follow vertical “drop” legs. + # On bump: skip nav waypoints (drop-first paths); bump area is small vs the obstacle. if _should_skip_nav_for_ns19_bump(): _steer_toward_world_point(_auth_walk_goal) move_and_slide() From fba2c8e171c9e2d29265c1b100ad93168c4f4d34 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:19:28 -0400 Subject: [PATCH 21/45] =?UTF-8?q?NS-19:=20large=20bump=20=E2=80=94=20wider?= =?UTF-8?q?=20underfoot=20rays=20+=20shallower=20frustum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add xz-offset down-rays (~0.42 m) so rim/lean on the 1 m Bump A still counts as on-bump for nav skip (single center column could miss). - Widen Bump A convex base 0.65→0.72 (top 0.5 unchanged): gentler ~34° sides vs ~45° to reduce CharacterBody3D snags; mesh unchanged. --- client/scenes/main.tscn | 2 +- client/scripts/player.gd | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 2d8b4f5..503a07a 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -49,7 +49,7 @@ size = Vector3(1, 0.15, 1) size = Vector3(0.85, 0.12, 0.85) [sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_frustum"] -points = PackedVector3Array(-0.65, -0.075, -0.65, 0.65, -0.075, -0.65, 0.65, -0.075, 0.65, -0.65, -0.075, 0.65, -0.5, 0.075, -0.5, 0.5, 0.075, -0.5, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) +points = PackedVector3Array(-0.72, -0.075, -0.72, 0.72, -0.075, -0.72, 0.72, -0.075, 0.72, -0.72, -0.075, 0.72, -0.5, 0.075, -0.5, 0.5, 0.075, -0.5, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) [sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_frustum"] points = PackedVector3Array(-0.575, -0.06, -0.575, 0.575, -0.06, -0.575, 0.575, -0.06, 0.575, -0.575, -0.06, 0.575, -0.425, 0.06, -0.425, 0.425, 0.06, -0.425, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index cc09f8a..900c60f 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -12,8 +12,17 @@ const MAX_CLIMB_SPEED: float = 2.6 const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 const NS19_RAY_DOWN: float = 3.0 -## Downward ray origins (Y offset from body origin) so we still detect bump under a tall capsule. -const NS19_UNDERFOOT_RAY_Y: Array[float] = [0.0, -0.45, -0.82] +## Origins (world-space offset from body) before casting down. Wider xz helps the 1 m Bump A rim +## where a single vertical line can clear the frustum; Bump B stays covered too. +const NS19_UNDERFOOT_OFFSETS: Array[Vector3] = [ + Vector3(0, 0, 0), + Vector3(0, -0.45, 0), + Vector3(0, -0.82, 0), + Vector3(0.42, -0.28, 0), + Vector3(-0.42, -0.28, 0), + Vector3(0, -0.28, 0.42), + Vector3(0, -0.28, -0.42), +] var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -45,8 +54,8 @@ func snap_to_server(world_pos: Vector3) -> void: func _should_skip_nav_for_ns19_bump() -> bool: - for y_off: float in NS19_UNDERFOOT_RAY_Y: - if _ns19_bump_under_ray(global_position + Vector3(0.0, y_off, 0.0)): + for off: Vector3 in NS19_UNDERFOOT_OFFSETS: + if _ns19_bump_under_ray(global_position + off): return true return false From 2291a0ec07d33363308142e019ec130d9c20b4c7 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:23:25 -0400 Subject: [PATCH 22/45] NS-19: move Bump B to clear capsule between bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frustum bases left ~0.33 m between closest corners while the player capsule is ~0.8 m wide, so crossing could wedge between A and B. Shift NS19BumpB −Z by 0.75 m (z −4.1 → −4.85) so AABB clearance ≈1 m. --- client/scenes/main.tscn | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 503a07a..7a25b54 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -150,7 +150,7 @@ surface_material_override/0 = SubResource("Mat_ns19_bump") shape = SubResource("Convex_ns19a_frustum") [node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable", "ns19_bump"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.85) collision_layer = 1 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900012] From b8176f8fa1ce5ef616a8db2a3e6081dfdb828694 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:26:59 -0400 Subject: [PATCH 23/45] =?UTF-8?q?NS-23:=20simplify=20client=20movement=20?= =?UTF-8?q?=E2=80=94=20horizontal=20only,=20no=20bump=20nav=20hacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stacked NS-19 workarounds (underfoot rays, plateau nav skip, 3D climb/descent steering, rim escape) with one rule: set horizontal velocity toward waypoint/goal, force velocity.y = 0, move_and_slide + floor_* handle height along geometry. Server MoveCommand remains the single place for allow/deny on illegal steps. --- client/scripts/player.gd | 98 +++++++--------------------------------- 1 file changed, 17 insertions(+), 81 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 900c60f..3912d82 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,28 +1,17 @@ extends CharacterBody3D -## NS-23: Nav waypoints + stored server goal for arrival. -## NS-19: any underfoot ray into `ns19_bump` → skip nav (waypoints); bumps are small vs obstacles. +## NS-23: Follow server move target on walkable geometry. +## +## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0. Height along ramps +## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, +## distance, bounds) is server MoveCommand only — not reimplemented here. +## +## NavigationAgent3D detours obstacles; steering uses target **xz** only. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 -## Must be **below** NS-19 bump rise (~0.12–0.15) or we "arrive" while still on the plateau when the -## goal is on the floor beside the bump (small horiz offset + ~0.15 m vertical). const VERT_ARRIVE_EPS: float = 0.055 -const MAX_CLIMB_SPEED: float = 2.6 -const MAX_DESCENT_SPEED: float = 2.2 const DIRECT_APPROACH_RADIUS: float = 0.85 -const NS19_RAY_DOWN: float = 3.0 -## Origins (world-space offset from body) before casting down. Wider xz helps the 1 m Bump A rim -## where a single vertical line can clear the frustum; Bump B stays covered too. -const NS19_UNDERFOOT_OFFSETS: Array[Vector3] = [ - Vector3(0, 0, 0), - Vector3(0, -0.45, 0), - Vector3(0, -0.82, 0), - Vector3(0.42, -0.28, 0), - Vector3(-0.42, -0.28, 0), - Vector3(0, -0.28, 0.42), - Vector3(0, -0.28, -0.42), -] var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -53,57 +42,13 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) -func _should_skip_nav_for_ns19_bump() -> bool: - for off: Vector3 in NS19_UNDERFOOT_OFFSETS: - if _ns19_bump_under_ray(global_position + off): - return true - return false - - -func _ns19_bump_under_ray(from: Vector3) -> bool: - var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var to: Vector3 = from + Vector3(0.0, -NS19_RAY_DOWN, 0.0) - var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) - q.collision_mask = 1 - q.exclude = [get_rid()] - var hit: Dictionary = space.intersect_ray(q) - if hit.is_empty(): - return false - var col: Variant = hit.get("collider") - var n: Node = col as Node - while n: - if n.is_in_group("ns19_bump"): - return true - n = n.get_parent() - return false - - -func _steer_toward_world_point(point: Vector3) -> void: +func _set_horizontal_velocity_toward(point: Vector3) -> void: var pos := global_position - var delta_h: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) - # Full 3D toward a waypoint that is mostly *below* (same xz, on the slope) while standing on - # the plateau yields almost no horizontal speed, so you never reach the rim. Nav in xz + floor - # slide handles ramps and departure from bump tops. - if delta_h.length_squared() > 1e-8: - velocity = delta_h.normalized() * MOVE_SPEED - return - var dy: float = point.y - pos.y - if dy > 0.08: - var delta: Vector3 = point - pos - if delta.length_squared() < 1e-8: - velocity = Vector3.ZERO - return - var desired: Vector3 = delta.normalized() * MOVE_SPEED - desired.y = clampf(desired.y, -MAX_DESCENT_SPEED, MAX_CLIMB_SPEED) - velocity = desired - elif dy < 0.0: - # Same xz, goal slightly lower (e.g. -0.03): old threshold left velocity at zero (“hang”). - var escape_h := Vector3(-global_transform.basis.z.x, 0.0, -global_transform.basis.z.z) - if escape_h.length_squared() < 1e-8: - escape_h = Vector3(1.0, 0.0, 0.0) - velocity = escape_h.normalized() * MOVE_SPEED - else: - velocity = Vector3.ZERO + var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) + if dh.length_squared() < 1e-10: + dh = Vector3(1.0, 0.0, 0.0) + velocity = dh.normalized() * MOVE_SPEED + velocity.y = 0.0 func _physics_process(_delta: float) -> void: @@ -122,31 +67,22 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - # Waypoints route around obstacles; steering stays horizontal-only (see _steer_toward_world_point) - # so bump plateaus do not get a mostly-vertical first segment. If next waypoint aligns under the - # player (small xz delta), fall back to steering toward the goal xz. var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: - _steer_toward_world_point(_auth_walk_goal) - move_and_slide() - return - - # On bump: skip nav waypoints (drop-first paths); bump area is small vs the obstacle. - if _should_skip_nav_for_ns19_bump(): - _steer_toward_world_point(_auth_walk_goal) + _set_horizontal_velocity_toward(_auth_walk_goal) move_and_slide() return if horiz_dist <= DIRECT_APPROACH_RADIUS: - _steer_toward_world_point(_auth_walk_goal) + _set_horizontal_velocity_toward(_auth_walk_goal) else: var next_path_position: Vector3 = _nav_agent.get_next_path_position() var to_next_h: Vector3 = Vector3( next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z ) if to_next_h.length_squared() > 0.0025: - _steer_toward_world_point(next_path_position) + _set_horizontal_velocity_toward(next_path_position) else: - _steer_toward_world_point(_auth_walk_goal) + _set_horizontal_velocity_toward(_auth_walk_goal) move_and_slide() From e1beb82a75f98421b0ce8ac4f708caddb0fd75df Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:29:31 -0400 Subject: [PATCH 24/45] =?UTF-8?q?NS-23:=20unstuck=20bump=20top=20=E2=80=94?= =?UTF-8?q?=20descend=20steers=20xz=20to=20goal;=20doc=20abs(dy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server already allows downward steps via abs(ΔY) vs MaxVerticalStep; document that explicitly. Client: when authoritative goal Y is below the body, skip nav waypoints and steer horizontally toward the goal only — mesh waypoints under the rim gave ~no horizontal speed (felt like wrong collision). --- client/scripts/player.gd | 10 +++++++++- .../Game/PositionState/MoveCommandValidation.cs | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 3912d82..1449e61 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -6,12 +6,15 @@ extends CharacterBody3D ## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, ## distance, bounds) is server MoveCommand only — not reimplemented here. ## -## NavigationAgent3D detours obstacles; steering uses target **xz** only. +## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims). const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 +## Server goal clearly below us (descending). Nav mesh waypoints are often under the rim first; +## steering at next gives almost no horizontal speed — walk out on xz toward the authorized goal. +const DESCEND_GOAL_Y_MARGIN: float = 0.06 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -73,6 +76,11 @@ func _physics_process(_delta: float) -> void: move_and_slide() return + if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN: + _set_horizontal_velocity_toward(_auth_walk_goal) + move_and_slide() + return + if horiz_dist <= DIRECT_APPROACH_RADIUS: _set_horizontal_velocity_toward(_auth_walk_goal) else: diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs index 0dc1c86..ef75be9 100644 --- a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs @@ -4,6 +4,8 @@ namespace NeonSprawl.Server.Game.PositionState; /// /// Precedence when multiple rules fail: , /// then , then . +/// Vertical uses abs(ΔY), so descending (target Y lower than current) is allowed when the +/// drop is within — same as climbing. /// public static class MoveCommandValidation { From 72eef09d155c6129d31d0e44b511797d62477762 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:32:02 -0400 Subject: [PATCH 25/45] =?UTF-8?q?NS-23:=20remove=20descend=20nav=20skip=20?= =?UTF-8?q?=E2=80=94=20restored=20obstacle=20pathing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit goal.y < body.y matched almost every move because ray picks use floor Y while CharacterBody3D origin is mid-capsule, so we always bee-lined and ignored waypoints. Rely on small-xz next-waypoint fallback for bump rims instead. --- client/scripts/player.gd | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 1449e61..2b11d34 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -6,15 +6,12 @@ extends CharacterBody3D ## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, ## distance, bounds) is server MoveCommand only — not reimplemented here. ## -## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims). +## Nav for detours; if next waypoint has almost no xz offset, steer xz toward goal. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 -## Server goal clearly below us (descending). Nav mesh waypoints are often under the rim first; -## steering at next gives almost no horizontal speed — walk out on xz toward the authorized goal. -const DESCEND_GOAL_Y_MARGIN: float = 0.06 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -76,11 +73,6 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN: - _set_horizontal_velocity_toward(_auth_walk_goal) - move_and_slide() - return - if horiz_dist <= DIRECT_APPROACH_RADIUS: _set_horizontal_velocity_toward(_auth_walk_goal) else: @@ -88,6 +80,7 @@ func _physics_process(_delta: float) -> void: var to_next_h: Vector3 = Vector3( next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z ) + # “goal Y < body Y” skips nav wrongly: pick is floor height, body origin is higher. if to_next_h.length_squared() > 0.0025: _set_horizontal_velocity_toward(next_path_position) else: From 8b2b64a32651c6aaac4cf0f54ae1e00f652562d8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:33:57 -0400 Subject: [PATCH 26/45] NS-23: bump step-off bypass using floor-under-feet, not body Y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ray down for floor Y under the capsule; skip nav only when goal is a bit lower than that surface, drop ≤0.55 m, and horiz ≤1.05 m. Matches leaving a bump without comparing pick/body origin (which broke obstacle pathing). Long crosses still use waypoints. --- client/scripts/player.gd | 42 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 2b11d34..bcf4e10 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -6,12 +6,16 @@ extends CharacterBody3D ## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, ## distance, bounds) is server MoveCommand only — not reimplemented here. ## -## Nav for detours; if next waypoint has almost no xz offset, steer xz toward goal. +## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 +const FLOOR_PROBE_DOWN: float = 4.0 +const STEP_OFF_FLOOR_MARGIN: float = 0.04 +const STEP_OFF_MAX_DROP: float = 0.55 +const STEP_OFF_MAX_HORIZ: float = 1.05 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -42,6 +46,37 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) +func _floor_y_under_body() -> float: + var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state + var from: Vector3 = global_position + var to: Vector3 = from + Vector3(0.0, -FLOOR_PROBE_DOWN, 0.0) + var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) + q.collision_mask = 1 + q.exclude = [get_rid()] + var hit: Dictionary = space.intersect_ray(q) + if hit.is_empty(): + return global_position.y + var pv: Variant = hit.get("position") + if pv is Vector3: + return (pv as Vector3).y + return global_position.y + + +func _nav_short_step_down_use_goal_direct() -> bool: + var floor_y: float = _floor_y_under_body() + if _auth_walk_goal.y >= floor_y - STEP_OFF_FLOOR_MARGIN: + return false + var drop: float = floor_y - _auth_walk_goal.y + if drop > STEP_OFF_MAX_DROP: + return false + var hd: float = Vector2( + _auth_walk_goal.x - global_position.x, _auth_walk_goal.z - global_position.z + ).length() + if hd > STEP_OFF_MAX_HORIZ: + return false + return true + + func _set_horizontal_velocity_toward(point: Vector3) -> void: var pos := global_position var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) @@ -73,6 +108,11 @@ func _physics_process(_delta: float) -> void: move_and_slide() return + if _nav_short_step_down_use_goal_direct(): + _set_horizontal_velocity_toward(_auth_walk_goal) + move_and_slide() + return + if horiz_dist <= DIRECT_APPROACH_RADIUS: _set_horizontal_velocity_toward(_auth_walk_goal) else: From dbae80627f691fe2bb6b261cd5348c7d5fa1e602 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:36:32 -0400 Subject: [PATCH 27/45] NS-23: Tighten bump step-off bypass (dual floor ray, drop band, horiz cap) - Sample floor Y from center and lower probe; use max for bump rim. - Only bypass nav for step-sized vertical drop with wider near-goal horiz. - Loosen next-waypoint stickiness so rim waypoints fall back to goal sooner. --- client/scripts/player.gd | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index bcf4e10..dae6c64 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -14,8 +14,13 @@ const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 const FLOOR_PROBE_DOWN: float = 4.0 const STEP_OFF_FLOOR_MARGIN: float = 0.04 -const STEP_OFF_MAX_DROP: float = 0.55 -const STEP_OFF_MAX_HORIZ: float = 1.05 +## Only NS-19-sized step downs (not cliffs); allows a wider horiz cap without bee-lining big drops. +const STEP_OFF_MIN_DROP: float = 0.055 +const STEP_OFF_MAX_DROP: float = 0.24 +const STEP_OFF_MAX_HORIZ: float = 3.0 +const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 +## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). +const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -46,20 +51,26 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) -func _floor_y_under_body() -> float: +func _ray_floor_y_at(from: Vector3) -> float: var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var from: Vector3 = global_position var to: Vector3 = from + Vector3(0.0, -FLOOR_PROBE_DOWN, 0.0) var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) q.collision_mask = 1 q.exclude = [get_rid()] var hit: Dictionary = space.intersect_ray(q) if hit.is_empty(): - return global_position.y + return from.y var pv: Variant = hit.get("position") if pv is Vector3: return (pv as Vector3).y - return global_position.y + return from.y + + +func _floor_y_under_body() -> float: + var y_center: float = _ray_floor_y_at(global_position) + var lower: Vector3 = global_position + Vector3(0.0, FLOOR_PROBE_BODY_Y_OFFSET, 0.0) + var y_lower: float = _ray_floor_y_at(lower) + return maxf(y_center, y_lower) func _nav_short_step_down_use_goal_direct() -> bool: @@ -67,7 +78,7 @@ func _nav_short_step_down_use_goal_direct() -> bool: if _auth_walk_goal.y >= floor_y - STEP_OFF_FLOOR_MARGIN: return false var drop: float = floor_y - _auth_walk_goal.y - if drop > STEP_OFF_MAX_DROP: + if drop < STEP_OFF_MIN_DROP or drop > STEP_OFF_MAX_DROP: return false var hd: float = Vector2( _auth_walk_goal.x - global_position.x, _auth_walk_goal.z - global_position.z @@ -120,8 +131,7 @@ func _physics_process(_delta: float) -> void: var to_next_h: Vector3 = Vector3( next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z ) - # “goal Y < body Y” skips nav wrongly: pick is floor height, body origin is higher. - if to_next_h.length_squared() > 0.0025: + if to_next_h.length_squared() > NEXT_WAYPOINT_MIN_HORIZ_SQ: _set_horizontal_velocity_toward(next_path_position) else: _set_horizontal_velocity_toward(_auth_walk_goal) From 3b4340830a72a2dafd397576b9c5edf89a5c9b70 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:39:45 -0400 Subject: [PATCH 28/45] =?UTF-8?q?NS-23:=20Fix=20bump=20depart=20=E2=80=94?= =?UTF-8?q?=20goal=20Y=20vs=20surface=20footing=20+=20arrive=20by=20footin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ray down at goal xz for destination surface Y; step-off compares two footings. - Arrive when horiz close and either capsule Y matches goal or underfoot matches dest footing (server may send center 0.9 or pick surface 0). --- client/scripts/player.gd | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index dae6c64..9c519e2 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -73,11 +73,24 @@ func _floor_y_under_body() -> float: return maxf(y_center, y_lower) +## Footing under goal xz (surface Y). Server goal.y may be capsule center (e.g. 0.9) or pick surface +## (e.g. 0) — do not compare that directly to `_floor_y_under_body()` (always surface). +func _destination_footing_y() -> float: + var g: Vector3 = _auth_walk_goal + var probe_top: float = maxf(global_position.y, g.y) + 4.0 + var probe: Vector3 = Vector3(g.x, probe_top, g.z) + var y: float = _ray_floor_y_at(probe) + if y >= probe_top - 0.05: + return g.y + return y + + func _nav_short_step_down_use_goal_direct() -> bool: var floor_y: float = _floor_y_under_body() - if _auth_walk_goal.y >= floor_y - STEP_OFF_FLOOR_MARGIN: + var dest_floor_y: float = _destination_footing_y() + if dest_floor_y >= floor_y - STEP_OFF_FLOOR_MARGIN: return false - var drop: float = floor_y - _auth_walk_goal.y + var drop: float = floor_y - dest_floor_y if drop < STEP_OFF_MIN_DROP or drop > STEP_OFF_MAX_DROP: return false var hd: float = Vector2( @@ -105,8 +118,11 @@ func _physics_process(_delta: float) -> void: var full_to_goal: Vector3 = _auth_walk_goal - global_position var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() - var vert_err: float = absf(full_to_goal.y) - if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS: + var vert_err_capsule: float = absf(full_to_goal.y) + var footing_match: bool = ( + absf(_floor_y_under_body() - _destination_footing_y()) <= VERT_ARRIVE_EPS + ) + if horiz_dist <= ARRIVE_EPS and (vert_err_capsule <= VERT_ARRIVE_EPS or footing_match): velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) From 127e67f02bd7c1aaa4a17e368d6ae192541ceb15 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:43:56 -0400 Subject: [PATCH 29/45] NS-23: Apply gravity when airborne so bump ledges are not sticky Horizontal seek only sets vx/vz; zero vy only on floor. Use project default gravity vector when not on floor (with and without walk goal) so stepping off NS-19 bumps can fall to the lower surface instead of hanging with vy=0. --- client/scripts/player.gd | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 9c519e2..2bd9154 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -2,9 +2,9 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## -## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0. Height along ramps -## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, -## distance, bounds) is server MoveCommand only — not reimplemented here. +## Horizontal seek sets `velocity.x`/`z` only. On floor `velocity.y` is cleared; in air project +## gravity applies so walking off bumps/steps can descend (vy was never forced to 0 while airborne). +## Move **legality** is server MoveCommand only — not reimplemented here. ## ## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. @@ -106,13 +106,33 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) if dh.length_squared() < 1e-10: dh = Vector3(1.0, 0.0, 0.0) - velocity = dh.normalized() * MOVE_SPEED - velocity.y = 0.0 + var h: Vector3 = dh.normalized() * MOVE_SPEED + velocity.x = h.x + velocity.z = h.z -func _physics_process(_delta: float) -> void: +func _apply_vertical_velocity_for_floor_or_gravity(delta: float) -> void: + if is_on_floor(): + velocity.y = 0.0 + return + var gmag: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) + var gvec: Variant = ProjectSettings.get_setting( + "physics/3d/default_gravity_vector", Vector3(0.0, -1.0, 0.0) + ) + if gvec is Vector3: + velocity += (gvec as Vector3) * gmag * delta + else: + velocity.y -= gmag * delta + + +func _physics_process(delta: float) -> void: if not _has_walk_goal: - velocity = Vector3.ZERO + if is_on_floor(): + velocity = Vector3.ZERO + else: + velocity.x = 0.0 + velocity.z = 0.0 + _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() return @@ -132,11 +152,13 @@ func _physics_process(_delta: float) -> void: var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _set_horizontal_velocity_toward(_auth_walk_goal) + _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() return if _nav_short_step_down_use_goal_direct(): _set_horizontal_velocity_toward(_auth_walk_goal) + _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() return @@ -152,4 +174,5 @@ func _physics_process(_delta: float) -> void: else: _set_horizontal_velocity_toward(_auth_walk_goal) + _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() From b1ce87c5cce5c58a7fd55279347b1a97204730ca Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:46:08 -0400 Subject: [PATCH 30/45] NS-23: Revert global gravity; use step-off-only descend vy Project gravity while walking made motion worse (jitter, floor fights). Restore horizontal-only seek everywhere except the short step-down bypass, where a fixed downward speed helps clear NS-19 bump lips without affecting normal nav or idle air state. --- client/scripts/player.gd | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 2bd9154..109c631 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -2,8 +2,8 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## -## Horizontal seek sets `velocity.x`/`z` only. On floor `velocity.y` is cleared; in air project -## gravity applies so walking off bumps/steps can descend (vy was never forced to 0 while airborne). +## Horizontal velocity toward nav waypoint or goal; `velocity.y` stays 0 except a short **step-off** +## nudge (see `_nav_short_step_down_use_goal_direct`) so NS-19 bumps can drop without full gravity. ## Move **legality** is server MoveCommand only — not reimplemented here. ## ## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. @@ -19,6 +19,8 @@ const STEP_OFF_MIN_DROP: float = 0.055 const STEP_OFF_MAX_DROP: float = 0.24 const STEP_OFF_MAX_HORIZ: float = 3.0 const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 +## Downward vy only while step-off bypass is active (not global gravity). +const STEP_OFF_DESCEND_SPEED: float = 1.6 ## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 @@ -106,33 +108,13 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) if dh.length_squared() < 1e-10: dh = Vector3(1.0, 0.0, 0.0) - var h: Vector3 = dh.normalized() * MOVE_SPEED - velocity.x = h.x - velocity.z = h.z + velocity = dh.normalized() * MOVE_SPEED + velocity.y = 0.0 -func _apply_vertical_velocity_for_floor_or_gravity(delta: float) -> void: - if is_on_floor(): - velocity.y = 0.0 - return - var gmag: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) - var gvec: Variant = ProjectSettings.get_setting( - "physics/3d/default_gravity_vector", Vector3(0.0, -1.0, 0.0) - ) - if gvec is Vector3: - velocity += (gvec as Vector3) * gmag * delta - else: - velocity.y -= gmag * delta - - -func _physics_process(delta: float) -> void: +func _physics_process(_delta: float) -> void: if not _has_walk_goal: - if is_on_floor(): - velocity = Vector3.ZERO - else: - velocity.x = 0.0 - velocity.z = 0.0 - _apply_vertical_velocity_for_floor_or_gravity(delta) + velocity = Vector3.ZERO move_and_slide() return @@ -152,13 +134,12 @@ func _physics_process(delta: float) -> void: var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _set_horizontal_velocity_toward(_auth_walk_goal) - _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() return if _nav_short_step_down_use_goal_direct(): _set_horizontal_velocity_toward(_auth_walk_goal) - _apply_vertical_velocity_for_floor_or_gravity(delta) + velocity.y = -STEP_OFF_DESCEND_SPEED move_and_slide() return @@ -174,5 +155,4 @@ func _physics_process(delta: float) -> void: else: _set_horizontal_velocity_toward(_auth_walk_goal) - _apply_vertical_velocity_for_floor_or_gravity(delta) move_and_slide() From f67705f4a393ea83c9a7f68fea318de761d4473c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:48:55 -0400 Subject: [PATCH 31/45] NS-23: Step-off footing uses capsule footprint max + wider horiz At bump lips, center/lower rays often hit the floor first while the body is still on the rim, so drop read ~0 and nav rim-follow resumed. Max floor Y over center, lower probe, and 8 xz offsets (cardinal + diagonal) keeps step-down bypass active until the whole footprint clears. Raise STEP_OFF_MAX_HORIZ to 8 m (still capped by small drop band). --- client/scripts/player.gd | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 109c631..7a016f8 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -17,8 +17,10 @@ const STEP_OFF_FLOOR_MARGIN: float = 0.04 ## Only NS-19-sized step downs (not cliffs); allows a wider horiz cap without bee-lining big drops. const STEP_OFF_MIN_DROP: float = 0.055 const STEP_OFF_MAX_DROP: float = 0.24 -const STEP_OFF_MAX_HORIZ: float = 3.0 +const STEP_OFF_MAX_HORIZ: float = 8.0 const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 +## Extra xz rays for step-off; max Y keeps bump support when center ray sees floor first. +const STEP_FOOTPRINT_HALF: float = 0.2 ## Downward vy only while step-off bypass is active (not global gravity). const STEP_OFF_DESCEND_SPEED: float = 1.6 ## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). @@ -75,6 +77,28 @@ func _floor_y_under_body() -> float: return maxf(y_center, y_lower) +## Max floor Y under capsule + xz offsets (step-off). Fights lip rays that hit ground too early. +func _floor_y_max_under_capsule_footprint() -> float: + var best: float = _floor_y_under_body() + var p: Vector3 = global_position + var h: float = STEP_FOOTPRINT_HALF + var oxz: Array[Vector3] = [ + Vector3(h, 0.0, 0.0), + Vector3(-h, 0.0, 0.0), + Vector3(0.0, 0.0, h), + Vector3(0.0, 0.0, -h), + Vector3(h, 0.0, h), + Vector3(h, 0.0, -h), + Vector3(-h, 0.0, h), + Vector3(-h, 0.0, -h), + ] + for d: Vector3 in oxz: + best = maxf(best, _ray_floor_y_at(p + d)) + var lower_from: Vector3 = p + d + Vector3(0.0, FLOOR_PROBE_BODY_Y_OFFSET, 0.0) + best = maxf(best, _ray_floor_y_at(lower_from)) + return best + + ## Footing under goal xz (surface Y). Server goal.y may be capsule center (e.g. 0.9) or pick surface ## (e.g. 0) — do not compare that directly to `_floor_y_under_body()` (always surface). func _destination_footing_y() -> float: @@ -88,7 +112,7 @@ func _destination_footing_y() -> float: func _nav_short_step_down_use_goal_direct() -> bool: - var floor_y: float = _floor_y_under_body() + var floor_y: float = _floor_y_max_under_capsule_footprint() var dest_floor_y: float = _destination_footing_y() if dest_floor_y >= floor_y - STEP_OFF_FLOOR_MARGIN: return false From 003750c7ae1e47afb2151b0577a9f28cbb67a6ae Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:52:04 -0400 Subject: [PATCH 32/45] NS-23: Direct step-down while on ns19_bump up to auth horiz limit Nav paths often wrap along bump rims when the goal is far; the 8 m cap then disabled step-off and left rim-following. Detect NS-19 bumps via group on footprint rays; when any hit is a bump and vertical drop is in the NS-19 band, steer straight to the goal up to 18 m (server max step). Non-bump podiums keep the 8 m close cap. Single footprint pass returns max_y + bump. --- client/scripts/player.gd | 77 +++++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 7a016f8..fda87b0 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -3,7 +3,7 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## ## Horizontal velocity toward nav waypoint or goal; `velocity.y` stays 0 except a short **step-off** -## nudge (see `_nav_short_step_down_use_goal_direct`) so NS-19 bumps can drop without full gravity. +## nudge (see `_use_direct_step_down_steering`) so NS-19 bumps can drop without full gravity. ## Move **legality** is server MoveCommand only — not reimplemented here. ## ## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. @@ -17,7 +17,10 @@ const STEP_OFF_FLOOR_MARGIN: float = 0.04 ## Only NS-19-sized step downs (not cliffs); allows a wider horiz cap without bee-lining big drops. const STEP_OFF_MIN_DROP: float = 0.055 const STEP_OFF_MAX_DROP: float = 0.24 -const STEP_OFF_MAX_HORIZ: float = 8.0 +## Past this, only direct step-down when footprint still touches `ns19_bump` (nav rim paths). +const STEP_OFF_CLOSE_HORIZ: float = 8.0 +## Match server `MovementValidation.MaxHorizontalStep` default; no bee-line past this. +const STEP_OFF_MAX_HORIZ_AUTH: float = 18.0 const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 ## Extra xz rays for step-off; max Y keeps bump support when center ray sees floor first. const STEP_FOOTPRINT_HALF: float = 0.2 @@ -55,13 +58,17 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) -func _ray_floor_y_at(from: Vector3) -> float: +func _ray_floor_hit(from: Vector3) -> Dictionary: var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state var to: Vector3 = from + Vector3(0.0, -FLOOR_PROBE_DOWN, 0.0) var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) q.collision_mask = 1 q.exclude = [get_rid()] - var hit: Dictionary = space.intersect_ray(q) + return space.intersect_ray(q) + + +func _ray_floor_y_at(from: Vector3) -> float: + var hit: Dictionary = _ray_floor_hit(from) if hit.is_empty(): return from.y var pv: Variant = hit.get("position") @@ -70,6 +77,15 @@ func _ray_floor_y_at(from: Vector3) -> float: return from.y +func _collider_in_ns19_bump(collider: Variant) -> bool: + var n: Node = collider as Node + while n: + if n.is_in_group("ns19_bump"): + return true + n = n.get_parent() + return false + + func _floor_y_under_body() -> float: var y_center: float = _ray_floor_y_at(global_position) var lower: Vector3 = global_position + Vector3(0.0, FLOOR_PROBE_BODY_Y_OFFSET, 0.0) @@ -77,9 +93,8 @@ func _floor_y_under_body() -> float: return maxf(y_center, y_lower) -## Max floor Y under capsule + xz offsets (step-off). Fights lip rays that hit ground too early. -func _floor_y_max_under_capsule_footprint() -> float: - var best: float = _floor_y_under_body() +## One ray pass: max floor Y under footprint + whether any hit is an NS-19 bump (`ns19_bump` group). +func _footprint_step_down_state() -> Dictionary: var p: Vector3 = global_position var h: float = STEP_FOOTPRINT_HALF var oxz: Array[Vector3] = [ @@ -92,11 +107,37 @@ func _floor_y_max_under_capsule_footprint() -> float: Vector3(-h, 0.0, h), Vector3(-h, 0.0, -h), ] + var best_y: float = 0.0 + var got: bool = false + var on_bump: bool = false + for yoff: float in [0.0, FLOOR_PROBE_BODY_Y_OFFSET]: + var from_c: Vector3 = p + Vector3(0.0, yoff, 0.0) + var hit_c: Dictionary = _ray_floor_hit(from_c) + if not hit_c.is_empty(): + var yc: float = (hit_c.get("position") as Vector3).y + if not got: + best_y = yc + got = true + else: + best_y = maxf(best_y, yc) + if not on_bump and _collider_in_ns19_bump(hit_c.get("collider")): + on_bump = true for d: Vector3 in oxz: - best = maxf(best, _ray_floor_y_at(p + d)) - var lower_from: Vector3 = p + d + Vector3(0.0, FLOOR_PROBE_BODY_Y_OFFSET, 0.0) - best = maxf(best, _ray_floor_y_at(lower_from)) - return best + for yoff2: float in [0.0, FLOOR_PROBE_BODY_Y_OFFSET]: + var from_o: Vector3 = p + d + Vector3(0.0, yoff2, 0.0) + var hit_o: Dictionary = _ray_floor_hit(from_o) + if not hit_o.is_empty(): + var yo: float = (hit_o.get("position") as Vector3).y + if not got: + best_y = yo + got = true + else: + best_y = maxf(best_y, yo) + if not on_bump and _collider_in_ns19_bump(hit_o.get("collider")): + on_bump = true + if not got: + best_y = _floor_y_under_body() + return {"max_y": best_y, "touches_ns19_bump": on_bump} ## Footing under goal xz (surface Y). Server goal.y may be capsule center (e.g. 0.9) or pick surface @@ -111,8 +152,10 @@ func _destination_footing_y() -> float: return y -func _nav_short_step_down_use_goal_direct() -> bool: - var floor_y: float = _floor_y_max_under_capsule_footprint() +func _use_direct_step_down_steering() -> bool: + var st: Dictionary = _footprint_step_down_state() + var floor_y: float = float(st["max_y"]) + var on_bump: bool = bool(st["touches_ns19_bump"]) var dest_floor_y: float = _destination_footing_y() if dest_floor_y >= floor_y - STEP_OFF_FLOOR_MARGIN: return false @@ -122,9 +165,11 @@ func _nav_short_step_down_use_goal_direct() -> bool: var hd: float = Vector2( _auth_walk_goal.x - global_position.x, _auth_walk_goal.z - global_position.z ).length() - if hd > STEP_OFF_MAX_HORIZ: + if hd > STEP_OFF_MAX_HORIZ_AUTH: return false - return true + if on_bump: + return true + return hd <= STEP_OFF_CLOSE_HORIZ func _set_horizontal_velocity_toward(point: Vector3) -> void: @@ -161,7 +206,7 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - if _nav_short_step_down_use_goal_direct(): + if _use_direct_step_down_steering(): _set_horizontal_velocity_toward(_auth_walk_goal) velocity.y = -STEP_OFF_DESCEND_SPEED move_and_slide() From 8053f08804b8dbbfa69d08eaae198a5f9e452e65 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:54:42 -0400 Subject: [PATCH 33/45] NS-23: Arrival footing uses footprint max_y (avoid false stop on bump lip) Premature arrival used _floor_y_under_body() vs dest footing; at the rim that can read floor while xz is already within ARRIVE_EPS, clearing the walk goal. Match step-down logic: compare destination footing to footprint max_y and cache footprint scan once per physics frame. --- client/scripts/player.gd | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index fda87b0..b8df20f 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -31,6 +31,8 @@ const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO +var _footprint_cache_frame: int = -1 +var _footprint_cache: Dictionary = {} @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -94,7 +96,7 @@ func _floor_y_under_body() -> float: ## One ray pass: max floor Y under footprint + whether any hit is an NS-19 bump (`ns19_bump` group). -func _footprint_step_down_state() -> Dictionary: +func _compute_footprint_step_down_state() -> Dictionary: var p: Vector3 = global_position var h: float = STEP_FOOTPRINT_HALF var oxz: Array[Vector3] = [ @@ -140,6 +142,14 @@ func _footprint_step_down_state() -> Dictionary: return {"max_y": best_y, "touches_ns19_bump": on_bump} +func _footprint_step_down_state() -> Dictionary: + var f: int = Engine.get_physics_frames() + if f != _footprint_cache_frame: + _footprint_cache = _compute_footprint_step_down_state() + _footprint_cache_frame = f + return _footprint_cache + + ## Footing under goal xz (surface Y). Server goal.y may be capsule center (e.g. 0.9) or pick surface ## (e.g. 0) — do not compare that directly to `_floor_y_under_body()` (always surface). func _destination_footing_y() -> float: @@ -190,9 +200,12 @@ func _physics_process(_delta: float) -> void: var full_to_goal: Vector3 = _auth_walk_goal - global_position var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() var vert_err_capsule: float = absf(full_to_goal.y) - var footing_match: bool = ( - absf(_floor_y_under_body() - _destination_footing_y()) <= VERT_ARRIVE_EPS - ) + var dest_foot_y: float = _destination_footing_y() + var fp: Dictionary = _footprint_step_down_state() + var max_foot_y: float = float(fp["max_y"]) + # Same lip issue as step-off: `_floor_y_under_body()` can read floor early and match `dest_foot_y` + # while xz is within ARRIVE_EPS — false “arrived”, goal clears. Use footprint max_y. + var footing_match: bool = absf(max_foot_y - dest_foot_y) <= VERT_ARRIVE_EPS if horiz_dist <= ARRIVE_EPS and (vert_err_capsule <= VERT_ARRIVE_EPS or footing_match): velocity = Vector3.ZERO _has_walk_goal = false From 38aa4bfcd53c0adce0081a494923f265b996e117 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:56:56 -0400 Subject: [PATCH 34/45] =?UTF-8?q?NS-23:=20Unstick=20bump=20rim=20=E2=80=94?= =?UTF-8?q?=20wall=20peel=20+=20goal=20boost=20when=20descending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convex frustum sides turn goal steering into tangential rim slide with little net progress. When footprint still hits ns19_bump and dest floor is lower by at least STEP_OFF_MIN_DROP, add horizontal wall-normal push (if it does not strongly oppose goal) plus a small goal-direction boost, cap xz speed. Slightly raise STEP_OFF_DESCEND_SPEED for the step-off branch. --- client/scripts/player.gd | 48 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index b8df20f..af0e337 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -25,7 +25,12 @@ const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 ## Extra xz rays for step-off; max Y keeps bump support when center ray sees floor first. const STEP_FOOTPRINT_HALF: float = 0.2 ## Downward vy only while step-off bypass is active (not global gravity). -const STEP_OFF_DESCEND_SPEED: float = 1.6 +const STEP_OFF_DESCEND_SPEED: float = 2.2 +## Peel off frustum wall when sliding tangentially along the rim (m/s, added to xz velocity). +const BUMP_RIM_WALL_PUSH: float = 2.6 +## Extra push toward goal xz while on a wall in that rim case (m/s). +const BUMP_RIM_GOAL_BOOST: float = 1.15 +const BUMP_RIM_MAX_HORIZ_MULT: float = 1.38 ## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 @@ -191,6 +196,44 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 +func _departing_ns19_bump_to_lower_floor() -> bool: + var fp: Dictionary = _footprint_step_down_state() + if not bool(fp["touches_ns19_bump"]): + return false + var max_y: float = float(fp["max_y"]) + var dest_y: float = _destination_footing_y() + return max_y >= dest_y + STEP_OFF_MIN_DROP + + +## Frustum walls turn “toward goal” into tangential rim slide; nudge out + along goal, capped. +func _apply_bump_rim_wall_unstick() -> void: + if not _departing_ns19_bump_to_lower_floor(): + return + var pos: Vector3 = global_position + var to_g: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z) + var lg: float = to_g.length_squared() + if lg < 1e-14: + return + to_g /= sqrt(lg) + if is_on_wall(): + var wn: Vector3 = get_wall_normal() + var wn_h: Vector3 = Vector3(wn.x, 0.0, wn.z) + var lw: float = wn_h.length_squared() + if lw > 1e-12: + wn_h /= sqrt(lw) + if wn_h.dot(to_g) >= -0.32: + velocity.x += wn_h.x * BUMP_RIM_WALL_PUSH + velocity.z += wn_h.z * BUMP_RIM_WALL_PUSH + velocity.x += to_g.x * BUMP_RIM_GOAL_BOOST + velocity.z += to_g.z * BUMP_RIM_GOAL_BOOST + var cap: float = MOVE_SPEED * BUMP_RIM_MAX_HORIZ_MULT + var h: Vector2 = Vector2(velocity.x, velocity.z) + if h.length_squared() > cap * cap: + h = h.normalized() * cap + velocity.x = h.x + velocity.z = h.y + + func _physics_process(_delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO @@ -216,12 +259,14 @@ func _physics_process(_delta: float) -> void: var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _set_horizontal_velocity_toward(_auth_walk_goal) + _apply_bump_rim_wall_unstick() move_and_slide() return if _use_direct_step_down_steering(): _set_horizontal_velocity_toward(_auth_walk_goal) velocity.y = -STEP_OFF_DESCEND_SPEED + _apply_bump_rim_wall_unstick() move_and_slide() return @@ -237,4 +282,5 @@ func _physics_process(_delta: float) -> void: else: _set_horizontal_velocity_toward(_auth_walk_goal) + _apply_bump_rim_wall_unstick() move_and_slide() From 430770e5bfe1eb4cec569adb81c25901f29df705 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:57:33 -0400 Subject: [PATCH 35/45] NS-23: Rename bump helpers; WALK_BUMP_STEP_GROUP for scene group Remove ticket-style names from collider/footprint APIs (_collider_in_bump_step_group, _departing_bump_step_to_lower_floor, touches_bump_step). Editor group string stays ns19_bump in one const until main.tscn renames the group. --- client/scripts/player.gd | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index af0e337..e319ba0 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -3,7 +3,7 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## ## Horizontal velocity toward nav waypoint or goal; `velocity.y` stays 0 except a short **step-off** -## nudge (see `_use_direct_step_down_steering`) so NS-19 bumps can drop without full gravity. +## nudge (see `_use_direct_step_down_steering`) so stepped bumps can drop without full gravity. ## Move **legality** is server MoveCommand only — not reimplemented here. ## ## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. @@ -14,10 +14,10 @@ const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 const FLOOR_PROBE_DOWN: float = 4.0 const STEP_OFF_FLOOR_MARGIN: float = 0.04 -## Only NS-19-sized step downs (not cliffs); allows a wider horiz cap without bee-lining big drops. +## Only small step-downs (not cliffs); allows a wider horiz cap without bee-lining big drops. const STEP_OFF_MIN_DROP: float = 0.055 const STEP_OFF_MAX_DROP: float = 0.24 -## Past this, only direct step-down when footprint still touches `ns19_bump` (nav rim paths). +## Past this, only direct step-down when footprint still touches a bump-step collider (nav rim). const STEP_OFF_CLOSE_HORIZ: float = 8.0 ## Match server `MovementValidation.MaxHorizontalStep` default; no bee-line past this. const STEP_OFF_MAX_HORIZ_AUTH: float = 18.0 @@ -33,6 +33,8 @@ const BUMP_RIM_GOAL_BOOST: float = 1.15 const BUMP_RIM_MAX_HORIZ_MULT: float = 1.38 ## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 +## Editor group on stepped bump colliders; value must match `main.tscn`. +const WALK_BUMP_STEP_GROUP: StringName = &"ns19_bump" var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -84,10 +86,10 @@ func _ray_floor_y_at(from: Vector3) -> float: return from.y -func _collider_in_ns19_bump(collider: Variant) -> bool: +func _collider_in_bump_step_group(collider: Variant) -> bool: var n: Node = collider as Node while n: - if n.is_in_group("ns19_bump"): + if n.is_in_group(WALK_BUMP_STEP_GROUP): return true n = n.get_parent() return false @@ -100,7 +102,7 @@ func _floor_y_under_body() -> float: return maxf(y_center, y_lower) -## One ray pass: max floor Y under footprint + whether any hit is an NS-19 bump (`ns19_bump` group). +## One ray pass: max floor Y under footprint + whether any hit uses `WALK_BUMP_STEP_GROUP`. func _compute_footprint_step_down_state() -> Dictionary: var p: Vector3 = global_position var h: float = STEP_FOOTPRINT_HALF @@ -127,7 +129,7 @@ func _compute_footprint_step_down_state() -> Dictionary: got = true else: best_y = maxf(best_y, yc) - if not on_bump and _collider_in_ns19_bump(hit_c.get("collider")): + if not on_bump and _collider_in_bump_step_group(hit_c.get("collider")): on_bump = true for d: Vector3 in oxz: for yoff2: float in [0.0, FLOOR_PROBE_BODY_Y_OFFSET]: @@ -140,11 +142,11 @@ func _compute_footprint_step_down_state() -> Dictionary: got = true else: best_y = maxf(best_y, yo) - if not on_bump and _collider_in_ns19_bump(hit_o.get("collider")): + if not on_bump and _collider_in_bump_step_group(hit_o.get("collider")): on_bump = true if not got: best_y = _floor_y_under_body() - return {"max_y": best_y, "touches_ns19_bump": on_bump} + return {"max_y": best_y, "touches_bump_step": on_bump} func _footprint_step_down_state() -> Dictionary: @@ -170,7 +172,7 @@ func _destination_footing_y() -> float: func _use_direct_step_down_steering() -> bool: var st: Dictionary = _footprint_step_down_state() var floor_y: float = float(st["max_y"]) - var on_bump: bool = bool(st["touches_ns19_bump"]) + var on_bump: bool = bool(st["touches_bump_step"]) var dest_floor_y: float = _destination_footing_y() if dest_floor_y >= floor_y - STEP_OFF_FLOOR_MARGIN: return false @@ -196,9 +198,9 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 -func _departing_ns19_bump_to_lower_floor() -> bool: +func _departing_bump_step_to_lower_floor() -> bool: var fp: Dictionary = _footprint_step_down_state() - if not bool(fp["touches_ns19_bump"]): + if not bool(fp["touches_bump_step"]): return false var max_y: float = float(fp["max_y"]) var dest_y: float = _destination_footing_y() @@ -207,7 +209,7 @@ func _departing_ns19_bump_to_lower_floor() -> bool: ## Frustum walls turn “toward goal” into tangential rim slide; nudge out + along goal, capped. func _apply_bump_rim_wall_unstick() -> void: - if not _departing_ns19_bump_to_lower_floor(): + if not _departing_bump_step_to_lower_floor(): return var pos: Vector3 = global_position var to_g: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z) From 25d21c0011c619897d99fc8cac3d7b0fc4bdc780 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 01:58:57 -0400 Subject: [PATCH 36/45] NS-23: Remove bump rim velocity unstick (fixes edge vibration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-frame wall-normal + goal boosts fought a flipping wall normal and capped re-vectoring, causing back-and-forth jitter. Drop _apply_bump_rim_wall_unstick and related helpers/constants; keep direct step-down + footprint logic only. STEP_OFF_DESCEND_SPEED 2.2 → 1.75. --- client/scripts/player.gd | 48 +--------------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index e319ba0..6a1d601 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -25,12 +25,7 @@ const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 ## Extra xz rays for step-off; max Y keeps bump support when center ray sees floor first. const STEP_FOOTPRINT_HALF: float = 0.2 ## Downward vy only while step-off bypass is active (not global gravity). -const STEP_OFF_DESCEND_SPEED: float = 2.2 -## Peel off frustum wall when sliding tangentially along the rim (m/s, added to xz velocity). -const BUMP_RIM_WALL_PUSH: float = 2.6 -## Extra push toward goal xz while on a wall in that rim case (m/s). -const BUMP_RIM_GOAL_BOOST: float = 1.15 -const BUMP_RIM_MAX_HORIZ_MULT: float = 1.38 +const STEP_OFF_DESCEND_SPEED: float = 1.75 ## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 ## Editor group on stepped bump colliders; value must match `main.tscn`. @@ -198,44 +193,6 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 -func _departing_bump_step_to_lower_floor() -> bool: - var fp: Dictionary = _footprint_step_down_state() - if not bool(fp["touches_bump_step"]): - return false - var max_y: float = float(fp["max_y"]) - var dest_y: float = _destination_footing_y() - return max_y >= dest_y + STEP_OFF_MIN_DROP - - -## Frustum walls turn “toward goal” into tangential rim slide; nudge out + along goal, capped. -func _apply_bump_rim_wall_unstick() -> void: - if not _departing_bump_step_to_lower_floor(): - return - var pos: Vector3 = global_position - var to_g: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z) - var lg: float = to_g.length_squared() - if lg < 1e-14: - return - to_g /= sqrt(lg) - if is_on_wall(): - var wn: Vector3 = get_wall_normal() - var wn_h: Vector3 = Vector3(wn.x, 0.0, wn.z) - var lw: float = wn_h.length_squared() - if lw > 1e-12: - wn_h /= sqrt(lw) - if wn_h.dot(to_g) >= -0.32: - velocity.x += wn_h.x * BUMP_RIM_WALL_PUSH - velocity.z += wn_h.z * BUMP_RIM_WALL_PUSH - velocity.x += to_g.x * BUMP_RIM_GOAL_BOOST - velocity.z += to_g.z * BUMP_RIM_GOAL_BOOST - var cap: float = MOVE_SPEED * BUMP_RIM_MAX_HORIZ_MULT - var h: Vector2 = Vector2(velocity.x, velocity.z) - if h.length_squared() > cap * cap: - h = h.normalized() * cap - velocity.x = h.x - velocity.z = h.y - - func _physics_process(_delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO @@ -261,14 +218,12 @@ func _physics_process(_delta: float) -> void: var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _set_horizontal_velocity_toward(_auth_walk_goal) - _apply_bump_rim_wall_unstick() move_and_slide() return if _use_direct_step_down_steering(): _set_horizontal_velocity_toward(_auth_walk_goal) velocity.y = -STEP_OFF_DESCEND_SPEED - _apply_bump_rim_wall_unstick() move_and_slide() return @@ -284,5 +239,4 @@ func _physics_process(_delta: float) -> void: else: _set_horizontal_velocity_toward(_auth_walk_goal) - _apply_bump_rim_wall_unstick() move_and_slide() From ee6c4b271825256c1b00a0476bb90c806a20ba73 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 02:01:17 -0400 Subject: [PATCH 37/45] =?UTF-8?q?NS-23:=20Simplify=20path-follow=20?= =?UTF-8?q?=E2=80=94=20drop=20bump/step-down=20heuristics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore a straight Godot nav pattern: steer toward get_next_path_position until finish or final approach, then toward goal; horizontal-only arrival by xz. Removes footprint rays, bump-step group, and step-off vy layering that fought the engine. Slightly raise Player floor_snap_length for small ledges. --- client/scenes/main.tscn | 2 +- client/scripts/player.gd | 179 ++------------------------------------- 2 files changed, 9 insertions(+), 172 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 7a25b54..7c592e5 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -201,7 +201,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 collision_mask = 1 floor_max_angle = 0.872665 -floor_snap_length = 0.28 +floor_snap_length = 0.38 safe_margin = 0.045 script = ExtResource("2_player") diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 6a1d601..b3cc6c6 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -2,39 +2,18 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## -## Horizontal velocity toward nav waypoint or goal; `velocity.y` stays 0 except a short **step-off** -## nudge (see `_use_direct_step_down_steering`) so stepped bumps can drop without full gravity. -## Move **legality** is server MoveCommand only — not reimplemented here. -## -## Nav for detours; short local step-down (feet vs goal Y, not body origin) may bypass waypoints. +## Plain Godot pattern: horizontal `velocity` toward `NavigationAgent3D`’s next path point (or the +## goal when finishing / final approach). `velocity.y` stays 0; height comes from `move_and_slide` +## and floor handling. No custom step-down / bump heuristics — obstacle pathing is best-effort; +## small steps rely on the engine + scene (snap, collider shapes). Move **legality** is server-only. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 -const VERT_ARRIVE_EPS: float = 0.055 +## Stop when close in xz only; server `y` may be surface or capsule center. const DIRECT_APPROACH_RADIUS: float = 0.85 -const FLOOR_PROBE_DOWN: float = 4.0 -const STEP_OFF_FLOOR_MARGIN: float = 0.04 -## Only small step-downs (not cliffs); allows a wider horiz cap without bee-lining big drops. -const STEP_OFF_MIN_DROP: float = 0.055 -const STEP_OFF_MAX_DROP: float = 0.24 -## Past this, only direct step-down when footprint still touches a bump-step collider (nav rim). -const STEP_OFF_CLOSE_HORIZ: float = 8.0 -## Match server `MovementValidation.MaxHorizontalStep` default; no bee-line past this. -const STEP_OFF_MAX_HORIZ_AUTH: float = 18.0 -const FLOOR_PROBE_BODY_Y_OFFSET: float = -0.55 -## Extra xz rays for step-off; max Y keeps bump support when center ray sees floor first. -const STEP_FOOTPRINT_HALF: float = 0.2 -## Downward vy only while step-off bypass is active (not global gravity). -const STEP_OFF_DESCEND_SPEED: float = 1.75 -## If next waypoint is within this xz of us, steer to goal (rim / underfoot waypoints). -const NEXT_WAYPOINT_MIN_HORIZ_SQ: float = 0.01 -## Editor group on stepped bump colliders; value must match `main.tscn`. -const WALK_BUMP_STEP_GROUP: StringName = &"ns19_bump" var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO -var _footprint_cache_frame: int = -1 -var _footprint_cache: Dictionary = {} @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -62,128 +41,6 @@ func snap_to_server(world_pos: Vector3) -> void: _nav_agent.set_target_position(world_pos) -func _ray_floor_hit(from: Vector3) -> Dictionary: - var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state - var to: Vector3 = from + Vector3(0.0, -FLOOR_PROBE_DOWN, 0.0) - var q: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) - q.collision_mask = 1 - q.exclude = [get_rid()] - return space.intersect_ray(q) - - -func _ray_floor_y_at(from: Vector3) -> float: - var hit: Dictionary = _ray_floor_hit(from) - if hit.is_empty(): - return from.y - var pv: Variant = hit.get("position") - if pv is Vector3: - return (pv as Vector3).y - return from.y - - -func _collider_in_bump_step_group(collider: Variant) -> bool: - var n: Node = collider as Node - while n: - if n.is_in_group(WALK_BUMP_STEP_GROUP): - return true - n = n.get_parent() - return false - - -func _floor_y_under_body() -> float: - var y_center: float = _ray_floor_y_at(global_position) - var lower: Vector3 = global_position + Vector3(0.0, FLOOR_PROBE_BODY_Y_OFFSET, 0.0) - var y_lower: float = _ray_floor_y_at(lower) - return maxf(y_center, y_lower) - - -## One ray pass: max floor Y under footprint + whether any hit uses `WALK_BUMP_STEP_GROUP`. -func _compute_footprint_step_down_state() -> Dictionary: - var p: Vector3 = global_position - var h: float = STEP_FOOTPRINT_HALF - var oxz: Array[Vector3] = [ - Vector3(h, 0.0, 0.0), - Vector3(-h, 0.0, 0.0), - Vector3(0.0, 0.0, h), - Vector3(0.0, 0.0, -h), - Vector3(h, 0.0, h), - Vector3(h, 0.0, -h), - Vector3(-h, 0.0, h), - Vector3(-h, 0.0, -h), - ] - var best_y: float = 0.0 - var got: bool = false - var on_bump: bool = false - for yoff: float in [0.0, FLOOR_PROBE_BODY_Y_OFFSET]: - var from_c: Vector3 = p + Vector3(0.0, yoff, 0.0) - var hit_c: Dictionary = _ray_floor_hit(from_c) - if not hit_c.is_empty(): - var yc: float = (hit_c.get("position") as Vector3).y - if not got: - best_y = yc - got = true - else: - best_y = maxf(best_y, yc) - if not on_bump and _collider_in_bump_step_group(hit_c.get("collider")): - on_bump = true - for d: Vector3 in oxz: - for yoff2: float in [0.0, FLOOR_PROBE_BODY_Y_OFFSET]: - var from_o: Vector3 = p + d + Vector3(0.0, yoff2, 0.0) - var hit_o: Dictionary = _ray_floor_hit(from_o) - if not hit_o.is_empty(): - var yo: float = (hit_o.get("position") as Vector3).y - if not got: - best_y = yo - got = true - else: - best_y = maxf(best_y, yo) - if not on_bump and _collider_in_bump_step_group(hit_o.get("collider")): - on_bump = true - if not got: - best_y = _floor_y_under_body() - return {"max_y": best_y, "touches_bump_step": on_bump} - - -func _footprint_step_down_state() -> Dictionary: - var f: int = Engine.get_physics_frames() - if f != _footprint_cache_frame: - _footprint_cache = _compute_footprint_step_down_state() - _footprint_cache_frame = f - return _footprint_cache - - -## Footing under goal xz (surface Y). Server goal.y may be capsule center (e.g. 0.9) or pick surface -## (e.g. 0) — do not compare that directly to `_floor_y_under_body()` (always surface). -func _destination_footing_y() -> float: - var g: Vector3 = _auth_walk_goal - var probe_top: float = maxf(global_position.y, g.y) + 4.0 - var probe: Vector3 = Vector3(g.x, probe_top, g.z) - var y: float = _ray_floor_y_at(probe) - if y >= probe_top - 0.05: - return g.y - return y - - -func _use_direct_step_down_steering() -> bool: - var st: Dictionary = _footprint_step_down_state() - var floor_y: float = float(st["max_y"]) - var on_bump: bool = bool(st["touches_bump_step"]) - var dest_floor_y: float = _destination_footing_y() - if dest_floor_y >= floor_y - STEP_OFF_FLOOR_MARGIN: - return false - var drop: float = floor_y - dest_floor_y - if drop < STEP_OFF_MIN_DROP or drop > STEP_OFF_MAX_DROP: - return false - var hd: float = Vector2( - _auth_walk_goal.x - global_position.x, _auth_walk_goal.z - global_position.z - ).length() - if hd > STEP_OFF_MAX_HORIZ_AUTH: - return false - if on_bump: - return true - return hd <= STEP_OFF_CLOSE_HORIZ - - func _set_horizontal_velocity_toward(point: Vector3) -> void: var pos := global_position var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) @@ -201,14 +58,7 @@ func _physics_process(_delta: float) -> void: var full_to_goal: Vector3 = _auth_walk_goal - global_position var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() - var vert_err_capsule: float = absf(full_to_goal.y) - var dest_foot_y: float = _destination_footing_y() - var fp: Dictionary = _footprint_step_down_state() - var max_foot_y: float = float(fp["max_y"]) - # Same lip issue as step-off: `_floor_y_under_body()` can read floor early and match `dest_foot_y` - # while xz is within ARRIVE_EPS — false “arrived”, goal clears. Use footprint max_y. - var footing_match: bool = absf(max_foot_y - dest_foot_y) <= VERT_ARRIVE_EPS - if horiz_dist <= ARRIVE_EPS and (vert_err_capsule <= VERT_ARRIVE_EPS or footing_match): + if horiz_dist <= ARRIVE_EPS: velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) @@ -221,22 +71,9 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - if _use_direct_step_down_steering(): - _set_horizontal_velocity_toward(_auth_walk_goal) - velocity.y = -STEP_OFF_DESCEND_SPEED - move_and_slide() - return - - if horiz_dist <= DIRECT_APPROACH_RADIUS: + if _nav_agent.is_navigation_finished() or horiz_dist <= DIRECT_APPROACH_RADIUS: _set_horizontal_velocity_toward(_auth_walk_goal) else: - var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next_h: Vector3 = Vector3( - next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z - ) - if to_next_h.length_squared() > NEXT_WAYPOINT_MIN_HORIZ_SQ: - _set_horizontal_velocity_toward(next_path_position) - else: - _set_horizontal_velocity_toward(_auth_walk_goal) + _set_horizontal_velocity_toward(_nav_agent.get_next_path_position()) move_and_slide() From 6c73557c3b73ba0d68a79221cfe32aebc866581f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 02:02:57 -0400 Subject: [PATCH 38/45] NS-23: Restore player.gd from e1beb82 (bumps ok, obstacle bee-line) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-requested rollback to the pre-72eef09 behavior: skip nav waypoints when auth goal.y is below body (floor pick vs mid-capsule), steering xz straight at the goal — good for bump departure, bad for gray-box detours. Revert floor_snap_length to 0.28 to match that snapshot. --- client/scenes/main.tscn | 2 +- client/scripts/player.gd | 33 +++++++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 7c592e5..7a25b54 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -201,7 +201,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 collision_mask = 1 floor_max_angle = 0.872665 -floor_snap_length = 0.38 +floor_snap_length = 0.28 safe_margin = 0.045 script = ExtResource("2_player") diff --git a/client/scripts/player.gd b/client/scripts/player.gd index b3cc6c6..1449e61 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -2,15 +2,19 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. ## -## Plain Godot pattern: horizontal `velocity` toward `NavigationAgent3D`’s next path point (or the -## goal when finishing / final approach). `velocity.y` stays 0; height comes from `move_and_slide` -## and floor handling. No custom step-down / bump heuristics — obstacle pathing is best-effort; -## small steps rely on the engine + scene (snap, collider shapes). Move **legality** is server-only. +## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0. Height along ramps +## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, +## distance, bounds) is server MoveCommand only — not reimplemented here. +## +## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims). const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 -## Stop when close in xz only; server `y` may be surface or capsule center. +const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 +## Server goal clearly below us (descending). Nav mesh waypoints are often under the rim first; +## steering at next gives almost no horizontal speed — walk out on xz toward the authorized goal. +const DESCEND_GOAL_Y_MARGIN: float = 0.06 var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO @@ -58,7 +62,8 @@ func _physics_process(_delta: float) -> void: var full_to_goal: Vector3 = _auth_walk_goal - global_position var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() - if horiz_dist <= ARRIVE_EPS: + var vert_err: float = absf(full_to_goal.y) + if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS: velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) @@ -71,9 +76,21 @@ func _physics_process(_delta: float) -> void: move_and_slide() return - if _nav_agent.is_navigation_finished() or horiz_dist <= DIRECT_APPROACH_RADIUS: + if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN: + _set_horizontal_velocity_toward(_auth_walk_goal) + move_and_slide() + return + + if horiz_dist <= DIRECT_APPROACH_RADIUS: _set_horizontal_velocity_toward(_auth_walk_goal) else: - _set_horizontal_velocity_toward(_nav_agent.get_next_path_position()) + var next_path_position: Vector3 = _nav_agent.get_next_path_position() + var to_next_h: Vector3 = Vector3( + next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z + ) + if to_next_h.length_squared() > 0.0025: + _set_horizontal_velocity_toward(next_path_position) + else: + _set_horizontal_velocity_toward(_auth_walk_goal) move_and_slide() From 39b476027042c458bb63dd18d189635634ac2762 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 02:06:06 -0400 Subject: [PATCH 39/45] NS-23: Skip move_and_slide when idle on floor (stop rest jitter) Zero-velocity move_and_slide each physics tick still reapplies floor snap and collision, which often reads as vibration. If there is no walk goal (or we just arrived), velocity is zero, and is_on_floor(), return early. --- client/scripts/player.gd | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 1449e61..0ff126f 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -7,6 +7,9 @@ extends CharacterBody3D ## distance, bounds) is server MoveCommand only — not reimplemented here. ## ## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims). +## +## Idle: skip `move_and_slide()` when on floor and velocity is zero — repeated slide + floor snap +## causes visible micro-vibration at rest. const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 @@ -54,9 +57,15 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 +func _skip_move_when_idle_on_floor() -> bool: + return is_on_floor() and velocity.length_squared() < 1e-12 + + func _physics_process(_delta: float) -> void: if not _has_walk_goal: velocity = Vector3.ZERO + if _skip_move_when_idle_on_floor(): + return move_and_slide() return @@ -67,6 +76,8 @@ func _physics_process(_delta: float) -> void: velocity = Vector3.ZERO _has_walk_goal = false _nav_agent.set_target_position(global_position) + if _skip_move_when_idle_on_floor(): + return move_and_slide() return From 2891a4553adb4cb5f26b37b451473702a4ea11a9 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 14:52:08 -0400 Subject: [PATCH 40/45] docs: NS-23 branch code review (2026-04-05) --- docs/reviews/2026-04-05-NS-23.md | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/reviews/2026-04-05-NS-23.md diff --git a/docs/reviews/2026-04-05-NS-23.md b/docs/reviews/2026-04-05-NS-23.md new file mode 100644 index 0000000..7516578 --- /dev/null +++ b/docs/reviews/2026-04-05-NS-23.md @@ -0,0 +1,49 @@ +# Code review: NS-23 client path-follow (`NS-23-client-path-follow`) + +**Date:** 2026-04-05 +**Scope:** Branch `NS-23-client-path-follow` vs `origin/main` (full NS-23 slice: client nav, wiring, docs, small server remark). +**Base:** `origin/main` + +## Verdict + +**Request changes** — implementation is coherent for the **accepted product tradeoff** (smooth bumps, obstacle pathing often requires multiple clicks), but **`docs/plans/NS-23-implementation-plan.md` and `client/README.md` still read as if obstacle avoidance always works**, which conflicts with shipped behavior and with the plan’s own AC wording. + +## Summary + +The branch adds **`NavigationRegion3D`** / **`NavigationAgent3D`**, sync nav bake in **`main.gd`**, boot snap vs post-move nav goal via **`PositionAuthorityClient`**’s second signal argument, and horizontal path-follow in **`player.gd`**. **`ground_pick.gd`** gains stepped walkable picking for steep surfaces. Server change is documentation-only on **`MoveCommandValidation`** (vertical step remark). + +**`player.gd`** intentionally skips nav waypoints when **`_auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN`**, so for typical picks (floor **Y** vs mid-capsule origin) the client often **bee-lines in xz** toward the goal. That matches the team’s stated tradeoff for prototype bumps but **undermines** “walk around **`Obstacle`**” as the default single-click experience. + +**Idle optimization:** skipping **`move_and_slide()`** when there is no walk goal, velocity is zero, and **`is_on_floor()`** is a reasonable jitter mitigation; residual vibration is tracked in **NS-24** (Tech Debt). + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NS-23-implementation-plan.md` | **Partially matches.** Structure and locked authority table match the wiring. **Conflicts:** AC checklist marks “continuous motion … around static obstacles” as done; with the descend bypass, that is **not** generally true for single clicks past the gray box. **Open questions** says “None” but the obstacle/bump tradeoff is material—should be recorded here (or in a “Locked tradeoff” subsection). | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** snapshot line for NS-23 nav path-follow + server authority unchanged. After merge, consider a **one-line** note that client nav is best-effort and may bee-line when the descend heuristic fires, if you want the module doc to warn downstream readers. | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server still owns **`PositionState`**; client presentation path remains valid. | +| `docs/decomposition/modules/module_dependency_register.md` / `documentation_and_implementation_alignment.md` | **N/A** deep diff; no register update strictly required for this review beyond optional E1.M1 nuance above. | +| `client/README.md` | **Partially matches / conflicts.** Manual step 4 still says the capsule **walks around `Obstacle`** toward the target; that is **misleading** with current **`player.gd`**. | +| `server/README.md` | **Matches** — NS-23 presentation vs straight-line validation is stated correctly. | + +## Blocking issues + +1. **Plan and README vs behavior:** Update **`docs/plans/NS-23-implementation-plan.md`** (and **`client/README.md`** manual check) to state the **locked tradeoff**: descend bypass (goal **Y** below body) causes **xz bee-line** toward the verified target; **navmesh detours around tall obstacles are not guaranteed** on a single click—players may need **segmented clicks** to wrap geometry. Adjust the AC checklist wording or unchecked items so the document is not self-contradictory. + +## Suggestions + +1. **`DESCEND_GOAL_Y_MARGIN`:** Add a one-line comment that this compares **authoritative target Y** (often **surface** from pick) to **CharacterBody3D** origin (**mid-capsule**), so the branch triggers for most floor targets—intentional for bumps, consequential for obstacles. +2. **`_skip_move_when_idle_on_floor`:** Document that **`is_on_floor()`** is updated by **`move_and_slide()`**; skipping slides when idle relies on the last frame’s floor state (acceptable for a static prototype floor). +3. **NS-24:** Keep **idle/rest vibration** out of NS-23 closure; link **NS-24** from the plan or README “Known issues” if you add a short note. + +## Nits + +- **Nit:** Consider renaming **`DESCEND_GOAL_Y_MARGIN`** to something that signals “auth pick Y vs body origin” to reduce future “why do we always bee-line?” confusion. + +## Verification + +- `cd server/NeonSprawl.Server && dotnet test` (no logic change expected, sanity). +- Godot **F5** with server: boot snap; click floor; confirm bump flow acceptable; confirm gray **Obstacle** often requires **multiple clicks** / partial path—aligned with updated docs. +- Confirm NS-19 reject targets still show rejection UX (`reasonCode` / label). +- `gdlint` / `gdformat` on touched GDScript if CI does not already run on the branch. From 14a6813985cbdccd875d17a58d6bfb6ef6e2bd3e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 14:56:52 -0400 Subject: [PATCH 41/45] =?UTF-8?q?NS-23:=20Document=20movement=20tradeoff?= =?UTF-8?q?=20=E2=80=94=20no=20required=20obstacle=20auto-nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align plan, client/server README, E1.M1 snapshot, and code review note with the chosen design: descend bypass favors smooth stepped surfaces; multi-click around obstacles is OK. NS-24 remains for idle jitter. --- client/README.md | 8 ++++--- .../modules/E1_M1_InputAndMovementRuntime.md | 2 +- docs/plans/NS-23-implementation-plan.md | 24 ++++++++++++------- docs/reviews/2026-04-05-NS-23.md | 2 ++ server/README.md | 2 +- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/client/README.md b/client/README.md index c349f57..e092ddd 100644 --- a/client/README.md +++ b/client/README.md @@ -11,7 +11,9 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** ## Authoritative movement (NS-16, NS-23) -With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NS-16/19); the client **walks** along a **baked navigation mesh** (`NavigationRegion3D` + **`NavigationAgent3D`** on the player) toward that verified position instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. +With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NS-16/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. + +**Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the pick’s **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` calls **`bake_navigation_mesh(false)`** (main-thread bake) after one **`process_frame`**, then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. After moving floor or obstacles, re-bake in the editor if needed. @@ -22,7 +24,7 @@ With the game server running ([`server/README.md`](../server/README.md)), each v 1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`). 2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`). 3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo). -4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** around **`Obstacle`** toward the authoritative target. NS-19 reject clicks still show the reject label and do **not** start a path. +4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NS-19 reject clicks still show the reject label and do **not** start a path. 5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged. If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes. @@ -42,7 +44,7 @@ The main scene includes a **prototype terminal** at the map center (same world * ## Movement prototype (NS-14 → NS-23) -**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion (NS-23). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). +**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion when following the mesh; it may steer **directly at the goal** in xz when the descend bypass applies (NS-23). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). - The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index 0bd2c11..607e12b 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -33,7 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende ## Implementation snapshot -- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow for click-to-move visuals while server authority unchanged ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). +- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). - **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry. - **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md). diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md index c057803..d60f159 100644 --- a/docs/plans/NS-23-implementation-plan.md +++ b/docs/plans/NS-23-implementation-plan.md @@ -14,11 +14,11 @@ ## Goal, scope, and out-of-scope -**Goal:** Deliver **visible** click-to-move: the avatar **follows a path** toward the clicked point using a **navigation mesh** baseline (obstacle-aware), not only steering at a goal or snapping on server ack alone. Aligns with E1.M1: path-follow baseline under server authority. +**Goal:** Deliver **visible** click-to-move: the avatar **moves** toward the verified server target using **`NavigationAgent3D`** + a **baked mesh** where applicable, instead of teleporting on the verifying **`GET`**. Aligns with E1.M1: path-follow baseline under server authority. **In scope** -- **Godot:** `NavigationRegion3D` (baked mesh) over the prototype walkable floor in `main.tscn`, respecting existing static obstacles; **`NavigationAgent3D`** (on player or child node) driving **`CharacterBody3D`** motion (`move_and_slide` toward next path position — follow Godot 4.x recommended pattern). +- **Godot:** `NavigationRegion3D` (baked mesh) over the prototype walkable floor in `main.tscn` (obstacle geometry included in bake for future use); **`NavigationAgent3D`** on the player driving **`CharacterBody3D`** motion (`move_and_slide` toward next path position when the client is following the mesh — Godot 4.x pattern). - **Pick integration:** Reuse **`ground_pick.gd`** (`walkable` group, upward normal); clicked point becomes **`NavigationAgent3D.target_position`** (after any needed Y / agent height adjustment consistent with current capsule). - **Server authority (unchanged contract):** One **`MoveCommand`** per successful pick with the **same world target** as today ([NS-16](NS-16-implementation-plan.md)); server **v1 snap** + **[NS-19](NS-19-implementation-plan.md)** validation unchanged. @@ -33,14 +33,22 @@ |--------|----------| | **When to POST** | **Once per click**, immediately with the **picked destination** (same JSON as today). No waypoint streaming to the server in this story. | | **Server truth** | Still **snap to target** on **200** after validation; **`GET /position`** after POST remains the reconciliation read (existing `PositionAuthorityClient` flow). | -| **Straight-line vs path** | NS-19 validates **displacement** from **current authoritative position** to **target** (horizontal + vertical limits). It does **not** check path clearance through geometry; **client nav** is responsible for **visible** obstacle avoidance. | +| **Straight-line vs path** | NS-19 validates **displacement** from **current authoritative position** to **target** (horizontal + vertical limits). It does **not** check path clearance through geometry. **Client** may follow nav waypoints when the descend bypass is inactive; **automatic obstacle detours are not required** for this slice (see **Locked — prototype movement tradeoff**). | | **After successful move** | **Do not** teleport the avatar on the verifying **`GET`** when the player is expected to walk: apply the authoritative position as a **navigation goal** and let the agent complete the path; **initial boot** `sync_from_server` remains a **hard snap** so spawn matches the server. | | **POST 400** | Existing behavior: emit **`move_rejected`**, show NS-19 UX; **do not** start (or clear) a nav path to that target. | | **Distance limits** | Default **`MaxHorizontalStep`** (e.g. 18 m) remains compatible with **single-command** long clicks across the prototype; if a future scene needs shorter steps, chain commands or tune limits in a later story. | +## Locked — prototype movement tradeoff + +| Topic | Decision | +|--------|----------| +| **Uneven surfaces vs obstacles** | The client uses a **descend bypass** in `player.gd`: when the authoritative goal **Y** is below the body origin (typical for floor picks vs mid-capsule), steering goes **straight in xz** toward the goal instead of following nav waypoints first. This keeps **stepped bumps** smooth; it does **not** guarantee routing around tall **static obstacles** on a **single** click. | +| **Player expectation** | **Multi-click** (or chained moves) is acceptable to navigate around geometry the client would otherwise bee-line into. **No** automatic obstacle navigation is required for NS-23 closure. | +| **Residual polish** | Visible idle jitter at rest is tracked as tech debt (**NS-24**), not a blocker for this story. | + ## Acceptance criteria checklist -- [x] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls). +- [x] Clicking a valid floor target produces **continuous motion** toward the verified target without sliding through geometry in the common case; **navmesh waypoints** are used when the descend bypass does not apply. **Obstacle detours on one click are not required** (tradeoff above). - [x] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout). - [x] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice. @@ -60,8 +68,8 @@ - Optional small API: second signal, enum on existing signal, or `sync_from_server`-only snap path — pick the smallest change that stays readable per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md). 4. **Documentation** - - **`client/README.md`:** Update movement section: navmesh path-follow + server authority one-liner. - - **`server/README.md`:** One short subsection or bullet: server validates **end target** and **step**; **client** nav is **presentational** for obstacle following in this slice. + - **`client/README.md`:** Movement section: nav agent + server authority, and the **uneven-surface vs obstacle** tradeoff (multi-click around obstacles). + - **`server/README.md`:** Server validates **end target** and **step**; **client** nav is **presentational** only (no server navmesh); obstacle detours not guaranteed. ## Resolved — agent shape vs capsule (was risk) @@ -106,12 +114,12 @@ Prototype player collider in [`main.tscn`](../../client/scenes/main.tscn): **`Ca | Action | What to cover | |--------|----------------| -| **Manual (required)** | Server running; click around obstacles — path goes around; click NS-19 reject targets — same rejection behavior as before; cold start — player matches server position. | +| **Manual (required)** | Server running; click-move toward targets (including stepped bumps); NS-19 reject targets — same rejection behavior as before; cold start — player matches server position. Optional: confirm multi-click can route around the prototype **`Obstacle`** when a single click bee-lines. | | **Automated** | None required for this story unless an existing headless hook can assert nav baking (unlikely); prefer manual for Godot nav. | ## Open questions / risks -None — agent/bake alignment and physics-frame path consumption are locked in **Resolved — agent shape vs capsule** and **Resolved — navigation sync / frame order** above. Reopen only if the scene scale or avatar collider changes materially. +**Prototype tradeoff** is explicit under **Locked — prototype movement tradeoff** (bumps vs single-click obstacle routing). Agent/bake alignment and physics-frame path consumption remain locked in **Resolved — agent shape vs capsule** and **Resolved — navigation sync / frame order**. Reopen if scene scale or avatar collider changes materially. ## PR / review diff --git a/docs/reviews/2026-04-05-NS-23.md b/docs/reviews/2026-04-05-NS-23.md index 7516578..9378aa5 100644 --- a/docs/reviews/2026-04-05-NS-23.md +++ b/docs/reviews/2026-04-05-NS-23.md @@ -4,6 +4,8 @@ **Scope:** Branch `NS-23-client-path-follow` vs `origin/main` (full NS-23 slice: client nav, wiring, docs, small server remark). **Base:** `origin/main` +**Resolution (same day):** Design updated — **automatic obstacle navigation is not expected**; docs/plan/README aligned (`NS-23` plan **Locked — prototype movement tradeoff**, `client/README`, `server/README`, E1.M1 snapshot). Prior **Request changes** on documentation mismatch is **addressed**. + ## Verdict **Request changes** — implementation is coherent for the **accepted product tradeoff** (smooth bumps, obstacle pathing often requires multiple clicks), but **`docs/plans/NS-23-implementation-plan.md` and `client/README.md` still read as if obstacle avoidance always works**, which conflicts with shipped behavior and with the plan’s own AC wording. diff --git a/server/README.md b/server/README.md index a1da7a0..d254523 100644 --- a/server/README.md +++ b/server/README.md @@ -63,7 +63,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of **`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply. -**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**—path-follow around static geometry. The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. +**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**; the client may follow waypoints when active, but **automatic obstacle detours on a single click are not guaranteed** (tradeoff for smooth movement on stepped geometry). The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. **NS-19 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation. From 8fe70a75de547512334bb62a42c68d1f255c9266 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 15:01:29 -0400 Subject: [PATCH 42/45] NS-23: Comment DESCEND_GOAL_Y_MARGIN (surface Y vs mid-capsule) --- client/scripts/player.gd | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 0ff126f..1ca6a66 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -15,8 +15,9 @@ const MOVE_SPEED: float = 5.0 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 -## Server goal clearly below us (descending). Nav mesh waypoints are often under the rim first; -## steering at next gives almost no horizontal speed — walk out on xz toward the authorized goal. +## Descend bypass: compares auth goal Y (often floor **surface** from pick) to `global_position.y` +## (**mid-capsule**). True for most floor clicks — smooth bumps; skips nav (bee-line xz), so +## obstacles may need multi-click. Nav waypoints under bump rims gave almost no horizontal speed. const DESCEND_GOAL_Y_MARGIN: float = 0.06 var _has_walk_goal: bool = false From 8956545d3cb18c1f3ae180e2462e14fa5e4b6b89 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 15:02:13 -0400 Subject: [PATCH 43/45] NS-23: Document idle skip vs is_on_floor / move_and_slide --- client/scripts/player.gd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 1ca6a66..2d5078e 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -58,6 +58,8 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 +## `is_on_floor()` comes from the last `move_and_slide()`. Skipping slide while idle reuses that +## stale floor state — OK for static floors; revisit if walkable geometry moves. func _skip_move_when_idle_on_floor() -> bool: return is_on_floor() and velocity.length_squared() < 1e-12 From d22d252b59388fe219773b5768383ef4ca3d6d9e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 15:04:09 -0400 Subject: [PATCH 44/45] docs: Link NS-24 for idle jitter (plan + client README) --- client/README.md | 2 ++ docs/plans/NS-23-implementation-plan.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/README.md b/client/README.md index e092ddd..d217da7 100644 --- a/client/README.md +++ b/client/README.md @@ -15,6 +15,8 @@ With the game server running ([`server/README.md`](../server/README.md)), each v **Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the pick’s **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. +**Known issue:** visible **idle / rest jitter** on the avatar — follow-up in [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) (Tech Debt). + - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` calls **`bake_navigation_mesh(false)`** (main-thread bake) after one **`process_frame`**, then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. After moving floor or obstacles, re-bake in the editor if needed. - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). diff --git a/docs/plans/NS-23-implementation-plan.md b/docs/plans/NS-23-implementation-plan.md index d60f159..821ab2f 100644 --- a/docs/plans/NS-23-implementation-plan.md +++ b/docs/plans/NS-23-implementation-plan.md @@ -44,7 +44,7 @@ |--------|----------| | **Uneven surfaces vs obstacles** | The client uses a **descend bypass** in `player.gd`: when the authoritative goal **Y** is below the body origin (typical for floor picks vs mid-capsule), steering goes **straight in xz** toward the goal instead of following nav waypoints first. This keeps **stepped bumps** smooth; it does **not** guarantee routing around tall **static obstacles** on a **single** click. | | **Player expectation** | **Multi-click** (or chained moves) is acceptable to navigate around geometry the client would otherwise bee-line into. **No** automatic obstacle navigation is required for NS-23 closure. | -| **Residual polish** | Visible idle jitter at rest is tracked as tech debt (**NS-24**), not a blocker for this story. | +| **Residual polish** | Visible idle jitter at rest is tracked as tech debt — [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) (not a blocker for this story). | ## Acceptance criteria checklist From f412da8f443126ae4964cc8c0f2bb9e36d636c6e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 5 Apr 2026 15:04:39 -0400 Subject: [PATCH 45/45] docs: Mark NS-23 code review blocking items and suggestions done --- docs/reviews/2026-04-05-NS-23.md | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/docs/reviews/2026-04-05-NS-23.md b/docs/reviews/2026-04-05-NS-23.md index 9378aa5..de4d4f1 100644 --- a/docs/reviews/2026-04-05-NS-23.md +++ b/docs/reviews/2026-04-05-NS-23.md @@ -4,48 +4,52 @@ **Scope:** Branch `NS-23-client-path-follow` vs `origin/main` (full NS-23 slice: client nav, wiring, docs, small server remark). **Base:** `origin/main` -**Resolution (same day):** Design updated — **automatic obstacle navigation is not expected**; docs/plan/README aligned (`NS-23` plan **Locked — prototype movement tradeoff**, `client/README`, `server/README`, E1.M1 snapshot). Prior **Request changes** on documentation mismatch is **addressed**. +**Follow-up:** Design and docs were aligned after the initial review; blocking items and suggestions below are **done** unless marked otherwise. ## Verdict -**Request changes** — implementation is coherent for the **accepted product tradeoff** (smooth bumps, obstacle pathing often requires multiple clicks), but **`docs/plans/NS-23-implementation-plan.md` and `client/README.md` still read as if obstacle avoidance always works**, which conflicts with shipped behavior and with the plan’s own AC wording. +**Approve** — original **Request changes** (plan/README vs obstacle wording) and review **suggestions** 1–3 are **resolved** in follow-up commits. Optional **nit** (rename `DESCEND_GOAL_Y_MARGIN`) remains open. ## Summary The branch adds **`NavigationRegion3D`** / **`NavigationAgent3D`**, sync nav bake in **`main.gd`**, boot snap vs post-move nav goal via **`PositionAuthorityClient`**’s second signal argument, and horizontal path-follow in **`player.gd`**. **`ground_pick.gd`** gains stepped walkable picking for steep surfaces. Server change is documentation-only on **`MoveCommandValidation`** (vertical step remark). -**`player.gd`** intentionally skips nav waypoints when **`_auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN`**, so for typical picks (floor **Y** vs mid-capsule origin) the client often **bee-lines in xz** toward the goal. That matches the team’s stated tradeoff for prototype bumps but **undermines** “walk around **`Obstacle`**” as the default single-click experience. +**`player.gd`** intentionally skips nav waypoints when **`_auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN`**, so for typical picks (floor **Y** vs mid-capsule origin) the client often **bee-lines in xz** toward the goal. That matches the **locked prototype tradeoff** (smooth bumps; multi-click around obstacles when needed). -**Idle optimization:** skipping **`move_and_slide()`** when there is no walk goal, velocity is zero, and **`is_on_floor()`** is a reasonable jitter mitigation; residual vibration is tracked in **NS-24** (Tech Debt). +**Idle optimization:** skipping **`move_and_slide()`** when there is no walk goal, velocity is zero, and **`is_on_floor()`** is a reasonable jitter mitigation; residual vibration is tracked in **[NS-24](https://neon-sprawl.atlassian.net/browse/NS-24)** (Tech Debt). ## Documentation checked -| Document | Result | -|----------|--------| -| `docs/plans/NS-23-implementation-plan.md` | **Partially matches.** Structure and locked authority table match the wiring. **Conflicts:** AC checklist marks “continuous motion … around static obstacles” as done; with the descend bypass, that is **not** generally true for single clicks past the gray box. **Open questions** says “None” but the obstacle/bump tradeoff is material—should be recorded here (or in a “Locked tradeoff” subsection). | -| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** snapshot line for NS-23 nav path-follow + server authority unchanged. After merge, consider a **one-line** note that client nav is best-effort and may bee-line when the descend heuristic fires, if you want the module doc to warn downstream readers. | -| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server still owns **`PositionState`**; client presentation path remains valid. | -| `docs/decomposition/modules/module_dependency_register.md` / `documentation_and_implementation_alignment.md` | **N/A** deep diff; no register update strictly required for this review beyond optional E1.M1 nuance above. | -| `client/README.md` | **Partially matches / conflicts.** Manual step 4 still says the capsule **walks around `Obstacle`** toward the target; that is **misleading** with current **`player.gd`**. | -| `server/README.md` | **Matches** — NS-23 presentation vs straight-line validation is stated correctly. | +| Document | Result (at closure) | +|----------|---------------------| +| `docs/plans/NS-23-implementation-plan.md` | **Matches** after **Locked — prototype movement tradeoff**, updated AC, tests, and **NS-24** link. | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** — snapshot notes single-click obstacle detours not guaranteed. | +| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server owns **`PositionState`**; client presentation path valid. | +| `docs/decomposition/modules/module_dependency_register.md` / `documentation_and_implementation_alignment.md` | **N/A** deep diff. | +| `client/README.md` | **Matches** — tradeoff, manual steps, **Known issue** + **NS-24** link. | +| `server/README.md` | **Matches** — client nav presentation; detours not guaranteed on one click. | ## Blocking issues -1. **Plan and README vs behavior:** Update **`docs/plans/NS-23-implementation-plan.md`** (and **`client/README.md`** manual check) to state the **locked tradeoff**: descend bypass (goal **Y** below body) causes **xz bee-line** toward the verified target; **navmesh detours around tall obstacles are not guaranteed** on a single click—players may need **segmented clicks** to wrap geometry. Adjust the AC checklist wording or unchecked items so the document is not self-contradictory. +**All resolved.** + +1. ~~**Plan and README vs behavior:**~~ **Done.** Plan and **`client/README.md`** now state the **locked tradeoff** (descend bypass → xz bee-line; navmesh detours not guaranteed on one click; multi-click OK). AC checklist and manual QA updated accordingly. ## Suggestions -1. **`DESCEND_GOAL_Y_MARGIN`:** Add a one-line comment that this compares **authoritative target Y** (often **surface** from pick) to **CharacterBody3D** origin (**mid-capsule**), so the branch triggers for most floor targets—intentional for bumps, consequential for obstacles. -2. **`_skip_move_when_idle_on_floor`:** Document that **`is_on_floor()`** is updated by **`move_and_slide()`**; skipping slides when idle relies on the last frame’s floor state (acceptable for a static prototype floor). -3. **NS-24:** Keep **idle/rest vibration** out of NS-23 closure; link **NS-24** from the plan or README “Known issues” if you add a short note. +**All done.** + +1. ~~**`DESCEND_GOAL_Y_MARGIN`:**~~ **Done.** Comment added in `client/scripts/player.gd` (surface pick Y vs mid-capsule; bumps vs obstacles). +2. ~~**`_skip_move_when_idle_on_floor`:**~~ **Done.** Doc comment above the helper (`is_on_floor()` / last `move_and_slide()`; static floors). +3. ~~**NS-24:**~~ **Done.** Out of NS-23 scope in plan; **[NS-24](https://neon-sprawl.atlassian.net/browse/NS-24)** linked from **`NS-23-implementation-plan.md`** and **`client/README.md`**. ## Nits -- **Nit:** Consider renaming **`DESCEND_GOAL_Y_MARGIN`** to something that signals “auth pick Y vs body origin” to reduce future “why do we always bee-line?” confusion. +- **Nit (optional):** Consider renaming **`DESCEND_GOAL_Y_MARGIN`** to something that signals “auth pick Y vs body origin” to reduce future “why do we always bee-line?” confusion. **Not done** — purely optional. ## Verification - `cd server/NeonSprawl.Server && dotnet test` (no logic change expected, sanity). -- Godot **F5** with server: boot snap; click floor; confirm bump flow acceptable; confirm gray **Obstacle** often requires **multiple clicks** / partial path—aligned with updated docs. +- Godot **F5** with server: boot snap; click floor; bump flow; gray **Obstacle** may need **multiple clicks** per docs. - Confirm NS-19 reject targets still show rejection UX (`reasonCode` / label). - `gdlint` / `gdformat` on touched GDScript if CI does not already run on the branch.