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).
pull/41/head
VinPropane 2026-04-15 23:39:22 -04:00
parent a7ad695143
commit 2b72d6bcb9
6 changed files with 505 additions and 42 deletions

View File

@ -9,6 +9,7 @@
[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"]
[ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"]
[ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"]
[ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"]
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
geometry_collision_mask = 1
@ -176,10 +177,22 @@ size = Vector3(1, 0.104, 6.2)
size = Vector3(1, 0.104, 6.2)
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_floor"]
size = Vector3(24, 0.2, 45)
size = Vector3(17.0, 0.2, 45)
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_floor"]
size = Vector3(24, 0.2, 45)
size = Vector3(17.0, 0.2, 45)
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_n"]
size = Vector3(7, 0.2, 20.048)
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_n"]
size = Vector3(7, 0.2, 20.048)
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_s"]
size = Vector3(7, 0.2, 20.432)
[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_s"]
size = Vector3(7, 0.2, 20.432)
[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_block"]
size = Vector3(4, 2, 4)
@ -964,14 +977,42 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34.5, 0, 0)
collision_mask = 3
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201002]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, -0.19204712)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, -0.19204712)
mesh = SubResource("BoxMesh_physics_ramp_test_floor")
surface_material_override/0 = SubResource("Mat_floor_slab")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201003]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, 0)
shape = SubResource("BoxShape3D_physics_ramp_test_floor")
[node name="PhysicsRampTestFloorExtensionNorthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201050 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 12.284)
collision_layer = 1
collision_mask = 3
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201051]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
mesh = SubResource("BoxMesh_physics_ramp_ext_fill_n")
surface_material_override/0 = SubResource("Mat_floor_slab")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201052]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_physics_ramp_ext_fill_n")
[node name="PhysicsRampTestFloorExtensionSouthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201053 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, -12.476)
collision_layer = 1
collision_mask = 3
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201054]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
mesh = SubResource("BoxMesh_physics_ramp_ext_fill_s")
surface_material_override/0 = SubResource("Mat_floor_slab")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201055]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_physics_ramp_ext_fill_s")
[node name="PhysicsRampTestBlock" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201010 groups=["occluder"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 40, 0, 0)
collision_mask = 3
@ -996,6 +1037,27 @@ surface_material_override/0 = SubResource("Mat_physics_ramp_test")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestWalkRamp" unique_id=4201022]
shape = SubResource("BoxShape3D_physics_ramp_test_walk")
[node name="PhysicsSmoothHillTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201040 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34, 0, 0)
collision_layer = 1
collision_mask = 3
script = ExtResource("10_smooth_hill")
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201041]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201042]
[node name="PhysicsSmoothDipTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201043 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 0)
collision_layer = 1
collision_mask = 3
script = ExtResource("10_smooth_hill")
kind = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201044]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201045]
[node name="PhysicsRampTestSteepRamp" type="StaticBody3D" parent="World" unique_id=4201030]
transform = Transform3D(-0.6, -0.8, 0, 0.8, 0.6, 0, 0, 0, 1, 42.75, 1, 0)
collision_layer = 1

View File

@ -7,10 +7,13 @@ extends Node3D
## (see `isometric_follow_camera.gd`).
## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see
## `random_floor_bumps.gd`) before nav bake.
## Prototype: `PhysicsRampTest*` (+X floor extension, block, gentle ramp on **+Z**, steep on **+X**).
## 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: `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).

View File

