Compare commits

...

79 Commits

Author SHA1 Message Date
VinPropane aee3ca80d8
Merge pull request #41 from ViPro-Technologies/NEON-29-expand-prototype-district
Neon 29 expand prototype district
2026-04-15 23:58:20 -04:00
VinPropane a442f02dbd NEON-29: Fix CI tests (horizontal step flag, idle_support vectors) 2026-04-15 23:57:13 -04:00
VinPropane 9bdd928c80 NEON-29: Fix gdlint (player returns/lines, split idle tests, gdlintrc cap) 2026-04-15 23:48:15 -04:00
VinPropane 2b72d6bcb9 NEON-29: Prototype smooth hill piece, movement script updates, NEO-22 plan
Add prototype_smooth_hill_piece for QA geometry alongside main scene wiring.
Refresh player locomotion and main scene integration from stashed work.
Track docs/plans/NEO-22-implementation-plan.md (WASD replaces click-to-move).
2026-04-15 23:39:47 -04:00
VinPropane a7ad695143 NEON-29: Apply stashed WIP (main.tscn, movement validation, docs) 2026-04-13 13:24:05 -04:00
VinPropane 4a51e64f25 Merge branch 'main' into NEON-29-expand-prototype-district 2026-04-13 13:22:51 -04:00
VinPropane 2333d29ece NEON-29: End column stair ghost-walk (arrive + stuck release).
Traces showed has_goal=true, vlat&&ncol, MOVE_SPEED XZ flipping while
stable=false: vert_err ~0.31 stayed above VERT_ARRIVE_EPS so arrival never
cleared the goal — not idle jitter.

Run vertical-route latch before arrival; when on floor with vlat&&ncol,
allow vert_err up to WALK_COLUMN_NEAR_ARRIVE_VERT (0.36 m) if horiz_dist
<= ARRIVE_EPS. Backup: clear_nav_goal after WALK_COLUMN_STUCK_FRAMES with
net drift < WALK_COLUMN_STUCK_MAX_DRIFT in the same regime.
2026-04-12 19:57:54 -04:00
VinPropane 50a8b609be NEON-29: Latch stable idle on ridged stair treads (idle jitter).
Idle jitter was not walk/peel: without has_goal, stable idle required
floor_normal·up >= 0.998. Tread+riser contacts often report ~0.996–0.997,
so stable idle never latched and rim settle nudge + idle move_and_slide
ran every tick. Use STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT (0.992) for the
floor-flat check when slide contacts are ridged and we are still using the
strict entry threshold.
2026-04-12 19:49:19 -04:00
VinPropane db7973d368 NEON-29: Tie tread XZ freeze to peel suspend + vert arrival band.
Replace vlat/ncol + tight vert_sep window (often missed or band-flickered) with:
peel suspend, horiz_dist <= 0.38 m, VERT_ARRIVE_EPS < verr <= 0.18 m, goal
not above feet. Aligns freeze with tread snap/peel regime; caps XZ against
5 m peel-suspend fallback.
2026-04-12 19:44:53 -04:00
VinPropane ea3903d8ab NEON-29: Freeze horizontal velocity on column tread wobble band.
When vlat&&ncol, XZ within ~0.92*ARRIVE_EPS, and vert_sep in a narrow band
above vertical-arrival noise, zero XZ velocity so nav slide cannot drive ~6 cm
Y oscillation on violet stairs. Descending fallback when column latch is off;
skip when goal is above feet (climb still needs XZ).
2026-04-12 19:41:39 -04:00
VinPropane ec903d9c11 NEON-29: Drop tread speed cap; widen stair continuation probe.
Remove WALK_TREAD_STABILITY horiz cap (it ran for whole vlat&&ncol band and
made the last steps sluggish without fixing jitter).

Raise WALK_CONTINUATION_MAX_BELOW_FEET 0.078→0.108 m so ~0.104 m tread drops
count as walk continuation; probe/debounced peel+snap stop flipping each tick.
Kept below ~0.25 m so ~0.3 m gold→floor void still rejects the slab.

Revert NAV_PATH_STEER_MIN_LOOKAHEAD to 0.22 m.
2026-04-12 19:15:27 -04:00
VinPropane 4a08ede736 NEON-29: Rename h_scale to avoid shadowing Node3D.scale. 2026-04-12 19:12:52 -04:00
VinPropane 50bb615763 NEON-29: Cap walk speed on tread stability band for stair jitter.
Steering reverse clamp was reverted (blocked arrival). Instead, when on floor
and (vlat && ncol && vert_sep band) or tight XZ + small vert_sep, clamp
horizontal speed to 1.35 m/s so move_and_slide does not drive riser ping-pong
at full MOVE_SPEED. Bump path steer min lookahead 0.22→0.28 m.
2026-04-12 19:12:43 -04:00
VinPropane bf968b1817 NEON-29: Revert steering reverse clamp (blocked arrival, void fall).
_walk_clamp_steering_reverse kept prior horizontal velocity whenever the new
steer-to-goal direction opposed current motion (dot < -0.35). That prevented
ever closing ARRIVE_EPS in many geometries, so walk never cleared, XZ stayed at
MOVE_SPEED, and air gravity could run indefinitely (y << 0).

Restore direct/path steering without the clamp; NAV_COLUMN_STEER_EXIT_DIST
back to 0.97.
2026-04-12 19:09:51 -04:00
VinPropane bfc70678c4 NEON-29: Reject 180° walk steering flips on stair edges.
Path lookahead vs direct goal could reverse horizontal velocity every physics
tick on treads (NEON-16), driving slide/snap Y jitter. Clamp new steer to
prior bearing when dot < -0.35 and speed is above a small threshold.

Widen NAV_COLUMN_STEER_EXIT_DIST (0.97→1.12) to reduce path/direct mode
chatter when horiz_dist wobbles near the old band.
2026-04-12 19:07:15 -04:00
VinPropane 038505ea3e NEON-29: Gate descend lip snap on debounced ledge peel.
DESCEND_LIP_SNAP_CAP (0.1) ran whenever goal was below feet by geometry,
independent of probe debounce. Feet_y noise on treads toggled 0.32 vs 0.1
snap and caused ~2–3 cm Y jitter. Apply lip cap only when debounced peel is
armed and not in peel suspend; otherwise keep FLOOR_SNAP_MOVING.
2026-04-12 19:03:50 -04:00
VinPropane 73d26a90d7 NEON-29: Debounce floor probe for ledge peel and zero snap.
Continuation rays reject stair treads (~0.104 m) under WALK_CONTINUATION_MAX_BELOW_FEET
(0.078 m), so is_on_floor + probe flickered every tick and toggled peel vs full snap
(Y jitter ~1.0 m). Arm peel/snap aggression only after WALK_PEEL_PROBE_FALSE_FRAMES
consecutive probe failures; deep descent still arms immediately.

Widen horiz peel suspend (0.34→0.46 m vert, 2→5 m XZ) for goal/tread separation gaps.
2026-04-12 19:00:59 -04:00
VinPropane 4e47e9a037 NEON-29: Extend tread peel suspend when vlat unlatches.
Widen vertical-route latch off-sep (0.038→0.085) so descent toward floor
does not drop vlat while feet still wobble on stairs.

Add horiz+vert fallback: suspend peel and restore snap when vert_sep ≤0.34 m
and horiz_dist ≤2 m, covering ncol=false paths where goal Y matches slab
height but tread peel still fought snap (violet stairs ~y=0.92).
2026-04-12 18:55:41 -04:00
VinPropane 26ad33230d NEON-29: Suspend ledge peel on column treads to stop idle jitter.
When vertical route is latched and nav column steering is active, peel
plus zero floor snap caused a 3-frame limit cycle (NEON-16 traces).
Skip peel and restore normal snap while feet are within 0.48 m of the
goal surface vertically.
2026-04-12 18:51:07 -04:00
VinPropane 33b6db979e NEON-29: Stair descent — tighter continuation probe + stronger vertical accel
Lower WALK_CONTINUATION_MAX_BELOW_FEET to 0.078 m (under ~0.104 m treads) so
the next tread is not treated as forward walk support; ledge peel runs on
descents and pulls down onto each step instead of gliding with weak air gravity.

Raise PLAYER_VERTICAL_ACCEL_MULTIPLIER to 9× for peel, walk-air, and idle-air.
2026-04-12 18:47:38 -04:00
VinPropane b924707202 NEON-29: Faster vertical descent — accel multiplier + peel until feet arrive
Apply PLAYER_VERTICAL_ACCEL_MULTIPLIER (~2.45×) to walk-airborne gravity,
ledge peel integration, and idle-airborne gravity for snappier falls.

