NEON-29: Reduce flat-floor idle XZ jitter after walks

Stop forcing moving floor_max_angle for the full post-stop window on open
flat support; tighten ridged wallish threshold; relax stable floor dot;
on arrival, take stable-idle path when support is already stable after
one idle tick. Add GdUnit case for shallow seam normals.
pull/41/head
VinPropane 2026-04-12 16:56:40 -04:00
parent 173c348030
commit 0f4c23b815
3 changed files with 26 additions and 5 deletions

View File

@ -22,8 +22,8 @@ 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.
## Slightly below 1.0 so Jolt does not flicker across the threshold on thin treads.
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.9996
## 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
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
@ -367,7 +367,8 @@ static func idle_slide_contacts_are_ridged(slide_normals: Array[Vector3]) -> boo
var ny: float = n.y
if ny > 0.65:
upish += 1
elif ny < 0.45:
# Steeper than ~70° from horizontal — shallow seam faces (ny≈0.4) are not “lip” walls.
elif ny < 0.35:
wallish += 1
return upish >= 1 and wallish >= 1
@ -598,7 +599,10 @@ func _snap_capsule_upright() -> void:
func _physics_process(delta: float) -> void:
_debug_trace_frame += 1
var use_loose_floor_angle: bool = _has_walk_goal or _floor_angle_loose_ticks > 0
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: after a walk on **flat**
# ground that still widened angle for ~0.8 s, `move_and_slide` + idle snap caused visible XZ
# jitter. Use moving max angle only while walking or when idle support is shallow / ridged.
var use_loose_floor_angle: bool = _has_walk_goal
if not _has_walk_goal:
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
@ -672,6 +676,15 @@ func _physics_process(delta: float) -> void:
_idle_manual_correction_ticks = 0
_nav_agent.set_target_position(global_position)
_physics_idle_tick(delta)
if _stable_idle_support():
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
_hold_idle_anchor()
_debug_trace_idle_state("arrival_stable")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _apply_idle_manual_correction():
_idle_manual_correction_ticks += 1
_debug_trace_transform("physics")

View File

@ -80,6 +80,14 @@ func test_idle_support_is_stable_true_when_loose_ticks_but_flat_open_support() -
assert_that(stable).is_true()
func test_idle_support_is_stable_true_when_loose_ticks_and_shallow_seam_not_ridged() -> void:
# Second normal has ny=0.42 — not “wallish” (ny < 0.35) for ridged lip detection.
var n2: Vector3 = Vector3(0.0, 0.42, sqrt(1.0 - 0.42 * 0.42)).normalized()
var slide_normals: Array[Vector3] = [Vector3.UP, n2]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
assert_that(stable).is_true()
func test_idle_support_is_stable_false_when_not_on_floor() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0)

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:** **`idle_support_is_stable`** used to return false for the entire **`FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP`** window whenever **`loose_ticks > 0`**, so the capsule ran **rim / corrective** idle on **flat** tread tops after every stop — **`idle_manual_correction`** nudges vs anchor caused visible **XZ/Y** noise. **Fix:** treat as stable during loose ticks unless **`idle_slide_contacts_are_ridged`** (floor + wall in slide normals). Slightly relaxed **`STABLE_IDLE_FLOOR_MIN_UP_DOT`** (**0.9998 → 0.9996**) for Jolt normal flicker.
- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`** no longer treats every **`loose_ticks > 0`** frame as unstable unless slides are **ridged** (true lip: up + steep wall normal). (2) **`use_loose_floor_angle`** no longer follows **`_floor_angle_loose_ticks` alone** — post-stop **50°** floor max on **open flat** caused **`move_and_slide`** XZ noise for ~0.8 s. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT`** **0.999**; ridged **wallish** threshold **ny < 0.35** (shallow seam faces not lips). (4) **Arrival** frame skips rim correction when already **stable** after the first idle tick.
- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access.
## Open questions / risks