NEON-29: fix step-climb regression (snap Y floor + step assist snap)

Two independent root causes kept the player from climbing terrace steps:

1. snap_to_server used the raw surface Y from the server (e.g. 0.6 m for a
   platform) as global_position.y directly.  With capsule total half-height
   = 0.9 m this placed the bottom at -0.3 m underground.  The stale
   is_on_floor()==true + _floor_angle_loose_ticks==0 path skipped every
   move_and_slide(), so Jolt never resolved the penetration and the capsule
   froze at an intermediate Y.
   Fix: clamp global_position.y >= CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
   (0.9 m) in snap_to_server; force FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
   corrective idle ticks afterwards.

2. Each _try_walk_step_assist() lift (0.11 m) was immediately undone by
   floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m snapping the capsule back
   to the lower floor, which was only 0.11 m below the lifted bottom.
   Fix: add WALK_STEP_ASSIST_SNAP = 0.09 m constant and _step_assist_active
   bool.  While climbing, floor_snap_length uses the smaller constant in
   both _after_walk_move_and_slide() and the main physics loop.  The flag
   is cleared when the capsule lands cleanly (is_on_floor && !is_on_wall),
   or when the nav goal is cleared/snapped to server.

Also: is_on_wall() now counts as support in _step_assist_has_support() so
the assist fires while the capsule is floating but pressed against a step
face; _step_assist_active cleared on arrival, snap, and goal-clear; all
temporary DBG print() statements removed.
pull/41/head
VinPropane 2026-04-11 22:15:24 -04:00
parent f0b7ac02eb
commit 453000d721
2 changed files with 40 additions and 8 deletions

View File