Peel used to clear when goal.y >= feet_y - DESCEND_GOAL_Y_MARGIN, which
stopped peeling ~6 cm above the click surface while XZ was already at the
destination — use vertical_arrival_error <= VERT_ARRIVE_EPS instead, with
a small guard when the goal is clearly above the feet (climb).
2026-04-12 18:44:51 -04:00
VinPropane a78b44263c NEON-29: Ledge peel via move_and_collide + fallback gravity
Direct global_position.y edits may not stick vs Jolt after move_and_slide.
Use move_and_collide for the peel step so motion goes through physics.

If get_gravity() is near zero, use -9.81 for peel, walk air, and idle air.
2026-04-12 18:41:20 -04:00
VinPropane fb1ac80fb0 NEON-29: Ledge peel via position — GROUNDED eats vy on floor
CharacterBody3D MOTION_MODE_GROUNDED move_and_slide clears downward
velocity while is_on_floor(), so gravity on velocity never produced
vertical motion off terrace lips (all prior probe/snap tweaks were no-ops).

After each walk move_and_slide, when the goal surface is below the feet
and probes/deep-descent say drop, accumulate peel_vy += g·dt and apply
global_position.y += peel_vy·dt. Airborne walk still uses velocity gravity.

Set physics/3d/default_gravity=9.81 explicitly in project.godot.
2026-04-12 18:36:53 -04:00
VinPropane 55ce6f3e3b NEON-29: Deep-descent walk gravity bypass for violet deck vs floor goal
Rays and is_on_floor() can still agree the capsule is supported while the
authoritative click is on the main floor far below (TerracePlatformB lip).

When feet are >0.42 m above the goal surface Y, always add walk gravity and
zero moving floor snap. Shorter drops (e.g. gold step to floor) still use
the probe heuristic only.
2026-04-12 18:31:01 -04:00
VinPropane 91b802c679 NEON-29: Fix walk ledge probes for descending + same-column clicks
Degenerate XZ (velocity and want_goal_h ~0) made _walk_has_close_floor_probe_below
return after center-only, suppressing ledge gravity for the whole move.

Add _resolve_walk_probe_dir_xz: when goal surface is below feet, prefer
horizontal bearing to the goal, else nav path lookahead, else fallbacks.
Non-descending motion keeps velocity-first probes.

Remove the nav-column-only want_goal_h override (superseded by descending
branch). Reverts the regression where the capsule stayed on violet until XZ.
2026-04-12 18:25:22 -04:00
VinPropane db1f4da068 NEON-29: Aim walk ledge probes at click when nav column descends
Column steering follows path tangents on the violet deck while the
authoritative goal sits on the lower floor; velocity-aligned support rays
kept hitting deck in the tangent direction and suppressed ledge gravity.
Use want_goal_h for probe offsets when vertical routing + column steer
+ on floor + goal surface below feet.
2026-04-12 18:18:43 -04:00
VinPropane c3fd9892e3 NEON-29: Ignore walk probe hits far below feet (void saw lower floor)
Down-rays use 0.32 m depth; a gold step is ~0.3 m above the slab, so a
forward sample over empty space still intersected the gray floor and
passed the up-normal test. That looked like continued support, so ledge
gravity and snap-off never fired.

Only treat a hit as walk support if its Y is within 0.14 m below the
current feet (one tread scale); deeper hits are a lower deck / pit.
2026-04-12 18:10:36 -04:00
VinPropane 6bd1acd9e7 NEON-29: Require forward floor probe to keep walk snap/gravity sane off ledges
Walk support used any ray hit under the foot disk, so the center sample
could still see the platform while the leading edge was over a void.
is_on_floor() stayed true from the lip, snap stayed strong, and vy was
zeroed each tick — horizontal skate until XZ arrival.

Now require a center hit plus at least one forward-biased sample in the
movement direction when horizontal intent is non-zero; use velocity XZ
for probe direction so nav/path steering matches the rays.
2026-04-12 18:08:01 -04:00
VinPropane 1f6625c659 NEON-29: Fix nav column steer ping-pong — path lookahead + air bee-line 2026-04-12 18:03:47 -04:00
VinPropane 7666cce7e5 NEON-29: Ledge fall — multi foot probes + zero snap when void
Single center ray + flat floor-normal gate missed tilted lip contacts
and still hit deck under capsule center while leading edge hung over void.
Moving FLOOR_SNAP_MOVING then cancelled gravity each tick.

Sample down rays at center and offsets along walk direction; depth 0.32;
set floor_snap_length 0 when is_on_floor but no probe hit.
2026-04-12 17:56:56 -04:00
VinPropane 6cb66d7567 NEON-29: Walk gravity when is_on_floor but feet probe finds void
Jolt + floor snap could keep is_on_floor true over open space toward a
lower click target, so vy stayed 0 until arrival. Add a short downward
ray under the capsule feet (WALK_SUPPORT_PROBE_DEPTH) and apply gravity
when no upward-facing hit is close, only on fairly level floor normals.
2026-04-12 17:50:09 -04:00
VinPropane 2821c5973b NEON-29: Fix nav path steer ping-pong (get_next_path_position)
Vertical+column branch scanned path for first point with XZ offset >5 cm;
Jolt moved the body across that edge each tick and flipped the chosen
waypoint → 180° velocity oscillation (vlat && ncol).

Use NavigationAgent3D.get_next_path_position() when navigation not
finished; fall back to auth goal when finished or degenerate.
2026-04-12 17:37:48 -04:00
VinPropane 865eed2e53 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.
2026-04-12 17:33:49 -04:00
VinPropane 591db65b4a NEON-29: Fix idle_support_is_stable test normal vs 0.999 dot
(0.02, 0.995).normalized() dots ~0.9998 with UP, so default
STABLE_IDLE_FLOOR_MIN_UP_DOT (0.999) still treats it as flat.
Use the same tilted normal as min_flat_up_dot_parameter test.
2026-04-12 17:27:37 -04:00
VinPropane edec9158e4 NEON-29: Hysteresis for walk vertical routing (tread ping-pong)
feet_y wobble from move_and_slide on approach treads crossed
DESCEND_GOAL_Y_MARGIN each frame, toggling path vs direct steer.
Schmitt latch (WALK_VERT_ROUTE_LATCH_ON_SEP/OFF_SEP) with reset on
nav lifecycle. Extend debug_idle_trace with feet_y, vert_sep, vlat,
ncol. Add player_test for latch reset.
2026-04-12 17:17:36 -04:00
VinPropane 46f8851a1a NEON-29: Hold-threshold stable idle + physics HUD coords
While _idle_stable_latched, use STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT (0.992) for
idle_support floor flatness so tread-edge normal flicker does not unlatch as
often. idle_support_is_stable gains optional min_flat_up_dot; unlatch streak 8.
Prototype position label updates in main _physics_process. Add GdUnit for param.
2026-04-12 17:13:37 -04:00
VinPropane 5f6058b0ce NEON-29: Stable idle on flat tread next to riser (loose+ridged)
loose_ticks+ridged no longer forces ~0.8s corrective idle when floor normal
is still level (tread corner with vertical riser). Gate that branch on
shallow floor (dot < IDLE_RIM_MIN_FLOOR_UP_DOT). Bump unlatch streak to 5.
Update GdUnit + NEON-29 plan.
2026-04-12 17:09:55 -04:00
VinPropane 8d9ad0ecd3 NEON-29: Stable idle latch + Y anchor for approach treads
Hold latched zero-vel idle through STABLE_IDLE_UNLATCH_TICKS consecutive
unstable readings so thin tread lips do not drop into corrective move_and_slide
every other frame. Idle anchor now freezes Y as well as XZ. Reset latch on
goal/snap/arrival; seed latch on arrival when support is stable after idle tick.
Update hold_idle_anchor GdUnit expectation.
2026-04-12 17:06:30 -04:00
VinPropane ff6c337e1d NEON-29: Hysteresis for nav column steering (walk ping-pong)
Single-radius switch at DIRECT_APPROACH_RADIUS let horiz_dist cross every
frame between direct-to-goal and nav-path steering, flipping velocity ~180°.
Schmitt band NAV_COLUMN_STEER_ENTER/EXIT with latched _walk_nav_column_steering;
reset on new goal, snap, arrival, clear.
2026-04-12 17:02:17 -04:00
VinPropane 0f4c23b815 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.
2026-04-12 16:56:40 -04:00
VinPropane 173c348030 NEON-29: Show player world x,y,z in prototype HUD
Add UICanvas/PlayerPositionLabel (top-left) and refresh each frame from main.gd.
2026-04-12 16:52:19 -04:00
VinPropane 1867e3e45c NEON-29: Fix idle jitter on flat treads after stop
idle_support_is_stable no longer treats every post-stop loose-tick frame as
unstable; only slide normals showing floor+wall (lip) keep corrective idle.
Deduplicate ridged-contact check via idle_slide_contacts_are_ridged.
Relax STABLE_IDLE_FLOOR_MIN_UP_DOT slightly for Jolt normal flicker.
Update GdUnit cases and NEON-29 plan note.
2026-04-12 16:50:21 -04:00
VinPropane 103f8bbe19 NEON-29: Add approach treads for green, gold step, violet terrace.
TerracePlatformC_Approach (PCS_*): four cardinals, 7.2 m arms, ~0.104 m rise.
TerraceStepB_Approach (TSB_*): S/E/W, 6.2×1.0 m NS treads.
TerracePlatformB_Approach (TPB_*): N/E/W with six risers for 0.6 m to floor;
south uses existing gold step. New ramp-tint materials. README + plan.
2026-04-12 16:43:02 -04:00
VinPropane bc7d318f4f NEON-29: Note approach-treads convention for next session; trim stale plan line. 2026-04-12 01:18:11 -04:00
VinPropane dbd9d67f45 NEON-29: Cap step assist to navigation agent_max_climb (0.35 m).
TerracePlatformB top is ~0.6 m above the floor; agent_max_climb only
limited nav links, while chained step assist could still climb in one
click. Refuse assist when goal surface is more than 0.35 m above feet
so the gold TerraceStepB path (or a second click) is required.
2026-04-12 01:12:10 -04:00
VinPropane e448075797 NEON-29: Bee-line walk to goal; nav path only for vertical routing.
Default get_next_path_position steering caused crawl/stuck behavior with
Jolt at 120 Hz. Steer XZ toward the authoritative target always except
when the goal surface is meaningfully above/below the feet and the
player is within DIRECT_APPROACH_RADIUS — then use the baked path for
ramps/treads. Update README and NEON-29 plan.
2026-04-12 01:07:35 -04:00
VinPropane 4df71ac0cc NEON-29: Fix floor walk stall from tight nav agent distances.
Revert path_desired_distance and target_desired_distance to 0.35; 0.22
was below practical per-tick progress toward waypoints and led to
oscillation or stuck path state. When NavigationAgent reports finished
but horizontal distance still exceeds ARRIVE_EPS, steer directly
toward the authoritative goal in XZ.
2026-04-12 01:03:40 -04:00
VinPropane 44984780d4 NEON-29: Widen TerracePlatformA approach treads past capsule width.
Tread run was 0.45 m vs 0.8 m capsule diameter, causing straddle stuck
on stairs and blocked motion toward edges when exiting. Use 1.0 m
depth (NS/WE boxes), recenter PAS_* transforms flush to the lip.
Tighten NavigationAgent path/target desired distance 0.35 to 0.22.
2026-04-12 00:59:40 -04:00
VinPropane 3a7658f55f NEON-29: Roll back walk oscillation on treads (stall replan, seam clear).
Remove walk-stall nav replan and seam-based step-assist clearing that
churned targets and toggled floor_block_on_wall on small rises. Restore
0.11 m assist with 8-tick cooldown; cap wallish snap only when the goal
is meaningfully above the feet. Drop descending_stall floor_block branch.
Document rollback in the implementation plan.
2026-04-12 00:56:13 -04:00
VinPropane 8d1785d288 NEON-29: Clear step assist on seam walls; replan nav on walk stall
Step assist stayed active when is_on_wall from mesh seams after landing on
the goal height — keep reduced snap and odd slides. Clear assist when
vert_err and feet vs goal show we are on the target slab.

