Relax floor_block whenever feet are >7 cm above a lower goal — stall/wall was too
late for step1→floor. Re-tighten only in the last centimeters (wallish/stall).
Apply DESCEND_WEAK_PEEL_MULT on_floor while descending every frame; ramp to
DESCEND_LIP_GRAVITY_MULT when stalled/wallish/carry. Skip peel when airborne
(full gravity from _apply_walk_air_gravity).
Always-on gravity while on_floor and descending made platform→floor feel glued
(slow vertical creep, no horizontal until landing). Remove that; use 7x gravity
only when lip_stall>0, wallish slide, or prior move left low horizontal speed
while still far from goal (carry_lip_from_prior_move).
Track _descend_lip_stall_ticks after descend/nomap moves; reset on new goal,
arrival, snap, clear_nav.
Relax floor_block_on_wall when goal is above feet and wallish (floor→step) or
goal below and (wallish or stall) so slides can clear terrace lips.
Reset floor_block_on_wall when idle or arrival.
Wall/slide heuristics missed many step→floor stalls (e.g. far floor clicks).
While descending with is_on_floor, always add gravity to vy before move_and_slide.
Cap floor_snap_length when feet stay meaningfully above the goal so strong snap
cannot yank the capsule back onto the step internal edge; cap lifts near landing
(feet within 8 cm of goal Y). Apply snap helper on nomap + descend branches.
First terrace step to main floor could stall: is_on_floor stayed true on the
step top while a vertical face zeroed horizontal motion; vy was reset to 0 so
no gravity ran. Taller drops broke contact sooner so second→floor worked.
While descending (goal below feet) and on floor, if is_on_wall or any slide
normal is wall-ish (ny < 0.55), integrate gravity into vy before move_and_slide.
_physics_idle_tick zeroed velocity with no gravity, so after walking off a
terrace or arriving slightly above the floor FLOOR_SNAP_IDLE could not reach
the ground and the body hovered until a new walk goal. Apply get_gravity()*delta
and FLOOR_SNAP_MOVING while !is_on_floor(). Budgeted idle branch now runs the
same physics when airborne instead of returning without move_and_slide.
- _set_horizontal_velocity_toward: optional fallback xz when delta.xz ~ 0 (avoids +X hack).
- Direct-approach branch: if horiz_dist to goal is ~0 but not arrived vertically, steer
using the first navigation path point with horizontal separation.
- Apply get_gravity()*delta while walking and not on_floor so capsules land and Jolt
registers floor contacts.
- Set Player collision_mask=3 explicitly; README collision bullet updated.
Player uses collision_layer 2 so camera pick rays (mask 1) skip the capsule.
Static bodies defaulted to collision_mask 1, so they never scanned layer 2 and
Godot produced no floor/wall contacts—walk appeared stuck with is_on_floor
false. Set collision_mask=3 on all NavigationRegion3D StaticBody3D walkables
and occluders; bumps inherit from Floor at spawn. Document the pairing in README.
capsule_feet_y was called with CAPSULE_HALF_HEIGHT (0.5, cylinder half only),
giving a "code-feet" that is 0.4 m above the actual capsule bottom.
From the first step (body Y=1.2): code-feet = 0.7, which is above the
platform goal (Y=0.6). 0.6 < 0.64 triggered the descend bypass even though
the player needed to ascend. Each bypass tick used FLOOR_SNAP_MOVING=0.32 m,
which snapped the capsule back to the step surface (0.11 m below the
assist-lifted bottom), undoing every step assist lift in an oscillation loop.
The player appeared to float at first-step height and could not reach the
second platform.
Fix: use CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS (=0.9, the actual
capsule bottom) for the feet_y in the descend bypass condition. The bypass
now fires only when the goal surface is genuinely below the player's physical
feet, not when the player needs to ascend through stepped geometry.
Also use WALK_STEP_ASSIST_SNAP instead of FLOOR_SNAP_MOVING in the descend
bypass when _step_assist_active is true, so any remaining overlap cannot undo
a step assist lift via the large snap.
The is_on_wall() short-circuits in _step_assist_has_support() and
_step_assist_wallish_blocks() caused the assist to fire whenever the capsule
touched any wall contact — including the sides of random floor bumps while
crossing flat ground toward a distant elevated goal. After a 0.11 m lift,
floor_snap_length = 0.09 m could not reach the original floor (0.11 m away)
and _step_assist_active never cleared, so the capsule floated permanently.
Fixes:
- Remove is_on_wall() blanket short-circuit from both functions.
- _step_assist_has_support() now takes want_dir_xz and accepts wall-ish
contacts only when they oppose the direction of travel (dot < -0.2),
i.e. genuine step faces, not sideways bump contacts.
- _step_assist_wallish_blocks() relies solely on the directional collision
loop (dot < -0.04); no is_on_wall() bypass.
- _try_walk_step_assist() reordered to compute want before calling both
functions so the same direction vector is used throughout.
- Added "floating free" fallback: if _step_assist_active and no collisions
and not on floor, clear the flag so FLOOR_SNAP_MOVING restores on the
next tick. Applied to all three physics paths.
Two independent root causes kept the player from climbing terrace steps:
1. snap_to_server used the raw surface Y from the server (e.g. 0.6 m for a
platform) as global_position.y directly. With capsule total half-height
= 0.9 m this placed the bottom at -0.3 m underground. The stale
is_on_floor()==true + _floor_angle_loose_ticks==0 path skipped every
move_and_slide(), so Jolt never resolved the penetration and the capsule
froze at an intermediate Y.
Fix: clamp global_position.y >= CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS
(0.9 m) in snap_to_server; force FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
corrective idle ticks afterwards.
2. Each _try_walk_step_assist() lift (0.11 m) was immediately undone by
floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m snapping the capsule back
to the lower floor, which was only 0.11 m below the lifted bottom.
Fix: add WALK_STEP_ASSIST_SNAP = 0.09 m constant and _step_assist_active
bool. While climbing, floor_snap_length uses the smaller constant in
both _after_walk_move_and_slide() and the main physics loop. The flag
is cleared when the capsule lands cleanly (is_on_floor && !is_on_wall),
or when the nav goal is cleared/snapped to server.
Also: is_on_wall() now counts as support in _step_assist_has_support() so
the assist fires while the capsule is floating but pressed against a step
face; _step_assist_active cleared on arrival, snap, and goal-clear; all
temporary DBG print() statements removed.
The vertical arrival error was computed with CAPSULE_HALF_HEIGHT (0.5),
placing code-feet at body_y - 0.5 = 0.4 on a floor-level player.
Step surfaces at Y=0.3 are only 0.1 m away from that reference, and
Jolt can nudge the body down to ~0.8 when the bottom hemisphere contacts
the step edge, making code-feet = 0.3 and vert_err = 0 — triggering
immediate arrival while the player is still at floor level.
Fix: pass CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS (= 0.9) to
vertical_arrival_error so actual feet = body_y - 0.9 = 0.0 on the
floor. Step goals (Y=0.3) now give vert_err=0.3 >> VERT_ARRIVE_EPS,
preventing premature arrival. Arrival fires correctly once the step
assist lifts the player onto the surface (body_y=1.2, err=0).
Add two tests pinning the new pre/post-climb behaviour.
_try_walk_step_assist compared goal surface Y to the capsule centre
(~0.9 m above floor) instead of the actual capsule bottom, disabling
the assist for any surface below ~0.925 m — every terrace we added.
The lift formula used offsets 0.45/0.55 instead of the full capsule
total half-height (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS = 0.9),
producing zero/negative lift.
Fix: guard uses actual_feet_y = pos.y - CAPSULE_HALF_HEIGHT - PLAYER_CAPSULE_RADIUS;
lift targets goal.y + total_half_height via clampf. Flat floor clicks
and cylinder bumps are unaffected (guard still disabled when goal.y is
at or below true feet level).
Makes the no-horizontal-limit intent explicit in config rather than
relying on the C# class default; prevents accidental re-enabling if
the default ever changes.
Horizontal distance was never a valid rejection reason; remove references
to FarPad as a reject target and clarify that MaxVerticalStep (pillar top)
is the only remaining server rejection criterion.
Expanded 45×45 district made the 18 m MaxHorizontalStep too restrictive
(floor diagonal ~63 m). Add HorizontalStepEnabled to MovementValidationOptions
(default false, matching DistrictBoundsEnabled pattern) so the check is
skipped unless explicitly opted in. Add test for disabled case (9 pass).
Update README and plan with the decision.
Replace 20×20 floor with 45×45 (~5× area). Add three occluder
obstacles (ObstacleB/C/D) distributed across the expanded space for
camera/occlusion stress QA. Add three terrace structures (TerracePlatformA,
TerraceStepB+TerracePlatformB, TerracePlatformC) for vertical variation;
each height transition respects agent_max_climb=0.35 m so the nav mesh
routes onto all surfaces. Update README with district layout table and
designer/QA limits.
Planning doc for expanding the prototype client district to ~5×
current footprint with terraces, step geometry, and extra obstacles
for camera/nav stress QA.
- story-end.md: do not call transitionJiraIssue until user chooses In Test/Done (or states it in the same message); note MCP server plugin-atlassian-atlassian.
- AGENTS.md: story lifecycle line matches.
- main.gd: class member order (prvvars before @onready); wrap doc lines to 100 cols.
- isometric_follow_camera.gd: wrap doc lines to 100 cols.
- position_authority_client.gd: gdformat.
- pre-push: resolve .venv-gd/Scripts/gdlint.exe and gdformat.exe (Windows venv).
- Add scripts/install-git-hooks.ps1; README notes hook install and why CI can still fail.
- Input action dev_toggle_occluder_obstacle (Ctrl+Shift+K): toggle obstacle off nav source via reparent to World, rebake NavigationRegion3D, sync NavigationAgent3D; physics/process/collision hardening unchanged intent.
- Occluder dictionary keys by instance id; related client fixes and README/project input note.
- Plan and review: PARSED_GEOMETRY_BOTH / runtime geometry removal lesson for future mechanics.
Apply the repo-local gdformat output to the touched GDScript files so the pre-push formatting hook passes. This is a formatting-only follow-up to the lint-hook cleanup.
Refactor the bump escape helper and shared test script loading so the branch passes the repo's pre-push GDScript lint checks. This keeps the runtime behavior intact while unblocking push and PR creation.
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.
docs/reviews/2026-04-10-NEON-16-followup.md: Request changes — two
blocking gdlint issues (export var order, line-length on
vertical_arrival_error) plus suggestions and nits.
ground_pick.gd now checks _collider_is_occluder before the walkable
break: when the ray hits a body in the "occluder" group it advances
OCCLUDER_PICK_THROUGH past the hit point and continues, unconditionally
and regardless of OcclusionPolicy fade state. Non-occluder click
targeting is unchanged.
Three unit tests added to ground_pick_test.gd covering occluder ancestry
detection. Plan open questions and module doc updated.
Review verdict updated to Approved; all blocking issues and suggestions
resolved and documented. Add auto-generated Godot .uid sidecar files for
occlusion_policy.gd and occlusion_policy_test.gd.
Fixes review blocking issue: when the follow target is null (freed,
renamed, or path unresolved) or the Camera3D child is missing,
_restore_all_occluders() is now called before returning so geometry
cannot be left stuck semi-transparent.
Adds OcclusionPolicy resource and wires it into IsometricFollowCamera.
Each _process frame, iterative rays from the camera eye to the player
focus detect bodies tagged "occluder"; their MeshInstance3D surfaces are
overridden with a transparent StandardMaterial3D (fade_alpha=0.25, instant).
Materials restore when the body clears the ray. Null-material surfaces
(e.g. the prototype Obstacle with no material assigned) receive a plain
transparent StandardMaterial3D so they fade correctly.
- client/scripts/occlusion_policy.gd — new Resource (enabled, fade_alpha,
occluder_group, occluder_collision_mask, max_occluder_cast_depth,
occluder_count_log_threshold, is_valid)
- client/resources/isometric_occlusion_policy.tres — default instance
- client/scripts/isometric_follow_camera.gd — occlusion_policy export,
_update_occlusion pipeline, _apply_occluder_fade/_restore_occluder helpers,
static occlusion_policy_is_valid guard, _exit_tree cleanup
- client/scenes/main.tscn — Obstacle tagged "occluder", policy assigned
- client/test/occlusion_policy_test.gd — new GdUnit4 unit tests
- client/test/isometric_follow_camera_test.gd — policy guard static tests
- docs/decomposition/modules/E1_M2_IsometricCameraController.md — NEON-27
snapshot + readability demo gate note
- docs/plans/NEON-27-implementation-plan.md — null-material decision recorded