@ -12,7 +12,9 @@ const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0
## Idle: tighter angle to reduce vertex / vertical-face flicker (~35°).
const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0
## Physics ticks to keep moving floor angle after walk goal clears (arrival).
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96
## Post-walk window for wider [member floor_max_angle] (~0.4 s @ 120 Hz); shorter tightens idle
## contact sooner and helps the anchor latch feel snappy on flat / mild slopes.
const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 48
const ARRIVE_EPS: float = 0.35
const VERT_ARRIVE_EPS: float = 0.055
const DIRECT_APPROACH_RADIUS: float = 0.85
@ -28,16 +30,29 @@ const NAV_COLUMN_STEER_EXIT_DIST: float = 0.97
const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22
## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer instead.
const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25
## When the new lookahead unit is almost opposite the last applied nav steer, slerp slowly — stops ±[code]MOVE_SPEED[/code]
## flips while [code]vlat && ncol[/code] (Jolt nudges XZ → different first waypoint past [member NAV_PATH_STEER_MIN_LOOKAHEAD]).
const NAV_PATH_STEER_SMOOTH_DOT: float = 0.22
const NAV_PATH_STEER_SMOOTH_SLERP: float = 0.58
const NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING: float = 0.16
## While [code]vlat && ncol[/code], ledge peel + zero floor snap fight [method move_and_slide] on approach treads
## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend peel and allow normal snap when the
## feet are within this vertical distance of the goal **surface** (m).
const WALK_PEEL_SUSPEND_VERT_SEP: float = 0.48
## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend only when [code]vsep[/code] is
## **tread-scale** — if this matched tall-pad descents (~0.48 m), column steering stays true while [code]vsep[/code]
## shrinks from metres to zero and peel was off for the last ~half metre (orange block slow float).
## Just above **two** QA tread drops (~0.208 m) so double risers still suspend.
const WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP: float = 0.23
## Fallback when [code]vlat[/code] has unlatched (feet and goal both near the same slab Y) but
## [code]ncol[/code] is false because [code]horiz_dist[/code] is past column hysteresis — peel + zero
## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below [member WALK_PEEL_SUSPEND_VERT_SEP]
## so shallow lips (~0.48 m) still peel; widened from 0.34 to cover ~0.350.47 m stair/goal gaps.
## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below a single tall lip so shallow
## lips (~0.350.47 m) still peel vs [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP].
const WALK_PEEL_SUSPEND_HORIZ_VERT_SEP: float = 0.46
const WALK_PEEL_SUSPEND_HORIZ_DIST: float = 5.0
## Broad [method _walk_peel_suspend_near_goal] (horiz + [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP]) is for
## tread-height wobble when XZ is already near the click. On **tall pads** (orange reject block, terraces),
## feet can stay tens of cm above the goal while [code]horiz_dist < 5[/code] — suspend then kills ledge peel
## for the whole last ~0.46 m of drop → [code]is_on_floor()[/code] + no [code]velocity.y[/code] feels like a slow float.
## Only allow that broad branch once the feet are within this of the goal surface **while descending**.
const WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL: float = 0.22
## With [method _walk_peel_suspend_near_goal] (tread snap/peel stability), zero XZ [member velocity] when
## close in XZ but vertical arrival still unresolved — avoids band-edge flicker from a tight [code]vert_sep[/code]
## window. [member WALK_PEEL_SUSPEND_HORIZ_DIST] can be 5 m; cap XZ here so we never freeze long crosses.
@ -62,6 +77,15 @@ const WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR: float = 0.22
const WALK_COLUMN_STUCK_FRAMES: int = 28
const WALK_COLUMN_STUCK_MAX_DRIFT: float = 0.045
const WALK_COLUMN_STUCK_HORIZ_MAX: float = 0.68
## After [method _walk_clip_horizontal_velocity_against_vertical_contacts], Jolt can alternate which
## vertical face “wins” on stair/column seams → post-clip [code]velocity.xz[/code] flips ~180° at
## ~[code]MOVE_SPEED[/code] while [code]horiz_dist[/code] stays large ([code]ncol[/code] hysteresis).
## Dot below this vs last ticks post-clip XZ → zero horizontal + brief suppress (NEO-14 traces).
const WALK_COL_SEAM_FLIP_DOT: float = -0.7
## Treat post-clip speed as “full fight” when above this fraction of [member MOVE_SPEED].
const WALK_COL_SEAM_SPEED_FRAC: float = 0.82
## Physics ticks to hold [code]velocity.xz == 0[/code] after a seam flip (odd/even frame alternation).
const WALK_COL_SEAM_SUPPRESS_TICKS: int = 3
## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with
## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member DESCEND_GOAL_Y_MARGIN])
## can flip path vs direct steering every tick → velocity sign ping-pong while [code]has_goal[/code].
@ -85,9 +109,13 @@ const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.998
## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge).
const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
## Consecutive [code]raw_stable[/code] ticks required before first idle anchor latch (entry debounce).
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 2
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 5
## Consecutive [code]raw_stable[/code] ticks before first idle anchor latch. Two was safe but Jolt
## heightmaps / slopes often insert a one-frame false negative → ~1 s extra jitter before latch.
const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 1
## When not [code]raw_stable[/code] and not yet latched, decay the enter streak instead of zeroing
## (brief floor-normal flicker no longer wipes two good ticks).
const STABLE_IDLE_ENTER_MISS_DECAY: int = 1
## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip).
const STABLE_IDLE_UNLATCH_TICKS: int = 10
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
@ -177,6 +205,8 @@ var _debug_last_transform_xz: Vector2 = Vector2.INF
var _debug_trace_frame: int = 0
## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing.
var _walk_nav_column_steering: bool = false
## Last horizontal nav-path steer unit (XZ); eases 180° waypoint switches (see [member NAV_PATH_STEER_SMOOTH_DOT]).
var _walk_nav_path_steer_hz: Vector3 = Vector3.ZERO
## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]).
var _walk_vert_route_latched: bool = false
## GROUNDED [method move_and_slide] clears downward [code]velocity.y[/code] on floor; accumulate peel
@ -188,6 +218,9 @@ var _walk_peel_no_probe_streak: int = 0
var _walk_debounced_wants_ledge_peel: bool = false
var _walk_column_stuck_frame: int = 0
var _walk_column_stuck_origin: Vector3 = Vector3.ZERO
## Post-clip XZ from last physics tick; used with [member WALK_COL_SEAM_FLIP_DOT] while [code]vlat && ncol[/code].
var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO
var _walk_col_seam_suppress_ticks: int = 0
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
@ -212,10 +245,13 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_ledge_peel_vy = 0.0
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
_walk_column_stuck_frame = 0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(world_pos)
@ -232,9 +268,12 @@ func clear_nav_goal() -> void:
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_peel_no_probe_streak = 0
_walk_debounced_wants_ledge_peel = false
_walk_column_stuck_frame = 0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(global_position)
@ -279,6 +318,9 @@ func snap_to_server(world_pos: Vector3) -> void:
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_nav_path_steer_hz = Vector3.ZERO
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(settled)
reset_physics_interpolation()
@ -335,6 +377,82 @@ func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool:
return false
## Remove horizontal velocity into **vertical** contacts using last tick's slide normals (and
## [method get_wall_normal] when [method is_on_wall]). Capsule lips / floor seams often never set
## [method is_on_wall] while [method move_and_slide] still fights full [code]MOVE_SPEED[/code] into
## the face → jitter. Same plane test as [method _step_assist_wallish_blocks]: [code]dot < -0.02[/code].
func _walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h: Vector3) -> void:
var v2 := Vector2(velocity.x, velocity.z)
for _iter: int in range(4):
var v_before: Vector2 = v2
for i: int in get_slide_collision_count():
var n: Vector3 = get_slide_collision(i).get_normal()
if n.y > 0.52:
continue
var n2 := Vector2(n.x, n.z)
if n2.length_squared() < 1e-10:
continue
n2 = n2.normalized()
var dot_v: float = v2.dot(n2)
if dot_v < -0.02:
v2 -= n2 * dot_v
if is_on_wall():
var wn: Vector3 = get_wall_normal()
if wn.y <= 0.35:
var wnh := Vector2(wn.x, wn.z)
if wnh.length_squared() > 1e-10:
wnh = wnh.normalized()
var dot_w: float = v2.dot(wnh)
if dot_w < -0.02:
v2 -= wnh * dot_w
if v2.distance_squared_to(v_before) < 1e-12:
break
# Goal through a vertical blocker but only micro tangential slip survives → kill floaty XZ.
var want3 := Vector3(want_goal_h.x, 0.0, want_goal_h.z)
if want3.length_squared() > 1e-10 and _step_assist_wallish_blocks(want3.normalized()):
if v2.length_squared() < 0.0025:
v2 = Vector2.ZERO
velocity.x = v2.x
velocity.z = v2.y
## While [code]_walk_vert_route_latched && _walk_nav_column_steering[/code], seam contacts can make
## [method _walk_clip_horizontal_velocity_against_vertical_contacts] output ~opposite [code]velocity.xz[/code]
## on consecutive ticks at nearly [member MOVE_SPEED] (slide normal order / riser vs tread).
func _walk_damp_vert_route_column_seam_flip() -> void:
if not (_walk_vert_route_latched and _walk_nav_column_steering):
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
return
if _walk_col_seam_suppress_ticks > 0:
_walk_col_seam_suppress_ticks -= 1
velocity.x = 0.0
velocity.z = 0.0
_walk_col_seam_prev_hz = Vector2.ZERO
return
var hz := Vector2(velocity.x, velocity.z)
var len2: float = hz.length_squared()
var thr: float = MOVE_SPEED * WALK_COL_SEAM_SPEED_FRAC
var thr2: float = thr * thr
if len2 < thr2 * 0.2:
if len2 > 1e-8:
_walk_col_seam_prev_hz = hz
else:
_walk_col_seam_prev_hz = Vector2.ZERO
return
var pl2: float = _walk_col_seam_prev_hz.length_squared()
if pl2 < thr2 * 0.16:
_walk_col_seam_prev_hz = hz
return
if hz.normalized().dot(_walk_col_seam_prev_hz.normalized()) < WALK_COL_SEAM_FLIP_DOT:
velocity.x = 0.0
velocity.z = 0.0
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = WALK_COL_SEAM_SUPPRESS_TICKS
else:
_walk_col_seam_prev_hz = hz
func _step_assist_can_raise_by(dy: float) -> bool:
if dy <= 0.0001:
return false
@ -407,8 +525,11 @@ func _set_horizontal_velocity_toward(
dh = Vector3(1.0, 0.0, 0.0)
else:
dh = dh.normalized()
# Same tick as [method _apply_walk_air_gravity]: zeroing [code]velocity.y[/code] every frame while
# airborne capped fall to one gravity delta per tick (~constant sink rate off tall pads).
var vy_air: float = velocity.y
velocity = dh * MOVE_SPEED
velocity.y = 0.0
velocity.y = 0.0 if is_on_floor() else vy_air
func _walk_probe_xz_offsets(move_dir_xz: Vector2) -> Array[Vector2]:
@ -549,8 +670,14 @@ func _walk_try_release_column_stuck_orbit(horiz_dist: float) -> bool:
func _walk_peel_suspend_near_goal(feet_y: float, horiz_dist: float) -> bool:
var vsep: float = absf(_auth_walk_goal.y - feet_y)
if _walk_vert_route_latched and _walk_nav_column_steering and vsep < WALK_PEEL_SUSPEND_VERT_SEP:
if _walk_vert_route_latched and _walk_nav_column_steering and vsep < WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP:
return true
var descending: bool = _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN
if (
descending
and feet_y > _auth_walk_goal.y + WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL
):
return false
return vsep <= WALK_PEEL_SUSPEND_HORIZ_VERT_SEP and horiz_dist <= WALK_PEEL_SUSPEND_HORIZ_DIST
@ -699,6 +826,7 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
# Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD]
# away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ).
if _nav_agent.is_navigation_finished():
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
var pos := global_position
@ -719,6 +847,7 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
steer_point = path[path.size() - 1]
var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z)
if dh.length_squared() < 1e-10:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
var dir: Vector3 = dh.normalized()
@ -726,10 +855,28 @@ func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -
if gh.length_squared() > 1e-10:
gh = gh.normalized()
if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
velocity = dir * MOVE_SPEED
velocity.y = 0.0
var dir_hz := Vector3(dir.x, 0.0, dir.z)
if dir_hz.length_squared() < 1e-10:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz)
return
dir_hz = dir_hz.normalized()
if _walk_nav_path_steer_hz.length_squared() > 1e-10:
var prev := Vector3(_walk_nav_path_steer_hz.x, 0.0, _walk_nav_path_steer_hz.z).normalized()
var dotp: float = dir_hz.dot(prev)
var t: float = (
NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING
if dotp < NAV_PATH_STEER_SMOOTH_DOT
else NAV_PATH_STEER_SMOOTH_SLERP
)
dir_hz = prev.slerp(dir_hz, t).normalized()
_walk_nav_path_steer_hz = dir_hz
var vy_air: float = velocity.y
velocity = dir_hz * MOVE_SPEED
velocity.y = 0.0 if is_on_floor() else vy_air
func _effective_gravity(_delta: float) -> Vector3:
@ -780,6 +927,23 @@ static func idle_slide_contacts_are_ridged(slide_normals: Array[Vector3]) -> boo
return upish >= 1 and wallish >= 1
## [method idle_slide_contacts_are_ridged] is true on heightmap facet seams (up + shallow “wall”
## micro-normals) while the floor normals up component still sits in the smooth single-slope band. Treat
## that like a uniform ramp: do not run idle rim nudges / stair-lip stability against it (QA hill).
## Same for **nearly level** aggregate normals (hill crest / corner): facet pairs are not tread+riser.
static func idle_ridged_stair_lip_only(slide_normals: Array[Vector3], floor_up_dot: float) -> bool:
if not idle_slide_contacts_are_ridged(slide_normals):
return false
if floor_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT:
return false
if (
floor_up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT
and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
):
return false
return true
static func idle_support_is_stable(
on_floor: bool,
floor_normal: Vector3,
@ -789,8 +953,8 @@ static func idle_support_is_stable(
) -> bool:
if not on_floor:
return false
var ridged: bool = idle_slide_contacts_are_ridged(slide_normals)
var up_dot: float = floor_normal.dot(Vector3.UP)
var ridged: bool = idle_ridged_stair_lip_only(slide_normals, up_dot)
# Single-surface inclines (walk ramps): flatter than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] but not
# stair lip geometry — latch stable idle instead of endless corrective [method _physics_idle_tick].
if (
@ -946,10 +1110,9 @@ func _idle_ridged_floor_contacts() -> bool:
func _maybe_idle_rim_settle_nudge() -> bool:
if get_slide_collision_count() < 1:
return false
var shallow_floor: bool = (
is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
)
var ridged: bool = _idle_ridged_floor_contacts()
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
var shallow_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
var ridged: bool = idle_ridged_stair_lip_only(_current_slide_normals(), floor_up_dot)
# Uniform walkable ramps (one dominant sloped floor, no tread+riser pair) still read as “shallow”
# vs [member IDLE_RIM_MIN_FLOOR_UP_DOT] — horizontal rim nudges slide along the ramp every tick → jitter.
if shallow_floor and not ridged:
@ -1056,13 +1219,19 @@ func _snap_capsule_upright() -> void:
func _physics_process(delta: float) -> void:
_debug_trace_frame += 1
# Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: after a walk on **flat**
# ground that still widened angle for ~0.8 s, `move_and_slide` + idle snap caused visible XZ
# jitter. Use moving max angle only while walking or when idle support is shallow / ridged.
# ground that still widened angle for the whole loose-tick window, `move_and_slide` + idle snap
# caused visible XZ jitter. Use moving max angle only while walking or when idle support is shallow / ridged.
var use_loose_floor_angle: bool = _has_walk_goal
if not _has_walk_goal:
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT
if shallow_idle_floor or _idle_ridged_floor_contacts():
# Raw facet ridging reads true on a heightmap **crest** while [code]get_floor_normal()[/code] is
# already ~level; widening [member floor_max_angle] there makes [method move_and_slide] hunt
# forever (HUD jitter on the pink hill tip).
if (
(shallow_idle_floor or idle_slide_contacts_are_ridged(_current_slide_normals()))
and floor_up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT
):
use_loose_floor_angle = true
floor_max_angle = deg_to_rad(
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
@ -1079,6 +1248,13 @@ func _physics_process(delta: float) -> void:
else:
_idle_stable_enter_streak = 0
elif _idle_stable_latched:
var crest_quiet: bool = (
is_on_floor()
and get_floor_normal().dot(Vector3.UP) >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 0.001
)
if raw_stable or crest_quiet:
_idle_stable_unlatch_streak = 0
else:
_idle_stable_unlatch_streak += 1
if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS:
_idle_stable_latched = false
@ -1086,7 +1262,7 @@ func _physics_process(delta: float) -> void:
_idle_stable_enter_streak = 0
_idle_anchor_active = false
else:
_idle_stable_enter_streak = 0
_idle_stable_enter_streak = maxi(0, _idle_stable_enter_streak - STABLE_IDLE_ENTER_MISS_DECAY)
if _idle_stable_latched:
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
@ -1179,6 +1355,8 @@ func _physics_process(delta: float) -> void:
_idle_stable_enter_streak = 0
_walk_nav_column_steering = false
_walk_vert_route_latched = false
_walk_col_seam_prev_hz = Vector2.ZERO
_walk_col_seam_suppress_ticks = 0
_nav_agent.set_target_position(global_position)
_physics_idle_tick(delta)
if _stable_idle_support():
@ -1212,21 +1390,20 @@ func _physics_process(delta: float) -> void:
_apply_floor_block_for_descend_at_wall(feet_y)
# Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked
# path only when vertical routing is latched **and** column hysteresis says so **and** we are
# on floor — mid-air column steering flips XZ velocity each tick against `move_and_slide`
# (NEON-16: `on_floor=false` + `vlat && ncol` while falling).
# On **shallow ramps** (dot below [member IDLE_RIM_MIN_FLOOR_UP_DOT]), path waypoints sit on a
# narrow ribbon — lookahead flips 180° each tick → violent oscillation (debug: alternating ±vel).
if (
needs_vertical_routing
and _walk_nav_column_steering
and is_on_floor()
and get_floor_normal().dot(Vector3.UP) >= IDLE_RIM_MIN_FLOOR_UP_DOT
):
# path only while **airborne** with column vertical routing (`on_floor=false` + `vlat && ncol`):
# mid-air the next waypoint can carry tangential intent around a column. On **floor**, the same
# path sampling on a narrow nav ribbon + shallow rim normals still flipped ±[code]MOVE_SPEED[/code]
# every tick (Z seam traces: [code]ncol=true[/code] stable [code]on_floor[/code], ~4 cm Z wobble).
# Shallow on-floor column approach therefore uses direct goal steer + [method _walk_clip_horizontal_velocity_against_vertical_contacts].
if needs_vertical_routing and _walk_nav_column_steering and not is_on_floor():
_set_horizontal_velocity_from_nav_path_or_goal(want_goal_h)
else:
_walk_nav_path_steer_hz = Vector3.ZERO
_set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h)
_walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h)
_walk_damp_vert_route_column_seam_flip()
if _walk_tread_freeze_horizontal_active(feet_y, horiz_dist):
velocity.x = 0.0
velocity.z = 0.0

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,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).