If horizontal speed stays low with a far goal, refresh NavigationAgent3D
target after 8 ticks to break stale paths.
2026-04-12 00:53:36 -04:00
VinPropane bfa932c834 NEON-29: TerracePlatformA cardinal approach treads (geometry fix)
Twelve walkable StaticBody treads (three per side) bridge floor to teal
lip; darker teal material. Fixes Jolt/box-lip stuck when script-only
mitigations had no user-visible effect. README + plan note.
2026-04-12 00:49:37 -04:00
VinPropane b96bec7981 NEON-29: Teal cardinal — snap cap during climb, descend stall, assist tune
Cap walk floor snap to WALK_STEP_ASSIST_SNAP when climbing into a blocking
wall (stops 0.32m snap fighting step assist). Restore descend lip snap cap;
relax floor_block when descend stalls (low vh, still far in xz). Step assist
lift 0.16 m, cooldown 1 tick.
2026-04-12 00:45:02 -04:00
VinPropane d81e915a0a NEON-29: Teal pad lips — wall normal assist, rim descend, +12mm lift
Step assist: use is_on_wall + get_wall_normal opposing want; keep slide loop
with dot < -0.02.

Descend: relax floor_block on shallow floor normal at internal edges; widen
vertical-ish slide threshold slightly.

Raise TerracePlatformA Y by 0.012 m vs main floor for coplanarity.
2026-04-12 00:42:25 -04:00
VinPropane b4c0defc29 NEON-29: Distinct colors for terraces and floor slab
TerracePlatformA teal, TerraceStepB gold, TerracePlatformB violet,
TerracePlatformC green; explicit neutral floor material. README props
table adds tint column and material ids for communication.
2026-04-12 00:40:32 -04:00
VinPropane 81ceeaab27 NEON-29: Doc fix — lip QA on TerracePlatformA, not Terrace B
Clarify README, plan, and player.gd comments: screenshots were the large
SE pad (10×10, 0.3 m step), not the NW TerraceStepB/TerracePlatformB pair.
2026-04-12 00:39:04 -04:00
VinPropane 00b5be7b0c NEON-29: Fix step assist at lips; descend floor_block only at wall
Remove the forward-velocity dot gate that blocked assist when pushing
head-on into a step (dot ~1 vs max 0.52).

Relax floor_block_on_wall only for true descends (goal below feet) when
is_on_wall or slide hits a vertical-ish normal, so platform deck motion
stays stable.
2026-04-12 00:37:38 -04:00
VinPropane 7d03b17136 NEON-29: Remove descend bypass; fix walk gravity on terraces
Applying gravity whenever the nav goal was below feet stayed true for the
entire walk across TerracePlatformB (feet ~0.6 m vs floor goal Y=0),
corrupting horizontal velocity and slide behavior.

Drop the descend bee-line branch; follow the nav mesh for vertical routing.
Restore full horizontal velocity set with vy=0 and air-only gravity.
Update README and NEON-29 plan to match.
2026-04-12 00:32:02 -04:00
VinPropane e73477da6b NEON-29: Fix walk velocity Y — real gravity on descend, no oscillation
Steer only XZ in _set_horizontal_velocity_toward so vy can accumulate.
Apply gravity when airborne or goal-below-feet; stomp vy on grounded
non-descend moves. Drop descend floor_block relax and on-floor gravity
mult; cap snap for full descend without a feet_y band that flipped
snap length at the lip.
2026-04-12 00:23:18 -04:00
VinPropane 3efe65d9bd NEON-29: Descend on-floor gravity + snap cap; revert terrace Y lift
Reverts the 15mm TerraceStepB/TerracePlatformB root offset that regressed
floor-to-step movement.

When the walk goal is below the feet, apply gravity even while is_on_floor
so lip contacts are not stuck with vy cleared every tick; cap descend
floor_snap_length so the upper surface does not win over internal edges.
2026-04-12 00:17:09 -04:00
VinPropane f20957975d NEON-29: Fix terrace B descend (geometry gap + simpler floor_block)
Raise TerraceStepB and TerracePlatformB 15mm so boxes are not coplanar
with the main floor top, reducing Jolt internal-edge hangs.

