Compare commits

...

48 Commits

Author SHA1 Message Date
VinPropane 3283f6d04c
Merge pull request #24 from ViPro-Technologies/chore/e1m1-ns10-docs-alignment
chore: align E1.M1 decomposition with NS-10 Done (Ready)
2026-04-05 15:17:15 -04:00
VinPropane 43b58a8bc7 chore: align E1.M1 decomposition with NS-10 Done (Ready) 2026-04-05 15:16:16 -04:00
VinPropane a39f9ac4c9
Merge pull request #23 from ViPro-Technologies/NS-23-client-path-follow
NS-23 client path follow
2026-04-05 15:06:59 -04:00
VinPropane f412da8f44 docs: Mark NS-23 code review blocking items and suggestions done 2026-04-05 15:04:39 -04:00
VinPropane d22d252b59 docs: Link NS-24 for idle jitter (plan + client README) 2026-04-05 15:04:09 -04:00
VinPropane 8956545d3c NS-23: Document idle skip vs is_on_floor / move_and_slide 2026-04-05 15:02:13 -04:00
VinPropane 8fe70a75de NS-23: Comment DESCEND_GOAL_Y_MARGIN (surface Y vs mid-capsule) 2026-04-05 15:01:29 -04:00
VinPropane 14a6813985 NS-23: Document movement tradeoff — no required obstacle auto-nav
Align plan, client/server README, E1.M1 snapshot, and code review note with
the chosen design: descend bypass favors smooth stepped surfaces; multi-click
around obstacles is OK. NS-24 remains for idle jitter.
2026-04-05 14:56:52 -04:00
VinPropane 2891a4553a docs: NS-23 branch code review (2026-04-05) 2026-04-05 14:52:08 -04:00
VinPropane 39b4760270 NS-23: Skip move_and_slide when idle on floor (stop rest jitter)
Zero-velocity move_and_slide each physics tick still reapplies floor snap and
collision, which often reads as vibration. If there is no walk goal (or we
just arrived), velocity is zero, and is_on_floor(), return early.
2026-04-05 02:06:06 -04:00
VinPropane 6c73557c3b NS-23: Restore player.gd from e1beb82 (bumps ok, obstacle bee-line)
User-requested rollback to the pre-72eef09 behavior: skip nav waypoints when
auth goal.y is below body (floor pick vs mid-capsule), steering xz straight at
the goal — good for bump departure, bad for gray-box detours. Revert
floor_snap_length to 0.28 to match that snapshot.
2026-04-05 02:02:57 -04:00
VinPropane ee6c4b2718 NS-23: Simplify path-follow — drop bump/step-down heuristics
Restore a straight Godot nav pattern: steer toward get_next_path_position until
finish or final approach, then toward goal; horizontal-only arrival by xz.
Removes footprint rays, bump-step group, and step-off vy layering that fought
the engine. Slightly raise Player floor_snap_length for small ledges.
2026-04-05 02:01:17 -04:00
VinPropane 25d21c0011 NS-23: Remove bump rim velocity unstick (fixes edge vibration)
Per-frame wall-normal + goal boosts fought a flipping wall normal and capped
re-vectoring, causing back-and-forth jitter. Drop _apply_bump_rim_wall_unstick
and related helpers/constants; keep direct step-down + footprint logic only.
STEP_OFF_DESCEND_SPEED 2.2 → 1.75.
2026-04-05 01:58:57 -04:00
VinPropane 430770e5bf NS-23: Rename bump helpers; WALK_BUMP_STEP_GROUP for scene group
Remove ticket-style names from collider/footprint APIs (_collider_in_bump_step_group,
_departing_bump_step_to_lower_floor, touches_bump_step). Editor group string stays
ns19_bump in one const until main.tscn renames the group.
2026-04-05 01:57:36 -04:00
VinPropane 38aa4bfcd5 NS-23: Unstick bump rim — wall peel + goal boost when descending
Convex frustum sides turn goal steering into tangential rim slide with little
net progress. When footprint still hits ns19_bump and dest floor is lower by
at least STEP_OFF_MIN_DROP, add horizontal wall-normal push (if it does not
strongly oppose goal) plus a small goal-direction boost, cap xz speed.
Slightly raise STEP_OFF_DESCEND_SPEED for the step-off branch.
2026-04-05 01:56:56 -04:00
VinPropane 8053f08804 NS-23: Arrival footing uses footprint max_y (avoid false stop on bump lip)
Premature arrival used _floor_y_under_body() vs dest footing; at the rim that
can read floor while xz is already within ARRIVE_EPS, clearing the walk goal.
Match step-down logic: compare destination footing to footprint max_y and
cache footprint scan once per physics frame.
2026-04-05 01:54:42 -04:00
VinPropane 003750c7ae NS-23: Direct step-down while on ns19_bump up to auth horiz limit
Nav paths often wrap along bump rims when the goal is far; the 8 m cap then
disabled step-off and left rim-following. Detect NS-19 bumps via
group on footprint rays; when any hit is a bump and vertical drop is in the
NS-19 band, steer straight to the goal up to 18 m (server max step). Non-bump
podiums keep the 8 m close cap. Single footprint pass returns max_y + bump.
2026-04-05 01:52:04 -04:00
VinPropane f67705f4a3 NS-23: Step-off footing uses capsule footprint max + wider horiz
At bump lips, center/lower rays often hit the floor first while the body is
still on the rim, so drop read ~0 and nav rim-follow resumed. Max floor Y
over center, lower probe, and 8 xz offsets (cardinal + diagonal) keeps
step-down bypass active until the whole footprint clears. Raise
STEP_OFF_MAX_HORIZ to 8 m (still capped by small drop band).
2026-04-05 01:48:55 -04:00
VinPropane b1ce87c5cc NS-23: Revert global gravity; use step-off-only descend vy
Project gravity while walking made motion worse (jitter, floor fights).
Restore horizontal-only seek everywhere except the short step-down bypass,
where a fixed downward speed helps clear NS-19 bump lips without affecting
normal nav or idle air state.
2026-04-05 01:46:08 -04:00
VinPropane 127e67f02b NS-23: Apply gravity when airborne so bump ledges are not sticky
Horizontal seek only sets vx/vz; zero vy only on floor. Use project default
gravity vector when not on floor (with and without walk goal) so stepping
off NS-19 bumps can fall to the lower surface instead of hanging with vy=0.
2026-04-05 01:43:56 -04:00
VinPropane 3b4340830a NS-23: Fix bump depart — goal Y vs surface footing + arrive by footing
- Ray down at goal xz for destination surface Y; step-off compares two footings.
- Arrive when horiz close and either capsule Y matches goal or underfoot matches
  dest footing (server may send center 0.9 or pick surface 0).
