NEON-29: Remove descend bypass; fix walk gravity on terraces

Applying gravity whenever the nav goal was below feet stayed true for the
entire walk across TerracePlatformB (feet ~0.6 m vs floor goal Y=0),
corrupting horizontal velocity and slide behavior.

Drop the descend bee-line branch; follow the nav mesh for vertical routing.
Restore full horizontal velocity set with vy=0 and air-only gravity.
Update README and NEON-29 plan to match.
pull/41/head
VinPropane 2026-04-12 00:32:02 -04:00
parent e73477da6b
commit 7d03b17136
3 changed files with 20 additions and 68 deletions

View File

@ -52,11 +52,11 @@ The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5×
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):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the picks **Y** is below the capsule **feet** by a small margin (smooth **stepped bumps**). Same-height floor clicks now stay on the baked **`NavigationAgent3D`** path instead of looking like a descend just because the capsule origin sits above the floor. **Automatic routing around tall obstacles on one click is still not guaranteed** for those true descend-bypass cases — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**Tradeoff (prototype):** With a valid nav map, horizontal motion follows **`NavigationAgent3D.get_next_path_position()`** (or direct approach near the goal). **No descend bypass:** bee-lining in xz whenever the pick **Y** is below the capsule **feet** applied gravity for the entire crossing of a tall terrace (e.g. **TerracePlatformB**) and broke movement; **terraces and stepped QA bumps rely on the baked mesh + step assist** instead. **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.
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival uses the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEON-30), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEON-25 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` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor.
@ -67,7 +67,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 servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEON-6 walk demo).
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). Server-rejected clicks 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 (follows **`NavigationAgent3D`** waypoints when the map is ready, or direct approach when near the goal). Server-rejected clicks show the reject label and do **not** start a path.
5. Click the **pedestal top** (orange box at ~(7.5, 0, 6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds `MaxVerticalStep`. The `MoveRejectFarPad` (blue pad at (9, 9)) is now a normal walkable surface; clicking it from any distance should succeed.
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
@ -96,7 +96,7 @@ The main scene includes a **prototype terminal** at the map center (same world *
## Movement prototype (NEON-2 → NEON-8)
**`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 (NEON-8). **`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 (NEON-8). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor.

View File

@ -27,11 +27,8 @@ const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.9998
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
const IDLE_RIM_SETTLE_STEP: float = 0.004
## Used by tests and vertical routing checks (feet vs goal surface).
const DESCEND_GOAL_Y_MARGIN: float = 0.06
## Cap floor snap for the whole descend (goal below feet) so a long snap does not glue the
## capsule to the upper surface across internal edges. No secondary feet_y band — that caused
## snap-length flip-flop and oscillation at the lip.
const DESCEND_LIP_SNAP_CAP: float = 0.1
## Y lift when blocked by wall-ish slide but nav goal is higher (stepped bumps).
const WALK_STEP_ASSIST_DELTA: float = 0.11
## Skip assist when horizontal velocity already aligns enough with want-dir (corners slide slowly).
@ -249,36 +246,20 @@ func _set_horizontal_velocity_toward(
dh = Vector3(1.0, 0.0, 0.0)
else:
dh = dh.normalized()
# Only steer XZ. Setting `velocity.y = 0` every tick prevented gravity from accumulating
# while descending (lip reported as on-floor) and forced artificial peel multipliers that
# fought floor snap and `floor_block_on_wall`.
velocity.x = dh.x * MOVE_SPEED
velocity.z = dh.z * MOVE_SPEED
velocity = dh * MOVE_SPEED
velocity.y = 0.0
func _walk_descending_by_goal(feet_y: float) -> bool:
return _has_walk_goal and _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
## Airborne walk ticks only. Do **not** add gravity just because the nav goal is below the
## feet — that stays true for the whole cross of e.g. TerracePlatformB (feet ~0.6 m, goal on
## floor Y=0) and poisons horizontal motion / slide resolution.
func _apply_walk_air_gravity(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
## Gravity while walking: normal fall when airborne, and when the nav goal is below the feet
## (descend) even if `is_on_floor()` at a terrace lip. Stomp Y when grounded on a non-descend
## move so flat walking does not inherit stale vertical velocity.
func _apply_walk_gravity(delta: float, feet_y: float) -> void:
var g_step: Vector3 = get_gravity() * delta
if (not is_on_floor()) or _walk_descending_by_goal(feet_y):
velocity += g_step
func _stomp_walk_vertical_when_grounded_not_descending(feet_y: float) -> void:
if is_on_floor() and not _walk_descending_by_goal(feet_y):
velocity.y = 0.0
func _walk_floor_snap_length(feet_y: float) -> float:
var fl: float = WALK_STEP_ASSIST_SNAP if _step_assist_active else FLOOR_SNAP_MOVING
if _walk_descending_by_goal(feet_y):
fl = minf(fl, DESCEND_LIP_SNAP_CAP)
return fl
func _walk_floor_snap_length() -> float:
return WALK_STEP_ASSIST_SNAP if _step_assist_active else FLOOR_SNAP_MOVING
## When the authoritative goal shares XZ with the capsule (e.g. terrace above), horizontal
@ -631,41 +612,13 @@ func _physics_process(delta: float) -> void:
_idle_manual_correction_ticks = 0
var feet_y: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
floor_block_on_wall = true
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, want_goal_h)
_apply_walk_gravity(delta, feet_y)
_stomp_walk_vertical_when_grounded_not_descending(feet_y)
floor_snap_length = _walk_floor_snap_length(feet_y)
move_and_slide()
_after_walk_move_and_slide()
if (
_step_assist_active
and (
(is_on_floor() and not is_on_wall())
or (not is_on_floor() and get_slide_collision_count() == 0)
)
):
_step_assist_active = false
_debug_trace_transform("physics")
_snap_capsule_upright()
return
# Use actual capsule bottom (total half = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS = 0.9 m)
# so the descend bypass only fires when the goal surface is genuinely below the player's
# physical feet. Using CAPSULE_HALF_HEIGHT alone (0.5) was ~0.4 m too high: from the first
# step (body 1.2 → code-feet 0.7) it incorrectly fired for the platform goal (Y=0.6 < 0.64),
# using FLOOR_SNAP_MOVING to pull the capsule back to the step after every assist lift.
if _walk_descending_by_goal(feet_y):
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
_apply_walk_gravity(delta, feet_y)
_stomp_walk_vertical_when_grounded_not_descending(feet_y)
floor_snap_length = _walk_floor_snap_length(feet_y)
_apply_walk_air_gravity(delta)
floor_snap_length = _walk_floor_snap_length()
move_and_slide()
_after_walk_move_and_slide()
if (
@ -698,9 +651,8 @@ func _physics_process(delta: float) -> void:
else:
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
_apply_walk_gravity(delta, feet_y)
_stomp_walk_vertical_when_grounded_not_descending(feet_y)
floor_snap_length = _walk_floor_snap_length(feet_y)
_apply_walk_air_gravity(delta)
floor_snap_length = _walk_floor_snap_length()
move_and_slide()
_after_walk_move_and_slide()
if _step_assist_active:

View File

@ -109,7 +109,7 @@ No new automated GDScript tests are added for this story — there is no new GDS
- **Arrival check using wrong capsule reference (resolved):** `vertical_arrival_error` was called with `CAPSULE_HALF_HEIGHT = 0.5`, giving code-feet at `body_y 0.5 = 0.4` at floor level. Step surfaces (Y = 0.3) are only 0.1 m from that reference. When the Jolt physics engine nudges the capsule body down to ~0.8 m (bottom hemisphere contacts the step edge), code-feet becomes 0.3 — matching the step surface Y and making `vert_err = 0 ≤ VERT_ARRIVE_EPS`. If the player is also within `ARRIVE_EPS = 0.35 m` horizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: pass `CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9) to `vertical_arrival_error` so actual feet = `body_y 0.9`. Step goal vert_err is now 0.3 >> 0.055 from floor height; arrival fires correctly only once the step assist has lifted the capsule onto the surface (body_y = 1.2, vert_err ≈ 0). Two regression tests added.
- **snap_to_server places capsule underground (resolved):** The server stores the raw surface Y from the client's click (e.g. 0.6 for a terrace platform), not the capsule body-centre Y (which should be 0.6 + 0.9 = 1.5). `snap_to_server` was setting `global_position.y` directly to this surface Y, putting the capsule bottom at 0.3 m. With `_floor_angle_loose_ticks = 0` and a stale `is_on_floor() = true`, the idle path's `_stable_idle_support()` check passed and held the player without calling `move_and_slide()`, preventing Jolt from resolving the penetration. Fixed: clamp `global_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9 m) in `snap_to_server`; also set `_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP` (96 ticks) to force corrective idle physics before the next walk goal.
- **Step assist undone by floor snap (resolved):** Each `_try_walk_step_assist()` cycle lifts the capsule 0.11 m. The prior `floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m` meant the snap always reached the lower floor (0.11 m below the lifted bottom), pulling the capsule straight back down every tick. The player "nudged and stopped" because each lift was immediately cancelled. Fixed: added `WALK_STEP_ASSIST_SNAP = 0.09 m` constant (below 0.11 m so the snap cannot reach the previous floor, but above 0.03 m so it catches the step surface once cleared) and a `_step_assist_active` boolean flag. During active climbing, `floor_snap_length` is set to `WALK_STEP_ASSIST_SNAP` in both `_after_walk_move_and_slide()` and the main physics loop. The flag is cleared when the player lands cleanly on a floor without wall contact (`is_on_floor() and not is_on_wall()`), or when the nav goal is cleared/snapped.
- **Terrace B descend / climb (resolved, iterated):** A **0.015 m** root lift on `TerraceStepB` / `TerracePlatformB` was tried to break coplanar internal edges with the main floor; it correlated with **floor → step** sticking and was **reverted**. **Descend** fixes iterated again: `_set_horizontal_velocity_toward` had been resetting **`velocity.y = 0`** every tick, so gravity never accumulated during walk (lip + on-floor made this look like “slow gravity”). **On-floor gravity multipliers** and **`floor_block_on_wall = false`** for descend then **fought** floor snap and brought back **oscillation**. **Current approach:** steer **XZ only**; apply **`get_gravity() * delta`** when **`!is_on_floor()`** *or* the nav goal is below the feet; **stomp `velocity.y`** only when **on floor** and **not** descending; keep **`floor_block_on_wall` true**; cap **`floor_snap_length`** for the **whole** descend (no extra feet_y band).
- **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.
## Open questions / risks