Remove descend lip stall, peel multipliers, and snap cap; keep a single
floor_block_on_wall=false policy for the whole descend branch to avoid
oscillation. Document in NEON-29 plan.
2026-04-12 00:14:10 -04:00
VinPropane 275645c6cf NEON-29: Descend floor_block + weak lip peel (fix stuck step/platform→floor)
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).
2026-04-12 00:07:36 -04:00
VinPropane ac9818a2cd NEON-29: Stop clearing floor_block_on_wall for ascent (fix floor→step 1)
Relaxing floor_block when goal was above feet and wallish prevented reliable
step-up; restore default true for climbing. Keep descend-only relaxation.
2026-04-12 00:05:18 -04:00
VinPropane d1f738249d NEON-29: Replace full-time descend gravity with stall + floor_block peel
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.
2026-04-12 00:03:52 -04:00
VinPropane cc63e9bdd7 NEON-29: Reliable descend lip peel + capped floor snap
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.
2026-04-11 23:58:09 -04:00
VinPropane f70dbe8f88 NEON-29: Descend lip peel when on_floor + wallish (first step → floor)
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.
2026-04-11 23:55:03 -04:00
VinPropane 2a25a24e0b NEON-29: Idle gravity + snap when airborne (ledge / step drop)
_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.
2026-04-11 23:51:25 -04:00
VinPropane 6b471b2102 NEON-29: Fix walk steering when goal shares XZ; air gravity; player mask
- _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.
2026-04-11 23:46:09 -04:00
VinPropane ae66b6b313 NEON-29: Fix player–world collision pairing (env mask includes layer 2)
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.
2026-04-11 23:42:14 -04:00
VinPropane cb001a9369 NEON-29: fix descend bypass using wrong feet_y, trapping player on first step
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.
2026-04-11 22:46:17 -04:00
VinPropane 1944eeafe8 NEON-29: fix step assist triggering on floor bumps (directional wall check)
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.
2026-04-11 22:30:07 -04:00
VinPropane 453000d721 NEON-29: fix step-climb regression (snap Y floor + step assist snap)
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.
2026-04-11 22:15:24 -04:00
VinPropane f0b7ac02eb NEON-29: document arrival-check capsule-reference fix in plan 2026-04-11 19:35:58 -04:00
VinPropane ef211ab4ac NEON-29: fix arrival check using actual capsule bottom for vert_err
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.
2026-04-11 19:35:39 -04:00
VinPropane 382e662f1d NEON-29: fix step assist guard and lift for flat-faced terrace steps
_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).
2026-04-11 19:21:37 -04:00
VinPropane 5d91ea4e17 NEON-29: explicitly set HorizontalStepEnabled:false in appsettings
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.
2026-04-11 18:24:52 -04:00
VinPropane afb810db9d NEON-29: correct README — FarPad no longer rejects, only pedestal top does
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.
2026-04-11 18:20:43 -04:00
VinPropane ac75af9c73 NEON-29: remove horizontal step limit; add HorizontalStepEnabled flag
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.
2026-04-11 18:17:32 -04:00
VinPropane 6879d283a9 NEON-29: expand prototype district to 45×45 with terraces and obstacles
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.
2026-04-11 18:12:33 -04:00
VinPropane 8b8296648b NEON-29: add implementation plan for district expansion
Planning doc for expanding the prototype client district to ~5×
current footprint with terraces, step geometry, and extra obstacles
for camera/nav stress QA.
2026-04-11 18:00:14 -04:00
21 changed files with 2585 additions and 170 deletions

View File

@ -2,6 +2,9 @@
"plugins": { "plugins": {
"atlassian": { "atlassian": {
"enabled": true "enabled": true
},
"linear": {
"enabled": true
} }
} }
} }

View File

@ -15,15 +15,51 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose**
The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the preNEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**. The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the preNEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**.
## Prototype district (NEON-29)
The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5× the pre-NEON-29 footprint). All geometry lives under `World/NavigationRegion3D` in `scenes/main.tscn`; `main.gd` rebakes the nav mesh at startup so the scene-file vertices are intentionally stale.
### Props at a glance
Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screenshots and discussion can name the right prop (`Mat_terrace_platform_a`, `Mat_terrace_b_step`, etc.). The main **floor** slab uses neutral gray (`Mat_floor_slab`). **Occluders** keep the default mesh gray.
| Node | Group | Tint | Size (m) | World XZ | Purpose |
|------|-------|------|----------|----------|---------|
| `Floor` | `walkable` | Neutral gray | 45 × 45 | (0, 0) | Main play surface |
| `PrototypeTerminal` | `walkable` | Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target |
| `MoveRejectPedestal` | `walkable` | Orange | 1.5 × 1.5 | (7.5, 6.5) | NEON-7 vertical reject |
| `MoveRejectFarPad` | `walkable` | Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) |
| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target |
| `ObstacleB` | `occluder` | Default gray | 3 × 3 | (10, 5) | Second occluder (wider angle from spawn) |
| `ObstacleC` | `occluder` | Default gray | 2 × 4 | (5, 12) | Tall occluder, south quadrant |
| `ObstacleD` | `occluder` | Default gray | 3 × 2 | (8, 16) | Far-south occluder |
| `TerracePlatformA` | `walkable` | **Teal** | 10 × 10, h=0.3 m | (13, 14) | Large SE pad; **three darker-teal approach treads per cardinal** (`TerracePlatformA_Approach` / `PAS_*`) — **1.0 m run** along the climb axis (~0.104 m rise each), wider than the **0.8 m** capsule diameter so the body is not wider than each tread |
| `TerraceStepB` | `walkable` | **Gold** | 6 × 3, h=0.3 m | (15, 8.5) | NW approach step; **`TerraceStepB_Approach`** / `TSB_*` — three treads per **S/E/W** (darker gold), **1.0 m** run on NS arms |
| `TerracePlatformB` | `walkable` | **Violet** | 6 × 6, h=0.6 m | (15, 13) | Upper NW terrace; **`TerracePlatformB_Approach`** / `TPB_*`**six** treads per **N/E/W** (~0.104 m rise) for **0.6 m** to floor (**south** uses gold step, not violet treads) |
| `TerracePlatformC` | `walkable` | **Green** | 8 × 8, h=0.3 m | (14, 14) | NE pad; **`TerracePlatformC_Approach`** / `PCS_*` — same tread pattern as teal (`PAS_*`), darker green albedo |
### Height variation notes
- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one click suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup.
- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — click the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violets **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention.
- **QA / thin props:** **`PrototypeTerminal`**, **`MoveRejectPedestal`**, **`MoveRejectFarPad`** are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment.
- **Occluders** are plain `StaticBody3D` with no `walkable` tag; the nav bake excludes their top surfaces, so they remain true obstacles for routing.
### Designer / QA limits
- Floor bounds: **±22.5 m** in X and Z from world origin. Clicks outside the `NavigationRegion3D` boundary (beyond the floor edge) will be rejected by `NavigationAgent3D` path queries.
- The `MoveRejectPedestal` (orange box) top is ~2.5 m above the floor and will be rejected by the server's `MaxVerticalStep` (**2.2 m** by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason when `HorizontalStepEnabled` is false.
- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically.
## Authoritative movement (NEO-7, NEO-11) ## Authoritative movement (NEO-7, NEO-11)
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 (NEO-7); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEO-11). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. 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 (NEO-7); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEO-11). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
**Tradeoff (prototype):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the picks **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. **Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the servers verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolts per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**NEO-10 / 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. **NEO-10 / 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 (NEO-14):** **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**. **Idle stability (NEO-14 / 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. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. 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 — NEO-20), `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` (NEO-15 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEO-20), `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` (NEO-15 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. - **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.
@ -34,8 +70,8 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`). 1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`).
2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`). 2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`).
3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEO-9 walk demo). 3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEO-9 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). NEO-10 reject clicks still show the reject label and do **not** start a path. 4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** in **XZ** straight toward that target (nav path only for **vertical** routing when you are already near the goal column on **flatter** floor). Server-rejected clicks 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. 5. Click the **pedestal top** (orange box at ~(7.5, 0, 6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds default **`MaxVerticalStep` (2.2 m)**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) is a normal walkable surface; clicking it from any distance should succeed.
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). 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. 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.
@ -63,9 +99,9 @@ The main scene includes a **prototype terminal** at the map center (same world *
## Movement prototype (NEO-5 → NEO-11) ## Movement prototype (NEO-5 → NEO-11)
**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion when following the mesh; it may steer **directly at the goal** in xz when the descend bypass applies (NEO-11). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). **`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
- The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. - The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor.
### Manual check (remnants) ### Manual check (remnants)

View File

@ -68,6 +68,7 @@ dev_toggle_occluder_obstacle={
common/physics_ticks_per_second=120 common/physics_ticks_per_second=120
3d/physics_engine="Jolt Physics" 3d/physics_engine="Jolt Physics"
3d/default_gravity=9.81
[rendering] [rendering]

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
extends Node3D extends Node3D
## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. ## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main.
## Flat tops on `occluder` bodies (horizontal normal) are valid destinations; vertical occluder
## faces still punch through so ground behind can be picked.
## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance ## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance
## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau. ## along the ray so the next hit can be flat floor beyond — fixes “stuck” when leaving plateau.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.) ## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
@ -64,6 +66,17 @@ func _try_pick(screen_pos: Vector2) -> void:
break break
var hit_collider: Variant = hit.get("collider") var hit_collider: Variant = hit.get("collider")
if _collider_is_occluder(hit_collider): if _collider_is_occluder(hit_collider):
var hit_normal_o: Variant = hit.get("normal", Vector3.ZERO)
if hit_normal_o is not Vector3:
break
var nrm_o: Vector3 = (hit_normal_o as Vector3).normalized()
var ndot_o: float = nrm_o.dot(Vector3.UP)
if ndot_o >= MIN_WALKABLE_NORMAL_DOT_UP:
var top_pos: Variant = hit.get("position")
if top_pos is Vector3:
chosen = top_pos as Vector3
have_chosen = true
break
var occluder_pos: Variant = hit.get("position") var occluder_pos: Variant = hit.get("position")
if occluder_pos is not Vector3: if occluder_pos is not Vector3:
break break

View File

@ -7,6 +7,15 @@ extends Node3D
## (see `isometric_follow_camera.gd`). ## (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see ## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
## `random_floor_bumps.gd`) before nav bake. ## `random_floor_bumps.gd`) before nav bake.
## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip;
## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z;
## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` /
## `PhysicsSmoothDipTest` (HeightMap collision).
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
## under `World` only (not nav source geometry).
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
## (updated in `_physics_process` so it matches physics ticks).
const MOVE_REJECT_MSG_SECONDS: float = 4.0 const MOVE_REJECT_MSG_SECONDS: float = 4.0
@ -29,10 +38,12 @@ var _dev_obstacle_smoke: Node3D
@onready var _authority: Node = $PositionAuthorityClient @onready var _authority: Node = $PositionAuthorityClient
@onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
func _ready() -> void: func _ready() -> void:
set_physics_process(true)
set_process_unhandled_key_input(true) set_process_unhandled_key_input(true)
await get_tree().process_frame await get_tree().process_frame
_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D _dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
@ -54,6 +65,13 @@ func _ready() -> void:
_radius_preview.call("setup_player", _player) _radius_preview.call("setup_player", _player)
func _physics_process(_delta: float) -> void:
if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label):
return
var p: Vector3 = _player.global_position
_player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z]
func _on_target_chosen(world: Vector3) -> void: func _on_target_chosen(world: Vector3) -> void:
_authority.call("submit_move_target", world) _authority.call("submit_move_target", world)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
extends StaticBody3D
## Procedural smooth grid for the physics ramp strip: raised hill or sunken dip.
## Uses HeightMapShape3D for collision (CharacterBody3D tunnels through ConcavePolygonShape3D
## trimesh).
## Keep the extension slab (`PhysicsRampTestFloorExtension`) short of this body's xz footprint so a
## dip is not covered by a flat floor collider/render.
enum PieceKind { HILL, DIP }
@export var kind: PieceKind = PieceKind.HILL
@export var footprint_x: float = 7.0
@export var footprint_z: float = 4.5
@export var amplitude: float = 0.42
@export var subdiv_x: int = 36
@export var subdiv_z: int = 22
## Slight Y offset so the rim is not coplanar with the slab (reduces z-fighting with the floor).
@export var base_lift: float = 0.022
func _ready() -> void:
collision_layer = 1
_build_mesh_and_visual()
_build_heightmap_collision()
func _build_mesh_and_visual() -> void:
var mi: MeshInstance3D = get_node_or_null("MeshInstance3D") as MeshInstance3D
if mi == null:
push_error("prototype_smooth_hill_piece: expected MeshInstance3D child.")
return
var sx: int = maxi(3, subdiv_x)
var sz: int = maxi(3, subdiv_z)
var half_x: float = footprint_x * 0.5
var half_z: float = footprint_z * 0.5
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
var verts := PackedVector3Array()
verts.resize((sx + 1) * (sz + 1))
var uvs := PackedVector2Array()
uvs.resize(verts.size())
var idx := 0
for j in sz + 1:
var v: float = float(j) / float(sz)
var z: float = lerpf(-half_z, half_z, v)
for i in sx + 1:
var u: float = float(i) / float(sx)
var x: float = lerpf(-half_x, half_x, u)
var envelope: float = sin(PI * u) * sin(PI * v)
var y: float = base_lift + height_sign * amplitude * envelope
verts[idx] = Vector3(x, y, z)
uvs[idx] = Vector2(u, v)
idx += 1
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for j in sz:
for i in sx:
var a: int = j * (sx + 1) + i
var b: int = a + 1
var c: int = a + (sx + 1)
var d: int = c + 1
for tri in [[a, c, b], [b, c, d]]:
st.set_uv(uvs[tri[0]])
st.add_vertex(verts[tri[0]])
st.set_uv(uvs[tri[1]])
st.add_vertex(verts[tri[1]])
st.set_uv(uvs[tri[2]])
st.add_vertex(verts[tri[2]])
st.generate_normals()
var mesh: ArrayMesh = st.commit()
mi.mesh = mesh
mi.material_override = _qa_material_for_kind()
func _build_heightmap_collision() -> void:
var cs: CollisionShape3D = get_node_or_null("CollisionShape3D") as CollisionShape3D
if cs == null:
push_error("prototype_smooth_hill_piece: expected CollisionShape3D child.")
return
var sx: int = maxi(3, subdiv_x)
var height_sign := 1.0 if kind == PieceKind.HILL else -1.0
var mw: int = maxi(3, sx + 1)
var md: int = maxi(3, subdiv_z + 1)
var s: float = footprint_x / float(mw - 1)
while (md - 1) * s < footprint_z - 0.0001 and md < 512:
md += 1
var data := PackedFloat32Array()
data.resize(mw * md)
for j in md:
var v: float = float(j) / float(md - 1) if md > 1 else 0.0
for i in mw:
var u: float = float(i) / float(mw - 1) if mw > 1 else 0.0
var envelope: float = sin(PI * u) * sin(PI * v)
var y_world: float = base_lift + height_sign * amplitude * envelope
data[j * mw + i] = y_world / s
var hm := HeightMapShape3D.new()
hm.map_width = mw
hm.map_depth = md
hm.map_data = data
cs.shape = hm
cs.scale = Vector3(s, s, s)
cs.position = Vector3.ZERO
func _qa_material_for_kind() -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.roughness = 0.45
m.metallic = 0.0
m.emission_enabled = true
m.emission_energy_multiplier = 0.85
if kind == PieceKind.HILL:
m.albedo_color = Color(0.98, 0.12, 0.42)
m.emission = Color(0.45, 0.02, 0.15)
else:
m.albedo_color = Color(0.08, 0.35, 0.98)
m.emission = Color(0.02, 0.12, 0.45)
return m

