From 1f6625c659208d954baaa977c2b1465f0f2c67b1 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 12 Apr 2026 18:03:47 -0400 Subject: [PATCH] =?UTF-8?q?NEON-29:=20Fix=20nav=20column=20steer=20ping-po?= =?UTF-8?q?ng=20=E2=80=94=20path=20lookahead=20+=20air=20bee-line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/README.md | 2 +- client/scripts/player.gd | 60 ++++++++++++++++------- docs/plans/NEON-29-implementation-plan.md | 2 +- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/client/README.md b/client/README.md index 1a9c9ed..67cce8c 100644 --- a/client/README.md +++ b/client/README.md @@ -55,7 +55,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen 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 (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. -**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is active (goal surface clearly above/below the feet, with Schmitt latch) **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) — so terraces / steps steer via **`NavigationAgent3D.get_next_path_position()`** toward the next waypoint. (A prior **manual** path scan with a **5 cm** “skip near waypoints” threshold caused **180° velocity flips** each physics tick; **`get_next_path_position()`** is **not** used as the **default** bee-line steer for all walking — that still regressed under **Jolt** at **120 Hz** when it was global.) **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. +**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolt’s per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. **NEON-7 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn. diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 5fb8cf5..8d7f320 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -22,6 +22,12 @@ const DIRECT_APPROACH_RADIUS: float = 0.85 ## alternates → velocity sign flips (Jolt ping-pong while [code]has_goal[/code]). const NAV_COLUMN_STEER_ENTER_DIST: float = 0.74 const NAV_COLUMN_STEER_EXIT_DIST: float = 0.97 +## First path waypoint used for column steer must be at least this far in XZ. [method NavigationAgent3D.get_next_path_position] +## can sit ~4 cm from the capsule; Jolt slides ~4 cm/tick → the “toward next” vector flips 180° every frame +## (`vlat && ncol` NEON-16 traces). A 22 cm gate matches foot-scale probes and stays stable on treads. +const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22 +## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer instead. +const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25 ## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with ## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member DESCEND_GOAL_Y_MARGIN]) ## can flip path vs direct steering every tick → velocity sign ping-pong while [code]has_goal[/code]. @@ -440,21 +446,42 @@ func _apply_floor_block_for_descend_at_wall(feet_y: float) -> void: ## When the authoritative goal shares XZ with the capsule (e.g. terrace above), horizontal ## `full_to_goal` is zero and steering must follow the baked path toward a ramp/step, not +X. func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -> void: - # Do **not** scan `get_current_navigation_path()` for the first point with - # `dh.length_squared() > 0.0025` (5 cm). Jolt nudges the capsule across that radius every - # physics tick → the chosen waypoint alternates → velocity sign flips 180° while - # `needs_vertical_routing && _walk_nav_column_steering` (NEON-16 debug trace). + # `get_next_path_position()` alone still ping-pongs: the engine’s “next” point can sit within + # Jolt’s per-tick slide distance, so the horizontal seek vector flips 180° each tick. + # Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD] + # away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ). if _nav_agent.is_navigation_finished(): _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) return - var next_pos: Vector3 = _nav_agent.get_next_path_position() - var dh: Vector3 = Vector3( - next_pos.x - global_position.x, 0.0, next_pos.z - global_position.z - ) - if dh.length_squared() < 1e-8: + var pos := global_position + var min_l2: float = NAV_PATH_STEER_MIN_LOOKAHEAD * NAV_PATH_STEER_MIN_LOOKAHEAD + var steer_point: Vector3 = _auth_walk_goal + var path: PackedVector3Array = _nav_agent.get_current_navigation_path() + if path.size() > 0: + var chosen: bool = false + for i: int in range(path.size()): + var wp: Vector3 = path[i] + var dx: float = wp.x - pos.x + var dz: float = wp.z - pos.z + if dx * dx + dz * dz >= min_l2: + steer_point = wp + chosen = true + break + if not chosen: + steer_point = path[path.size() - 1] + var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z) + if dh.length_squared() < 1e-10: _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) - else: - _set_horizontal_velocity_toward(next_pos, fallback_goal_xz) + return + var dir: Vector3 = dh.normalized() + var gh: Vector3 = Vector3(fallback_goal_xz.x, 0.0, fallback_goal_xz.z) + if gh.length_squared() > 1e-10: + gh = gh.normalized() + if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT: + _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) + return + velocity = dir * MOVE_SPEED + velocity.y = 0.0 static func capsule_feet_y(body_origin_y: float, capsule_half_height: float) -> float: @@ -872,11 +899,10 @@ func _physics_process(delta: float) -> void: ) _apply_floor_block_for_descend_at_wall(feet_y) - # Horizontal motion toward the **authoritative** click target (XZ) by default. A **global** - # `get_next_path_position()` steer regressed under Jolt / 120 Hz (“stuck” / slow advance). - # Use the baked path only when vertical routing is latched **and** column hysteresis says so; - # then **`_set_horizontal_velocity_from_nav_path_or_goal`** uses **`get_next_path_position()`** - # (not a 5 cm path scan — that ping-ponged waypoints every tick). + # Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked + # path only when vertical routing is latched **and** column hysteresis says so **and** we are + # on floor — mid-air column steering flips XZ velocity each tick against `move_and_slide` + # (NEON-16: `on_floor=false` + `vlat && ncol` while falling). var vert_sep: float = absf(_auth_walk_goal.y - feet_y) if not _walk_vert_route_latched: if vert_sep > WALK_VERT_ROUTE_LATCH_ON_SEP: @@ -891,7 +917,7 @@ func _physics_process(delta: float) -> void: _walk_nav_column_steering = true elif horiz_dist >= NAV_COLUMN_STEER_EXIT_DIST: _walk_nav_column_steering = false - if needs_vertical_routing and _walk_nav_column_steering: + if needs_vertical_routing and _walk_nav_column_steering and is_on_floor(): _set_horizontal_velocity_from_nav_path_or_goal(want_goal_h) else: _set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h) diff --git a/docs/plans/NEON-29-implementation-plan.md b/docs/plans/NEON-29-implementation-plan.md index 96f1a14..68c200d 100644 --- a/docs/plans/NEON-29-implementation-plan.md +++ b/docs/plans/NEON-29-implementation-plan.md @@ -112,7 +112,7 @@ No new automated GDScript tests are added for this story — there is no new GDS - **Terrace B descend / climb (resolved, iterated):** Scene lifts and **descend-bypass** bee-lines caused regressions (stuck climbs, lip oscillation, wrong-direction slides). **Removed the descend bypass:** vertical moves (e.g. platform ↔ floor) use the **baked nav path + step assist** only. **Walk gravity** is **`!is_on_floor()`** again — never “goal below feet” while still walking on an upper surface (that applied gravity across the whole **TerracePlatformB** deck and destroyed horizontal motion). **`velocity.y`** is cleared by the horizontal steer each tick as before. - **Lip stuck (floor→platform / platform→floor):** QA screenshots for this were on **`TerracePlatformA`** (10 × 10 large SE pad, **0.3 m** single step — not the NW **Terrace B** step/platform pair). Step assist required **`vel_h.dot(want) ≤ 0.52`**, so a **head-on** push into the vertical face (dot ≈ 1) **never** triggered the lift. **Removed** that gate. For **platform→floor**, **`floor_block_on_wall = false`** only when the nav goal is **below** the feet **and** **`is_on_wall()`** or a **wall-ish slide normal** exists, so open deck walking stays stable. - **Teal pad still stuck (follow-up):** Step assist used **slide normals only**; Jolt often exposes the lip via **`is_on_wall()`** / **`get_wall_normal()`** instead — re-added a **directional** wall check (`wnh.dot(want) < -0.02`). Descend relax now also triggers on **shallow floor normal** (`get_floor_normal().dot(UP) < 0.992`) and slightly wider vertical-ish slide band. **`TerracePlatformA`** root **Y = 0.012 m** breaks **coplanarity** with the main floor without the larger gap that hurt **TerraceStepB** approach. -- **Nav waypoint steering dropped (movement):** Following **`get_next_path_position()`** by default caused **near-zero** horizontal progress under **Jolt** + **120 Hz** (waypoint advance / finished edge cases), read as universal **stuck** movement even with the server up. **`player.gd`** now **bee-lines in XZ** toward **`_auth_walk_goal`** and uses path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis. **`_set_horizontal_velocity_from_nav_path_or_goal`** uses **`get_next_path_position()`**; a prior **5 cm** path-point scan caused **180° velocity flips** when Jolt nudged the capsule across the threshold each tick. +- **Nav waypoint steering dropped (movement):** Following **`get_next_path_position()`** by default caused **near-zero** horizontal progress under **Jolt** + **120 Hz** (waypoint advance / finished edge cases), read as universal **stuck** movement even with the server up. **`player.gd`** now **bee-lines in XZ** toward **`_auth_walk_goal`** and uses path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis **and** **`is_on_floor()`** (mid-air column steer caused **180° XZ flips** while falling). **`_set_horizontal_velocity_from_nav_path_or_goal`** scans **`get_current_navigation_path()`** for the first waypoint **≥ `NAV_PATH_STEER_MIN_LOOKAHEAD` (0.22 m)** in XZ, with a **goal-direction dot** guard — raw **`get_next_path_position()`** and a **5 cm** scan both sat inside Jolt’s per-tick slide and could flip velocity **180°** each tick. - **Walk ledge / void gravity (iterated):** **`is_on_floor()`** + **`floor_snap_length`** could keep the capsule **glued** while crossing void to a lower click. **Fix:** **multi-sample** down rays under the **foot disk** (offsets along **move direction**; depth ~**0.32**); apply gravity when **none** hit; **`floor_snap_length = 0`** that tick so snap does not cancel **`velocity.y`**. Dropped a **floor-normal** gate that skipped the probe on **lip** contacts. - **Step assist vs `agent_max_climb`:** Nav **`agent_max_climb = 0.35`** only constrained mesh links; **bee-line + chained step assist** could still climb **TerracePlatformB** (~**0.6 m** from floor) in one click. **`_try_walk_step_assist`** now returns false when **`_auth_walk_goal.y > feet + WALK_STEP_ASSIST_MAX_SURFACE_DELTA`** (**0.35**, same as the mesh). - **Teal cardinal / tread shake (rollback):** Aggressive tweaks (**0.16** lift, **1**-tick cooldown, **`descending_stall`** toggling **`floor_block_on_wall`**, **seam-based** step-assist clear, **walk-stall nav replan**) caused **oscillation** on the small approach treads. **Reverted** to **`WALK_STEP_ASSIST_DELTA = 0.11`**, **`WALK_STEP_ASSIST_COOLDOWN_TICKS = 8`**, **no** stall replan, **no** `descending_stall` branch, **no** seam-based assist clear — only the original clear (clean floor without wall, or airborne with no slides). **Climb** wallish snap cap applies only when **`goal.y > feet_y + 0.12`** so shallow tread-to-tread motion is not capped to assist snap every frame.