2026-04-05 01:39:45 -04:00
VinPropane dbae80627f NS-23: Tighten bump step-off bypass (dual floor ray, drop band, horiz cap)
- Sample floor Y from center and lower probe; use max for bump rim.
- Only bypass nav for step-sized vertical drop with wider near-goal horiz.
- Loosen next-waypoint stickiness so rim waypoints fall back to goal sooner.
2026-04-05 01:36:32 -04:00
VinPropane 8b2b64a326 NS-23: bump step-off bypass using floor-under-feet, not body Y
Ray down for floor Y under the capsule; skip nav only when goal is a bit
lower than that surface, drop ≤0.55 m, and horiz ≤1.05 m. Matches
leaving a bump without comparing pick/body origin (which broke obstacle
pathing). Long crosses still use waypoints.
2026-04-05 01:33:57 -04:00
VinPropane 72eef09d15 NS-23: remove descend nav skip — restored obstacle pathing
goal.y < body.y matched almost every move because ray picks use floor Y
while CharacterBody3D origin is mid-capsule, so we always bee-lined and
ignored waypoints. Rely on small-xz next-waypoint fallback for bump rims
instead.
2026-04-05 01:32:02 -04:00
VinPropane e1beb82a75 NS-23: unstuck bump top — descend steers xz to goal; doc abs(dy)
Server already allows downward steps via abs(ΔY) vs MaxVerticalStep; document
that explicitly.

Client: when authoritative goal Y is below the body, skip nav waypoints and
steer horizontally toward the goal only — mesh waypoints under the rim
gave ~no horizontal speed (felt like wrong collision).
2026-04-05 01:29:31 -04:00
VinPropane b8176f8fa1 NS-23: simplify client movement — horizontal only, no bump nav hacks
Replace stacked NS-19 workarounds (underfoot rays, plateau nav skip, 3D
climb/descent steering, rim escape) with one rule: set horizontal
velocity toward waypoint/goal, force velocity.y = 0, move_and_slide +
floor_* handle height along geometry. Server MoveCommand remains the
single place for allow/deny on illegal steps.
2026-04-05 01:26:59 -04:00
VinPropane 2291a0ec07 NS-19: move Bump B to clear capsule between bumps
Frustum bases left ~0.33 m between closest corners while the player
capsule is ~0.8 m wide, so crossing could wedge between A and B. Shift
NS19BumpB −Z by 0.75 m (z −4.1 → −4.85) so AABB clearance ≈1 m.
2026-04-05 01:23:25 -04:00
VinPropane fba2c8e171 NS-19: large bump — wider underfoot rays + shallower frustum
- Add xz-offset down-rays (~0.42 m) so rim/lean on the 1 m Bump A still
  counts as on-bump for nav skip (single center column could miss).
- Widen Bump A convex base 0.65→0.72 (top 0.5 unchanged): gentler ~34°
  sides vs ~45° to reduce CharacterBody3D snags; mesh unchanged.
2026-04-05 01:19:28 -04:00
VinPropane 35d16d15db NS-23: harden bump departure (always skip nav on bump, steer dy<0)
- If any of three down-rays (body + two lower origins) hits ns19_bump,
  skip NavigationAgent waypoints unconditionally on that footprint.
- Steering: any goal lower than origin with same xz (dy < 0) uses rim
  escape; dy in (-0.04, 0) previously zeroed velocity and could hang.
2026-04-05 01:16:07 -04:00
VinPropane 66d7438a59 NS-23: broaden NS-19 bump nav bypass for departure
Plateau-only (strict flat normal + is_on_floor) missed rim poses and
convex top edges. Skip waypoints when a down ray from body or feet
offset hits ns19_bump and either the goal is lower than the body origin
(typical floor click off bump) or we are on a flatter floor normal
(~15°). Keeps waypoint routing on bump slopes when climbing past
obstacles unless the move is clearly downward.
2026-04-05 01:14:19 -04:00
VinPropane db1fe0f46b NS-23: skip nav waypoints on NS-19 bump plateaus only
Restore obstacle routing via waypoints elsewhere; on bump tops nav often
prefers a downward segment and breaks departure. Detect plateau with
is_on_floor + flat normal + ray down into group ns19_bump; then steer
only toward authoritative goal xz. Slopes keep waypoint pathing.
2026-04-05 01:12:11 -04:00
VinPropane a5ec8df4a7 NS-23: terminal collision, nav around obstacle, agent under region
- Add BoxShape3D to PrototypeTerminal (layer 1, walkable) so the player
  cannot pass through; matches terminal mesh size.
- Bake navigation from collision layer 1 only so the player (layer 2) is
  not merged into the mesh; reparent Player under NavigationRegion3D so
  NavigationAgent3D resolves a valid map.
- Restore nav waypoint following for long horizontal legs; keep horizontal
  steering toward next/goal so bump departure behavior stays sane.
2026-04-05 01:10:16 -04:00
VinPropane 05d7916cc7 NS-23: fix bump departure — stepped pick + goal-only steer
Ground pick: step the ray through steep walkable hits (0.09 < dot UP < 0.64)
so a view that grazes bump sides still resolves to flat floor beyond; walls
(dot ~0) stop the chain.

Player: drop NavigationAgent3D waypoint following; steer only toward the
server goal in xz so plateau paths are not dominated by vertical nav
segments. move_and_slide handles slopes and obstacles.
2026-04-05 01:07:02 -04:00
VinPropane 725bef1133 NS-23: allow ground pick on bump slopes (fix stuck departure)
MIN_WALKABLE_NORMAL_DOT_UP 0.82 is stricter than NS-19 frustum slopes
(~45° / dot 0.71 on Bump A, ~39° / 0.78 on B). Clicks off the plateau
often hit the slope first; the pick was dropped with no MoveCommand.
Lower to 0.64 so ramps register; vertical walls remain ~0.
2026-04-05 01:03:51 -04:00
VinPropane 4b9e30c624 NS-23: fix false arrival when leaving bump tops
VERT_ARRIVE_EPS (0.16) was larger than the bump rise (~0.12–0.15), so a
floor click beside the plateau often satisfied arrival (horiz + vert) on
the first frame and cleared the walk goal before any motion.

Tighten vertical arrival to 0.055. Add a steer fallback when the target
shares xz but is lower (plateau vs floor underfoot) so we still pick a
horizontal escape toward the rim.
2026-04-05 01:01:22 -04:00
VinPropane 7ffcf54d80 NS-23: horizontal nav steering so bump-top departure works
Path waypoints from the plateau often sit on the slope almost under the
player (same xz, lower y). 3D steer + y clamp gave almost no horizontal
velocity, so standing on top then clicking off looked stuck. Steer in xz
at MOVE_SPEED and let move_and_slide follow floor/slope; keep 3D only for
vertical climb when xz is aligned.
2026-04-05 00:58:10 -04:00
VinPropane f6baf3bf72 NS-23: NS-19 bump single frustum collider (fix departure edges)
Replace top slab + four ramp wedges with one ConvexPolygonShape3D frustum
per bump so plateau and slopes are one manifold (no collider seams at
table edges when walking off).