View File

@ -0,0 +1 @@
uid://dqm8s4k2h1xwy

View File

@ -0,0 +1,75 @@
# Idle-support stability tests for res://scripts/player.gd (static helpers).
extends GdUnitTestSuite
const PLAYER_SCRIPT: Script = preload("res://scripts/player.gd")
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)
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]
# Must be steeper than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] (0.998) *and* below
# [member IDLE_SLOPE_STABLE_MIN_UP_DOT] (0.88), or the ramp short-circuit treats support as stable.
var tilted_floor: Vector3 = Vector3(0.5, 0.82, 0.0).normalized()
assert_that(tilted_floor.dot(Vector3.UP)).is_less(
PLAYER_SCRIPT.get("IDLE_SLOPE_STABLE_MIN_UP_DOT")
)
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, tilted_floor, slide_normals, 0)
assert_that(stable).is_false()
func test_idle_support_is_stable_min_flat_up_dot_parameter() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var floor_n: Vector3 = Vector3(0.05, 0.99875, 0.0).normalized()
var strict: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.999)
var holdish: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.992)
assert_that(strict).is_false()
assert_that(holdish).is_true()
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 = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
assert_that(stable).is_true()
func test_idle_support_is_stable_true_when_loose_ticks_and_ridged_but_floor_still_level() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)]
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_loose_ticks_and_ridged_and_shallow_floor() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)]
# Normal must be outside the ramp-stable band [IDLE_SLOPE_STABLE_MIN_UP_DOT, IDLE_RIM) or
# `idle_ridged_stair_lip_only` is false and the ramp short-circuit returns stable. Use a tread
# tilt above the rim dot but still below the ridged hold threshold (0.992).
var floor_n: Vector3 = Vector3(0.14, 0.98, 0.0).normalized()
var up_dot: float = floor_n.dot(Vector3.UP)
assert_that(up_dot).is_greater_equal(PLAYER_SCRIPT.get("IDLE_RIM_MIN_FLOOR_UP_DOT") as float)
assert_that(up_dot).is_less(PLAYER_SCRIPT.get("STABLE_IDLE_FLOOR_MIN_UP_DOT") as float)
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 12)
assert_that(stable).is_false()
func test_idle_support_is_stable_true_when_loose_ticks_but_flat_open_support() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
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_true_when_loose_ticks_and_shallow_seam_not_ridged() -> void:
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)
assert_that(stable).is_false()

View File

@ -0,0 +1 @@
uid://b7k2idle8support1test

View File

