NEON-29: Ease flat-floor idle latch (HUD XZ drift)

Raise stable-idle floor up-dot entry to 0.998 so Jolt normals on a flat
box can latch the idle anchor; 0.999 rarely tripped and corrective
move_and_slide + rim/bump nudges kept moving XZ.

Add STABLE_IDLE_ENTER_STREAK_FRAMES (2) before first latch; arrival
path still latches immediately. Unlatch budget 8→10 ticks.
pull/41/head
VinPropane 2026-04-12 17:33:49 -04:00
parent 591db65b4a
commit 865eed2e53
3 changed files with 26 additions and 8 deletions

View File

@ -33,14 +33,18 @@ const FLOOR_SNAP_IDLE: float = 0.11
## Below this floor-normal dot up, idle tick uses **moving** `floor_max_angle` (rim).
const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968
## Stable flat idle support: skip corrective idle motion when support is effectively level.
## Below 1.0 so Jolt seam normals do not flicker across the threshold (idle XZ noise).
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.999
## 0.998 (~3.6°): 0.999 still saw open-floor HUD drift — Jolt [code]get_floor_normal()[/code] often
## reports ~0.99910.9992 on a flat box, so the old 0.999 gate rarely latched the idle anchor and
## [code]move_and_slide[/code] + rim/bump nudges kept nudging XZ.
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.998
## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge).
const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
## Consecutive [code]raw_stable[/code] ticks required before first idle anchor latch (entry debounce).
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 2
## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip).
const STABLE_IDLE_UNLATCH_TICKS: int = 8
const STABLE_IDLE_UNLATCH_TICKS: int = 10
## 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).
@ -90,6 +94,7 @@ var _idle_anchor_y: float = 0.0
## Latched stable idle: stay on zero-vel + anchor through brief Jolt false negatives on treads.
var _idle_stable_latched: bool = false
var _idle_stable_unlatch_streak: int = 0
var _idle_stable_enter_streak: int = 0
var _idle_manual_correction_ticks: int = 0
var _debug_last_idle_xz: Vector2 = Vector2.INF
var _debug_idle_heartbeat: int = 0
@ -120,6 +125,7 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(world_pos)
@ -134,6 +140,7 @@ func clear_nav_goal() -> void:
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(global_position)
@ -174,6 +181,7 @@ func snap_to_server(world_pos: Vector3) -> void:
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(settled)
@ -678,13 +686,21 @@ func _physics_process(delta: float) -> void:
var raw_stable: bool = _stable_idle_support()
if raw_stable:
_idle_stable_unlatch_streak = 0
_idle_stable_latched = true
if not _idle_stable_latched:
_idle_stable_enter_streak += 1
if _idle_stable_enter_streak >= STABLE_IDLE_ENTER_STREAK_FRAMES:
_idle_stable_latched = true
else:
_idle_stable_enter_streak = 0
elif _idle_stable_latched:
_idle_stable_unlatch_streak += 1
if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS:
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_idle_anchor_active = false
else:
_idle_stable_enter_streak = 0
if _idle_stable_latched:
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
@ -748,6 +764,7 @@ func _physics_process(delta: float) -> void:
_idle_manual_correction_ticks = 0
_idle_stable_latched = false
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_nav_agent.set_target_position(global_position)
@ -755,6 +772,7 @@ func _physics_process(delta: float) -> void:
if _stable_idle_support():
_idle_stable_latched = true
_idle_stable_unlatch_streak = 0
_idle_stable_enter_streak = 0
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE

View File

@ -70,9 +70,9 @@ func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
# Must have floor_normal.dot(UP) < STABLE_IDLE_FLOOR_MIN_UP_DOT (0.999).
# (0.02, 0.995).normalized() dots ~0.9998 and incorrectly passes as "flat" at 0.999.
var tilted_floor: Vector3 = Vector3(0.05, 0.99875, 0.0).normalized()
# Must have floor_normal.dot(UP) < STABLE_IDLE_FLOOR_MIN_UP_DOT (0.998).
# (0.05, 0.99875).normalized() dots ~0.999 and passes 0.998; use a slightly steeper tilt.
var tilted_floor: Vector3 = Vector3(0.07, 0.9975, 0.0).normalized()
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, tilted_floor, slide_normals, 0)
assert_that(stable).is_false()

View File

@ -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. (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.
- **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.998** (was 0.999 — Jolt flat-floor normals often ~0.9991+ so the anchor rarely latched and **`move_and_slide`** + nudges drifted HUD XZ); **`STABLE_IDLE_ENTER_STREAK_FRAMES` 2**; **`STABLE_IDLE_UNLATCH_TICKS` 10**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**. (4) **Arrival** stable shortcut (immediate latch, no enter streak). (5) **Nav column steer** hysteresis. (6) **`needs_vertical_routing` Schmitt latch**; **`debug_idle_trace`** walking extras.
- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access.
## Open questions / risks