@ -33,7 +33,15 @@ const WALK_STEP_ASSIST_DELTA: float = 0.11
## Skip assist when horizontal velocity already aligns enough with want-dir (corners slide slowly). ## Skip assist when horizontal velocity already aligns enough with want-dir (corners slide slowly).
const WALK_STEP_ASSIST_MAX_FORWARD_DOT: float = 0.52 const WALK_STEP_ASSIST_MAX_FORWARD_DOT: float = 0.52
const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8 const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8
## Capsule height 1.0 in scene: origin at center, feet ~ **`y - 0.5`**. ## Floor-snap length while step-climbing. Small enough that it cannot reach the lower floor
## (~0.11 m away after the first lift) but large enough to catch the step surface once the
## capsule clears the face (~0.03 m away after the third lift for a 0.3 m step).
const WALK_STEP_ASSIST_SNAP: float = 0.09
## CapsuleShape3D in scene: height = 1.0 (cylinder portion), radius = 0.4.
## Total half-height from body origin to physical bottom = 0.5 + 0.4 = 0.9
## (`CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS`).
## `CAPSULE_HALF_HEIGHT` alone (0.5) is the cylinder half; used only where that
## distinction matters (e.g. the descend-bypass feet estimate).
const CAPSULE_HALF_HEIGHT: float = 0.5 const CAPSULE_HALF_HEIGHT: float = 0.5
const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload( const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload(
"res://scripts/random_floor_bump_collision_constants.gd" "res://scripts/random_floor_bump_collision_constants.gd"
@ -51,6 +59,9 @@ var _has_walk_goal: bool = false
var _auth_walk_goal: Vector3 = Vector3.ZERO var _auth_walk_goal: Vector3 = Vector3.ZERO
var _floor_angle_loose_ticks: int = 0 var _floor_angle_loose_ticks: int = 0
var _step_assist_cooldown: int = 0 var _step_assist_cooldown: int = 0
## True while the player is actively climbing a step; suppresses the normal large floor
## snap so it cannot pull the capsule back to the lower surface between assist cycles.
var _step_assist_active: bool = false
var _idle_anchor_active: bool = false var _idle_anchor_active: bool = false
var _idle_anchor_xz: Vector2 = Vector2.ZERO var _idle_anchor_xz: Vector2 = Vector2.ZERO
var _idle_manual_correction_ticks: int = 0 var _idle_manual_correction_ticks: int = 0
@ -75,6 +86,7 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_auth_walk_goal = world_pos _auth_walk_goal = world_pos
_has_walk_goal = true _has_walk_goal = true
_floor_angle_loose_ticks = 0 _floor_angle_loose_ticks = 0
_step_assist_active = false
_idle_anchor_active = false _idle_anchor_active = false
_idle_manual_correction_ticks = 0 _idle_manual_correction_ticks = 0
_nav_agent.set_target_position(world_pos) _nav_agent.set_target_position(world_pos)
@ -84,6 +96,7 @@ func clear_nav_goal() -> void:
velocity = Vector3.ZERO velocity = Vector3.ZERO
_has_walk_goal = false _has_walk_goal = false
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_active = false
_idle_anchor_active = false _idle_anchor_active = false
_idle_manual_correction_ticks = 0 _idle_manual_correction_ticks = 0
_nav_agent.set_target_position(global_position) _nav_agent.set_target_position(global_position)
@ -107,19 +120,27 @@ func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) ->
func snap_to_server(world_pos: Vector3) -> void: func snap_to_server(world_pos: Vector3) -> void:
global_position = world_pos # The server stores the surface Y of the last move target, not the capsule body-centre Y.
# Clamping to the minimum standing height (total half = 0.9 m) prevents the player from
# being placed underground (which, combined with a stale is_on_floor() returning true,
# would skip all corrective move_and_slide() calls and leave the capsule stuck).
var total_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
var settled := Vector3(world_pos.x, maxf(world_pos.y, total_half), world_pos.z)
global_position = settled
velocity = Vector3.ZERO velocity = Vector3.ZERO
_has_walk_goal = false _has_walk_goal = false
_floor_angle_loose_ticks = 0 # Force corrective idle ticks so Jolt resolves any residual physics state before walking.
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_cooldown = 0 _step_assist_cooldown = 0
_step_assist_active = false
_idle_anchor_active = false _idle_anchor_active = false
_idle_manual_correction_ticks = 0 _idle_manual_correction_ticks = 0
_nav_agent.set_target_position(world_pos) _nav_agent.set_target_position(settled)
reset_physics_interpolation() reset_physics_interpolation()
func _step_assist_has_support() -> bool: func _step_assist_has_support() -> bool:
if is_on_floor(): if is_on_floor() or is_on_wall():
return true return true
for i: int in get_slide_collision_count(): for i: int in get_slide_collision_count():
if get_slide_collision(i).get_normal().y > 0.35: if get_slide_collision(i).get_normal().y > 0.35:
@ -187,8 +208,10 @@ func _after_walk_move_and_slide() -> void:
if _step_assist_cooldown > 0: if _step_assist_cooldown > 0:
return return
if _try_walk_step_assist(): if _try_walk_step_assist():
_step_assist_active = true
_step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS _step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS
floor_snap_length = FLOOR_SNAP_MOVING # Reduced snap so the lower floor (0.11 m below lifted capsule) is out of range.
floor_snap_length = WALK_STEP_ASSIST_SNAP
move_and_slide() move_and_slide()
@ -503,6 +526,7 @@ func _physics_process(_delta: float) -> void:
velocity = Vector3.ZERO velocity = Vector3.ZERO
_has_walk_goal = false _has_walk_goal = false
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_step_assist_active = false
_idle_anchor_active = false _idle_anchor_active = false
_idle_manual_correction_ticks = 0 _idle_manual_correction_ticks = 0
_nav_agent.set_target_position(global_position) _nav_agent.set_target_position(global_position)
@ -518,9 +542,11 @@ func _physics_process(_delta: float) -> void:
var nav_map: RID = _nav_agent.get_navigation_map() var nav_map: RID = _nav_agent.get_navigation_map()
if NavigationServer3D.map_get_iteration_id(nav_map) == 0: if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
_set_horizontal_velocity_toward(_auth_walk_goal) _set_horizontal_velocity_toward(_auth_walk_goal)
floor_snap_length = FLOOR_SNAP_MOVING floor_snap_length = WALK_STEP_ASSIST_SNAP if _step_assist_active else FLOOR_SNAP_MOVING
move_and_slide() move_and_slide()
_after_walk_move_and_slide() _after_walk_move_and_slide()
if _step_assist_active and is_on_floor() and not is_on_wall():
_step_assist_active = false
_debug_trace_transform("physics") _debug_trace_transform("physics")
_snap_capsule_upright() _snap_capsule_upright()
return return
@ -531,6 +557,8 @@ func _physics_process(_delta: float) -> void:
floor_snap_length = FLOOR_SNAP_MOVING floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide() move_and_slide()
_after_walk_move_and_slide() _after_walk_move_and_slide()
if _step_assist_active and is_on_floor() and not is_on_wall():
_step_assist_active = false
_debug_trace_transform("physics") _debug_trace_transform("physics")
_snap_capsule_upright() _snap_capsule_upright()
return return
@ -547,8 +575,10 @@ func _physics_process(_delta: float) -> void:
else: else:
_set_horizontal_velocity_toward(_auth_walk_goal) _set_horizontal_velocity_toward(_auth_walk_goal)
floor_snap_length = FLOOR_SNAP_MOVING floor_snap_length = WALK_STEP_ASSIST_SNAP if _step_assist_active else FLOOR_SNAP_MOVING
move_and_slide() move_and_slide()
_after_walk_move_and_slide() _after_walk_move_and_slide()
if _step_assist_active and is_on_floor() and not is_on_wall():
_step_assist_active = false
_debug_trace_transform("physics") _debug_trace_transform("physics")
_snap_capsule_upright() _snap_capsule_upright()

View File

@ -107,6 +107,8 @@ No new automated GDScript tests are added for this story — there is no new GDS
- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only. - **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only.
- **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y CAPSULE_HALF_HEIGHT PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`. - **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y CAPSULE_HALF_HEIGHT PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`.
- **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. - **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.
## Open questions / risks ## Open questions / risks