NEON-16: harden idle jitter follow-up
Harden idle settling so flat ground, obstacle contacts, and rare bump-edge cases stop drifting instead of looping corrective nudges. Also resolves the saved review follow-up items and syncs the runtime docs to the final feet-height and idle-anchor behavior.pull/37/head
parent
6f4f5d4350
commit
1f5c1e7619
|
|
@ -19,11 +19,11 @@ The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow
|
|||
|
||||
With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
|
||||
|
||||
**Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the pick’s **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
|
||||
**Tradeoff (prototype):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the pick’s **Y** is below the capsule **feet** by a small margin (smooth **stepped bumps**). Same-height floor clicks now stay on the baked **`NavigationAgent3D`** path instead of looking like a descend just because the capsule origin sits above the floor. **Automatic routing around tall obstacles on one click is still not guaranteed** for those true descend-bypass cases — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
|
||||
|
||||
**NEON-7 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn.
|
||||
|
||||
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** after motion. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
|
||||
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
|
||||
|
||||
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEON-30), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEON-25 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring).
|
||||
- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor.
|
||||
|
|
@ -36,7 +36,7 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
|
|||
3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEON-6 walk demo).
|
||||
4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NEON-7 reject clicks still show the reject label and do **not** start a path.
|
||||
5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged.
|
||||
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate on the floor or the **random green bumps** (positions change each run).
|
||||
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
|
||||
|
||||
If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ const FLOOR_SNAP_MOVING: float = 0.32
|
|||
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.
|
||||
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.9998
|
||||
## 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`**).
|
||||
const IDLE_RIM_SETTLE_STEP: float = 0.004
|
||||
const DESCEND_GOAL_Y_MARGIN: float = 0.06
|
||||
|
|
@ -38,11 +42,22 @@ const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload(
|
|||
const IDLE_BUMP_ESCAPE_STEP: float = 0.014
|
||||
## Player capsule radius; wall touch can happen past axis distance **`col_r`**.
|
||||
const PLAYER_CAPSULE_RADIUS: float = 0.4
|
||||
const DEBUG_IDLE_TRACE_DELTA_EPS: float = 0.0001
|
||||
const DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES: int = 60
|
||||
const DEBUG_TRANSFORM_TRACE_DELTA_EPS: float = 0.000001
|
||||
@export var debug_idle_trace: bool = false
|
||||
|
||||
var _has_walk_goal: bool = false
|
||||
var _auth_walk_goal: Vector3 = Vector3.ZERO
|
||||
var _floor_angle_loose_ticks: int = 0
|
||||
var _step_assist_cooldown: int = 0
|
||||
var _idle_anchor_active: bool = false
|
||||
var _idle_anchor_xz: Vector2 = Vector2.ZERO
|
||||
var _idle_manual_correction_ticks: int = 0
|
||||
var _debug_last_idle_xz: Vector2 = Vector2.INF
|
||||
var _debug_idle_heartbeat: int = 0
|
||||
var _debug_last_transform_xz: Vector2 = Vector2.INF
|
||||
var _debug_trace_frame: int = 0
|
||||
|
||||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||
|
||||
|
|
@ -52,12 +67,16 @@ func _ready() -> void:
|
|||
_nav_agent.avoidance_enabled = false
|
||||
_nav_agent.set_target_position(global_position)
|
||||
floor_block_on_wall = true
|
||||
if debug_idle_trace:
|
||||
set_notify_transform(true)
|
||||
|
||||
|
||||
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
|
||||
_auth_walk_goal = world_pos
|
||||
_has_walk_goal = true
|
||||
_floor_angle_loose_ticks = 0
|
||||
_idle_anchor_active = false
|
||||
_idle_manual_correction_ticks = 0
|
||||
_nav_agent.set_target_position(world_pos)
|
||||
|
||||
|
||||
|
|
@ -65,6 +84,8 @@ func clear_nav_goal() -> void:
|
|||
velocity = Vector3.ZERO
|
||||
_has_walk_goal = false
|
||||
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
|
||||
_idle_anchor_active = false
|
||||
_idle_manual_correction_ticks = 0
|
||||
_nav_agent.set_target_position(global_position)
|
||||
|
||||
|
||||
|
|
@ -74,6 +95,8 @@ func snap_to_server(world_pos: Vector3) -> void:
|
|||
_has_walk_goal = false
|
||||
_floor_angle_loose_ticks = 0
|
||||
_step_assist_cooldown = 0
|
||||
_idle_anchor_active = false
|
||||
_idle_manual_correction_ticks = 0
|
||||
_nav_agent.set_target_position(world_pos)
|
||||
reset_physics_interpolation()
|
||||
|
||||
|
|
@ -154,12 +177,133 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void:
|
|||
velocity.y = 0.0
|
||||
|
||||
|
||||
static func capsule_feet_y(body_origin_y: float, capsule_half_height: float) -> float:
|
||||
return body_origin_y - capsule_half_height
|
||||
|
||||
|
||||
static func vertical_arrival_error(
|
||||
goal_y: float, body_origin_y: float, capsule_half_height: float
|
||||
) -> float:
|
||||
return absf(goal_y - capsule_feet_y(body_origin_y, capsule_half_height))
|
||||
|
||||
|
||||
func _physics_idle_tick() -> void:
|
||||
velocity = Vector3.ZERO
|
||||
floor_snap_length = FLOOR_SNAP_IDLE
|
||||
move_and_slide()
|
||||
|
||||
|
||||
static func idle_support_is_stable(
|
||||
on_floor: bool, floor_normal: Vector3, _slide_normals: Array[Vector3], loose_ticks: int
|
||||
) -> bool:
|
||||
## `_slide_normals` is reserved for future extra-contact analysis.
|
||||
if not on_floor or loose_ticks > 0:
|
||||
return false
|
||||
if floor_normal.dot(Vector3.UP) < STABLE_IDLE_FLOOR_MIN_UP_DOT:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _current_slide_normals() -> Array[Vector3]:
|
||||
var normals: Array[Vector3] = []
|
||||
for i: int in get_slide_collision_count():
|
||||
normals.append(get_slide_collision(i).get_normal())
|
||||
return normals
|
||||
|
||||
|
||||
func _stable_idle_support() -> bool:
|
||||
return idle_support_is_stable(
|
||||
is_on_floor(), get_floor_normal(), _current_slide_normals(), _floor_angle_loose_ticks
|
||||
)
|
||||
|
||||
|
||||
func _hold_idle_anchor() -> void:
|
||||
var pos := global_position
|
||||
if not _idle_anchor_active:
|
||||
_idle_anchor_xz = Vector2(pos.x, pos.z)
|
||||
_idle_anchor_active = true
|
||||
return
|
||||
global_position = Vector3(_idle_anchor_xz.x, pos.y, _idle_anchor_xz.y)
|
||||
|
||||
|
||||
func _debug_trace_idle_state(tag: String) -> void:
|
||||
if not debug_idle_trace or _has_walk_goal:
|
||||
_debug_idle_heartbeat = 0
|
||||
_debug_last_idle_xz = Vector2.INF
|
||||
return
|
||||
var pos_xz := Vector2(global_position.x, global_position.z)
|
||||
var should_log: bool = false
|
||||
if _debug_last_idle_xz == Vector2.INF:
|
||||
should_log = true
|
||||
elif pos_xz.distance_to(_debug_last_idle_xz) > DEBUG_IDLE_TRACE_DELTA_EPS:
|
||||
should_log = true
|
||||
elif _debug_idle_heartbeat >= DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES:
|
||||
should_log = true
|
||||
if should_log:
|
||||
push_warning(
|
||||
(
|
||||
(
|
||||
"NEON-16 idle trace [%s] pos=%s vel=%s on_floor=%s loose=%d "
|
||||
+ "stable=%s anchor=%s anchor_xz=%s floor=%s slides=%s"
|
||||
)
|
||||
% [
|
||||
tag,
|
||||
global_position,
|
||||
velocity,
|
||||
is_on_floor(),
|
||||
_floor_angle_loose_ticks,
|
||||
_stable_idle_support(),
|
||||
_idle_anchor_active,
|
||||
_idle_anchor_xz,
|
||||
get_floor_normal(),
|
||||
_current_slide_normals(),
|
||||
]
|
||||
)
|
||||
)
|
||||
_debug_last_idle_xz = pos_xz
|
||||
_debug_idle_heartbeat = 0
|
||||
else:
|
||||
_debug_idle_heartbeat += 1
|
||||
|
||||
|
||||
func _debug_trace_transform(tag: String) -> void:
|
||||
if not debug_idle_trace:
|
||||
return
|
||||
var pos_xz := Vector2(global_position.x, global_position.z)
|
||||
if (
|
||||
_debug_last_transform_xz != Vector2.INF
|
||||
and pos_xz.distance_to(_debug_last_transform_xz) < DEBUG_TRANSFORM_TRACE_DELTA_EPS
|
||||
and tag != "physics"
|
||||
):
|
||||
return
|
||||
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"
|
||||
)
|
||||
% [
|
||||
tag,
|
||||
_debug_trace_frame,
|
||||
global_position,
|
||||
velocity,
|
||||
_has_walk_goal,
|
||||
is_on_floor(),
|
||||
_floor_angle_loose_ticks,
|
||||
_stable_idle_support(),
|
||||
_idle_anchor_active,
|
||||
_idle_anchor_xz,
|
||||
]
|
||||
)
|
||||
)
|
||||
_debug_last_transform_xz = pos_xz
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_TRANSFORM_CHANGED:
|
||||
_debug_trace_transform("notify")
|
||||
|
||||
|
||||
func _idle_ridged_floor_contacts() -> bool:
|
||||
var upish := 0
|
||||
var wallish := 0
|
||||
|
|
@ -172,14 +316,14 @@ func _idle_ridged_floor_contacts() -> bool:
|
|||
return upish >= 1 and wallish >= 1
|
||||
|
||||
|
||||
func _maybe_idle_rim_settle_nudge() -> void:
|
||||
func _maybe_idle_rim_settle_nudge() -> bool:
|
||||
if get_slide_collision_count() < 1:
|
||||
return
|
||||
return false
|
||||
var shallow_floor: bool = (
|
||||
is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
|
||||
)
|
||||
if not shallow_floor and not _idle_ridged_floor_contacts():
|
||||
return
|
||||
return false
|
||||
var h := Vector3.ZERO
|
||||
if shallow_floor:
|
||||
h = Vector3(get_floor_normal().x, 0.0, get_floor_normal().z)
|
||||
|
|
@ -191,14 +335,15 @@ func _maybe_idle_rim_settle_nudge() -> void:
|
|||
if nh.length_squared() > h.length_squared():
|
||||
h = nh
|
||||
if h.length_squared() < 1e-12:
|
||||
return
|
||||
return false
|
||||
var step: Vector3 = -h.normalized() * IDLE_RIM_SETTLE_STEP
|
||||
if test_move(global_transform, step, null, safe_margin):
|
||||
return
|
||||
return false
|
||||
global_position += step
|
||||
return true
|
||||
|
||||
|
||||
func _maybe_idle_bump_proximity_escape() -> void:
|
||||
func _maybe_idle_bump_proximity_escape() -> bool:
|
||||
var feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT
|
||||
for node: Node in get_tree().get_nodes_in_group(
|
||||
BUMP_COLLISION_CONSTS_SCRIPT.RANDOM_FLOOR_BUMP_MESH_GROUP
|
||||
|
|
@ -233,7 +378,8 @@ func _maybe_idle_bump_proximity_escape() -> void:
|
|||
if d > r_mesh * 0.9 and d < col_r + 0.32:
|
||||
if not test_move(global_transform, away, null, safe_margin):
|
||||
global_position += away
|
||||
return
|
||||
return true
|
||||
return false
|
||||
# On / above the disc, hugging the **visual** rim — step onto open slab.
|
||||
if (
|
||||
feet_y >= bottom_y - 0.06
|
||||
|
|
@ -243,7 +389,8 @@ func _maybe_idle_bump_proximity_escape() -> void:
|
|||
):
|
||||
if not test_move(global_transform, away, null, safe_margin):
|
||||
global_position += away
|
||||
return
|
||||
return true
|
||||
return false
|
||||
# Beside vertical cylinder: axis distance may exceed **`col_r`** while capsule still touches wall.
|
||||
if (
|
||||
feet_y <= top_y + 0.14
|
||||
|
|
@ -253,17 +400,30 @@ func _maybe_idle_bump_proximity_escape() -> void:
|
|||
):
|
||||
if not test_move(global_transform, away, null, safe_margin):
|
||||
global_position += away
|
||||
return
|
||||
return true
|
||||
return false
|
||||
return false
|
||||
|
||||
|
||||
func _apply_idle_manual_correction() -> bool:
|
||||
# Prefer explicit bump-rim escape over the generic shallow-floor settle so spawned
|
||||
# cylinder edges resolve radially off the lip instead of orbiting around it.
|
||||
if _maybe_idle_bump_proximity_escape():
|
||||
return true
|
||||
return _maybe_idle_rim_settle_nudge()
|
||||
|
||||
|
||||
func _snap_capsule_upright() -> void:
|
||||
# TODO(NS-24 follow-on): With facing yaw, clear pitch/roll only; keep Y rotation.
|
||||
# Full **`Basis.IDENTITY`** would erase look direction.
|
||||
if global_transform.basis.orthonormalized().is_equal_approx(Basis.IDENTITY):
|
||||
return
|
||||
var p: Vector3 = global_position
|
||||
global_transform = Transform3D(Basis.IDENTITY, p)
|
||||
|
||||
|
||||
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
|
||||
if not _has_walk_goal:
|
||||
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
|
||||
|
|
@ -274,42 +434,75 @@ func _physics_process(_delta: float) -> void:
|
|||
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
|
||||
)
|
||||
if not _has_walk_goal:
|
||||
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("stable")
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
if _idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS:
|
||||
velocity = Vector3.ZERO
|
||||
floor_snap_length = FLOOR_SNAP_IDLE
|
||||
if _floor_angle_loose_ticks > 0:
|
||||
_floor_angle_loose_ticks -= 1
|
||||
_hold_idle_anchor()
|
||||
_debug_trace_idle_state("budgeted")
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
_idle_anchor_active = false
|
||||
_physics_idle_tick()
|
||||
_maybe_idle_rim_settle_nudge()
|
||||
_maybe_idle_bump_proximity_escape()
|
||||
if _apply_idle_manual_correction():
|
||||
_idle_manual_correction_ticks += 1
|
||||
else:
|
||||
_idle_manual_correction_ticks = 0
|
||||
if _floor_angle_loose_ticks > 0:
|
||||
_floor_angle_loose_ticks -= 1
|
||||
_debug_trace_idle_state("corrective")
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
|
||||
var full_to_goal: Vector3 = _auth_walk_goal - global_position
|
||||
var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length()
|
||||
var vert_err: float = absf(full_to_goal.y)
|
||||
var vert_err: float = vertical_arrival_error(
|
||||
_auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT
|
||||
)
|
||||
if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS:
|
||||
velocity = Vector3.ZERO
|
||||
_has_walk_goal = false
|
||||
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
|
||||
_idle_anchor_active = false
|
||||
_idle_manual_correction_ticks = 0
|
||||
_nav_agent.set_target_position(global_position)
|
||||
_physics_idle_tick()
|
||||
_maybe_idle_rim_settle_nudge()
|
||||
_maybe_idle_bump_proximity_escape()
|
||||
if _apply_idle_manual_correction():
|
||||
_idle_manual_correction_ticks += 1
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
|
||||
_idle_manual_correction_ticks = 0
|
||||
|
||||
var nav_map: RID = _nav_agent.get_navigation_map()
|
||||
if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
|
||||
_set_horizontal_velocity_toward(_auth_walk_goal)
|
||||
floor_snap_length = FLOOR_SNAP_MOVING
|
||||
move_and_slide()
|
||||
_after_walk_move_and_slide()
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
|
||||
if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN:
|
||||
if _auth_walk_goal.y < capsule_feet_y(global_position.y, CAPSULE_HALF_HEIGHT) - DESCEND_GOAL_Y_MARGIN:
|
||||
_set_horizontal_velocity_toward(_auth_walk_goal)
|
||||
floor_snap_length = FLOOR_SNAP_MOVING
|
||||
move_and_slide()
|
||||
_after_walk_move_and_slide()
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
return
|
||||
|
||||
|
|
@ -328,4 +521,5 @@ func _physics_process(_delta: float) -> void:
|
|||
floor_snap_length = FLOOR_SNAP_MOVING
|
||||
move_and_slide()
|
||||
_after_walk_move_and_slide()
|
||||
_debug_trace_transform("physics")
|
||||
_snap_capsule_upright()
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ func test_snap_to_server_resets_goal_position_and_velocity() -> void:
|
|||
|
||||
func test_set_authoritative_nav_goal_sets_goal_and_target() -> void:
|
||||
var p := _make_player()
|
||||
p.set("_idle_anchor_active", true)
|
||||
var goal := Vector3(10.0, 0.0, 3.0)
|
||||
p.set_authoritative_nav_goal(goal)
|
||||
assert_that(p.get("_has_walk_goal")).is_true()
|
||||
assert_that(p.get("_idle_anchor_active")).is_false()
|
||||
assert_that(p.get("_auth_walk_goal")).is_equal(goal)
|
||||
var nav: NavigationAgent3D = p.get_node("NavigationAgent3D") as NavigationAgent3D
|
||||
assert_that(nav.target_position).is_equal(goal)
|
||||
|
|
@ -39,5 +41,114 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
|
|||
p.clear_nav_goal()
|
||||
assert_that(p.velocity).is_equal(Vector3.ZERO)
|
||||
assert_that(p.get("_has_walk_goal")).is_false()
|
||||
assert_that(p.get("_idle_anchor_active")).is_false()
|
||||
var nav: NavigationAgent3D = p.get_node("NavigationAgent3D") as NavigationAgent3D
|
||||
assert_that(nav.target_position).is_equal(p.global_position)
|
||||
|
||||
|
||||
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = load("res://scripts/player.gd").idle_support_is_stable(
|
||||
true,
|
||||
Vector3.UP,
|
||||
slide_normals,
|
||||
0
|
||||
)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = load("res://scripts/player.gd").idle_support_is_stable(
|
||||
true,
|
||||
Vector3(0.02, 0.995, 0.0).normalized(),
|
||||
slide_normals,
|
||||
0
|
||||
)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()]
|
||||
var stable: bool = load("res://scripts/player.gd").idle_support_is_stable(
|
||||
true,
|
||||
Vector3.UP,
|
||||
slide_normals,
|
||||
0
|
||||
)
|
||||
assert_that(stable).is_true()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_loose_ticks_active() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = load("res://scripts/player.gd").idle_support_is_stable(
|
||||
true,
|
||||
Vector3.UP,
|
||||
slide_normals,
|
||||
12
|
||||
)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_idle_support_is_stable_false_when_not_on_floor() -> void:
|
||||
var slide_normals: Array[Vector3] = [Vector3.UP]
|
||||
var stable: bool = load("res://scripts/player.gd").idle_support_is_stable(
|
||||
false,
|
||||
Vector3.UP,
|
||||
slide_normals,
|
||||
0
|
||||
)
|
||||
assert_that(stable).is_false()
|
||||
|
||||
|
||||
func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
|
||||
var p := _make_player()
|
||||
p.global_position = Vector3(1.0, 0.5, 2.0)
|
||||
p.call("_hold_idle_anchor")
|
||||
assert_that(p.get("_idle_anchor_active")).is_true()
|
||||
assert_that(p.get("_idle_anchor_xz")).is_equal(Vector2(1.0, 2.0))
|
||||
|
||||
p.global_position = Vector3(1.2, 0.7, 2.3)
|
||||
p.call("_hold_idle_anchor")
|
||||
assert_that(p.global_position).is_equal(Vector3(1.0, 0.7, 2.0))
|
||||
|
||||
|
||||
func test_snap_to_server_clears_idle_anchor() -> void:
|
||||
var p := _make_player()
|
||||
p.set("_idle_anchor_active", true)
|
||||
p.set("_idle_anchor_xz", Vector2(9.0, 9.0))
|
||||
p.snap_to_server(Vector3(3.0, 0.9, 4.0))
|
||||
assert_that(p.get("_idle_anchor_active")).is_false()
|
||||
|
||||
|
||||
func test_capsule_feet_y_uses_body_origin_minus_half_height() -> void:
|
||||
var script := load("res://scripts/player.gd")
|
||||
var feet_y: float = script.capsule_feet_y(0.545678, 0.5)
|
||||
assert_that(absf(feet_y - 0.045678)).is_less(0.000001)
|
||||
|
||||
|
||||
func test_vertical_arrival_error_uses_capsule_feet_height() -> void:
|
||||
var script := load("res://scripts/player.gd")
|
||||
var goal_y := 0.045678
|
||||
var body_origin_y := 0.545678
|
||||
var err: float = script.vertical_arrival_error(goal_y, body_origin_y, 0.5)
|
||||
assert_that(absf(err)).is_less(0.000001)
|
||||
|
||||
|
||||
func test_vertical_arrival_error_stays_large_for_origin_height_only_match() -> void:
|
||||
var script := load("res://scripts/player.gd")
|
||||
var goal_y := 0.545678
|
||||
var body_origin_y := 0.545678
|
||||
var err: float = script.vertical_arrival_error(goal_y, body_origin_y, 0.5)
|
||||
assert_that(absf(err - 0.5)).is_less(0.000001)
|
||||
|
||||
|
||||
func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
|
||||
var script := load("res://scripts/player.gd")
|
||||
var goal_y := 0.0
|
||||
var body_origin_y := 0.4
|
||||
var descend_margin := script.get("DESCEND_GOAL_Y_MARGIN") as float
|
||||
var below_feet_margin: bool = (
|
||||
goal_y < script.capsule_feet_y(body_origin_y, 0.5) - descend_margin
|
||||
)
|
||||
assert_that(below_feet_margin).is_false()
|
||||
|
|
|
|||
|
|
@ -12,64 +12,83 @@
|
|||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** **Stable rest pose** — no perceptible micro-vibration when horizontal velocity is zero, there is no active walk goal, and the avatar stands on walkable geometry, including prototype floors and **NEON-7** stepped bumps.
|
||||
**Goal:** Re-open **NEON-16** to restore a **stable rest pose** after later camera work made the prototype jitter worse: no perceptible idle vibration on flat walkable ground, and no continuous **x/z** drift while the avatar has no active walk goal.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Diagnose and fix (or materially reduce) idle jitter on the prototype **`CharacterBody3D`** player driven by **`player.gd`** (`move_and_slide`, floor interaction, **`NavigationAgent3D`** coupling as relevant).
|
||||
- Reproduce the current regression in the prototype scene and separate **true player transform drift** from **camera presentation amplification**.
|
||||
- Diagnose and fix idle jitter in the prototype **`CharacterBody3D`** player driven by **`player.gd`** and/or the follow rig in **`isometric_follow_camera.gd`** when evidence shows the camera is making tiny corrections read as larger visible jitter.
|
||||
- Preserve **NEON-8** behavior: walking, arrival at goal, **`snap_to_server`** on boot, descend bypass tradeoff, and **NEON-7** validation UX on the server side (client must not regress those flows).
|
||||
- Document non-obvious fixes in **`client/README.md`** and/or the **`player.gd`** header (per Jira).
|
||||
- Add or update focused **GdUnit** coverage for any new pure helper or guard logic introduced by the fix.
|
||||
- Document non-obvious fixes in **`client/README.md`** and the living plan if the final solution changes the anti-jitter pattern or camera integration expectations.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Full prediction/reconciliation, server-side nav, or redesign of **MoveCommand** / **PositionAuthorityClient** contracts.
|
||||
- Replacing the capsule with a different locomotion stack unless a minimal inspector tweak is sufficient.
|
||||
- Replacing the locomotion stack or redesigning the isometric follow camera beyond the smallest anti-jitter hardening needed for this regression.
|
||||
- Product decisions about enabling camera yaw/orbit; this story only hardens the fixed-yaw prototype behavior.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] At rest (no click-to-move goal, feet on floor), **no visible vibration** in the default prototype scene for **several seconds** of observation.
|
||||
- [x] **Walking**, **arrival**, and **`snap_to_server`** still behave correctly; **no regression** on NEON-7 bumps or the **NEON-8** obstacle / descend-bypass tradeoff documented in **`client/README.md`**.
|
||||
- [x] If a **scene** or **project** setting change is required, it is **checked in** and called out for designers (README or short comment in scene notes).
|
||||
- [ ] At rest (no click-to-move goal, feet on floor), **no visible vibration** in the default prototype scene for **several seconds** of observation on flat ground, and no measurable-looking **x/z** drift at idle.
|
||||
- [ ] **Walking**, **arrival**, and **`snap_to_server`** still behave correctly; **no regression** on NEON-7 bumps or the **NEON-8** obstacle / descend-bypass tradeoff documented in **`client/README.md`**.
|
||||
- [ ] If camera-follow hardening is required, it does **not** introduce new visible lag, framing drift, or occlusion regressions in the current E1.M2 stack.
|
||||
- [ ] If a **scene** or **project** setting change is required, it is **checked in** and called out for designers (README or short comment in scene notes).
|
||||
|
||||
## Resolution (shipped)
|
||||
## Resolution
|
||||
|
||||
**Jolt Physics** + **`physics_interpolation` off**; QA bumps on **separate `StaticBody3D`** bodies (no floor+bump compound internal edges); idle **`floor_max_angle`** loosened on rim/straddle; **rim settle** + **`random_floor_bump_mesh`** lip/wall escape; **`NavigationAgent3D.avoidance_enabled`** false, **`floor_block_on_wall`** true; **no `global_transform` in `_process`**; **visual** capsule **mesh Y lift** vs collision to reduce **z-fight** with bump tops. Shared bump collider fudge: **`client/scripts/random_floor_bump_collision_constants.gd`**.
|
||||
**Re-opened 2026-04-09 after NEON-25/26/27/30 follow-camera work:** the earlier NEON-16 mitigation shipped, but testing now reports worse visible idle jitter even on a flat surface. The current branch treats that as **real idle correction noise first**, not purely a camera problem.
|
||||
|
||||
**Implemented on this branch:** when the capsule already has **stable flat support** (floor normal effectively upright and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle **`move_and_slide()`** / rim-escape loop entirely and keeps the body parked. The flat-idle classification was broadened so incidental extra contacts on an otherwise flat support do not re-enable the old correction loop. Stable grounded idle also keeps an explicit **x/z anchor**, clamping away any residual horizontal creep until movement resumes or support is no longer classified as stable. Goal arrival and the descend-bypass check now both measure against the capsule **feet height** instead of the body origin, which stops same-height floor clicks near obstacles from dropping into the old straight-line wall push. **`_snap_capsule_upright()`** also avoids rewriting **`global_transform`** when the basis is already identity, reducing needless transform churn during idle.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Reproduce and isolate** in Godot 4.x editor: Remote **Transform** on the player (and camera parent if suspected), **Visible Collision Shapes**, and frame / physics profiler to see whether jitter is position, floor snap, or render-only.
|
||||
2. **CharacterBody3D / physics** — iterate on documented levers: **`move_and_slide`** at idle with **`floor_snap_length = 0`**; **`safe_margin`**; compound **`Floor`** collision; **`physics/3d/physics_engine`** (**Jolt Physics** vs Godot Physics — Jolt targets internal-edge / compound-shape jitter); **`floor_stop_on_slope`**, **`motion_mode`**, **`up_direction`** if still needed.
|
||||
3. **Navigation agent** — confirm **`NavigationAgent3D`** target updates at rest (`set_target_position(global_position)` on clear/arrive) do not trigger path churn each frame; adjust only if measurements show agent-driven motion.
|
||||
4. **Camera / parenting** — rule out camera or child node noise if transform root is stable.
|
||||
5. **Ship** the smallest change that clears the AC (code, scene, or project), then update **`client/README.md`** to remove or narrow the “Known issue” idle jitter line and describe the chosen pattern.
|
||||
1. **Reproduce and isolate** in Godot 4.x editor: Remote **Transform** on the player and camera rig, **Visible Collision Shapes**, and short idle observation on flat ground first, then random bumps, to determine whether the player root is really moving in **x/z**.
|
||||
2. **CharacterBody3D / physics** — inspect the current idle path in **`player.gd`**: idle **`move_and_slide()`**, rim settle, bump proximity escape, and upright snap. Prevent continuous idle correction on stable flat contacts while preserving the original bump / rim escape behavior that NEON-16 added for rougher geometry.
|
||||
3. **Camera / parenting** — if the player still only moves by tiny amounts, harden **`isometric_follow_camera.gd`** so the rig does not magnify sub-threshold target motion. Preferred direction: smooth or deadzone the **focus** used by **`desired_eye_world()`**, **`look_at()`**, and occlusion instead of smoothing only the eye position.
|
||||
4. **Navigation agent** — confirm **`NavigationAgent3D`** rest targets (`set_target_position(global_position)` on clear/arrive) are not reintroducing path churn that feeds the idle correction loop.
|
||||
5. **Ship** the smallest change that clears the AC, then update **`client/README.md`** and related docs with the final anti-jitter pattern and verification notes.
|
||||
|
||||
**Shipped note:** Idle uses **non-zero** **`floor_snap_length`** (see **`player.gd`**) rather than zero snap from the investigation list; straddle/rim logic and bump geometry split were the main levers. Code review: **`docs/reviews/2026-04-05-NEON-16.md`**.
|
||||
## Decisions
|
||||
|
||||
| Topic | Choice | Rationale |
|
||||
|------|---------|-----------|
|
||||
| **Regression framing** | Treat this as a **re-opened NEON-16** fix, not a brand new camera story. | Jira already tracks idle vibration, and the new symptom is the same bug surface with later camera work as a likely amplifier. |
|
||||
| **Debug order** | Prove whether **player transform drift** exists before shipping a camera-only mitigation. | A camera-only band-aid could hide a real **`CharacterBody3D`** rest-state bug. |
|
||||
| **Camera hardening scope** | Only harden follow behavior if player-side cleanup is insufficient or if raw focus tracking is clearly amplifying tiny motion. | Keeps the fix minimal and avoids reopening broader E1.M2 follow-camera design work. |
|
||||
| **Flat idle strategy** | Detect **stable flat support** and skip corrective idle movement in that state. | The regression report is specifically about persistent idle **x/z** drift on a flat surface, where the old rim/bump correction loop should not be necessary. |
|
||||
| **Stable support classification** | Let a strongly upright floor normal win even when physics reports incidental extra contacts. | Flat supports were still falling back into the correction path when side contacts were reported even though the capsule should have been considered settled. |
|
||||
| **Residual drift containment** | Keep a stable-grounded idle **x/z anchor** and restore it each frame while idle remains stable. | Even if the exact remaining source of horizontal creep is hard to reproduce in headless runs, this guarantees flat idle does not visibly wander once the player has settled. |
|
||||
| **Descend bypass framing** | Treat “below the player” using the capsule **feet**, not the body origin. | Floor picks on the same slab always sit below the capsule origin, which was incorrectly forcing direct steering into obstacle walls and reintroducing jitter after contact. |
|
||||
| **Upright snap writes** | Skip the upright rewrite when the basis is already **`Basis.IDENTITY`**. | Avoids touching **`global_transform`** every idle frame when there is nothing to correct. |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `client/scripts/random_floor_bump_collision_constants.gd` | Single source for QA bump **`CylinderShape3D`** radius fudge vs mesh and **`RANDOM_FLOOR_BUMP_MESH_GROUP`** (shared by **`random_floor_bumps.gd`** and **`player.gd`** lip escape). |
|
||||
| None. | This follow-up should fit in existing movement, camera, test, and documentation files. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/player.gd` | Idle **`move_and_slide`** policy, walking snap, arrival path, bump lip escape (imports **`random_floor_bump_collision_constants.gd`**). |
|
||||
| `client/scenes/main.tscn` | Player **`CharacterBody3D`** / **`NavigationAgent3D`** inspector properties, collision layers, or nav region only if diagnosis requires it (AC: designer-visible changes documented). |
|
||||
| `client/project.godot` | Physics or project defaults (e.g. physics ticks, 3D defaults) only if required and safe for the whole project. |
|
||||
| `client/README.md` | Replace **Known issue: idle / rest jitter** with the fix summary and any manual verification deltas. |
|
||||
| `client/scripts/player.gd` | Primary idle-motion diagnosis and fix: idle **`move_and_slide`** policy, rim/bump escape guards, and any extracted helper logic used to distinguish stable floor contact from real corrective motion. |
|
||||
| `client/test/player_test.gd` | Add regression coverage for any new idle guard/helper logic added to **`player.gd`**. |
|
||||
| `client/README.md` | Update the documented anti-jitter pattern and manual verification steps once the final fix is known. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Action | What to cover |
|
||||
|--------|----------------|
|
||||
| **Manual (required)** | Run main scene with server as in **NEON-4/NEON-8** README steps: stand idle **10+ s** at spawn and after a move; click-move including **stepped bumps** (NEON-7 props); cold boot **`snap_to_server`**; far/vertical rejects still show NEON-7 UX; optional multi-click around **`Obstacle`** per NEON-8 tradeoff. |
|
||||
| **Automated** | **None** — no Godot/GDScript test harness in repo; server **xUnit** suites unchanged unless an unrelated regression appears (not expected). |
|
||||
| **Manual (required)** | Run main scene with server as in **NEON-4/NEON-8** README steps: stand idle **10+ s** at spawn on flat ground, stand idle again after a move, idle on the random green bumps, click-move including **stepped bumps** (NEON-7 props), cold boot **`snap_to_server`**, far/vertical rejects, and NEON-30 occluder click-through sanity. |
|
||||
| **Automated** | Updated **`client/test/player_test.gd`** with stable-idle plus capsule-feet helper coverage. Headless GdUnit run: **52 tests, 0 errors, 0 failures** via Godot **4.6** and **`res://addons/gdUnit4/bin/GdUnitCmdTool.gd`**. One-off headless idle probe against **`main.tscn`** observed **`x_range=0`** and **`z_range=0`** across a **240-physics-frame** idle sample after settle. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
**Resolved for prototype QA:** Primary causes were **compound internal edges** (mitigated by per-bump bodies), **physics/render** artifacts (**interpolation**, **`CharacterBody3D`** transform desync, **z-fight**), and **rim/wall** contacts (mitigated by idle policy + small escapes). **Remaining:** **`_snap_capsule_upright()`** uses full **identity basis** — when **yaw** facing is added, preserve **Y** rotation only (see **`TODO`** in **`player.gd`**). Moving platforms / uneven nav mesh not validated.
|
||||
- The current regression report says jitter is now worse **even on flat ground**, so the earlier assumption that the problem was mainly bump/rim contact may no longer be sufficient.
|
||||
- A player-only fix may still leave visible follow jitter if the camera continues to aim at raw focus each render frame.
|
||||
- A camera-only fix may hide real **`CharacterBody3D`** drift and make future movement regressions harder to see.
|
||||
- **`_snap_capsule_upright()`** still uses full **identity basis** — when **yaw** facing is added, preserve **Y** rotation only (see **`TODO`** in **`player.gd`**). Moving platforms / uneven nav mesh not validated.
|
||||
|
||||
## PR / review
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground
|
|||
|
||||
## Blocking issues
|
||||
|
||||
1. **`@export var debug_idle_trace` declaration order violates `gdlint` `class-definitions-order` (CI will fail).**
|
||||
1. ~~**`@export var debug_idle_trace` declaration order violates `gdlint` `class-definitions-order` (CI will fail).**~~ Done. Moved `@export var debug_idle_trace` to the const/export block in `client/scripts/player.gd` so it now precedes all non-export vars.
|
||||
|
||||
`@export` vars must appear *before* public and private vars. Currently `debug_idle_trace` is on line 56, sandwiched between private vars that start on line 49:
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground
|
|||
|
||||
Move `@export var debug_idle_trace` to immediately after the last `const` block (before any `var` declarations), or at minimum before the first `var _…` line, so it precedes all non-export vars per gdlint order: `const → @export vars → public vars → private vars`.
|
||||
|
||||
2. **`vertical_arrival_error` function signature is 109 characters — exceeds the 100-char `gdlint` `max-line-length` limit (CI will fail).**
|
||||
2. ~~**`vertical_arrival_error` function signature is 109 characters — exceeds the 100-char `gdlint` `max-line-length` limit (CI will fail).**~~ Done. Wrapped the signature across multiple lines in `client/scripts/player.gd` to satisfy `max-line-length`.
|
||||
|
||||
```gdscript
|
||||
# 109 chars — too long
|
||||
|
|
@ -68,7 +68,7 @@ This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground
|
|||
|
||||
## Suggestions
|
||||
|
||||
1. **`set_notify_transform(true)` is always enabled regardless of `debug_idle_trace`.**
|
||||
1. ~~**`set_notify_transform(true)` is always enabled regardless of `debug_idle_trace`.**~~ Done. `client/scripts/player.gd` now only registers transform-change notifications in `_ready()` when `debug_idle_trace` is enabled.
|
||||
`_notification(NOTIFICATION_TRANSFORM_CHANGED)` is invoked by the engine on every transform write (physics, snap, anchor hold) in both debug and release builds. The callback itself bails quickly when `debug_idle_trace` is false, so functional correctness is fine, but the notification dispatch fires unconditionally at 120 TPS. Consider gating the registration:
|
||||
|
||||
```gdscript
|
||||
|
|
@ -78,7 +78,7 @@ This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground
|
|||
|
||||
Or enable it only in debug builds (`OS.is_debug_build()`). At two bumps and one player this overhead is negligible, but the pattern will scale poorly if the per-node notification cost is revisited later.
|
||||
|
||||
2. **`idle_support_is_stable`: the `slide_normals` parameter is silently ignored via `@warning_ignore` + no-op self-assignment.**
|
||||
2. ~~**`idle_support_is_stable`: the `slide_normals` parameter is silently ignored via `@warning_ignore` + no-op self-assignment.**~~ Done. Renamed the parameter to `_slide_normals` and replaced the no-op assignment with a brief reserved-for-future comment in `client/scripts/player.gd`.
|
||||
The intent (reserve for future extra-contact analysis) is reasonable, but the current pattern is easy to misread:
|
||||
|
||||
```gdscript
|
||||
|
|
@ -92,10 +92,10 @@ This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground
|
|||
|
||||
Either drop the parameter until it is used (callers pass `_current_slide_normals()` which allocates a new array each call), or add a `##` doc comment explicitly noting "reserved — not yet evaluated". If the parameter is intentionally kept for API-stability reasons, a `@warning_ignore` on the declaration rather than a no-op assignment is cleaner in GDScript.
|
||||
|
||||
3. **`set_authoritative_nav_goal` clears `_idle_anchor_active` but has no test coverage for that invariant.**
|
||||
3. ~~**`set_authoritative_nav_goal` clears `_idle_anchor_active` but has no test coverage for that invariant.**~~ Done. `client/test/player_test.gd` now seeds `_idle_anchor_active` before `set_authoritative_nav_goal()` and asserts the flag is cleared.
|
||||
The new test `test_snap_to_server_clears_idle_anchor` covers `snap_to_server`, and `test_clear_nav_goal_clears_velocity_and_nav_target` checks `_idle_anchor_active`. The same assertion for `set_authoritative_nav_goal` is absent; a one-liner would close the gap.
|
||||
|
||||
4. **`_floor_angle_loose_ticks` is never decremented in the "budgeted" anchor path.**
|
||||
4. ~~**`_floor_angle_loose_ticks` is never decremented in the "budgeted" anchor path.**~~ Done. Added the missing decrement to the budgeted idle-anchor branch in `client/scripts/player.gd`.
|
||||
When `_idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS` the code holds the anchor and returns early — skipping the decrement that only lives in the corrective branch. In practice the player is already anchored so the loose angle has no visible effect, but arrival with bumps could hold `FLOOR_MAX_ANGLE_MOVING_DEG` longer than intended if the budget is hit quickly. Consider adding a decrement in the budgeted path:
|
||||
|
||||
```gdscript
|
||||
|
|
|
|||
Loading…
Reference in New Issue