NEON-29: Hysteresis for walk vertical routing (tread ping-pong)

feet_y wobble from move_and_slide on approach treads crossed
DESCEND_GOAL_Y_MARGIN each frame, toggling path vs direct steer.
Schmitt latch (WALK_VERT_ROUTE_LATCH_ON_SEP/OFF_SEP) with reset on
nav lifecycle. Extend debug_idle_trace with feet_y, vert_sep, vlat,
ncol. Add player_test for latch reset.
pull/41/head
VinPropane 2026-04-12 17:17:36 -04:00
parent 46f8851a1a
commit edec9158e4
3 changed files with 47 additions and 7 deletions

View File

@ -22,6 +22,11 @@ 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
## 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].
const WALK_VERT_ROUTE_LATCH_ON_SEP: float = 0.10
const WALK_VERT_ROUTE_LATCH_OFF_SEP: float = 0.038
const FLOOR_SNAP_MOVING: float = 0.32
## Idle floor snap length (stronger to pin rim contacts).
const FLOOR_SNAP_IDLE: float = 0.11
@ -92,6 +97,8 @@ var _debug_last_transform_xz: Vector2 = Vector2.INF
var _debug_trace_frame: int = 0
## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing.
var _walk_nav_column_steering: bool = false
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
var _walk_vert_route_latched: bool = false
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
@ -114,6 +121,7 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(world_pos)
@ -127,6 +135,7 @@ func clear_nav_goal() -> void:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(global_position)
@ -166,6 +175,7 @@ func snap_to_server(world_pos: Vector3) -> void:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(settled)
reset_physics_interpolation()
@ -501,11 +511,21 @@ func _debug_trace_transform(tag: String) -> void:
and tag != "physics"
):
return
var extra: String = ""
if _has_walk_goal:
var fy: float = capsule_feet_y(
global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
)
var vsep: float = absf(_auth_walk_goal.y - fy)
extra = (
" feet_y=%s vert_sep=%.4f vlat=%s ncol=%s"
% [fy, vsep, _walk_vert_route_latched, _walk_nav_column_steering]
)
print(
(
(
"NEON-16 transform trace [%s] frame=%d pos=%s vel=%s has_goal=%s "
+ "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s"
+ "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s%s"
)
% [
tag,
@ -518,6 +538,7 @@ func _debug_trace_transform(tag: String) -> void:
_stable_idle_support(),
_idle_anchor_active,
_idle_anchor_xz,
extra,
]
)
)
@ -728,6 +749,7 @@ func _physics_process(delta: float) -> void:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(global_position)
_physics_idle_tick(delta)
if _stable_idle_support():
@ -760,10 +782,14 @@ func _physics_process(delta: float) -> void:
# reads as “stuck” or a few centimeters per click. Use the baked path only when we are close in
# XZ but the goal surface is clearly above or below the feet — same column as a terrace /
# step / drop — so the mesh can steer us toward a ramp or tread instead of hugging a wall.
var needs_vertical_routing: bool = (
_auth_walk_goal.y > feet_y + DESCEND_GOAL_Y_MARGIN
or _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
)
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:
_walk_vert_route_latched = true
else:
if vert_sep < WALK_VERT_ROUTE_LATCH_OFF_SEP:
_walk_vert_route_latched = false
var needs_vertical_routing: bool = _walk_vert_route_latched
if not needs_vertical_routing:
_walk_nav_column_steering = false
elif horiz_dist <= NAV_COLUMN_STEER_ENTER_DIST:

View File

@ -48,6 +48,20 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
assert_that(nav.target_position).is_equal(p.global_position)
func test_nav_goal_lifecycle_resets_vert_route_latch() -> void:
var p := _make_player()
p.set_authoritative_nav_goal(Vector3(1.0, 0.0, 2.0))
p.set("_walk_vert_route_latched", true)
p.clear_nav_goal()
assert_that(p.get("_walk_vert_route_latched")).is_false()
p.set("_walk_vert_route_latched", true)
p.set_authoritative_nav_goal(Vector3(3.0, 0.0, 4.0))
assert_that(p.get("_walk_vert_route_latched")).is_false()
p.set("_walk_vert_route_latched", true)
p.snap_to_server(Vector3(0.0, 0.9, 0.0))
assert_that(p.get("_walk_vert_route_latched")).is_false()
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)

