diff --git a/client/scripts/player.gd b/client/scripts/player.gd index af99442..a2b76d4 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -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: diff --git a/client/test/player_test.gd b/client/test/player_test.gd index e24ced2..8a4fc76 100644 --- a/client/test/player_test.gd +++ b/client/test/player_test.gd @@ -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) diff --git a/docs/plans/NEON-29-implementation-plan.md b/docs/plans/NEON-29-implementation-plan.md index 9635518..43af9b8 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 **`_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