@ -48,36 +48,18 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
assert_that(nav.target_position).is_equal(p.global_position) assert_that(nav.target_position).is_equal(p.global_position)
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void: func test_nav_goal_lifecycle_resets_vert_route_latch() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP] var p := _make_player()
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0) p.set_authoritative_nav_goal(Vector3(1.0, 0.0, 2.0))
assert_that(stable).is_true() p.set("_walk_vert_route_latched", true)
p.clear_nav_goal()
assert_that(p.get("_walk_vert_route_latched")).is_false()
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void: p.set("_walk_vert_route_latched", true)
var slide_normals: Array[Vector3] = [Vector3.UP] p.set_authoritative_nav_goal(Vector3(3.0, 0.0, 4.0))
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable( assert_that(p.get("_walk_vert_route_latched")).is_false()
true, Vector3(0.02, 0.995, 0.0).normalized(), slide_normals, 0 p.set("_walk_vert_route_latched", true)
) p.snap_to_server(Vector3(0.0, 0.9, 0.0))
assert_that(stable).is_false() assert_that(p.get("_walk_vert_route_latched")).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 = PLAYER_SCRIPT.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 = PLAYER_SCRIPT.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 = PLAYER_SCRIPT.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: func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
@ -89,7 +71,7 @@ func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
p.global_position = Vector3(1.2, 0.7, 2.3) p.global_position = Vector3(1.2, 0.7, 2.3)
p.call("_hold_idle_anchor") p.call("_hold_idle_anchor")
assert_that(p.global_position).is_equal(Vector3(1.0, 0.7, 2.0)) assert_that(p.global_position).is_equal(Vector3(1.0, 0.5, 2.0))
func test_snap_to_server_clears_idle_anchor() -> void: func test_snap_to_server_clears_idle_anchor() -> void:
@ -119,6 +101,39 @@ func test_vertical_arrival_error_stays_large_for_origin_height_only_match() -> v
assert_that(absf(err - 0.5)).is_less(0.000001) assert_that(absf(err - 0.5)).is_less(0.000001)
func test_arrival_vert_err_large_for_step_goal_from_floor() -> void:
# Player at floor height (body Y=0.9). Goal at step surface (Y=0.3).
# Using actual capsule bottom (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS) the error
# must exceed VERT_ARRIVE_EPS so the arrival check does NOT fire prematurely.
var goal_y := 0.3
var body_y := 0.9
var full_half: float = (
(
PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float
+ PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS")
)
as float
)
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half)
assert_that(err).is_greater(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float)
func test_arrival_vert_err_zero_when_standing_on_step() -> void:
# Player body centred at goal_y + total_half_height (i.e. actually standing on the step).
# Error must be within VERT_ARRIVE_EPS so arrival fires correctly once the player is up.
var goal_y := 0.3
var full_half: float = (
(
PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float
+ PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS")
)
as float
)
var body_y: float = goal_y + full_half
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half)
assert_that(err).is_less_equal(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float)
func test_flat_floor_goal_is_not_below_feet_height_margin() -> void: func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
var goal_y := 0.0 var goal_y := 0.0
var body_origin_y := 0.4 var body_origin_y := 0.4
@ -127,3 +142,8 @@ func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
goal_y < PLAYER_SCRIPT.capsule_feet_y(body_origin_y, 0.5) - descend_margin goal_y < PLAYER_SCRIPT.capsule_feet_y(body_origin_y, 0.5) - descend_margin
) )
assert_that(below_feet_margin).is_false() assert_that(below_feet_margin).is_false()
func test_step_assist_max_surface_delta_matches_navigation_mesh_agent_max_climb() -> void:
# Keep in sync with NavigationMesh `agent_max_climb` in `scenes/main.tscn`.
assert_that(PLAYER_SCRIPT.get("WALK_STEP_ASSIST_MAX_SURFACE_DELTA") as float).is_equal(0.35)

View File

