From 2b72d6bcb91ee3f0da6f5a367fb643f371ce4123 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 15 Apr 2026 23:39:22 -0400 Subject: [PATCH] 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). --- client/scenes/main.tscn | 70 ++++- client/scripts/main.gd | 11 +- client/scripts/player.gd | 245 +++++++++++++++--- client/scripts/prototype_smooth_hill_piece.gd | 124 +++++++++ .../prototype_smooth_hill_piece.gd.uid | 1 + docs/plans/NEO-22-implementation-plan.md | 96 +++++++ 6 files changed, 505 insertions(+), 42 deletions(-) create mode 100644 client/scripts/prototype_smooth_hill_piece.gd create mode 100644 client/scripts/prototype_smooth_hill_piece.gd.uid create mode 100644 docs/plans/NEO-22-implementation-plan.md diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 6fafa7e..ffe3f6d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index eee2139..ce4946e 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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). diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 80c46f9..72750c0 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -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.35–0.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.35–0.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 tick’s 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 normal’s 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,14 +1248,21 @@ func _physics_process(delta: float) -> void: else: _idle_stable_enter_streak = 0 elif _idle_stable_latched: - _idle_stable_unlatch_streak += 1 + 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 _idle_stable_unlatch_streak = 0 _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 diff --git a/client/scripts/prototype_smooth_hill_piece.gd b/client/scripts/prototype_smooth_hill_piece.gd new file mode 100644 index 0000000..f5fb451 --- /dev/null +++ b/client/scripts/prototype_smooth_hill_piece.gd @@ -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 diff --git a/client/scripts/prototype_smooth_hill_piece.gd.uid b/client/scripts/prototype_smooth_hill_piece.gd.uid new file mode 100644 index 0000000..386ddfb --- /dev/null +++ b/client/scripts/prototype_smooth_hill_piece.gd.uid @@ -0,0 +1 @@ +uid://dqm8s4k2h1xwy diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md new file mode 100644 index 0000000..7283e0d --- /dev/null +++ b/docs/plans/NEO-22-implementation-plan.md @@ -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 player’s **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 plan’s 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 what’s 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:** Today’s **`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).