Add CharacterBody3D safe_margin to reduce edge contact with physics.
2026-04-05 00:55:15 -04:00
VinPropane 38abf6d5d2 NS-23: NS-19 bump collision top-cap only (fix ramp snag)
Full bump box shared vertical faces with convex ramp wedges, doubling
collision and snagging CharacterBody3D on and off. Replace with thin
top BoxShape3D; ramps own all side contact.

Raise Player floor_max_angle to 50° and floor_snap_length slightly for
cleaner ramp floor snapping.
2026-04-05 00:51:32 -04:00
VinPropane 0e79250f1a NS-23: NS-19 bump convex ramps; drop autostep
- Add four invisible ConvexPolygonShape3D ramp wedges per bump (walkable) so
  move_and_slide can climb/descend without test_move ledge hacks.
- Remove _try_autostep / _maybe_autostep_after_slide and LEDGE_DROP_STEPS;
  they mis-detected geometry and fought CharacterBody3D slide.
- Fix duplicate subresource unique_id on BumpA RampWest and BumpB RampSouth.
2026-04-05 00:48:51 -04:00
VinPropane 394e2a71fb NS-23: Fix const LEDGE_DROP_STEPS (Godot const expression rule) 2026-04-05 00:44:15 -04:00
VinPropane b007c7fe58 NS-23: Autostep down+forward to walk off bump edges 2026-04-05 00:42:50 -04:00
VinPropane 6fd49a2e1b NS-23: Auto-step onto NS-19 bumps via CharacterBody3D.test_move 2026-04-05 00:39:30 -04:00
VinPropane 6d00070c00 NS-23: Climb NS-19 bumps with 3D steer + vertical arrival + floor snap 2026-04-05 00:36:43 -04:00
VinPropane 510422d8a2 NS-23: Drive motion from stored server goal; ignore agent finished flag 2026-04-05 00:33:28 -04:00
VinPropane f49c8b88d2 NS-23: Fix nav movement (sync bake, map guard, path/fallback steering) 2026-04-05 00:29:58 -04:00
VinPropane 0ac059fa3b NS-23: NavigationAgent3D path-follow with server move authority 2026-04-05 00:23:03 -04:00
VinPropane 1f4e3c49f6 NS-23: Resolve agent tuning and nav sync risks in implementation plan 2026-04-05 00:14:37 -04:00
VinPropane ec3c0ee4fe NS-23: Add implementation plan and E1.M1 snapshot for path-follow kickoff 2026-04-05 00:10:11 -04:00
15 changed files with 431 additions and 94 deletions

View File

@ -9,20 +9,25 @@ Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible)
Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** child nodes and scripts; put picking, server sync, and similar features in **dedicated scripts** (typically under `scripts/`) and connect via **signals** or small APIs. Use **Autoloads** only when several scenes truly need the same global. Full guidance for contributors and tooling: [`.cursor/rules/godot-client-script-organization.md`](../.cursor/rules/godot-client-script-organization.md).
## Authoritative movement (NS-16)
## Authoritative movement (NS-16, NS-23)
With the game server running ([`server/README.md`](../server/README.md)), the client sends a **`MoveCommand`** over HTTP and **snaps** the avatar to the servers **`PositionState`** after a follow-up **`GET`**.
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 (NS-16/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify), thin `scripts/main.gd` wiring.
**Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the picks **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**Known issue:** visible **idle / rest jitter** on the avatar — follow-up in [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) (Tech Debt).
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-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` calls **`bake_navigation_mesh(false)`** (main-thread bake) after one **`process_frame`**, then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. After moving floor or obstacles, re-bake in the editor if needed.
- **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`).
- On startup, **`sync_from_server()`** runs once so the capsule matches the server before you click.
### Manual check (NS-16)
### Manual check (NS-16 + NS-23)
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`).
3. Open the client in Godot and run the main scene (**F5**). The player should jump to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NS-18 walk demo).
4. **Left-click** the floor: the client POSTs the target, then GETs position; the avatar **snaps** to the authoritative coordinates (no client-side walk to the click).
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` / NS-18 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). NS-19 reject clicks still show the reject label and do **not** start a path.
5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged.
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.
@ -39,16 +44,16 @@ The main scene includes a **prototype terminal** at the map center (same world *
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane).
3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
## Movement prototype (NS-14, legacy local steering)
## Movement prototype (NS-14 → NS-23)
The **`CharacterBody3D`** still uses horizontal steering + **`move_and_slide()`** in **`player.gd`**, but **NS-16** drives placement via **`snap_to_server()`** after each successful sync, so you will mostly see **snapping**, not walking to the click. Obstacle sliding from NS-14 applies only if local goals diverge from server snaps in future work.
**`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 (NS-23). **`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.
### Manual check (NS-14 remnants)
### Manual check (remnants)
1. Run the main scene (**F5**) **with server running** for NS-16 behavior above.
2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repos intended demo—**run the server** for NS-16.
1. Run the main scene (**F5**) **with server running** for movement behavior above.
2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repos intended demo—**run the server** for the demo path.
### Clicks still ignored?

View File