@ -0,0 +1,96 @@
# NEO-22 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-22 |
| **Title** | Retire prototype click-to-move; ship WASD locomotion on the client |
| **Linear** | [NEO-22](https://linear.app/neon-sprawl/issue/NEO-22) |
| **Parent** | [E1.M1 InputAndMovementRuntime](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4) (or current movement project) |
| **Supersedes / deprecates (prototype)** | [NEO-5](NEO-5-implementation-plan.md), [NEO-11](NEO-11-implementation-plan.md) click-to-move surface; [NEO-14](NEO-14-implementation-plan.md) seam/jitter mitigations tied to nav + goal steering |
## Goal, scope, and out-of-scope
**Goal:** Replace **mouse click → ground pick → `MoveCommand` → nav path → `player.gd` goal steering** with **keyboard WASD** (and optional gamepad later) as the **primary** locomotion input in the Godot prototype. Movement should feel predictable on stepped geometry without the column / nav-ribbon / slide-normal fight that click-to-move accumulated.
**In scope**
- **Input:** `project.godot` actions (or code) for **W/A/S/D**; read them in a thin place (`main.gd` or a small `locomotion_input.gd`) and drive the players **horizontal** intent each physics tick.
- **Locomotion model:** `CharacterBody3D` + `move_and_slide()` with velocity derived from **wish direction × speed**, camera-relative or world-relative (decide in [Decisions](#decisions)); **no `NavigationAgent3D` path goal** for routine walking unless a later slice reintroduces hybrid “click to mark + auto-run” explicitly.
- **Remove / disable** left-click ground pick as the default move trigger (`ground_pick.gd` → `main.gd``PositionAuthorityClient.submit_move_target`). Either delete the flow for this slice or gate it behind a dev flag documented as off by default.
- **Server / authority:** Resolve how WASD maps to **NEO-7 `MoveCommand`** and **authoritative position** (see [Open questions](#open-questions--risks)). Minimum acceptable outcomes are spelled in acceptance criteria — pick one path and document it in `client/README.md` + decomposition.
- **Cleanup:** Delete or drastically narrow **NEO-11 / NEO-14 / NEO-16** workaround blocks in `player.gd` (column seam damp, nav column steering, idle trace scaffolding tied to click goals) **only** after WASD path is stable; prefer one commit that switches input then a follow-up that deletes dead code.
- **Docs:** Update `client/README.md`, `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`, and this plans checklist when shipped.
**Out of scope**
- Full **client prediction + server reconciliation** for FPS-grade netcode (unless explicitly pulled into this story by product).
- **Click-to-interact** changes (NEO-12-style interaction) except where click conflicts with removed move binding (document if unchanged).
- **Occlusion / camera** feature work (NEO-17/20) beyond whats needed so WASD direction feels correct with the follow camera.
- **Production** navmesh tooling for NPCs — only player move input changes here.
## Acceptance criteria checklist
- [ ] **WASD** moves the avatar on the default **`main.tscn`** floor with **no** left-click requirement; speed and direction are stable on flat ground and on **stepped** QA props without the prior ±`MOVE_SPEED` seam oscillation at column/stair corners (subjective + optional `debug_idle_trace` spot-check).
- [ ] **Left-click** no longer starts a walk by default (or is clearly dev-only and off in normal play, documented).
- [ ] **Authoritative position** still converges with the server: document whether **continuous** sync, **throttled** `MoveCommand`, or **temporary client-only** prototype applies — and add the minimal client/server change so CI + manual steps do not lie.
- [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions.
- [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion.
- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial.
## Technical approach
1. **Linear + branch:** Issue **NEO-22** exists in Backlog; on kickoff set **In Progress** and branch `neo-22-retire-click-to-move-implement-wasd-locomotion-godot-client` (or team naming).
2. **Authority model (blocking):** Decide with product/server owner:
- **A)** Client integrates WASD locally only for prototype; server receives **rate-limited** position or move deltas (may need new endpoint — likely **out of this slice** unless already planned), or
- **B)** Keep **discrete `MoveCommand`** but drive target from **keyboard-sampled “stick to last intent”** (awkward), or
- **C)** Server accepts a **stream** of small moves (largest change — schedule separately).
Document chosen option in README before merging locomotion feel work.
3. **Input layer:** Map WASD to `Vector2` in screen/camera space → world XZ tangent to `Camera3D` basis (same pattern as many third-person controllers); normalize; zero when no keys.
4. **`player.gd` refactor:** Replace `_has_walk_goal` / `_auth_walk_goal` steering path with **wish velocity** from parent or injected each `_physics_process`; keep gravity, floor snap policy, and `snap_to_server` entry points. Strip `NavigationAgent3D` dependency for default walk if decision says so (agent node can stay in scene unused until a later hybrid story).
5. **Remove click wiring:** Disconnect `target_chosen``submit_move_target` in `main.gd` for default build; optionally keep `ground_pick.gd` for debug “teleport goal” behind `OS.is_debug_build()` flag.
6. **Tests + manual:** Run README manual path; add unit test for direction math if extracted.
## Decisions
| Topic | Choice | Rationale |
|------|--------|-----------|
| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. |
| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. |
| **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. |
## Files to add
| Path | Purpose |
|------|---------|
| None required. | Prefer extending existing scripts unless `locomotion_input.gd` (or similar) is needed to keep `main.gd` thin per `godot-client-script-organization`. If extracted, add here and list in “Files to modify” as N/A for that line. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/project.godot` | Declare **`move_forward` / `move_back` / `move_left` / `move_right`** (or reuse Godot defaults) bound to WASD. |
| `client/scripts/main.gd` | Stop wiring click pick to `submit_move_target` by default; optionally forward WASD intent to player/authority per chosen model. |
| `client/scripts/ground_pick.gd` | Disable default `_input` pick **or** gate behind export/debug; update header comments (NS-16 lineage). |
| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity; delete obsolete seam/nav state once stable. |
| `client/scripts/position_authority_client.gd` | Align with authority model (throttle, stream, or document client-only prototype). |
| `client/scenes/main.tscn` | Only if `NavigationAgent3D` or nodes are removed/changed or input singleton paths change. |
| `client/README.md` | Replace “click-move” manual checks with WASD + server steps; note deprecation of click-to-move prototype. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Snapshot: click-to-move retired in favor of WASD for prototype; link this plan. |
| `docs/plans/NEO-11-implementation-plan.md` | Add one-line “superseded for locomotion input by NEO-22” in header or Resolution if policy is to annotate predecessors. |
## Tests
| File | Coverage |
|------|----------|
| `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. |
| `client/test/position_authority_client_test.gd` | **Change:** align with new submit/sync cadence if `PositionAuthorityClient` changes. |
| **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. |
## Open questions / risks
- **Server contract:** Todays **`MoveCommand`** is **discrete target**-shaped ([NEO-7](NEO-7-implementation-plan.md)). WASD needs **continuous** intent — either extend the protocol, rate-limit many small commands, or temporarily diverge prototype authority. **This is the main schedule risk**; do not merge feel-only client changes without an explicit written decision in README + this plan.
- **Interaction vs pick:** Confirm **E** / interaction does not rely on the same mouse button in a way that breaks when pick is disabled.
- **Gamepad:** Out of scope unless trivial (`Input.get_vector`); note for follow-up story.
- **Jira naming:** If the team uses **NEON-*** keys elsewhere, align Linear/Git subject lines per [linear-git-naming](../../.cursor/rules/jira-git-naming.md).

View File

@ -0,0 +1,130 @@
# NEON-29 Implementation Plan — Expand prototype client district for camera/nav stress QA
## Story reference
- **Key:** [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29)
- **Title:** E1.M2: Expand prototype client district for camera/nav stress QA
- **Parent epic:** [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1)
- **Labels:** E1, E1.M2, client-qa-district, decomposition
---
## Goal, scope, and out-of-scope
### Goal
Provide a **~5× footprint** (order-of-magnitude) prototype play space versus the current district, with **more obstacles** and **height-changing regions** (steps, terraces, bumps) so day-to-day client work can stress **motion, framing, and readability** for the camera, navigation, ground pick, and interaction systems.
### Scope
- `client/scenes/main.tscn`: expand walkable floor / nav source geometry; add props that participate in `NavigationRegion3D` bake and `walkable` picking as today; include elevated terrace/step platforms for vertical variation.
- `client/README.md`: short runbook note covering district scale, intent, and any new designer-facing limits.
- Optional: pointer in `docs/decomposition/modules/E1_M2_IsometricCameraController.md` that this geography is the default stress playground for client stories.
### Out of scope
- New camera features (zoom bands NEON-26, occlusion NEON-27) — geography may help test them, but this story does not implement them.
- Server schema or MoveCommand contract changes unless existing NEON-7 step/bounds defaults visibly break on the larger map (in that case: document + minimal config tune only).
- Production art pass or zone graph / Epic 4 content.
---
## Acceptance criteria checklist
- [ ] Playable area is **materially larger** (~5× order of magnitude vs pre-change footprint) with **additional obstacles** and **vertical variation** usable with click-to-move.
- [ ] **NavigationRegion3D** bakes successfully at startup; long clicks behave per existing NEON-8 tradeoffs (document if new geometry exposes new edge cases).
- [ ] **Ground pick** and `walkable` grouping remain correct on new surfaces.
- [ ] README updated for anyone running F5 + server.
---
## Technical approach
### Current baseline
The current `main.tscn` district is a single **20 × 20** unit flat floor (`BoxMesh_floor`, placed at Y = -0.1), giving an approximate playable area of ~400 sq units. Static props:
- `PrototypeTerminal` (0.9 × 1 × 0.4) at origin
- `MoveRejectPedestal` (1.5 × 2.5 × 1.5) at (7.5, 0, -6.5)
- `MoveRejectFarPad` (2.5 × 0.12 × 2.5) at (9, 0.06, 9)
- `Obstacle` (2 × 2 × 2, `occluder` group) at (6, 0, 5)
- Player spawn at (-5, 0.9, -5)
The `NavigationMesh` stored in the scene file is intentionally stale; `main.gd` calls `bake_navigation_mesh(false)` at startup so the live mesh always reflects actual collision geometry.
### Expansion plan
**Floor size:** Replace the 20 × 20 floor with a **45 × 45** unit floor, giving ~2025 sq units (~5× of 400). Keep floor mesh center at origin (X=0, Z=0) so spawn position and existing props stay roughly central.
**Additional obstacles (nav stress + camera occlusion):** Add 34 extra `StaticBody3D` nodes in the `occluder` group, distributed across the expanded area — positioned so the follow camera must work around varying angles.
**Vertical variation (terraces / steps):** Add 23 raised terrace platforms as `StaticBody3D` nodes in the `walkable` group, elevated ~0.52.5 m above the main floor, with ramp/step approach geometry. These are `StaticBody3D` siblings of `Floor` under `NavigationRegion3D` so the runtime bake includes them as nav sources. Each terrace:
- Has its own `CollisionShape3D` (BoxShape3D).
- Is in the `walkable` group so ground-pick raycasts hit it correctly.
- Has a small `StaticBody3D` ramp or step approach (also `walkable`) so `NavigationAgent3D` can route onto it within the NEON-8 step/climb parameters (`agent_max_climb = 0.35` in existing NavMesh settings — ramp grade must respect this, or use multi-step approach).
**MoveReject props:** Keep `MoveRejectPedestal` and `MoveRejectFarPad` in place — they exercise NEON-7 reject behavior and must remain reachable. Shift `MoveRejectFarPad` outward if needed to stay within the expanded floor bounds and remain "far."
**NavigationMesh settings:** No changes to agent parameters (height=1.0, radius=0.4, max_climb=0.35, cell_size=0.15) — they remain aligned with the player capsule per NEON-8. The stale baked mesh in the scene will update at runtime. The `geometry_collision_mask = 1` stays, so all collision-layer-1 bodies under the region contribute.
**NEON-7 edge case check:** With a larger floor the server's `step_size` / `MaxDistance` defaults may now allow clicks that would previously have been out-of-range. Document in the README if this changes observed reject behavior; no server code change unless a new reject case is broken (not just shifted in range).
---
## Files to add
None. All geometry lives in the existing scene file; no new GDScript logic is required.
---
## Files to modify
| File | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Replace 20×20 floor with 45×45; add terrace/step platforms and extra obstacle props; update pre-baked `NavigationMesh` vertices (will be overwritten at runtime, but keeping a reasonable approximation avoids editor warnings). |
| `client/README.md` | Add district runbook note: new footprint, vertical variation, intent as stress playground, updated manual-check geometry references. |
| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Optional lightweight pointer that this expanded geography is the default QA map for E1.M2 camera stories. |
---
## Tests
No new automated GDScript tests are added for this story — there is no new GDScript logic. This story is pure scene geometry.
**Manual verification replaces automated tests:**
1. **F5** with server running: confirm player spawns and nav bake completes without errors in Output.
2. Click-to-move across the expanded floor, up/down each terrace, and through obstacles — confirm `NavigationAgent3D` routes and `move_and_slide` behaves per NEON-8 tradeoffs.
3. Ground-pick regression: click behind each new `occluder`-tagged obstacle with player in and out of occlusion fade range (per NEON-30 manual check in README).
4. Confirm `MoveRejectPedestal` and `MoveRejectFarPad` still reject correctly.
5. Follow camera: walk to far corners of the expanded floor — confirm `IsometricFollowCamera` tracks smoothly without snapping or losing the player (snap_distance not tripped by longer walks).
6. Interaction radius: walk near `PrototypeTerminal` to confirm range indicators still work correctly.
---
## Decisions
- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only.
- **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y CAPSULE_HALF_HEIGHT PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`.
- **Arrival check using wrong capsule reference (resolved):** `vertical_arrival_error` was called with `CAPSULE_HALF_HEIGHT = 0.5`, giving code-feet at `body_y 0.5 = 0.4` at floor level. Step surfaces (Y = 0.3) are only 0.1 m from that reference. When the Jolt physics engine nudges the capsule body down to ~0.8 m (bottom hemisphere contacts the step edge), code-feet becomes 0.3 — matching the step surface Y and making `vert_err = 0 ≤ VERT_ARRIVE_EPS`. If the player is also within `ARRIVE_EPS = 0.35 m` horizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: pass `CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9) to `vertical_arrival_error` so actual feet = `body_y 0.9`. Step goal vert_err is now 0.3 >> 0.055 from floor height; arrival fires correctly only once the step assist has lifted the capsule onto the surface (body_y = 1.2, vert_err ≈ 0). Two regression tests added.
- **snap_to_server places capsule underground (resolved):** The server stores the raw surface Y from the client's click (e.g. 0.6 for a terrace platform), not the capsule body-centre Y (which should be 0.6 + 0.9 = 1.5). `snap_to_server` was setting `global_position.y` directly to this surface Y, putting the capsule bottom at 0.3 m. With `_floor_angle_loose_ticks = 0` and a stale `is_on_floor() = true`, the idle path's `_stable_idle_support()` check passed and held the player without calling `move_and_slide()`, preventing Jolt from resolving the penetration. Fixed: clamp `global_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9 m) in `snap_to_server`; also set `_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP` (96 ticks) to force corrective idle physics before the next walk goal.
- **Step assist undone by floor snap (resolved):** Each `_try_walk_step_assist()` cycle lifts the capsule 0.11 m. The prior `floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m` meant the snap always reached the lower floor (0.11 m below the lifted bottom), pulling the capsule straight back down every tick. The player "nudged and stopped" because each lift was immediately cancelled. Fixed: added `WALK_STEP_ASSIST_SNAP = 0.09 m` constant (below 0.11 m so the snap cannot reach the previous floor, but above 0.03 m so it catches the step surface once cleared) and a `_step_assist_active` boolean flag. During active climbing, `floor_snap_length` is set to `WALK_STEP_ASSIST_SNAP` in both `_after_walk_move_and_slide()` and the main physics loop. The flag is cleared when the player lands cleanly on a floor without wall contact (`is_on_floor() and not is_on_wall()`), or when the nav goal is cleared/snapped.
- **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 path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis **and** **`is_on_floor()`** (mid-air column steer caused **180° XZ flips** while falling). **`_set_horizontal_velocity_from_nav_path_or_goal`** scans **`get_current_navigation_path()`** for the first waypoint **`NAV_PATH_STEER_MIN_LOOKAHEAD` (0.22 m)** in XZ, with a **goal-direction dot** guard — raw **`get_next_path_position()`** and a **5 cm** scan both sat inside Jolts per-tick slide and could flip velocity **180°** each tick.
- **Walk ledge / void gravity (iterated):** **`is_on_floor()`** + **`floor_snap_length`** could keep the capsule **glued** while crossing void to a lower click. **Fix:** **multi-sample** down rays under the **foot disk** (offsets along **move direction**; depth ~**0.32**); apply gravity when **none** hit; **`floor_snap_length = 0`** that tick so snap does not cancel **`velocity.y`**. Dropped a **floor-normal** gate that skipped the probe on **lip** contacts.
- **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**.
## 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.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
- **NavMesh bake time:** A 45×45 floor with several terraces will bake more polygons than the 20×20 baseline. Expected to be negligible for `bake_navigation_mesh(false)` on desktop, but monitor Output for a perceptible pause.
- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping.

View File

@ -32,7 +32,8 @@ function-preload-variable-name: ([A-Z][a-z0-9]*)+
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*' function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*) load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)* loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
max-file-lines: 1000 # `player.gd` is a single CharacterBody3D controller (~1.4k lines); keep headroom until it splits.
max-file-lines: 1600
max-line-length: 100 max-line-length: 100
max-public-methods: 20 max-public-methods: 20
max-returns: 6 max-returns: 6

View File

@ -115,7 +115,11 @@ public class MoveCommandApiTests
{ {
b.ConfigureTestServices(services => b.ConfigureTestServices(services =>
{ {
services.PostConfigure<GamePositionOptions>(o => o.MovementValidation.MaxHorizontalStep = 1.0); services.PostConfigure<GamePositionOptions>(o =>
{
o.MovementValidation.HorizontalStepEnabled = true;
o.MovementValidation.MaxHorizontalStep = 1.0;
});
}); });
}); });
var client = factory.CreateClient(); var client = factory.CreateClient();

View File

@ -5,9 +5,11 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
public class MoveCommandValidationTests public class MoveCommandValidationTests
{ {
private static MovementValidationOptions Rules(double maxH, double maxV, bool bounds = false) => private static MovementValidationOptions Rules(
double maxH, double maxV, bool bounds = false, bool horizontalEnabled = true) =>
new() new()
{ {
HorizontalStepEnabled = horizontalEnabled,
MaxHorizontalStep = maxH, MaxHorizontalStep = maxH,
MaxVerticalStep = maxV, MaxVerticalStep = maxV,
DistrictBoundsEnabled = bounds, DistrictBoundsEnabled = bounds,
@ -63,6 +65,15 @@ public class MoveCommandValidationTests
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
} }
[Fact]
public void TryValidate_ShouldAllow_WhenHorizontalDisabled()
{
var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = 1000, Y = 0, Z = 1000 };
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code));
Assert.Equal("", code);
}
[Fact] [Fact]
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled() public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
{ {

View File

@ -18,13 +18,16 @@ public static class MoveCommandValidation
{ {
reasonCode = ""; reasonCode = "";
var dx = to.X - from.X; if (rules.HorizontalStepEnabled)
var dz = to.Z - from.Z;
var horizontal = Math.Sqrt(dx * dx + dz * dz);
if (horizontal > rules.MaxHorizontalStep)
{ {
reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded; var dx = to.X - from.X;
return false; var dz = to.Z - from.Z;
var horizontal = Math.Sqrt(dx * dx + dz * dz);
if (horizontal > rules.MaxHorizontalStep)
{
reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded;
return false;
}
} }
var dy = Math.Abs(to.Y - from.Y); var dy = Math.Abs(to.Y - from.Y);

View File

@ -3,11 +3,15 @@ namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary> /// <summary>Per-command movement sanity limits (NS-19). Bound from <c>Game:MovementValidation</c>.</summary>
public sealed class MovementValidationOptions public sealed class MovementValidationOptions
{ {
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.</summary> /// <summary>When true, enforces <see cref="MaxHorizontalStep"/>. False (default) means no horizontal limit.</summary>
public bool HorizontalStepEnabled { get; set; }
/// <summary>Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit.
/// Only applied when <see cref="HorizontalStepEnabled"/> is true.</summary>
public double MaxHorizontalStep { get; set; } = 18.0; public double MaxHorizontalStep { get; set; } = 18.0;
/// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary> /// <summary>Max absolute delta on Y in one move (meters). Inclusive at the limit.</summary>
public double MaxVerticalStep { get; set; } = 1.25; public double MaxVerticalStep { get; set; } = 2.2;
/// <summary>When true, target must lie inside the axis-aligned district box (inclusive min/max).</summary> /// <summary>When true, target must lie inside the axis-aligned district box (inclusive min/max).</summary>
public bool DistrictBoundsEnabled { get; set; } public bool DistrictBoundsEnabled { get; set; }

View File

@ -14,8 +14,9 @@
"Z": -5 "Z": -5
}, },
"MovementValidation": { "MovementValidation": {
"HorizontalStepEnabled": false,
"MaxHorizontalStep": 18, "MaxHorizontalStep": 18,
"MaxVerticalStep": 1.25, "MaxVerticalStep": 2.2,
"DistrictBoundsEnabled": false "DistrictBoundsEnabled": false
} }
} }

View File

@ -70,11 +70,11 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
| Config key | Meaning | Default (see `appsettings.json`) | | Config key | Meaning | Default (see `appsettings.json`) |
|------------|---------|-----------------------------------| |------------|---------|-----------------------------------|
| `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` | | `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` |
| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `1.25` | | `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `2.2` |
| `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` | | `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` |
| `Game:MovementValidation:DistrictMinX``DistrictMaxZ` | District extents when enabled | ±12 / Y 2…24 | | `Game:MovementValidation:DistrictMinX``DistrictMaxZ` | District extents when enabled | ±12 / Y 2…24 |
**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**814 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~2.5 m above floor so a floor-level click yields **`vertical_step_exceeded`**; **far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`**. The clients **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls. **Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**814 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so a floor-level click still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so clicking the floor from it is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. The clients **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls.
Request body (example): Request body (example):