View File

@ -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 **`_set_horizontal_velocity_from_nav_path_or_goal`** only when **`needs_vertical_routing`** (goal **Y** vs feet beyond **`DESCEND_GOAL_Y_MARGIN`**) **and** **`horiz_dist <= DIRECT_APPROACH_RADIUS`**.
- **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 **`_set_horizontal_velocity_from_nav_path_or_goal`** only when **`needs_vertical_routing`** (latched via **`WALK_VERT_ROUTE_LATCH_*`** on **`abs(goal.y - feet_y)`**, not the raw **`DESCEND_GOAL_Y_MARGIN`** alone — tread bounce was flipping routing every tick) **and** **`horiz_dist <= NAV_COLUMN_STEER_*`** hysteresis.
- **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.
- **Teal pad — geometry wins:** Script-only mitigations still failed in practice; **`TerracePlatformA_Approach`** adds **twelve** `walkable` **`StaticBody3D`** treads (three per cardinal) with **~0.104 m** rise each, **darker teal** albedo, flush to the platform lip — nav + `CharacterBody3D` use **sloped contact** instead of fighting a **single vertical face**. **Follow-up:** tread **run** was **0.45 m** along the climb axis while the capsule **diameter is 0.8 m** — the body was wider than each tread, causing straddle / stuck motion when **entering** and blocking horizontal progress toward the lip when **exiting**. Treads widened to **1.0 m** run with positions re-centered. **`NavigationAgent3D`** `path_desired_distance` / `target_desired_distance` were briefly lowered to **0.22** but that caused **floor** movement to **stall**; **reverted to 0.35**.
@ -120,7 +120,7 @@ No new automated GDScript tests are added for this story — there is no new GDS
## Approach treads (district convention)
- **Shipped in scene:** **`TerracePlatformC_Approach`** (`PCS_*`, teal-style **3×4** cardinals, **7.2 m** NS / **WE** span), **`TerraceStepB_Approach`** (`TSB_*`, **S/E/W** only — north meets violet), **`TerracePlatformB_Approach`** (`TPB_*`, **N/E/W**, **six** ~**0.104 m** risers for **0.6 m** deck height; south remains **gold step**). **Smooth ramps** for other props can follow the same rise/run idea without new `player.gd` logic.
- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`**: post-stop **`loose_ticks` + ridged** blocks stable idle only when **`floor_normal.dot(UP) < IDLE_RIM_MIN_FLOOR_UP_DOT`** — flat tread + vertical riser (e.g. **TerraceStepB_Approach** corner) no longer spends ~0.8 s in corrective idle. (2) **`use_loose_floor_angle`** not tied to **`loose_ticks` alone**. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT` 0.999**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**; **`STABLE_IDLE_UNLATCH_TICKS` 5**. (4) **Arrival** stable shortcut; **nav column steer** hysteresis.
- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`**: post-stop **`loose_ticks` + ridged** blocks stable idle only when **`floor_normal.dot(UP) < IDLE_RIM_MIN_FLOOR_UP_DOT`** — flat tread + vertical riser (e.g. **TerraceStepB_Approach** corner) no longer spends ~0.8 s in corrective idle. (2) **`use_loose_floor_angle`** not tied to **`loose_ticks` alone**. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT` 0.999**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**; **`STABLE_IDLE_UNLATCH_TICKS` 5**. (4) **Arrival** stable shortcut; **nav column steer** hysteresis. (5) **`needs_vertical_routing` Schmitt latch** (`WALK_VERT_ROUTE_LATCH_ON_SEP` / `WALK_VERT_ROUTE_LATCH_OFF_SEP`): **`feet_y`** wobble on treads was toggling path vs direct steer every tick (velocity flip while **`has_goal`**); **`debug_idle_trace`** appends **`feet_y`**, **`vert_sep`**, **`vlat`**, **`ncol`** when walking.
- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access.
## Open questions / risks