@ -33,6 +33,9 @@ size = Vector3(0.9, 1, 0.4)
[sub_resource type="StandardMaterial3D" id="Mat_terminal"]
albedo_color = Color(0.28, 0.38, 0.45, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_terminal"]
size = Vector3(0.9, 1, 0.4)
[sub_resource type="BoxMesh" id="BoxMesh_marker"]
size = Vector3(0.22, 0.35, 0.22)
@ -42,14 +45,14 @@ albedo_color = Color(0.15, 0.4, 1, 1)
[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_a"]
size = Vector3(1, 0.15, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_a"]
size = Vector3(1, 0.15, 1)
[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_b"]
size = Vector3(0.85, 0.12, 0.85)
[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_bump_b"]
size = Vector3(0.85, 0.12, 0.85)
[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_frustum"]
points = PackedVector3Array(-0.72, -0.075, -0.72, 0.72, -0.075, -0.72, 0.72, -0.075, 0.72, -0.72, -0.075, 0.72, -0.5, 0.075, -0.5, 0.5, 0.075, -0.5, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5)
[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_frustum"]
points = PackedVector3Array(-0.575, -0.06, -0.575, 0.575, -0.06, -0.575, 0.575, -0.06, 0.575, -0.575, -0.06, 0.575, -0.425, 0.06, -0.425, 0.425, 0.06, -0.425, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425)
[sub_resource type="BoxMesh" id="BoxMesh_ns19_pedestal"]
size = Vector3(1.5, 2.5, 1.5)
@ -72,6 +75,17 @@ albedo_color = Color(0.92, 0.42, 0.12, 1)
[sub_resource type="StandardMaterial3D" id="Mat_ns19_farpad"]
albedo_color = Color(0.32, 0.38, 0.55, 1)
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
geometry_parsed_geometry_type = 2
geometry_collision_mask = 1
geometry_source_geometry_mode = 0
cell_size = 0.15
cell_height = 0.15
agent_radius = 0.4
agent_height = 1.0
agent_max_climb = 0.35
border_size = 0.15
[node name="Main" type="Node3D" unique_id=1358372723]
script = ExtResource("1_main")
@ -86,24 +100,31 @@ transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.5
current = true
fov = 50.0
[node name="Floor" type="StaticBody3D" parent="World" unique_id=1800743112 groups=["walkable"]]
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World" unique_id=8880001]
navigation_mesh = SubResource("NavigationMesh_district")
[node name="Floor" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1800743112 groups=["walkable"]]
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor" unique_id=152652175]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
mesh = SubResource("BoxMesh_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor" unique_id=409532142]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Floor" unique_id=409532142]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_floor")
[node name="PrototypeTerminal" type="StaticBody3D" parent="World" unique_id=1700001]
[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PrototypeTerminal" unique_id=1700002]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002]
mesh = SubResource("BoxMesh_terminal")
surface_material_override/0 = SubResource("Mat_terminal")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006]
shape = SubResource("BoxShape3D_terminal")
[node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003]
script = ExtResource("6_rad")
@ -117,74 +138,85 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05)
mesh = SubResource("BoxMesh_marker")
surface_material_override/0 = SubResource("Mat_marker_base")
[node name="NS19BumpA" type="StaticBody3D" parent="World" unique_id=1900001 groups=["walkable"]]
[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable", "ns19_bump"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.4, 0.075, -2.6)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpA" unique_id=1900002]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900002]
mesh = SubResource("BoxMesh_ns19_bump_a")
surface_material_override/0 = SubResource("Mat_ns19_bump")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpA" unique_id=1900003]
shape = SubResource("BoxShape3D_ns19_bump_a")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003]
shape = SubResource("Convex_ns19a_frustum")
[node name="NS19BumpB" type="StaticBody3D" parent="World" unique_id=1900011 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1)
[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable", "ns19_bump"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.85)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpB" unique_id=1900012]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900012]
mesh = SubResource("BoxMesh_ns19_bump_b")
surface_material_override/0 = SubResource("Mat_ns19_bump")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19BumpB" unique_id=1900013]
shape = SubResource("BoxShape3D_ns19_bump_b")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013]
shape = SubResource("Convex_ns19b_frustum")
[node name="NS19RejectPedestal" type="StaticBody3D" parent="World" unique_id=1900021 groups=["walkable"]]
[node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19RejectPedestal" unique_id=1900022]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900022]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0)
mesh = SubResource("BoxMesh_ns19_pedestal")
surface_material_override/0 = SubResource("Mat_ns19_pedestal")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19RejectPedestal" unique_id=1900023]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900023]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0)
shape = SubResource("BoxShape3D_ns19_pedestal")
[node name="NS19FarPad" type="StaticBody3D" parent="World" unique_id=1900031 groups=["walkable"]]
[node name="NS19FarPad" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900031 groups=["walkable"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0.06, 9)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19FarPad" unique_id=1900032]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900032]
mesh = SubResource("BoxMesh_ns19_farpad")
surface_material_override/0 = SubResource("Mat_ns19_farpad")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NS19FarPad" unique_id=1900033]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900033]
shape = SubResource("BoxShape3D_ns19_farpad")
[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763]
[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5)
collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle" unique_id=750473628]
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
mesh = SubResource("BoxMesh_obstacle")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle" unique_id=1344302688]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Obstacle" unique_id=1344302688]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
shape = SubResource("BoxShape3D_obstacle")
[node name="Player" type="CharacterBody3D" parent="." unique_id=352931696]
[node name="Player" type="CharacterBody3D" parent="World/NavigationRegion3D" unique_id=352931696]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5)
collision_layer = 2
collision_mask = 1
floor_max_angle = 0.872665
floor_snap_length = 0.28
safe_margin = 0.045
script = ExtResource("2_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590]
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Player" unique_id=1695755590]
shape = SubResource("CapsuleShape3D_player")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=2027034386]
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="World/NavigationRegion3D/Player" unique_id=8880002]
avoidance_enabled = false
path_desired_distance = 0.35
target_desired_distance = 0.35
path_height_offset = 0.0
radius = 0.4
height = 1.0
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Player" unique_id=2027034386]
mesh = SubResource("CapsuleMesh_player")
[node name="GroundPick" type="Node3D" parent="." unique_id=2500001]

View File

@ -1,14 +1,23 @@
extends Node3D
## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main.
## NS-19: ignores hits on **steep** surfaces (e.g. pedestal **walls**) so players cannot chain small
## vertical steps up a vertical face; only **floor-like** normals (high dot with UP) count.
## 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.
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
signal target_chosen(world: Vector3)
## Minimum `hit_normal.dot(Vector3.UP)` to accept a pick (0 = vertical wall, 1 = flat floor).
const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.82
const MIN_WALKABLE_NORMAL_DOT_UP: float = 0.64
## Nudge past a steep walkable triangle so the ray can hit ground behind (meters along view ray).
const STEEP_PICK_THROUGH: float = 0.14
## Max ray segments (steep walkable → advance) before giving up.
const MAX_PICK_SEGMENTS: int = 24
## Below this dot, treat as wall — do not step through (pedestal sides stay non-pickable).
const WALL_NORMAL_DOT_CUTOFF: float = 0.09
## Fallback when `Viewport.get_camera_3d()` is null (e.g. some editor setups). Set from `main.gd`
## in `_ready`.
@ -29,27 +38,45 @@ func _try_pick(screen_pos: Vector2) -> void:
if cam == null:
return
var origin: Vector3 = cam.project_ray_origin(screen_pos)
var ray_dir: Vector3 = cam.project_ray_normal(screen_pos)
var to: Vector3 = origin + ray_dir * 2000.0
var query := PhysicsRayQueryParameters3D.create(origin, to)
query.collision_mask = 1
if ray_dir.length_squared() < 1e-12:
return
ray_dir = ray_dir.normalized()
var from: Vector3 = cam.project_ray_origin(screen_pos)
var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
var hit: Dictionary = space.intersect_ray(query)
if hit.is_empty():
return
var collider: Variant = hit.get("collider")
if not _collider_is_walkable(collider):
return
var hit_normal: Variant = hit.get("normal", Vector3.ZERO)
if hit_normal is not Vector3:
return
var nrm: Vector3 = (hit_normal as Vector3).normalized()
if nrm.dot(Vector3.UP) < MIN_WALKABLE_NORMAL_DOT_UP:
return
var hit_pos: Variant = hit.get("position")
if hit_pos is Vector3:
target_chosen.emit(hit_pos as Vector3)
const RAY_LEN: float = 2000.0
var chosen: Vector3 = Vector3.ZERO
var have_chosen: bool = false
for _i in range(MAX_PICK_SEGMENTS):
var to: Vector3 = from + ray_dir * RAY_LEN
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collision_mask = 1
var hit: Dictionary = space.intersect_ray(query)
if hit.is_empty():
break
if not _collider_is_walkable(hit.get("collider")):
break
var hit_normal: Variant = hit.get("normal", Vector3.ZERO)
if hit_normal is not Vector3:
break
var nrm: Vector3 = (hit_normal as Vector3).normalized()
var ndot: float = nrm.dot(Vector3.UP)
if ndot >= MIN_WALKABLE_NORMAL_DOT_UP:
var pos_var: Variant = hit.get("position")
if pos_var is Vector3:
chosen = pos_var as Vector3
have_chosen = true
break
if ndot <= WALL_NORMAL_DOT_CUTOFF:
break
var hp_var: Variant = hit.get("position")
if hp_var is not Vector3:
break
from = (hp_var as Vector3) + ray_dir * STEEP_PICK_THROUGH
if have_chosen:
target_chosen.emit(chosen)
func _collider_is_walkable(collider: Variant) -> bool:

View File

@ -2,6 +2,7 @@ extends Node3D
## NS-16: composes ground pick + server authority; see `ground_pick.gd` /
## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes).
## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
const MOVE_REJECT_MSG_SECONDS: float = 4.0
@ -9,7 +10,8 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
var _move_reject_msg_token: int = 0
@onready var _camera: Camera3D = $World/Camera3D
@onready var _player: CharacterBody3D = $Player
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
@onready var _player: CharacterBody3D = $World/NavigationRegion3D/Player
@onready var _ground_pick: Node3D = $GroundPick
@onready var _authority: Node = $PositionAuthorityClient
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@ -17,6 +19,12 @@ var _move_reject_msg_token: int = 0
func _ready() -> void:
await get_tree().process_frame
# Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map
# exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents).
_nav_region.bake_navigation_mesh(false)
await get_tree().physics_frame
await get_tree().physics_frame
_ground_pick.set("fallback_camera", _camera)
_ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen"))
_authority.connect(
@ -32,11 +40,16 @@ func _on_target_chosen(world: Vector3) -> void:
_authority.call("submit_move_target", world)
func _on_authoritative_position(world: Vector3) -> void:
_player.snap_to_server(world)
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
if apply_as_snap:
_player.snap_to_server(world)
else:
_player.set_authoritative_nav_goal(world)
func _on_move_rejected(reason_code: String) -> void:
# Rejected POST never reached VERIFY_GET, so no new nav target was applied; do not clear an
# in-flight path from a prior successful move.
push_warning("Move rejected: %s" % reason_code)
_move_reject_msg_token += 1
var token: int = _move_reject_msg_token

View File

@ -1,37 +1,110 @@
extends CharacterBody3D
## NS-14: click-to-move using horizontal steering + move_and_slide (not NavigationAgent3D).
## Obstacles are handled by physics sliding, not pathfinding — see client README.
## NS-23: Follow server move target on walkable geometry.
##
## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0. Height along ramps
## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step,
## distance, bounds) is server MoveCommand only — not reimplemented here.
##
## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims).
##
## Idle: skip `move_and_slide()` when on floor and velocity is zero — repeated slide + floor snap
## causes visible micro-vibration at rest.
const MOVE_SPEED: float = 5.0
const ARRIVE_EPS: float = 0.35
const VERT_ARRIVE_EPS: float = 0.055
const DIRECT_APPROACH_RADIUS: float = 0.85
## Descend bypass: compares auth goal Y (often floor **surface** from pick) to `global_position.y`
## (**mid-capsule**). True for most floor clicks — smooth bumps; skips nav (bee-line xz), so
## obstacles may need multi-click. Nav waypoints under bump rims gave almost no horizontal speed.
const DESCEND_GOAL_Y_MARGIN: float = 0.06
var _goal: Vector3
var _has_walk_goal: bool = false
var _auth_walk_goal: Vector3 = Vector3.ZERO
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
func _ready() -> void:
_goal = global_position
_nav_agent.set_target_position(global_position)
func set_move_goal(world_pos: Vector3) -> void:
_goal = world_pos
_goal.y = global_position.y
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_auth_walk_goal = world_pos
_has_walk_goal = true
_nav_agent.set_target_position(world_pos)
func clear_nav_goal() -> void:
velocity = Vector3.ZERO
_has_walk_goal = false
_nav_agent.set_target_position(global_position)
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight
## the sync.
func snap_to_server(world_pos: Vector3) -> void:
global_position = world_pos
velocity = Vector3.ZERO
_goal = world_pos
_has_walk_goal = false
_nav_agent.set_target_position(world_pos)
func _set_horizontal_velocity_toward(point: Vector3) -> void:
var pos := global_position
var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z)
if dh.length_squared() < 1e-10:
dh = Vector3(1.0, 0.0, 0.0)
velocity = dh.normalized() * MOVE_SPEED
velocity.y = 0.0
## `is_on_floor()` comes from the last `move_and_slide()`. Skipping slide while idle reuses that
## stale floor state — OK for static floors; revisit if walkable geometry moves.
func _skip_move_when_idle_on_floor() -> bool:
return is_on_floor() and velocity.length_squared() < 1e-12
func _physics_process(_delta: float) -> void:
var to_goal: Vector3 = _goal - global_position
to_goal.y = 0.0
if to_goal.length() <= ARRIVE_EPS:
if not _has_walk_goal:
velocity = Vector3.ZERO
if _skip_move_when_idle_on_floor():
return
move_and_slide()
return
var full_to_goal: Vector3 = _auth_walk_goal - global_position
var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length()
var vert_err: float = absf(full_to_goal.y)
if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS:
velocity = Vector3.ZERO
_has_walk_goal = false
_nav_agent.set_target_position(global_position)
if _skip_move_when_idle_on_floor():
return
move_and_slide()
return
var nav_map: RID = _nav_agent.get_navigation_map()
if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
_set_horizontal_velocity_toward(_auth_walk_goal)
move_and_slide()
return
if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN:
_set_horizontal_velocity_toward(_auth_walk_goal)
move_and_slide()
return
if horiz_dist <= DIRECT_APPROACH_RADIUS:
_set_horizontal_velocity_toward(_auth_walk_goal)
else:
velocity = to_goal.normalized() * MOVE_SPEED
velocity.y = 0.0
var next_path_position: Vector3 = _nav_agent.get_next_path_position()
var to_next_h: Vector3 = Vector3(
next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z
)
if to_next_h.length_squared() > 0.0025:
_set_horizontal_velocity_toward(next_path_position)
else:
_set_horizontal_velocity_toward(_auth_walk_goal)
move_and_slide()

View File

@ -2,9 +2,10 @@ extends Node
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport).
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
signal authoritative_position_received(world: Vector3)
signal authoritative_position_received(world: Vector3, apply_as_snap: bool)
signal move_rejected(reason_code: String)
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
@ -79,7 +80,7 @@ func _on_request_completed(
Phase.BOOT_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text)
_emit_position_from_response(text, true)
Phase.POST_MOVE:
if response_code == 400:
_emit_move_rejection_if_present(text)
@ -96,7 +97,7 @@ func _on_request_completed(
Phase.VERIFY_GET:
_busy = false
if response_code == 200:
_emit_position_from_response(text)
_emit_position_from_response(text, false)
func _emit_move_rejection_if_present(json_text: String) -> void:
@ -110,7 +111,7 @@ func _emit_move_rejection_if_present(json_text: String) -> void:
move_rejected.emit("unknown")
func _emit_position_from_response(json_text: String) -> void:
func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void:
var parsed: Variant = JSON.parse_string(json_text)
if parsed == null:
return
@ -124,4 +125,4 @@ func _emit_position_from_response(json_text: String) -> void:
var x: float = float(pos.get("x", 0.0))
var y: float = float(pos.get("y", 0.0))
var z: float = float(pos.get("z", 0.0))
authoritative_position_received.emit(Vector3(x, y, z))
authoritative_position_received.emit(Vector3(x, y, z), apply_as_snap)

View File

@ -22,6 +22,7 @@ Deliver the foundational client runtime: movement, locked isometric camera, worl
- Key contracts: `MoveCommand`, `PositionState`, `InteractionRequest`
- Dependencies: None
- Stage target: Prototype
- Status: **Ready** in the [module dependency register](../modules/module_dependency_register.md); Jira [NS-10](https://neon-sprawl.atlassian.net/browse/NS-10) **Done** for prototype scope — [module doc](../modules/E1_M1_InputAndMovementRuntime.md#implementation-snapshot) for snapshot and follow-on work.
### E1.M2 - IsometricCameraController

View File

@ -7,7 +7,7 @@
| **Module ID** | E1.M1 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); [implementation snapshot](#implementation-snapshot)) |
| **Status** | Ready — prototype milestone complete ([NS-10](https://neon-sprawl.atlassian.net/browse/NS-10) Done); see [dependency register](module_dependency_register.md) and [implementation snapshot](#implementation-snapshot) |
## Purpose
@ -33,8 +33,8 @@ Contract readiness is tracked in the [module dependency register](module_depende
## Implementation snapshot
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17).
- **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17). Jira **[NS-10](https://neon-sprawl.atlassian.net/browse/NS-10)** (E1.M1 Feature) is **Done** for this prototype scope.
- **Follow-on:** Client prediction/reconciliation; Epic 1 Slice 1 telemetry and movement-loop polish; optional **Protobuf** wire promotion for `MoveCommand` / `PositionState` per [contracts.md](contracts.md).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
## Module dependencies

View File

@ -45,7 +45,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| Module | Register status | Snapshot | Plans / pointers |
|--------|-----------------|----------|-------------------|
| E1.M1 | In Progress | Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client sync (NS-16); **InteractionRequest** + horizontal range (NS-18). Prediction / full slice still open. | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), [NS-17](../../plans/NS-17-implementation-plan.md), [NS-18](../../plans/NS-18-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
| E1.M1 | Ready | Prototype milestone **Done** ([NS-10](https://neon-sprawl.atlassian.net/browse/NS-10)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot sync + path-follow ([NS-16](../../plans/NS-16-implementation-plan.md), [NS-23](../../plans/NS-23-implementation-plan.md)); **InteractionRequest** + horizontal range ([NS-18](../../plans/NS-18-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), [NS-17](../../plans/NS-17-implementation-plan.md), [NS-18](../../plans/NS-18-implementation-plan.md), [NS-19](../../plans/NS-19-implementation-plan.md), [NS-23](../../plans/NS-23-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
---

View File

@ -12,7 +12,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|---|---|---|---|---|---|
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | In Progress |
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | Ready |
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | Planned |
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | Planned |
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned |

View File

@ -54,7 +54,7 @@
5. **Godot client****Split scripts by concern** (required repo policy: [godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md)); do **not** grow a monolithic `main.gd` with pick + HTTP + wiring.
- **`ground_pick.gd`** on a new **`Node3D`** child (e.g. `GroundPick`): owns walkable **ray pick** (logic moved out of NS-14 `main.gd`). Exposes **`target_chosen(Vector3)`** when the user clicks valid ground. **`main.gd`** sets **`fallback_camera`** (or equivalent) in `_ready`.
- **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3)`** so **`main`** only snaps the player.
- **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3, bool)`** (NS-23: second arg = boot snap vs nav goal) so **`main`** snaps on boot and path-follows after move verify.
- **`main.gd`:** Thin composer only—connect signals, call **`sync_from_server()`** on run, delegate pick and HTTP to the two child scripts.
- Align **client README** with NS-14 note: server authority path replaces pure local steering for this demo.

View File

@ -0,0 +1,126 @@
# NS-23 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NS-23 |
| **Title** | E1.M1: Client path-follow baseline (navigation + visible locomotion) |
| **Jira** | [NS-23](https://neon-sprawl.atlassian.net/browse/NS-23) |
| **Parent context** | [NS-10 — E1.M1 InputAndMovementRuntime](https://neon-sprawl.atlassian.net/browse/NS-10) · [NS-1 — Epic 1](https://neon-sprawl.atlassian.net/browse/NS-1) · Slice 1 — Movement and position sync |
| **Decomposition** | [E1.M1 — InputAndMovementRuntime](../decomposition/modules/E1_M1_InputAndMovementRuntime.md) |
**Delivery:** Ship **`docs/plans/NS-23-implementation-plan.md`** on the **same branch / PR** as the NS-23 implementation (plan + code together), matching the NS-19 pattern.
## Goal, scope, and out-of-scope
**Goal:** Deliver **visible** click-to-move: the avatar **moves** toward the verified server target using **`NavigationAgent3D`** + a **baked mesh** where applicable, instead of teleporting on the verifying **`GET`**. Aligns with E1.M1: path-follow baseline under server authority.
**In scope**
- **Godot:** `NavigationRegion3D` (baked mesh) over the prototype walkable floor in `main.tscn` (obstacle geometry included in bake for future use); **`NavigationAgent3D`** on the player driving **`CharacterBody3D`** motion (`move_and_slide` toward next path position when the client is following the mesh — Godot 4.x pattern).
- **Pick integration:** Reuse **`ground_pick.gd`** (`walkable` group, upward normal); clicked point becomes **`NavigationAgent3D.target_position`** (after any needed Y / agent height adjustment consistent with current capsule).
- **Server authority (unchanged contract):** One **`MoveCommand`** per successful pick with the **same world target** as today ([NS-16](NS-16-implementation-plan.md)); server **v1 snap** + **[NS-19](NS-19-implementation-plan.md)** validation unchanged.
**Out of scope (per Jira)**
- Full prediction/reconciliation, MMO-grade netcode, server-side nav mesh validation.
- Replacing **`InteractionRequest`** / range rules.
## Locked — client nav vs server authority (this slice)
| Topic | Decision |
|--------|----------|
| **When to POST** | **Once per click**, immediately with the **picked destination** (same JSON as today). No waypoint streaming to the server in this story. |
| **Server truth** | Still **snap to target** on **200** after validation; **`GET /position`** after POST remains the reconciliation read (existing `PositionAuthorityClient` flow). |
| **Straight-line vs path** | NS-19 validates **displacement** from **current authoritative position** to **target** (horizontal + vertical limits). It does **not** check path clearance through geometry. **Client** may follow nav waypoints when the descend bypass is inactive; **automatic obstacle detours are not required** for this slice (see **Locked — prototype movement tradeoff**). |
| **After successful move** | **Do not** teleport the avatar on the verifying **`GET`** when the player is expected to walk: apply the authoritative position as a **navigation goal** and let the agent complete the path; **initial boot** `sync_from_server` remains a **hard snap** so spawn matches the server. |
| **POST 400** | Existing behavior: emit **`move_rejected`**, show NS-19 UX; **do not** start (or clear) a nav path to that target. |
| **Distance limits** | Default **`MaxHorizontalStep`** (e.g. 18 m) remains compatible with **single-command** long clicks across the prototype; if a future scene needs shorter steps, chain commands or tune limits in a later story. |
## Locked — prototype movement tradeoff
| Topic | Decision |
|--------|----------|
| **Uneven surfaces vs obstacles** | The client uses a **descend bypass** in `player.gd`: when the authoritative goal **Y** is below the body origin (typical for floor picks vs mid-capsule), steering goes **straight in xz** toward the goal instead of following nav waypoints first. This keeps **stepped bumps** smooth; it does **not** guarantee routing around tall **static obstacles** on a **single** click. |
| **Player expectation** | **Multi-click** (or chained moves) is acceptable to navigate around geometry the client would otherwise bee-line into. **No** automatic obstacle navigation is required for NS-23 closure. |
| **Residual polish** | Visible idle jitter at rest is tracked as tech debt — [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) (not a blocker for this story). |
## Acceptance criteria checklist
- [x] Clicking a valid floor target produces **continuous motion** toward the verified target without sliding through geometry in the common case; **navmesh waypoints** are used when the descend bypass does not apply. **Obstacle detours on one click are not required** (tradeoff above).
- [x] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout).
- [x] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice.
## Technical approach
1. **`main.tscn` / navigation**
- Add **`NavigationRegion3D`** covering walkable floor; include obstacle **`StaticBody3D`** geometry in baking as required by Godot 4 so paths wrap obstacles.
- Bake after layout changes; document any **editor step** in `client/README.md` if bake is not fully reproducible from scene alone.
2. **Player / agent**
- Attach **`NavigationAgent3D`**; replace NS-14-style **direct steering toward `_goal`** with **path following** (velocity toward `get_next_path_position()` or equivalent, `move_and_slide`).
- Keep **`snap_to_server`** for **boot** and any **hard reconcile** case you explicitly document (default: boot only).
3. **Wiring (`main.gd` / `PositionAuthorityClient`)**
- On **`target_chosen`**: call **`submit_move_target`** as today (POST → GET).
- Distinguish **authoritative position from boot** vs **after successful move** so **`main.gd`** snaps on boot but sets **nav target** (no snap) after verify when implementing path-follow.
- Optional small API: second signal, enum on existing signal, or `sync_from_server`-only snap path — pick the smallest change that stays readable per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md).
4. **Documentation**
- **`client/README.md`:** Movement section: nav agent + server authority, and the **uneven-surface vs obstacle** tradeoff (multi-click around obstacles).
- **`server/README.md`:** Server validates **end target** and **step**; **client** nav is **presentational** only (no server navmesh); obstacle detours not guaranteed.
## Resolved — agent shape vs capsule (was risk)
Prototype player collider in [`main.tscn`](../../client/scenes/main.tscn): **`CapsuleShape3D_player`** — **`radius = 0.4`**, **`height = 1.0`**.
| Setting | Locked choice |
|--------|----------------|
| **`NavigationAgent3D.radius` / `height`** | Match the **physics** capsule (**0.4** / **1.0**) so nav clearance matches what `move_and_slide` actually sweeps. |
| **Navmesh bake agent** | In **`NavigationRegion3D`** bake settings, use the **same** radius/height (or Godots equivalent **agent** parameters) so baked polygons match runtime pathfinding. |
| **Corner clipping vs “stuck”** | If the mesh clips into box corners, **increase** agent radius slightly and **rebake** (wider corridor on the mesh) before shrinking physics. Do not silently shrink the capsule without an explicit design change. |
| **Vertical alignment** | Keep **`path_height_offset`** (or equivalent) at **0** unless the capsule foot position and floor snap misalign; the pick already supplies floor **`Y`**. |
| **Arrival** | Align **`target_desired_distance`** (and related agent tolerances) with existing player **`ARRIVE_EPS`** (~**0.35** m) so stop distance matches prior steering feel unless QA says otherwise. |
## Resolved — navigation sync / frame order (was risk)
| Topic | Locked choice |
|--------|----------------|
| **Where motion runs** | Path queries (`get_next_path_position`, velocity toward next point) and **`move_and_slide`** run only in **`Player._physics_process`**, not in `_process` or UI thread. |
| **When `target_position` is set** | `main.gd` (or signal handler) may set the agent target in response to **`target_chosen`** / authority callbacks; the **first** consumable segment appears after the navigation map updates. |
| **First frame after new target** | If **`NavigationAgent3D.is_navigation_finished()`** or a zero-length first step appears on the **same** frame as `target_position` assignment, **defer** one tick: `await get_tree().physics_frame` **once** after setting target **or** simply rely on **next** `_physics_process` (preferred — avoid await in hot path). **Do not** poll path from `_input` without deferral. |
| **Map readiness** | After adding **`NavigationRegion3D`**, ensure the region is **baked** and enabled before F5; startup **`sync_from_server`** snap does not need a path. |
## Files to add (expected)
| Path | Purpose |
|------|---------|
| None required by name | Prefer extending existing `player.gd` / scene unless a dedicated `player_navigation.gd` reduces `main.gd` bloat. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | `NavigationRegion3D`, bake source meshes, agent node setup. |
| `client/scripts/player.gd` | Path-follow via `NavigationAgent3D`; clarify header (NS-14 → NS-23 evolution). |
| `client/scripts/main.gd` | Boot snap vs post-move nav goal wiring. |
| `client/scripts/position_authority_client.gd` | If needed: signal shape or phase so main can snap only on boot. |
| `client/README.md` | Manual check steps for path-follow + server. |
| `server/README.md` | Client nav vs authority note (AC). |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Implementation snapshot when NS-23 ships. |
## Tests
| Action | What to cover |
|--------|----------------|
| **Manual (required)** | Server running; click-move toward targets (including stepped bumps); NS-19 reject targets — same rejection behavior as before; cold start — player matches server position. Optional: confirm multi-click can route around the prototype **`Obstacle`** when a single click bee-lines. |
| **Automated** | None required for this story unless an existing headless hook can assert nav baking (unlikely); prefer manual for Godot nav. |
## Open questions / risks
**Prototype tradeoff** is explicit under **Locked — prototype movement tradeoff** (bumps vs single-click obstacle routing). Agent/bake alignment and physics-frame path consumption remain locked in **Resolved — agent shape vs capsule** and **Resolved — navigation sync / frame order**. Reopen if scene scale or avatar collider changes materially.
## PR / review
Cross-check [E1.M1](../decomposition/modules/E1_M1_InputAndMovementRuntime.md), [client README](../../client/README.md), and [server README](../../server/README.md). Confirm NS-19 props in `main.tscn` still behave after nav region changes.

View File

@ -0,0 +1,55 @@
# Code review: NS-23 client path-follow (`NS-23-client-path-follow`)
**Date:** 2026-04-05
**Scope:** Branch `NS-23-client-path-follow` vs `origin/main` (full NS-23 slice: client nav, wiring, docs, small server remark).
**Base:** `origin/main`
**Follow-up:** Design and docs were aligned after the initial review; blocking items and suggestions below are **done** unless marked otherwise.
## Verdict
**Approve** — original **Request changes** (plan/README vs obstacle wording) and review **suggestions** 13 are **resolved** in follow-up commits. Optional **nit** (rename `DESCEND_GOAL_Y_MARGIN`) remains open.
## Summary
The branch adds **`NavigationRegion3D`** / **`NavigationAgent3D`**, sync nav bake in **`main.gd`**, boot snap vs post-move nav goal via **`PositionAuthorityClient`**s second signal argument, and horizontal path-follow in **`player.gd`**. **`ground_pick.gd`** gains stepped walkable picking for steep surfaces. Server change is documentation-only on **`MoveCommandValidation`** (vertical step remark).
**`player.gd`** intentionally skips nav waypoints when **`_auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN`**, so for typical picks (floor **Y** vs mid-capsule origin) the client often **bee-lines in xz** toward the goal. That matches the **locked prototype tradeoff** (smooth bumps; multi-click around obstacles when needed).
**Idle optimization:** skipping **`move_and_slide()`** when there is no walk goal, velocity is zero, and **`is_on_floor()`** is a reasonable jitter mitigation; residual vibration is tracked in **[NS-24](https://neon-sprawl.atlassian.net/browse/NS-24)** (Tech Debt).
## Documentation checked
| Document | Result (at closure) |
|----------|---------------------|
| `docs/plans/NS-23-implementation-plan.md` | **Matches** after **Locked — prototype movement tradeoff**, updated AC, tests, and **NS-24** link. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** — snapshot notes single-click obstacle detours not guaranteed. |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server owns **`PositionState`**; client presentation path valid. |
| `docs/decomposition/modules/module_dependency_register.md` / `documentation_and_implementation_alignment.md` | **N/A** deep diff. |
| `client/README.md` | **Matches** — tradeoff, manual steps, **Known issue** + **NS-24** link. |
| `server/README.md` | **Matches** — client nav presentation; detours not guaranteed on one click. |
## Blocking issues
**All resolved.**
1. ~~**Plan and README vs behavior:**~~ **Done.** Plan and **`client/README.md`** now state the **locked tradeoff** (descend bypass → xz bee-line; navmesh detours not guaranteed on one click; multi-click OK). AC checklist and manual QA updated accordingly.
## Suggestions
**All done.**
1. ~~**`DESCEND_GOAL_Y_MARGIN`:**~~ **Done.** Comment added in `client/scripts/player.gd` (surface pick Y vs mid-capsule; bumps vs obstacles).
2. ~~**`_skip_move_when_idle_on_floor`:**~~ **Done.** Doc comment above the helper (`is_on_floor()` / last `move_and_slide()`; static floors).
3. ~~**NS-24:**~~ **Done.** Out of NS-23 scope in plan; **[NS-24](https://neon-sprawl.atlassian.net/browse/NS-24)** linked from **`NS-23-implementation-plan.md`** and **`client/README.md`**.
## Nits
- **Nit (optional):** Consider renaming **`DESCEND_GOAL_Y_MARGIN`** to something that signals “auth pick Y vs body origin” to reduce future “why do we always bee-line?” confusion. **Not done** — purely optional.
## Verification
- `cd server/NeonSprawl.Server && dotnet test` (no logic change expected, sanity).
- Godot **F5** with server: boot snap; click floor; bump flow; gray **Obstacle** may need **multiple clicks** per docs.
- Confirm NS-19 reject targets still show rejection UX (`reasonCode` / label).
- `gdlint` / `gdformat` on touched GDScript if CI does not already run on the branch.

View File

@ -4,6 +4,8 @@ namespace NeonSprawl.Server.Game.PositionState;
/// <remarks>
/// <para><b>Precedence</b> when multiple rules fail: <see cref="MoveCommandReasonCodes.HorizontalStepExceeded"/>,
/// then <see cref="MoveCommandReasonCodes.VerticalStepExceeded"/>, then <see cref="MoveCommandReasonCodes.OutOfBounds"/>.</para>
/// <para>Vertical uses <c>abs(ΔY)</c>, so <b>descending</b> (target Y lower than current) is allowed when the
/// drop is within <see cref="MovementValidationOptions.MaxVerticalStep"/> — same as climbing.</para>
/// </remarks>
public static class MoveCommandValidation
{

View File

@ -63,6 +63,8 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
**`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply.
**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**; the client may follow waypoints when active, but **automatic obstacle detours on a single click are not guaranteed** (tradeoff for smooth movement on stepped geometry). The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice.
**NS-19 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation.
| Config key | Meaning | Default (see `appsettings.json`) |