diff --git a/.cursor/rules/godot-client-script-organization.md b/.cursor/rules/godot-client-script-organization.md index 2f78b5a..168a2d6 100644 --- a/.cursor/rules/godot-client-script-organization.md +++ b/.cursor/rules/godot-client-script-organization.md @@ -12,7 +12,7 @@ Avoid a single **monolithic `main.gd`**. Treat the main scene root as a **compos - **`main.gd` (scene root):** Bootstrapping only—attach children, connect signals, optional high-level toggles. If a block grows past ~50 lines or a clear concern appears, **extract** it. - **Avatar / props:** Keep behavior on the node that owns it (e.g. `player.gd` on `CharacterBody3D`). -- **Cross-cutting features:** Prefer a **child `Node`** (or `Node3D`) with its own script, e.g. ground picking → `ground_pick.gd`, server HTTP sync → `position_authority_client.gd`. Use **signals** or small public methods so `main` does not call into deep implementation details. +- **Cross-cutting features:** Prefer a **child `Node`** (or `Node3D`) with its own script, e.g. server HTTP sync → `position_authority_client.gd`, interaction POST → `interaction_request_client.gd`. Use **signals** or small public methods so `main` does not call into deep implementation details. ## `class_name` vs headless / CI @@ -30,8 +30,8 @@ Use **sparingly** (e.g. shared config many scenes need). Default to **scene-loca ## Examples ```text -✅ main.gd: _ready() → get_node, connect ground_pick.target_selected to _on_target -✅ ground_pick.gd: raycast + walkable check → emit target_selected(Vector3) +✅ main.gd: _ready() → get_node, connect authority.authoritative_position_received to _on_snap +✅ position_authority_client.gd: HTTP + emit authoritative_position_received ❌ main.gd: 300 lines of HTTP + ray math + player tuning in one file ``` diff --git a/client/README.md b/client/README.md index abfd7a0..e0bf5ae 100644 --- a/client/README.md +++ b/client/README.md @@ -13,7 +13,7 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** ## Follow camera (NEO-15) -The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the pre–NEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. **`ground_pick`** uses the viewport camera; **`main.gd`** sets **`fallback_camera`** to **`World/IsometricFollowCamera/Camera3D`**. +The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow_camera.gd`): client-local **isometric follow** for **`Player`** (`NodePath` **`../Player`**), with damped eye motion (`follow_smoothness`, exponential lerp) and **teleport snap** when the eye is farther than **`snap_distance`** from the desired pose (e.g. after **`snap_to_server`**). Framing exports **`follow_distance`**, **`pitch_elevation_deg`**, and **`presentation_yaw_deg`** match the pre–NEO-15 static camera at spawn. **Orbit is off in the prototype:** **`allow_yaw`** is false, **`CameraState.yaw`** stays **0**, and no rotate input is read—enable **`allow_yaw`** later and drive **`_orbit_yaw_rad`** from input on the rig (see **`docs/decomposition/modules/E1_M2_IsometricCameraController.md`**). **`CameraState`** (`scripts/camera_state.gd`) is refreshed every **`_physics_process`** tick on the rig (**`process_physics_priority`** after **`Player`**) so framing tracks **`move_and_slide`** without display-vs-physics jitter. ## Prototype district (NEON-29) @@ -29,7 +29,7 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen | `PrototypeTerminal` | `walkable` | Slate blue | 0.9 × 0.4 | (0, 0) | Interaction target | | `MoveRejectPedestal` | `walkable` | Orange | 1.5 × 1.5 | (7.5, −6.5) | NEON-7 vertical reject | | `MoveRejectFarPad` | `walkable` | Blue-gray | 2.5 × 2.5 | (9, 9) | Near-floor walkable pad; no longer a reject target (horizontal limit removed NEON-29) | -| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Original occlusion / click-through target | +| `Obstacle` | `occluder` | Default gray | 2 × 2 | (6, 5) | Occlusion policy demo | | `ObstacleB` | `occluder` | Default gray | 3 × 3 | (−10, 5) | Second occluder (wider angle from spawn) | | `ObstacleC` | `occluder` | Default gray | 2 × 4 | (5, −12) | Tall occluder, south quadrant | | `ObstacleD` | `occluder` | Default gray | 3 × 2 | (−8, −16) | Far-south occluder | @@ -40,41 +40,43 @@ Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screen ### Height variation notes -- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one click suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup. -- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — click the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violet’s **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention. +- **Single-step terraces (A, C):** 0.3 m rise — within `agent_max_climb = 0.35`, so the nav mesh routes directly from floor onto the pad; one steady WASD approach suffices. **`TerracePlatformA`** (teal) sits **12 mm** above the main floor slab and has **cardinal walkable treads** (`TerracePlatformA_Approach`) so movement does not rely on a bare vertical box lip; nav rebakes at startup. +- **Two-level terrace B:** floor → `TerraceStepB` (**gold**, 0.3 m) → `TerracePlatformB` (**violet**, +0.3 m). **`agent_max_climb = 0.35`** applies to the nav mesh **and** to **walk step assist**: you cannot cheese the violet deck from the floor in one move — use the **gold step** (or the platform after you are already on the step), not the violet top from below. **Approach treads** on gold (**`TSB_*`**) and on violet’s **north / east / west** (**`TPB_*`**, six risers for 0.6 m) match the teal convention. - **QA / thin props:** **`PrototypeTerminal`**, **`MoveRejectPedestal`**, **`MoveRejectFarPad`** are unchanged (terminal kiosk, vertical reject demo, near-floor pad). Add ramps or treads later if they need full walk-up treatment. - **Occluders** are plain `StaticBody3D` with no `walkable` tag; the nav bake excludes their top surfaces, so they remain true obstacles for routing. ### Designer / QA limits -- Floor bounds: **±22.5 m** in X and Z from world origin. Clicks outside the `NavigationRegion3D` boundary (beyond the floor edge) will be rejected by `NavigationAgent3D` path queries. +- Floor bounds: **±22.5 m** in X and Z from world origin. Nav queries outside the `NavigationRegion3D` boundary (beyond the floor edge) fail as expected. - The `MoveRejectPedestal` (orange box) top is ~2.5 m above the floor and will be rejected by the server's `MaxVerticalStep` (**2.2 m** by default) — the only remaining move-rejection criterion with default server config. Horizontal distance is never a rejection reason when `HorizontalStepEnabled` is false. -- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy and NEON-30 click-through both apply automatically. +- New obstacles are tagged `"occluder"` so the NEON-27 occlusion policy applies automatically. -## Authoritative movement (NEO-7, NEO-11) +## Authoritative movement (NEO-7, NEO-11, NEO-22) -With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEO-7); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEO-11). **Boot** `sync_from_server()` **snaps** once so spawn matches the server. +With the game server running ([`server/README.md`](../server/README.md)), **WASD** (input actions **`move_*`** in `project.godot`) drives **camera-relative XZ** wish direction on the **`Player`**, and **`main.gd`** periodically **`POST`**s **`/game/players/{id}/move-stream`** with the **latest** body-centre sample while you are moving (no stream while standing still, so idle anchor does not fight HTTP). **`PositionAuthorityClient`** coalesces to **one target per request** so a slow round-trip cannot apply a stale multi-step chain after the capsule has moved. The server validates each leg with the same rules as a single **`MoveCommand`**, applies **all targets in order** (reject = **no** partial apply), and returns **`PositionStateResponse`**. A successful **`move-stream`** response is treated as an **ack only** (no client teleport): the echoed position typically trails **`move_and_slide`** by roughly one RTT, so snapping it caused rubber-banding. **`authoritative_position_received`** is emitted only from **boot** `GET` so **`main.gd`** can **`snap_to_server`**. Left-click **ground pick / `POST /move`** has been removed from the client. -**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolt’s per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the click (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles on one click is still not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. +**Boot** `sync_from_server()` **snaps** once so spawn matches the server. + +**Tradeoff (prototype):** Horizontal motion is a **direct XZ bee-line** toward the server’s verified target. The baked **`NavigationMesh`** is still used when **vertical routing** is latched **and** the capsule is inside the **nav column** hysteresis band (`NAV_COLUMN_STEER_ENTER_DIST` / `EXIT`) **and** **`is_on_floor()`** — terraces / steps then steer along **`get_current_navigation_path()`** using the first waypoint at least **`NAV_PATH_STEER_MIN_LOOKAHEAD`** (~22 cm) away in XZ (unlike a **5 cm** scan or raw **`get_next_path_position()`**, which both sat inside Jolt’s per-tick slide and could flip velocity **180°** every tick). While **airborne**, column path steering is **off** so horizontal motion stays a bee-line toward the movement goal (avoids fall-time XZ oscillation). **No descend bypass** for gravity while on an upper deck (same as before). While walking, **gravity** still runs when **`is_on_floor()`** is false; if **snap / lip contacts** keep **`is_on_floor()`** true over open space (e.g. gold deck → gray floor), **`player.gd`** casts **several short down rays** under the **foot disk** (center + offsets along move direction so the **leading** edge off a deck is tested) and applies **gravity** when **none** hit upward-facing ground within **`WALK_SUPPORT_PROBE_DEPTH`**. In that case **`floor_snap_length`** is set to **0** for the tick so **moving snap** does not cancel **`velocity.y`** on every physics step. **Automatic routing around tall obstacles in one straight push is still not guaranteed** — steer around e.g. the gray **`Obstacle`** with WASD as needed. **NEO-10 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn. -**Idle stability (NEO-14 / NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**. +**Idle stability (NEO-14 / NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. **Uniform shallow ramps** can latch stable idle; **nav-column path steering** is gated to flatter floors so ramp ribbons do not flip velocity each tick. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat-floor arrivals clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**. -- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEO-20), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEO-15 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). +- **Scripts:** `scripts/position_authority_client.gd` (`PositionAuthorityClient`: boot `GET`, `move-stream` `POST`), `scripts/player.gd` (WASD locomotion + nav assist when a goal exists), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEO-15 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). - **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor. - **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`). -### Manual check (NEO-7 + NEO-11) +### Manual check (NEO-7 + NEO-11 + NEO-22) 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 **snap** to the server’s default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEO-9 walk demo). -4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** in **XZ** straight toward that target (nav path only for **vertical** routing when you are already near the goal column on **flatter** floor). Server-rejected clicks show the reject label and do **not** start a path. -5. Click the **pedestal top** (orange box at ~(7.5, 0, −6.5)) to confirm **`vertical_step_exceeded`** rejection — the top is ~2.5 m above floor, which exceeds default **`MaxVerticalStep` (2.2 m)**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) is a normal walkable surface; clicking it from any distance should succeed. -6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run). +4. **WASD:** walk on the floor; the HUD position label should update and the server should accept **`move-stream`** steps (watch server logs or use a proxy if needed). **Release keys** and confirm the capsule settles to **idle** without runaway drift. +5. Walk to the **orange reject pedestal** and try to climb it with WASD; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`** (watch the **`MoveRejectLabel`** or server logs). The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface. +6. After movement, **stand idle** for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run). -If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes. +If the server is **down**, boot **`GET`** fails silently (check Output for warnings). **`move-stream`** submissions are skipped while the HTTP client is busy, then resume. ## Interaction + range preview (NEO-9) @@ -86,32 +88,20 @@ The main scene includes a **prototype terminal** at the map center (same world * ### Manual check (NEO-9) 1. Run the game server and client as in NEO-7. -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). +2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **WASD** 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. -### Manual check (NEO-20 — occluder click-through) - -1. Run the main scene (**F5**) with the server running. -2. **While `Obstacle` is opaque** (player not behind it): left-click the ground directly behind `World/NavigationRegion3D/Obstacle` (roughly `(6, 0, 5)` + a few units further). The player should receive a valid move target and walk toward that position — not stop or ignore the click. -3. **While `Obstacle` is faded** (player positioned so the occlusion policy is actively fading the obstacle): repeat the same click. Behavior should be identical — the occluder fade state must not affect pick success. -4. **Regression — normal floor:** click a clear area of the floor; confirm the player walks there as before. -5. **Regression — far pad / pedestal:** confirm click-rejection behavior on `MoveRejectPedestal` and `MoveRejectFarPad` is unchanged. - ## Movement prototype (NEO-5 → NEO-11) **`player.gd`** steers **XZ** toward the authoritative goal and uses **`NavigationAgent3D`**’s **current path** only for the **vertical-routing** case above (on sufficiently flat floor); **`move_and_slide()`** does the motion. **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). -- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor. +- The avatar is on **physics layer 2** with **collision_mask** **3** (scans layers **1** and **2** so all walkables/occluders pair reliably). Environment **StaticBody3D** nodes stay on **collision_layer** **1** with **collision_mask** **3** as well. ### Manual check (remnants) 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 repo’s intended demo—**run the server** for the demo path. -### Clicks still ignored? - -In the **Game** dock, **Input** must be active (not the **2D** / **3D** scene-picking tools) so events go to the game. If Input is on and clicks still do nothing, pick rays must use the **viewport’s current camera** and **mouse position** (the script does this so the embedded Game view matches the ray). - ## First run 1. Install [Godot 4.x](https://godotengine.org/download). @@ -150,7 +140,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. -**Scope:** Unit tests cover **`player.gd`**, **`position_authority_client.gd`**, **`ground_pick.gd`** (walkable collider check + occluder ancestry check), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`**, full **`_input` / ray pick** flows, and scene wiring are **not** automated here—use manual checks above. +**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. diff --git a/client/project.godot b/client/project.godot index 5ab0b4b..05e57ce 100644 --- a/client/project.godot +++ b/client/project.godot @@ -63,6 +63,26 @@ dev_toggle_occluder_obstacle={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":75,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null) ] } +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":65,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":68,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +] +} +move_forward={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":87,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) +] +} +move_back={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index ffe3f6d..cd0fc52 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -2,7 +2,6 @@ [ext_resource type="Script" uid="uid://bh04b3iify0hd" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"] -[ext_resource type="Script" uid="uid://v0xpntk6uumr" path="res://scripts/ground_pick.gd" id="3_ground"] [ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"] [ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"] [ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"] @@ -1106,9 +1105,6 @@ cast_shadow = 0 mesh = SubResource("CapsuleMesh_player") surface_material_override/0 = SubResource("Mat_player_capsule") -[node name="GroundPick" type="Node3D" parent="." unique_id=2500001] -script = ExtResource("3_ground") - [node name="PositionAuthorityClient" type="Node" parent="." unique_id=2500002] script = ExtResource("4_auth") diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd deleted file mode 100644 index d52b798..0000000 --- a/client/scripts/ground_pick.gd +++ /dev/null @@ -1,124 +0,0 @@ -extends Node3D - -## NS-16: walkable ground ray pick. Emits world target for MoveCommand; camera wired from main. -## Flat tops on `occluder` bodies (horizontal normal) are valid destinations; vertical occluder -## faces still punch through so ground behind can be picked. -## NS-19: reject vertical faces (pedestal). Stepped ray: steep walkable hits (bump slopes) advance -## 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.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 - -## Nudge past an occluder body so the ray reaches walkable ground behind it (meters along view ray). -const OCCLUDER_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`. -var fallback_camera: Camera3D - - -func _ready() -> void: - set_process_input(true) - - -func _input(event: InputEvent) -> void: - if event is InputEventMouseButton: - var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: - _try_pick(get_viewport().get_mouse_position()) - - -func _try_pick(screen_pos: Vector2) -> void: - var cam: Camera3D = get_viewport().get_camera_3d() - if cam == null: - cam = fallback_camera - if cam == null: - return - - var ray_dir: Vector3 = cam.project_ray_normal(screen_pos) - 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 - 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 - var hit_collider: Variant = hit.get("collider") - if _collider_is_occluder(hit_collider): - var hit_normal_o: Variant = hit.get("normal", Vector3.ZERO) - if hit_normal_o is not Vector3: - break - var nrm_o: Vector3 = (hit_normal_o as Vector3).normalized() - var ndot_o: float = nrm_o.dot(Vector3.UP) - if ndot_o >= MIN_WALKABLE_NORMAL_DOT_UP: - var top_pos: Variant = hit.get("position") - if top_pos is Vector3: - chosen = top_pos as Vector3 - have_chosen = true - break - var occluder_pos: Variant = hit.get("position") - if occluder_pos is not Vector3: - break - from = (occluder_pos as Vector3) + ray_dir * OCCLUDER_PICK_THROUGH - continue - if not _collider_is_walkable(hit_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: - var n: Node = collider as Node - while n: - if n.is_in_group("walkable"): - return true - n = n.get_parent() - return false - - -func _collider_is_occluder(collider: Variant) -> bool: - var n: Node = collider as Node - while n: - if n.is_in_group("occluder"): - return true - n = n.get_parent() - return false diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ce4946e..438bf09 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,8 +1,10 @@ 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. +## NS-16: server authority; see `position_authority_client.gd`. NS-18: interaction POST + radius +## glow preview (see child nodes). +## NEO-22: WASD locomotion + `move-stream` samples (`player.gd`, +## `position_authority_client.gd`). +## NS-23: bakes `NavigationRegion3D` on startup; boot snap from authority `GET`. ## NEO-15: follow camera is `World/IsometricFollowCamera/Camera3D` ## (see `isometric_follow_camera.gd`). ## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see @@ -19,8 +21,30 @@ extends Node3D const MOVE_REJECT_MSG_SECONDS: float = 4.0 +## Throttle `move-stream` POSTs (~20 Hz at 120 physics ticks/sec). +const STREAM_PHYSICS_INTERVAL: int = 6 + +## Skip `move-stream` while standing still so idle + anchor are not fighting HTTP snaps. +const STREAM_IDLE_VEL_HZ_SQ: float = 0.0004 + +## Ignore authority snaps smaller than this (m) to avoid micro-flicker from float / echo noise. +const AUTH_SNAP_MIN_HORIZONTAL: float = 0.08 +const AUTH_SNAP_MIN_VERTICAL: float = 0.04 + +## While moving, ignore stream snaps smaller than this (XZ m): responses are often one RTT behind +## integrated `move_and_slide`, so snapping causes rubber-band / bounciness. +const AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: float = 0.55 +## Horizontal speed² above this (m²/s²) counts as “moving” for snap suppression (~0.35 m/s). +const AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ: float = 0.12 + ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 +var _stream_physics_counter: int = 0 +## After the first `apply_as_snap` authority update, micro-snaps may be skipped +## (boot always applies). +var _allow_authority_soft_snap_skip: bool = false +## Next `apply_as_snap` must not be suppressed (e.g. `move-stream` 400 → `sync_from_server`). +var _authority_force_snap_next: bool = false ## [InputMap] [code]dev_toggle_occluder_obstacle[/code] (default **Ctrl+Shift+K**): ## Restored when the obstacle is shown again. @@ -34,7 +58,6 @@ var _dev_obstacle_smoke: Node3D @onready var _world: Node3D = $World @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D @onready var _player: CharacterBody3D = $World/Player -@onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel @@ -54,8 +77,6 @@ func _ready() -> void: _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( "authoritative_position_received", Callable(self, "_on_authoritative_position") ) @@ -68,24 +89,66 @@ func _ready() -> void: func _physics_process(_delta: float) -> void: if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label): return + var wish: Vector3 = _locomotion_wish_world_xz_from_camera() + _player.call("set_locomotion_wish_world_xz", wish) + var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared() + var moving: bool = wish.length_squared() > 1e-10 or vel_hz_sq > STREAM_IDLE_VEL_HZ_SQ + if moving: + _stream_physics_counter += 1 + if _stream_physics_counter >= STREAM_PHYSICS_INTERVAL: + _stream_physics_counter = 0 + var batch: Array = [_player.global_position] + _authority.call("submit_stream_targets", batch) + else: + _stream_physics_counter = 0 var p: Vector3 = _player.global_position _player_pos_label.text = "x: %.3f\ny: %.3f\nz: %.3f" % [p.x, p.y, p.z] -func _on_target_chosen(world: Vector3) -> void: - _authority.call("submit_move_target", world) +func _locomotion_wish_world_xz_from_camera() -> Vector3: + var v: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back") + if v.length_squared() < 1e-8: + return Vector3.ZERO + if not is_instance_valid(_camera): + return Vector3.ZERO + var forward: Vector3 = -_camera.global_transform.basis.z + forward.y = 0.0 + if forward.length_squared() < 1e-10: + return Vector3.ZERO + forward = forward.normalized() + var right: Vector3 = forward.cross(Vector3.UP) + if right.length_squared() < 1e-10: + return Vector3.ZERO + right = right.normalized() + var w: Vector3 = right * v.x + forward * (-v.y) + if w.length_squared() < 1e-10: + return Vector3.ZERO + return w.normalized() 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) + if not apply_as_snap: + return + var force: bool = _authority_force_snap_next + _authority_force_snap_next = false + var p: Vector3 = _player.global_position + var dh: float = Vector2(world.x - p.x, world.z - p.z).length() + var dy: float = absf(world.y - p.y) + if _allow_authority_soft_snap_skip and not force: + var wish_len2: float = _player._locomotion_wish_world_xz.length_squared() + var vel_hz_sq: float = Vector2(_player.velocity.x, _player.velocity.z).length_squared() + var locomoting: bool = wish_len2 > 1e-10 or vel_hz_sq > AUTH_SNAP_LOCOMOTION_VEL_HZ_SQ + if locomoting and dh < AUTH_SNAP_LOCOMOTION_SKIP_MAX_DH: + return + if dh < AUTH_SNAP_MIN_HORIZONTAL and dy < AUTH_SNAP_MIN_VERTICAL: + return + _allow_authority_soft_snap_skip = true + _player.snap_to_server(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. + _authority_force_snap_next = true + # Rejected stream: server state may differ; next snap is forced. push_warning("Move rejected: %s" % reason_code) _move_reject_msg_token += 1 var token: int = _move_reject_msg_token diff --git a/client/scripts/player.gd b/client/scripts/player.gd index f112ff9..8cd91fa 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,6 +1,7 @@ extends CharacterBody3D -## NS-23 path-follow; NS-24 idle. Details in `client/README.md` (movement, idle). +## NS-23 path-follow; NS-24 idle. NEO-22: WASD locomotion via [method set_locomotion_wish_world_xz]. +## Details in `client/README.md` (movement, idle). ## ## Summary: Jolt, no interp; dual floor_max_angle; rim/straddle, bump lip escape. ## Snap upright after motion. Do not set global_transform in _process. @@ -220,6 +221,7 @@ const CAPSULE_HALF_HEIGHT: float = 0.5 const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload( "res://scripts/random_floor_bump_collision_constants.gd" ) +const PLAYER_LOCOMOTION_WASD_SCRIPT := preload("res://scripts/player_locomotion_wasd.gd") ## Idle radial push per tick off bump lip / wall (meters; 120 Hz physics). const IDLE_BUMP_ESCAPE_STEP: float = 0.014 ## Player capsule radius; wall touch can happen past axis distance **`col_r`**. @@ -271,6 +273,9 @@ var _walk_column_stuck_origin: Vector3 = Vector3.ZERO ## && ncol[/code]. var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO var _walk_col_seam_suppress_ticks: int = 0 +## Normalized world XZ direction from WASD (Y ignored). Zero when idle. +var _locomotion_wish_world_xz: Vector3 = Vector3.ZERO +var _wasd_locomotion: RefCounted @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -279,6 +284,9 @@ func _ready() -> void: _nav_agent.avoidance_enabled = false _nav_agent.set_target_position(global_position) floor_block_on_wall = true + _wasd_locomotion = PLAYER_LOCOMOTION_WASD_SCRIPT.new( + self, CAPSULE_HALF_HEIGHT, PLAYER_CAPSULE_RADIUS, MOVE_SPEED, FLOOR_SNAP_MOVING + ) if debug_idle_trace: set_notify_transform(true) @@ -324,6 +332,7 @@ func clear_nav_goal() -> void: _walk_column_stuck_frame = 0 _walk_col_seam_prev_hz = Vector2.ZERO _walk_col_seam_suppress_ticks = 0 + _wasd_locomotion.reset_for_nav_or_snap() _nav_agent.set_target_position(global_position) @@ -344,6 +353,15 @@ func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) -> _nav_agent.set_target_position(global_position) +func set_locomotion_wish_world_xz(dir: Vector3) -> void: + var h := Vector3(dir.x, 0.0, dir.z) + if h.length_squared() < 1e-10: + _locomotion_wish_world_xz = Vector3.ZERO + _wasd_locomotion.reset_scrape_feet_lock() + else: + _locomotion_wish_world_xz = h.normalized() + + func snap_to_server(world_pos: Vector3) -> void: # The server stores the surface Y of the last move target, not the capsule body-centre Y. # Clamping to the minimum standing height (total half = 0.9 m) prevents the player from @@ -371,8 +389,10 @@ func snap_to_server(world_pos: Vector3) -> void: _walk_nav_path_steer_hz = Vector3.ZERO _walk_col_seam_prev_hz = Vector2.ZERO _walk_col_seam_suppress_ticks = 0 + _wasd_locomotion.reset_for_nav_or_snap() _nav_agent.set_target_position(settled) reset_physics_interpolation() + _locomotion_wish_world_xz = Vector3.ZERO ## Returns true when the capsule has something to stand on **or** is pressing against a step face @@ -1359,6 +1379,12 @@ func _physics_process(delta: float) -> void: floor_max_angle = deg_to_rad( FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG ) + var wish_active: bool = _locomotion_wish_world_xz.length_squared() > 1e-10 + if wish_active: + use_loose_floor_angle = true + floor_max_angle = deg_to_rad(FLOOR_MAX_ANGLE_MOVING_DEG) + _wasd_locomotion.run_wasd(delta) + return if not _has_walk_goal: _physics_process_no_walk_goal(delta) return diff --git a/client/scripts/player_locomotion_wasd.gd b/client/scripts/player_locomotion_wasd.gd new file mode 100644 index 0000000..6ba5ae0 --- /dev/null +++ b/client/scripts/player_locomotion_wasd.gd @@ -0,0 +1,306 @@ +extends RefCounted + +## NEO-22 WASD wall scrape / floor seam tick (extracted from [code]player.gd[/code] for +## [kbd]gdlint[/kbd] [code]max-file-lines[/code]). Owns coyote + scrape feet state; calls back into +## the [CharacterBody3D] player for physics and walk helpers. + +const LOCOMOTION_FLOOR_COYOTE_TICKS: int = 8 +const LOCOMOTION_WALL_SCRAPE_PROBE_HOLD_VY_MAX: float = -1.05 +const LOCOMOTION_WALL_SCRAPE_SNAP_FALLBACK: float = 0.12 +const LOCOMOTION_SCRAPE_FEET_LOCK_LERP: float = 0.2 +const LOCOMOTION_SCRAPE_FEET_CORRECT_MAX: float = 0.008 +const LOCOMOTION_SCRAPE_FEET_ERR_GATE: float = 0.075 +const LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: float = 0.1 +const LOCOMOTION_SCRAPE_RAY_PROBE_Y_LERP: float = 0.42 +const LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN: float = 0.055 +const LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M: float = 0.032 +## Sentinel when no walkable floor hit (tests compare to this). +const FLOOR_RAY_FEET_INVALID: float = -1.0e9 +## Must match [code]player.gd[/code] walk continuation probe (same ray math as +## [method _walk_ray_hit_up_floor]). +const _WALK_SUPPORT_PROBE_DEPTH: float = 0.32 +const _WALK_SUPPORT_PROBE_MIN_UP_DOT: float = 0.42 +const _WALK_CONTINUATION_MAX_BELOW_FEET: float = 0.108 + +var _player: CharacterBody3D +var _cap_half: float +var _cap_radius: float +var _move_speed: float +var _floor_snap_moving: float + +var _floor_coyote: int = 0 +var _scrape_feet_lock_valid: bool = false +var _scrape_feet_y_lock: float = 0.0 +var _scrape_probe_y_ema_valid: bool = false +var _scrape_probe_y_ema: float = 0.0 + + +## Pure: walk continuation rules after a downward feet probe hit (same thresholds as +## [method _floor_ray_feet_y]). +static func evaluate_floor_ray_hit_y( + feet_y: float, + hit_y: float, + normal: Vector3, + continuation_max_below_feet: float, + min_floor_up_dot: float +) -> float: + if hit_y < feet_y - continuation_max_below_feet: + return FLOOR_RAY_FEET_INVALID + if normal.dot(Vector3.UP) <= min_floor_up_dot: + return FLOOR_RAY_FEET_INVALID + return hit_y + + +## Pure: median of one or more feet Y samples (sorted); empty → [constant FLOOR_RAY_FEET_INVALID]. +static func median_feet_y_from_samples(samples: Array[float]) -> float: + if samples.is_empty(): + return FLOOR_RAY_FEET_INVALID + var y_vals: Array[float] = samples.duplicate() + y_vals.sort() + var mid: int = (y_vals.size() - 1) >> 1 + return y_vals[mid] + + +## Pure: XZ after micro-slip projection onto the wish line (or unchanged if out of band). +static func xz_after_micro_slip_projection( + anchor_xz: Vector2, player_xz: Vector2, wish_xz: Vector2, perp_limit_m: float +) -> Vector2: + var w2s: float = wish_xz.length_squared() + if w2s < 1e-12: + return player_xz + var w2n: Vector2 = wish_xz / sqrt(w2s) + var along: float = (player_xz - anchor_xz).dot(w2n) + var p2_on: Vector2 = anchor_xz + w2n * along + var perp_len: float = (player_xz - p2_on).length() + if perp_len > perp_limit_m or perp_len <= 1e-7: + return player_xz + return Vector2(p2_on.x, p2_on.y) + + +## Pure: non-negative wish-aligned horizontal speed, capped at [param move_speed]. +static func horizontal_velocity_aligned_to_wish( + wish_xz: Vector2, vel_xz: Vector2, move_speed: float +) -> Vector2: + if wish_xz.length_squared() < 1e-10: + return vel_xz + var w2: Vector2 = wish_xz.normalized() + var along: float = vel_xz.dot(w2) + if along < 0.0: + along = 0.0 + along = minf(move_speed, along) + return Vector2(w2.x * along, w2.y * along) + + +func _init( + player: CharacterBody3D, + cap_half: float, + cap_radius: float, + move_speed: float, + floor_snap_moving: float +) -> void: + _player = player + _cap_half = cap_half + _cap_radius = cap_radius + _move_speed = move_speed + _floor_snap_moving = floor_snap_moving + + +func reset_scrape_feet_lock() -> void: + _scrape_feet_lock_valid = false + _scrape_probe_y_ema_valid = false + + +func reset_for_nav_or_snap() -> void: + _floor_coyote = 0 + reset_scrape_feet_lock() + + +func _feet_y() -> float: + return _player.global_position.y - _cap_half - _cap_radius + + +func _has_scrape_vertical_contact() -> bool: + if _player.is_on_wall(): + return true + for i: int in _player.get_slide_collision_count(): + var n: Vector3 = _player.get_slide_collision(i).get_normal() + if n.y < 0.48 and n.y > -0.22: + return true + return false + + +func _project_global_xz_on_wish_line_if_micro_slip(anchor_xz: Vector2, wish_xz: Vector2) -> void: + var p2: Vector2 = Vector2(_player.global_position.x, _player.global_position.z) + var new_xz: Vector2 = xz_after_micro_slip_projection( + anchor_xz, p2, wish_xz, LOCOMOTION_SCRAPE_WISH_LINE_MICRO_PERP_M + ) + if new_xz.is_equal_approx(p2): + return + _player.global_position.x = new_xz.x + _player.global_position.z = new_xz.y + + +func _align_velocity_xz_to_wish() -> void: + var wish: Vector3 = _player._locomotion_wish_world_xz + var w2 := Vector2(wish.x, wish.z) + var v2 := Vector2(_player.velocity.x, _player.velocity.z) + var out: Vector2 = horizontal_velocity_aligned_to_wish(w2, v2, _move_speed) + _player.velocity.x = out.x + _player.velocity.z = out.y + + +func _floor_ray_feet_y( + space: PhysicsDirectSpaceState3D, anchor_feet_xz: Vector2, off_xz: Vector2 +) -> float: + var feet_y: float = _player.global_position.y - _cap_half - _cap_radius + var feet_x: float = anchor_feet_xz.x + off_xz.x + var feet_z: float = anchor_feet_xz.y + off_xz.y + var probe_from := Vector3(feet_x, feet_y + 0.04, feet_z) + var probe_end := Vector3(feet_x, feet_y - _WALK_SUPPORT_PROBE_DEPTH, feet_z) + var q := PhysicsRayQueryParameters3D.create(probe_from, probe_end) + q.collision_mask = _player.collision_mask + q.exclude = [_player.get_rid()] + var res: Dictionary = space.intersect_ray(q) + if res.is_empty() or not res.has("normal") or not res.has("position"): + return FLOOR_RAY_FEET_INVALID + var hit_y: float = (res["position"] as Vector3).y + var n: Vector3 = res["normal"] + return evaluate_floor_ray_hit_y( + feet_y, hit_y, n, _WALK_CONTINUATION_MAX_BELOW_FEET, _WALK_SUPPORT_PROBE_MIN_UP_DOT + ) + + +func _floor_ray_feet_y_median( + space: PhysicsDirectSpaceState3D, anchor_feet_xz: Vector2, wish_probe: Vector2 +) -> float: + var offs: Array[Vector2] = [Vector2.ZERO] + var ws2: float = wish_probe.length_squared() + if ws2 > 1e-10: + var wp: Vector2 = wish_probe / sqrt(ws2) + var perp: Vector2 = Vector2(-wp.y, wp.x) * LOCOMOTION_SCRAPE_RAY_FEET_MEDIAN_SPAN + offs.append(perp) + offs.append(-perp) + var y_vals: Array[float] = [] + for k: int in range(offs.size()): + var y_one: float = _floor_ray_feet_y(space, anchor_feet_xz, offs[k]) + if y_one != FLOOR_RAY_FEET_INVALID: + y_vals.append(y_one) + return median_feet_y_from_samples(y_vals) + + +func run_wasd(delta: float) -> void: + if _player._has_walk_goal: + _player.clear_nav_goal() + _player._idle_anchor_active = false + _player.floor_block_on_wall = true + var scrape_ray_anchor_feet_xz := Vector2(_player.global_position.x, _player.global_position.z) + var feet_y: float = _feet_y() + var wish: Vector3 = _player._locomotion_wish_world_xz + _player.velocity.x = wish.x * _move_speed + _player.velocity.z = wish.z * _move_speed + var wish_probe: Vector2 = Vector2(wish.x, wish.z) + var wish_probe_active: bool = wish_probe.length_squared() > 1e-10 + var locomotion_probe_floor: bool = ( + wish_probe_active and _player._walk_has_close_floor_probe_below(wish_probe) + ) + # Slide normals reflect the **previous** tick until [method CharacterBody3D.move_and_slide] runs. + var scrape_vertical_pre_slide: bool = _has_scrape_vertical_contact() + var scrape_probe_hold: bool = ( + locomotion_probe_floor + and scrape_vertical_pre_slide + and _player.velocity.y >= LOCOMOTION_WALL_SCRAPE_PROBE_HOLD_VY_MAX + ) + if _player.is_on_floor(): + _floor_coyote = LOCOMOTION_FLOOR_COYOTE_TICKS + elif scrape_probe_hold: + _floor_coyote = LOCOMOTION_FLOOR_COYOTE_TICKS + else: + _floor_coyote = maxi(0, _floor_coyote - 1) + var locomotion_grounded_y: bool = _player.is_on_floor() or _floor_coyote > 0 + var wish_active_scrape_vertical_pre: bool = wish_probe_active and scrape_vertical_pre_slide + var scrape_micro_slip_xz: bool = locomotion_grounded_y and wish_active_scrape_vertical_pre + if locomotion_grounded_y: + _player.velocity.y = 0.0 + else: + _player._apply_walk_air_gravity(delta, feet_y, Vector2.ZERO) + _player._walk_clip_horizontal_velocity_against_vertical_contacts(wish) + _player.floor_snap_length = _floor_snap_moving + if locomotion_grounded_y and wish_active_scrape_vertical_pre: + if locomotion_probe_floor: + _player.floor_snap_length = 0.0 + else: + _player.floor_snap_length = minf( + _floor_snap_moving, LOCOMOTION_WALL_SCRAPE_SNAP_FALLBACK + ) + _player.move_and_slide() + if scrape_micro_slip_xz: + _project_global_xz_on_wish_line_if_micro_slip(scrape_ray_anchor_feet_xz, wish_probe) + var feet_after: float = _feet_y() + var scrape_vertical_post_slide: bool = _has_scrape_vertical_contact() + var on_floor_post_slide: bool = _player.is_on_floor() + var post_slide_wish_floor_scrape: bool = ( + wish_probe_active and on_floor_post_slide and scrape_vertical_post_slide + ) + if post_slide_wish_floor_scrape: + var ray_absorbed: bool = false + if locomotion_probe_floor: + var w3d := _player.get_world_3d() + if w3d != null: + var probe_feet_y: float = _floor_ray_feet_y_median( + w3d.direct_space_state, scrape_ray_anchor_feet_xz, wish_probe + ) + if probe_feet_y != FLOOR_RAY_FEET_INVALID: + var raw_absorb_dy: float = probe_feet_y - feet_after + var absorb_dy: float = raw_absorb_dy + if absf(raw_absorb_dy) > LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX: + _scrape_probe_y_ema_valid = false + else: + if _scrape_probe_y_ema_valid: + _scrape_probe_y_ema = lerpf( + _scrape_probe_y_ema, + probe_feet_y, + LOCOMOTION_SCRAPE_RAY_PROBE_Y_LERP + ) + else: + _scrape_probe_y_ema = probe_feet_y + _scrape_probe_y_ema_valid = true + absorb_dy = _scrape_probe_y_ema - feet_after + if ( + absf(absorb_dy) <= LOCOMOTION_SCRAPE_RAY_FEET_ABSORB_MAX + and absf(absorb_dy) > 1e-5 + ): + _player.global_position.y += absorb_dy + feet_after = _feet_y() + _scrape_feet_y_lock = feet_after + _scrape_feet_lock_valid = true + ray_absorbed = true + if not ray_absorbed: + if not _scrape_feet_lock_valid: + _scrape_feet_y_lock = feet_after + _scrape_feet_lock_valid = true + else: + if absf(_scrape_feet_y_lock - feet_after) > LOCOMOTION_SCRAPE_FEET_ERR_GATE: + _scrape_feet_y_lock = feet_after + else: + _scrape_feet_y_lock = lerpf( + _scrape_feet_y_lock, feet_after, LOCOMOTION_SCRAPE_FEET_LOCK_LERP + ) + var pull_feet: float = _scrape_feet_y_lock - feet_after + var step_y: float = clampf( + pull_feet, + -LOCOMOTION_SCRAPE_FEET_CORRECT_MAX, + LOCOMOTION_SCRAPE_FEET_CORRECT_MAX + ) + if absf(step_y) > 1e-6: + _player.global_position.y += step_y + if scrape_micro_slip_xz: + _project_global_xz_on_wish_line_if_micro_slip(scrape_ray_anchor_feet_xz, wish_probe) + else: + reset_scrape_feet_lock() + if post_slide_wish_floor_scrape: + if locomotion_probe_floor: + _align_velocity_xz_to_wish() + if locomotion_grounded_y: + _player.velocity.y = 0.0 + _player._snap_capsule_upright() + _player._debug_trace_transform("physics") diff --git a/client/scripts/position_authority_client.gd b/client/scripts/position_authority_client.gd index 3166a57..6805ecb 100644 --- a/client/scripts/position_authority_client.gd +++ b/client/scripts/position_authority_client.gd @@ -1,14 +1,17 @@ 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). +## `GET` position for boot snap; `submit_stream_targets` POSTs `move-stream` (WASD locomotion). +## NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`. +## NS-23: second signal arg on `authoritative_position_received` — always `true` (boot snap only). +## A successful `move-stream` 200 is ack-only (no position signal; avoids RTT rubber-band). ## (No `class_name` so headless/CI can load the project before `.godot` import exists.) signal authoritative_position_received(world: Vector3, apply_as_snap: bool) signal move_rejected(reason_code: String) -enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET } +enum Phase { BOOT_GET, STREAM_POST } + +const MOVE_STREAM_MAX_PER_REQUEST: int = 12 @export var base_url: String = "http://127.0.0.1:5253" @export var dev_player_id: String = "dev-local-1" @@ -18,8 +21,9 @@ var _http: Node var _busy: bool = false var _phase: Phase = Phase.BOOT_GET -## Latest click target while [_busy] (POST or VERIFY in flight); coalesced so clicks are not lost. -var _pending_move_target: Variant = null +## Queued world samples for NEO-22 move-stream (FIFO). +var _stream_queue: Array[Vector3] = [] +var _stream_in_flight_count: int = 0 func _create_http_request() -> Node: @@ -43,38 +47,51 @@ func sync_from_server() -> void: _request_get() -func submit_move_target(world: Vector3) -> void: +## NEO-22: append samples; each POST sends **only the latest** queued position so slow HTTP cannot +## apply a stale multi-step chain after the capsule has already moved (rubber-band / bounce). +func submit_stream_targets(targets: Array) -> void: if _busy: - _pending_move_target = world - return - _pending_move_target = null - _start_move_post(world) + # In-flight POST already encodes an older chain; drop queued crumbs so we do not replay + # past intent after this response lands. + _stream_queue.clear() + for t: Variant in targets: + if t is Vector3: + _stream_queue.append(t as Vector3) + while _stream_queue.size() > 48: + _stream_queue.pop_front() + _try_flush_pending() -func _try_flush_pending_move() -> void: +func _try_flush_pending() -> void: if _busy: return - if _pending_move_target is Vector3: - var w: Vector3 = _pending_move_target as Vector3 - _pending_move_target = null - _start_move_post(w) + if not _stream_queue.is_empty(): + var latest: Vector3 = _stream_queue[_stream_queue.size() - 1] + _stream_queue.clear() + _start_stream_post([latest]) -func _start_move_post(world: Vector3) -> void: +func _start_stream_post(batch: Array) -> void: + if batch.is_empty(): + return _busy = true - _phase = Phase.POST_MOVE - var url := "%s/game/players/%s/move" % [_base_root(), _player_path_segment()] - var payload := { - "schemaVersion": 1, - "target": {"x": world.x, "y": world.y, "z": world.z}, - } + _stream_in_flight_count = batch.size() + _phase = Phase.STREAM_POST + var targets_json: Array = [] + for t: Variant in batch: + if t is Vector3: + var v: Vector3 = t as Vector3 + targets_json.append({"x": v.x, "y": v.y, "z": v.z}) + var payload := {"schemaVersion": 1, "targets": targets_json} var body := JSON.stringify(payload) var headers := PackedStringArray(["Content-Type: application/json"]) + var url := "%s/game/players/%s/move-stream" % [_base_root(), _player_path_segment()] var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body) if err != OK: - push_warning("PositionAuthorityClient: POST failed to start (%s)" % err) + push_warning("PositionAuthorityClient: move-stream POST failed to start (%s)" % err) _busy = false - _try_flush_pending_move() + _stream_in_flight_count = 0 + _try_flush_pending() func _base_root() -> String: @@ -92,7 +109,7 @@ func _request_get() -> void: if err != OK: push_warning("PositionAuthorityClient: GET failed to start (%s)" % err) _busy = false - _try_flush_pending_move() + _try_flush_pending() func _on_request_completed( @@ -101,7 +118,8 @@ func _on_request_completed( if _result != HTTPRequest.RESULT_SUCCESS: push_warning("PositionAuthorityClient: HTTP failed (result=%s); releasing busy." % _result) _busy = false - _try_flush_pending_move() + _stream_in_flight_count = 0 + _try_flush_pending() return var text := body.get_string_from_utf8() @@ -111,31 +129,31 @@ func _on_request_completed( _busy = false if response_code == 200: _emit_position_from_response(text, true) - _try_flush_pending_move() - Phase.POST_MOVE: + _try_flush_pending() + Phase.STREAM_POST: if response_code == 400: _emit_move_rejection_if_present(text) + _stream_queue.clear() + _stream_in_flight_count = 0 _busy = false - _try_flush_pending_move() + sync_from_server() return if response_code != 200: push_warning( - "PositionAuthorityClient: move POST unexpected code %s" % response_code + "PositionAuthorityClient: move-stream unexpected code %s" % response_code ) + _stream_in_flight_count = 0 _busy = false - _try_flush_pending_move() + _try_flush_pending() return - _phase = Phase.VERIFY_GET - _request_get() - Phase.VERIFY_GET: + _stream_in_flight_count = 0 _busy = false if response_code == 200: - _emit_position_from_response(text, false) - else: - push_warning( - "PositionAuthorityClient: VERIFY GET unexpected code %s" % response_code - ) - _try_flush_pending_move() + # Do not emit `authoritative_position_received` here. The body echoes server position + # after applying the batch, which typically lags the client's integrated capsule by + # roughly one RTT — snapping caused visible rubber-banding during WASD. + pass + _try_flush_pending() func _emit_move_rejection_if_present(json_text: String) -> void: diff --git a/client/test/ground_pick_test.gd b/client/test/ground_pick_test.gd deleted file mode 100644 index 1c4b916..0000000 --- a/client/test/ground_pick_test.gd +++ /dev/null @@ -1,69 +0,0 @@ -# Tests walkable collider ancestry for res://scripts/ground_pick.gd. -# Full ray / viewport pick flow stays manual (see README). -extends GdUnitTestSuite - -const GroundPickScript := preload("res://scripts/ground_pick.gd") - - -func test_collider_is_walkable_true_when_ancestor_in_walkable_group() -> void: - var gp := Node3D.new() - gp.set_script(GroundPickScript) - auto_free(gp) - add_child(gp) - var floor_root := StaticBody3D.new() - floor_root.add_to_group("walkable") - var mesh_child := MeshInstance3D.new() - floor_root.add_child(mesh_child) - gp.add_child(floor_root) - var walkable := bool(gp.call("_collider_is_walkable", mesh_child)) - assert_that(walkable).is_true() - - -func test_collider_is_walkable_false_without_walkable_group() -> void: - var gp := Node3D.new() - gp.set_script(GroundPickScript) - auto_free(gp) - add_child(gp) - var body := StaticBody3D.new() - gp.add_child(body) - var ok := bool(gp.call("_collider_is_walkable", body)) - assert_that(ok).is_false() - - -func test_collider_is_occluder_true_when_ancestor_in_occluder_group() -> void: - var gp := Node3D.new() - gp.set_script(GroundPickScript) - auto_free(gp) - add_child(gp) - var obstacle := StaticBody3D.new() - obstacle.add_to_group("occluder") - var mesh_child := MeshInstance3D.new() - obstacle.add_child(mesh_child) - gp.add_child(obstacle) - var is_occluder := bool(gp.call("_collider_is_occluder", mesh_child)) - assert_that(is_occluder).is_true() - - -func test_collider_is_occluder_false_without_occluder_group() -> void: - var gp := Node3D.new() - gp.set_script(GroundPickScript) - auto_free(gp) - add_child(gp) - var body := StaticBody3D.new() - gp.add_child(body) - var is_occluder := bool(gp.call("_collider_is_occluder", body)) - assert_that(is_occluder).is_false() - - -func test_collider_is_occluder_false_for_walkable_only_ancestor() -> void: - var gp := Node3D.new() - gp.set_script(GroundPickScript) - auto_free(gp) - add_child(gp) - var floor_root := StaticBody3D.new() - floor_root.add_to_group("walkable") - var mesh_child := MeshInstance3D.new() - floor_root.add_child(mesh_child) - gp.add_child(floor_root) - var is_occluder := bool(gp.call("_collider_is_occluder", mesh_child)) - assert_that(is_occluder).is_false() diff --git a/client/test/player_locomotion_wasd_test.gd b/client/test/player_locomotion_wasd_test.gd new file mode 100644 index 0000000..ebfedcf --- /dev/null +++ b/client/test/player_locomotion_wasd_test.gd @@ -0,0 +1,114 @@ +# Unit tests for static helpers on `res://scripts/player_locomotion_wasd.gd` (NEO-22 seam math). +extends GdUnitTestSuite + +const LocomotionWasdScript: GDScript = preload("res://scripts/player_locomotion_wasd.gd") +## Must match [constant FLOOR_RAY_FEET_INVALID] in [code]player_locomotion_wasd.gd[/code]. +const FLOOR_RAY_FEET_INVALID: float = -1.0e9 + + +func test_evaluate_floor_ray_hit_y_rejects_hit_too_far_below_feet() -> void: + var feet_y := 0.0 + var hit_y := feet_y - 0.2 + var n := Vector3.UP + var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42) + assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID) + + +func test_evaluate_floor_ray_hit_y_rejects_shallow_floor_normal() -> void: + var feet_y := 0.0 + var hit_y := 0.0 + # Up component ~0.37 so dot(Vector3.UP, n) is below min_floor_up_dot (0.42). + var n := Vector3(0.85, 0.35, 0.0).normalized() + var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42) + assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID) + + +func test_evaluate_floor_ray_hit_y_accepts_valid_hit() -> void: + var feet_y := 0.0 + var hit_y := -0.04 + var n := Vector3.UP + var out: float = LocomotionWasdScript.evaluate_floor_ray_hit_y(feet_y, hit_y, n, 0.108, 0.42) + assert_that(out).is_equal(hit_y) + + +func test_median_feet_y_from_samples_empty_is_invalid() -> void: + var empty: Array[float] = [] + var out: float = LocomotionWasdScript.median_feet_y_from_samples(empty) + assert_that(out).is_equal(FLOOR_RAY_FEET_INVALID) + + +func test_median_feet_y_from_samples_single() -> void: + var samples: Array[float] = [0.12] + var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples) + assert_that(out).is_equal(0.12) + + +func test_median_feet_y_from_samples_three_sorted_middle() -> void: + var samples: Array[float] = [0.3, 0.1, 0.2] + var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples) + assert_that(out).is_equal(0.2) + + +func test_median_feet_y_from_samples_two_uses_lower_index() -> void: + # Matches runtime: mid index (n-1)>>1 picks the smaller of two sorted values. + var samples: Array[float] = [0.5, 0.1] + var out: float = LocomotionWasdScript.median_feet_y_from_samples(samples) + assert_that(out).is_equal(0.1) + + +func test_xz_after_micro_slip_projection_zero_wish_unchanged() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.02, 0.03) + var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, Vector2.ZERO, 0.032 + ) + assert_that(out).is_equal(player_xz) + + +func test_xz_after_micro_slip_projection_large_perp_unchanged() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.0, 0.2) + var wish := Vector2(1.0, 0.0) + var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, wish, 0.032 + ) + assert_that(out).is_equal(player_xz) + + +func test_xz_after_micro_slip_projection_nudges_small_perp() -> void: + var anchor := Vector2.ZERO + var player_xz := Vector2(0.0, 0.01) + var wish := Vector2(1.0, 0.0) + var out: Vector2 = LocomotionWasdScript.xz_after_micro_slip_projection( + anchor, player_xz, wish, 0.032 + ) + assert_that(out.x).is_equal(0.0) + assert_that(out.y).is_equal(0.0) + + +func test_horizontal_velocity_aligned_to_wish_zero_wish_returns_velocity() -> void: + var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2.ZERO, Vector2(3.0, 4.0), 5.0 + ) + assert_that(out).is_equal(Vector2(3.0, 4.0)) + + +func test_horizontal_velocity_aligned_to_wish_clamps_negative_along_to_zero() -> void: + var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(1.0, 0.0), Vector2(-2.0, 0.0), 5.0 + ) + assert_that(out).is_equal(Vector2.ZERO) + + +func test_horizontal_velocity_aligned_to_wish_caps_speed() -> void: + var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(1.0, 0.0), Vector2(10.0, 0.0), 5.0 + ) + assert_that(out).is_equal(Vector2(5.0, 0.0)) + + +func test_horizontal_velocity_aligned_to_wish_preserves_wish_direction() -> void: + var out: Vector2 = LocomotionWasdScript.horizontal_velocity_aligned_to_wish( + Vector2(0.0, 1.0), Vector2(0.0, 2.0), 5.0 + ) + assert_that(out).is_equal(Vector2(0.0, 2.0)) diff --git a/client/test/player_test.gd b/client/test/player_test.gd index 9fe91ad..f87d5c5 100644 --- a/client/test/player_test.gd +++ b/client/test/player_test.gd @@ -36,6 +36,20 @@ func test_set_authoritative_nav_goal_sets_goal_and_target() -> void: assert_that(nav.target_position).is_equal(goal) +func test_set_locomotion_wish_world_xz_normalizes_horizontal() -> void: + var p := _make_player() + p.call("set_locomotion_wish_world_xz", Vector3(3.0, 9.0, 4.0)) + var w: Vector3 = p.get("_locomotion_wish_world_xz") as Vector3 + assert_that(w.y).is_equal(0.0) + assert_that(absf(w.length_squared() - 1.0)).is_less(0.0001) + + +func test_set_locomotion_wish_world_xz_zero_for_near_zero() -> void: + var p := _make_player() + p.call("set_locomotion_wish_world_xz", Vector3(0.0, 1.0, 0.0)) + assert_that(p.get("_locomotion_wish_world_xz") as Vector3).is_equal(Vector3.ZERO) + + func test_clear_nav_goal_clears_velocity_and_nav_target() -> void: var p := _make_player() p.velocity = Vector3(1.0, 0.0, 0.0) diff --git a/client/test/position_authority_client_test.gd b/client/test/position_authority_client_test.gd index bc59ada..5e954c2 100644 --- a/client/test/position_authority_client_test.gd +++ b/client/test/position_authority_client_test.gd @@ -31,6 +31,7 @@ class MockHttpTransport: # Holds _busy without completing (never emits request_completed). class HangingHttpTransport: extends Node + @warning_ignore("unused_signal") signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) @@ -46,68 +47,74 @@ class HangingHttpTransport: return OK -func _make_client(mock: Node) -> Node: +func _make_client(http_transport: Node) -> Node: var c: Node = PositionAuthorityTestDouble.new() - c.set("injected_http", mock) - auto_free(mock) + c.set("injected_http", http_transport) + auto_free(http_transport) auto_free(c) add_child(c) return c func test_sync_boot_get_200_emits_snap() -> void: - var mock := MockHttpTransport.new() - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":-5}}') - var c := _make_client(mock) + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":-5}}') + var c := _make_client(transport) monitor_signals(c) c.sync_from_server() - await assert_signal(c).is_emitted( - "authoritative_position_received", Vector3(1.0, 0.9, -5.0), true - ) + assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, -5.0), true) func test_sync_boot_get_200_malformed_position_does_not_emit() -> void: - var mock := MockHttpTransport.new() - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":"invalid"}') - var c := _make_client(mock) + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":"invalid"}') + var c := _make_client(transport) monitor_signals(c) c.sync_from_server() - await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received") + assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received") -func test_move_post_400_emits_move_rejected() -> void: - var mock := MockHttpTransport.new() - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}') - var c := _make_client(mock) +func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void: + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}') + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":0,"y":0.9,"z":0}}') + var c := _make_client(transport) monitor_signals(c) - c.submit_move_target(Vector3(1.0, 2.0, 3.0)) - await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded") + c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)]) + assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded") + assert_signal(c).is_emitted("authoritative_position_received", Vector3(0.0, 0.9, 0.0), true) -func test_move_post_400_without_reason_emits_unknown() -> void: - var mock := MockHttpTransport.new() - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}") - var c := _make_client(mock) +func test_move_stream_post_400_without_reason_emits_unknown() -> void: + var transport := MockHttpTransport.new() + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}") + transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":1}}') + var c := _make_client(transport) monitor_signals(c) - c.submit_move_target(Vector3.ZERO) - await assert_signal(c).is_emitted("move_rejected", "unknown") - - -func test_move_post_200_then_verify_get_emits_nav_goal() -> void: - var mock := MockHttpTransport.new() - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, "") - mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":2,"y":0.5,"z":-1}}') - var c := _make_client(mock) - monitor_signals(c) - c.submit_move_target(Vector3(9.0, 0.0, 9.0)) - await assert_signal(c).is_emitted( - "authoritative_position_received", Vector3(2.0, 0.5, -1.0), false - ) + c.submit_stream_targets([Vector3.ZERO]) + assert_signal(c).is_emitted("move_rejected", "unknown") + assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, 1.0), true) func test_second_sync_while_busy_is_ignored() -> void: - var mock := HangingHttpTransport.new() - var c := _make_client(mock) + var transport := HangingHttpTransport.new() + var c := _make_client(transport) c.sync_from_server() c.sync_from_server() - assert_that(mock.request_count).is_equal(1) + assert_that(transport.request_count).is_equal(1) + + +func test_move_stream_post_200_does_not_emit_position() -> void: + var transport := MockHttpTransport.new() + transport.enqueue( + HTTPRequest.RESULT_SUCCESS, + 200, + ( + '{"schemaVersion":1,"playerId":"dev-local-1","position":{"x":-4.9,"y":0.9,"z":-5},' + + '"sequence":3}' + ) + ) + var c := _make_client(transport) + monitor_signals(c) + c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)]) + assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received") diff --git a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md index d6bef71..c749fdf 100644 --- a/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md +++ b/docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md @@ -33,7 +33,7 @@ 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); **NEO-10** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEO-10](../../plans/NEO-10-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neo-7-neo-10)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on application shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEO-11** — client **`NavigationRegion3D` / `NavigationAgent3D`** for click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEO-11](../../plans/NEO-11-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neon-4-neon-8)). **`InteractionRequest`** + server-side horizontal range check ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)** (E1.M1 Feature) is **Done** for this prototype scope. +- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **`POST …/move-stream`** (NEO-22) for ordered multi-target applies with the same validation rules; **NEO-10** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NEO-10](../../plans/NEO-10-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-neo-7-neo-10)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on application shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot client **`move-stream`** + **WASD** default locomotion and legacy **`POST`/`GET`** move for **debug** click-to-move ([NEO-22](../../plans/NEO-22-implementation-plan.md), [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NEO-11** — client **`NavigationRegion3D` / `NavigationAgent3D`** for **debug** click-to-move visuals while server authority unchanged; **single-click obstacle detours not guaranteed** (see plan tradeoff) ([NEO-11](../../plans/NEO-11-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-neo-7-neo-11-neo-22)). **`InteractionRequest`** + server-side horizontal range check ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-neon-6)). See [server README — Position persistence](../../../server/README.md#position-persistence-neon-5). Jira **[E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)** (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). diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md index 7283e0d..23afe5a 100644 --- a/docs/plans/NEO-22-implementation-plan.md +++ b/docs/plans/NEO-22-implementation-plan.md @@ -19,7 +19,7 @@ - **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. +- **Server / authority:** **Option C** — server accepts a **stream** of small validated moves (see [Decisions](#decisions)); document wire shape and cadence in `client/README.md` + `server/README.md` + decomposition. **NEO-7** `POST …/move` may remain for click/debug targets or be narrowed once the stream path owns routine locomotion (decide before merge). - **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. @@ -34,19 +34,15 @@ - [ ] **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. +- [ ] **Authoritative position** still converges with the server via the **stream / small-step** contract (option **C**); README + tests describe batching or per-tick cadence 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. +- [x] **GdUnit** (or existing harness) covers pure helpers on `player_locomotion_wasd.gd` (floor-ray continuation, median feet Y, micro-slip XZ projection, wish-aligned horizontal velocity). ## 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. +1. **Linear + branch:** **2026-04-17 kickoff** — Linear **In Progress**; story branch **`NEO-22-retire-click-to-move-wasd-locomotion`** (issue id first per [linear-git-naming](../../.cursor/rules/linear-git-naming.md)). +2. **Authority model:** **Option C** — introduce a **continuous-locomotion** server path (small validated steps or **batched** steps per HTTP call in this slice; **WebSocket / tick pipe** deferred unless explicitly pulled in). Reuse or mirror **NEO-10**-style limits (max step, ΔY) where applicable; assign **sequence** / ordering rules so out-of-order repeats are safe. **NEO-7** `POST …/move` stays available for debug or one-shot targets until stream-only is intentional. Document the contract in `client/README.md` and `server/README.md` before merge. 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. @@ -57,14 +53,19 @@ | 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. | +| **Kickoff** | **2026-04-17** | Branch `NEO-22-retire-click-to-move-wasd-locomotion`; status In Progress in Linear. | +| **Camera vs world steering** | **Camera-relative XZ** | Implemented in `main.gd` via `_camera` basis and `Input.get_vector` on **`move_*`** actions. | | **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. | +| **Authority (WASD → server)** | **Option C — stream of small moves** | Aligns wire semantics with continuous input; leaves room for batched HTTP now and a tick/WebSocket transport later without rebranding “every WASD sample as a click destination.” | ## 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. | +| `server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs` (name at implementer discretion) | Versioned JSON body for **one or more** small authoritative steps (or equivalent “locomotion frame”) applied in order; pairs with new `MapPost` route. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs` (matching test file) | Happy path, rejection, unknown player, and ordering/sequence behavior for the new endpoint. | +| `client/scripts/locomotion_input.gd` (optional) | Thin WASD → wish vector if `main.gd` would otherwise grow; omit if logic stays trivially inline. | +| `client/scripts/player_locomotion_wasd.gd` | WASD wall-scrape / seam tick extracted from `player.gd` so **`gdlint` `max-file-lines`** (1600) stays green without raising the cap. | ## Files to modify @@ -73,8 +74,11 @@ | `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/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity (delegates active WASD tick to `player_locomotion_wasd.gd`); delete obsolete seam/nav state once stable. | +| `client/scripts/position_authority_client.gd` | Add **stream submit** path (batch or frequent small-step POSTs), backoff/coalesce while in flight, and alignment with server **sequence** after locomotion frames. | +| `server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs` | Register **option C** route; delegate to store / validation (may loop `TryApplyMoveTarget` or new store helper for multi-step). | +| `server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs` (and/or new validator) | Reuse or factor shared rules so stream steps and legacy `MoveCommand` stay consistent where intended. | +| `server/README.md` | Document new movement stream endpoint, schema version, and relationship to NEO-7 `POST …/move`. | | `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. | @@ -85,12 +89,19 @@ | 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. | +| `client/test/player_locomotion_wasd_test.gd` | **Add:** unit tests for static seam/locomotion math (`evaluate_floor_ray_hit_y`, median samples, micro-slip projection, horizontal velocity alignment). | +| `client/test/position_authority_client_test.gd` | **Change:** align with **stream** submit cadence, pending queue, and sequence handling. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs` | **Change only if** legacy `POST …/move` behavior or shared validation changes; otherwise leave as regression guard for one-shot moves. | | **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. +- **Wire shape for C:** Single-step POST at high cadence vs **batched** array per POST vs early **WebSocket** — pick for NEO-22 to control server load and client complexity; document in README. +- **Legacy `MoveCommand`:** Whether **NEO-7** remains for debug/teleport only or is unified with stream handler — avoid two conflicting sources of truth without documenting precedence. - **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). + +## Client follow-up (done) + +- **Click-to-move removed:** Deleted `client/scripts/ground_pick.gd` and `client/test/ground_pick_test.gd`, removed **`GroundPick`** from `client/scenes/main.tscn`, dropped **`PositionAuthorityClient.submit_move_target`** and **`POST_MOVE` / `VERIFY_GET`** phases. **`main.gd`** no longer wires pick → move. Boot **`GET`** still emits **`authoritative_position_received(..., true)`** for **`snap_to_server`**; the `apply_as_snap == false` branch is unused until a future server-driven nav goal exists. diff --git a/docs/reviews/2026-04-17-NEO-22.md b/docs/reviews/2026-04-17-NEO-22.md new file mode 100644 index 0000000..c7113d9 --- /dev/null +++ b/docs/reviews/2026-04-17-NEO-22.md @@ -0,0 +1,50 @@ +# Code review — NEO-22 WASD locomotion (wall scrape / floor seam) + +**Date:** 2026-04-17 +**Scope:** Branch `NEO-22-retire-click-to-move-wasd-locomotion`; working-tree diff for `client/scripts/player.gd` only (unstaged vs `HEAD`). No PR URL supplied. +**Base:** `origin/main` @ merge-base `aee3ca80d8451952c62fff7e8e72899e0d62bf68` (inferred). + +## Verdict + +**Approve** (blocking CI line-count issue resolved; remaining bullets are non-blocking suggestions.) + +## Summary + +The WASD tick implements floor coyote time, wall-scrape detection (including slide normals when `is_on_floor` / `is_on_wall` disagree with Jolt), wish-aligned velocity cleanup when a floor continuation probe says walkable floor exists ahead, micro XZ projection onto the wish line to damp seam lip slip, median floor-ray feet Y sampling, and post–`move_and_slide` feet Y smoothing / one-frame absorption (in `player_locomotion_wasd.gd` + callbacks on `player.gd`). Intent matches NEO-22 acceptance language (stable WASD on stepped geometry; NEO-14-class seam mitigation). **Repository CI:** `player.gd` is split so `gdlint` `max-file-lines` should pass. Secondary risks are behavioral (global coyote zeros vertical velocity for several ticks after *any* floor loss) and maintainability (hot-path branching split across two files). + +## Documentation checked + +| Path | Assessment | +|------|------------| +| `docs/plans/NEO-22-implementation-plan.md` | ~~**Partially matches** — WASD seam stability and `player.gd` locomotion work align with goal and acceptance bullets; remaining plan checklist / cleanup bullets not re-verified line-by-line in this review pass.~~ *Reviewed; defer remaining checklist + doc sync to story close-out.* | +| `docs/plans/NEO-14-implementation-plan.md` | **N/A** for literal checklist (not re-read end-to-end); comments in code cite NEO-14 seam behavior — **matches** spirit of prior seam/jitter work applied to WASD. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E1.M1 remains the owning module; no new contract surfaces in this diff. | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | ~~**Partially matches** — implementation snapshot describes WASD + move-stream; **Purpose** still reads “click-to-move or path-follow baseline,” legacy until a doc pass.~~ *Reviewed; Purpose wording update deferred to doc-only work.* | +| `docs/decomposition/modules/client_server_authority.md` | **N/A** — no server or wire changes; client-side `CharacterBody3D` nudges remain in local prediction territory. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A** — no register status row change required for this slice alone. | + +## Blocking issues + +1. ~~**`gdlint` `max-file-lines` will fail after merge.** Repo `gdlintrc` sets `max-file-lines: 1600` with an explicit note that `player.gd` is a large controller. `origin/main`’s `client/scripts/player.gd` is **1483** lines; the working tree is **~1781** lines, i.e. **over the 1600 limit**. CI (`gdlint` on `client/scripts/`) should fail until lines are trimmed, config is temporarily raised with a tracked follow-up to split the script, or locomotion helpers move to another file under the NEO-22 refactor plan.~~ Done. WASD locomotion tick moved to `client/scripts/player_locomotion_wasd.gd`; `player.gd` is back under the 1600 line cap (`gdlint` / `gdformat` clean). + +## Suggestions + +1. ~~**Coyote vs ledge fall:** Floor coyote refreshes on `is_on_floor()` or `scrape_probe_hold`, otherwise decrements. While grounded-by-coyote is true, `velocity.y` is forced to `0.0` before `move_and_slide()`. That means **several physics ticks with no gravity** after leaving walkable support even on an open ledge (no wall scrape), which may feel like a short hover. If coyote is only meant to paper over Jolt floor flicker during wall scrape, consider narrowing when `velocity.y` is zeroed (e.g. require scrape vertical contact or probe hold), and keep normal air gravity otherwise.~~ *Reviewed; not implementing in this pass — revisit only if open-ledge playtest shows unacceptable hover.* + +2. ~~**Hot-path calls:** Scrape vertical contact checks can scan all slide collisions and are invoked multiple times in one WASD tick. Caching the result once per tick would reduce work and keep behavior deterministic if slide ordering ever changes.~~ Done. `run_wasd` caches `_has_scrape_vertical_contact()` once **before** `move_and_slide()` (`scrape_vertical_pre_slide`) and once **after** (`scrape_vertical_post_slide`); pre vs post split preserves correct floor/slide state. + +3. ~~**NEO-22 test AC:** The plan asks for GdUnit coverage when wish/scrape logic is non-trivial. Floor-ray feet Y, micro-slip projection, and wish alignment helpers in `player_locomotion_wasd.gd` are good candidates for small pure tests with a stubbed `PhysicsDirectSpaceState3D` or extracted math-only helpers.~~ Done. `client/test/player_locomotion_wasd_test.gd` covers the extracted static helpers; full `run_wasd` + `move_and_slide` remains manual / integration territory. + +4. ~~**Readability:** The post–`move_and_slide` block repeats the same scrape-contact predicates; folding into a single boolean or early section may reduce mistake surface when tuning.~~ Done. `wish_active_scrape_vertical_pre`, `post_slide_wish_floor_scrape`, and related locals in `player_locomotion_wasd.gd`. + +## Nits + +- ~~**Nit:** In floor-ray median sampling, `var y_vals: Array = []` could use a typed array (`Array[float]`) for consistency with typed GDScript elsewhere.~~ *Reviewed; source already uses `Array[float]` in `_floor_ray_feet_y_median`.* + +- ~~**Nit:** `wish_probe` duplicates direction already in `_locomotion_wish_world_xz`; low cost, but if refactors continue, a single source avoids drift.~~ *Reviewed; not deduplicating in this pass.* + +## Verification + +- Install tooling per `gdscript-style` / CI: `pip install "gdtoolkit==4.5.0"`, then `gdlint client/scripts client/test` and `gdformat --check client/scripts client/test`. +- Godot manual: WASD along stepped props and column-like seams from NEO-14 QA; confirm no regression on flat idle (NEO-14 idle path unchanged in diff but worth a smoke check). +- ~~Walk off an open ledge with no wall: confirm fall start timing matches design (see coyote suggestion).~~ *Reviewed; optional QA tied to suggestion #1 (not pursuing coyote change here).* diff --git a/gdlintrc b/gdlintrc index 4fcc846..9771feb 100644 --- a/gdlintrc +++ b/gdlintrc @@ -32,7 +32,7 @@ function-preload-variable-name: ([A-Z][a-z0-9]*)+ function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*' load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*) loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)* -# `player.gd` is a single CharacterBody3D controller (~1.4k lines); keep headroom until it splits. +# `player.gd` is the CharacterBody3D controller; WASD tick lives in `player_locomotion_wasd.gd`. max-file-lines: 1600 max-line-length: 100 max-public-methods: 20 diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs new file mode 100644 index 0000000..186c88a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveStreamApiTests.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.PositionState; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.PositionState; + +public sealed class MoveStreamApiTests +{ + [Fact] + public async Task PostMoveStream_ShouldApplyChainAndIncrementSequence_WhenTwoSmallSteps() + { + await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b => + { + b.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.MovementValidation.HorizontalStepEnabled = true; + o.MovementValidation.MaxHorizontalStep = 1.0; + }); + }); + }); + var client = factory.CreateClient(); + var before = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); + Assert.NotNull(before); + var seq0 = before!.Sequence; + + var body = new MoveStreamRequest + { + SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, + Targets = + [ + new PositionVector { X = -4.9, Y = 0.9, Z = -5.0 }, + new PositionVector { X = -4.8, Y = 0.9, Z = -5.0 }, + ], + }; + + var postResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move-stream", body); + var postBody = await postResponse.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, postResponse.StatusCode); + Assert.NotNull(postBody); + Assert.Equal(seq0 + 2, postBody!.Sequence); + Assert.Equal(-4.8, postBody.Position.X); + Assert.Equal(0.9, postBody.Position.Y); + Assert.Equal(-5.0, postBody.Position.Z); + } + + [Fact] + public async Task PostMoveStream_ShouldReturnNotFound_WhenPlayerIsUnknown() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var body = new MoveStreamRequest + { + SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, + Targets = [new PositionVector { X = 0, Y = 0.9, Z = 0 }], + }; + + var response = await client.PostAsJsonAsync("/game/players/unknown-player/move-stream", body); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMoveStream_ShouldReturn400_WhenTargetsEmpty() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var content = new StringContent( + "{\"schemaVersion\":1,\"targets\":[]}", + Encoding.UTF8, + "application/json"); + + var response = await client.PostAsync("/game/players/dev-local-1/move-stream", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostMoveStream_ShouldReturn400WithReason_WhenSecondStepExceedsHorizontalLimit() + { + await using var factory = new InMemoryWebApplicationFactory().WithWebHostBuilder(b => + { + b.ConfigureTestServices(services => + { + services.PostConfigure(o => + { + o.MovementValidation.HorizontalStepEnabled = true; + o.MovementValidation.MaxHorizontalStep = 0.2; + }); + }); + }); + var client = factory.CreateClient(); + var body = new MoveStreamRequest + { + SchemaVersion = MoveStreamRequest.CurrentSchemaVersion, + Targets = + [ + new PositionVector { X = -4.95, Y = 0.9, Z = -5.0 }, + new PositionVector { X = -4.0, Y = 0.9, Z = -5.0 }, + ], + }; + + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/move-stream", body); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var rej = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(rej); + Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, rej!.ReasonCode); + + var after = await client.GetFromJsonAsync("/game/players/dev-local-1/position"); + Assert.NotNull(after); + Assert.Equal(-5.0, after!.Position.X); + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs b/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs new file mode 100644 index 0000000..a754a14 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/MoveStreamRequest.cs @@ -0,0 +1,20 @@ +namespace NeonSprawl.Server.Game.PositionState; + +/// +/// HTTP JSON body for POST /game/players/{{id}}/move-stream (NEO-22): ordered absolute +/// targets applied in one request; each leg uses the same rules as . +/// +public sealed class MoveStreamRequest +{ + /// Maximum number of targets accepted per request (inclusive). + public const int MaxTargets = 24; + + /// Schema version for this contract; must match . + public const int CurrentSchemaVersion = 1; + + /// Contract version; must equal . + public int SchemaVersion { get; init; } + + /// World targets applied in order; each must pass validation from the position after the previous apply. + public IReadOnlyList? Targets { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index ce3ca25..e27a3a9 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -72,6 +72,65 @@ public static class PositionStateApi return Results.Json(response); }); + app.MapPost( + "/game/players/{id}/move-stream", + (string id, MoveStreamRequest? body, IPositionStateStore store, IOptions gameOptions) => + { + if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var targets = body.Targets; + if (targets is null || targets.Count == 0 || targets.Count > MoveStreamRequest.MaxTargets) + { + return Results.BadRequest(); + } + + if (!store.TryGetPosition(id, out var current)) + { + return Results.NotFound(); + } + + var rules = gameOptions.Value.MovementValidation; + // Validate the full chain before applying so a mid-chain reject does not leave a partial apply. + var probe = current; + foreach (var target in targets) + { + if (!MoveCommandValidation.TryValidate(probe, target, rules, out var reasonCode)) + { + var rejected = new MoveCommandRejectedResponse + { + SchemaVersion = MoveCommandRejectedResponse.CurrentSchemaVersion, + ReasonCode = reasonCode, + }; + return Results.Json(rejected, statusCode: StatusCodes.Status400BadRequest); + } + + probe = new PositionSnapshot(target.X, target.Y, target.Z, probe.Sequence); + } + + PositionSnapshot lastSnap = current; + foreach (var target in targets) + { + if (!store.TryApplyMoveTarget(id, target.X, target.Y, target.Z, out var snap)) + { + return Results.NotFound(); + } + + lastSnap = snap; + } + + var response = new PositionStateResponse + { + SchemaVersion = PositionStateResponse.CurrentSchemaVersion, + PlayerId = id, + Position = new PositionVector { X = lastSnap.X, Y = lastSnap.Y, Z = lastSnap.Z }, + Sequence = lastSnap.Sequence, + }; + return Results.Json(response); + }); + return app; } } diff --git a/server/README.md b/server/README.md index 63f378d..4447ffb 100644 --- a/server/README.md +++ b/server/README.md @@ -63,7 +63,9 @@ 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 (NEO-11):** 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 (NEO-10) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice. +**`POST /game/players/{id}/move-stream` (NEO-22):** JSON body **`MoveStreamRequest`** (`schemaVersion` **1**, **`targets`**: ordered array of world positions, max **24** per request). The server **validates the entire chain** against **`Game:MovementValidation`** (same rules as a single move) **before** applying anything, then applies each target in order; **`sequence`** increases by **one per applied target**. Response body matches **`PositionStateResponse`**. On the first failing leg the handler returns **400** with **`MoveCommandRejectedResponse`** and **does not** change stored position. + +**Client navigation (NEO-11):** The Godot prototype uses a **baked navigation mesh** for **local** vertical routing / step assist when applicable; **WASD** sends **`move-stream`** samples and the server validates **straight-line** legs (NEO-10) from the **last authoritative position** to each **requested** position. The server does **not** simulate navmesh. Cheating or divergent paths are out of scope for this slice. **NEO-10 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. @@ -74,7 +76,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of | `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` | | `Game:MovementValidation:DistrictMinX` … `DistrictMaxZ` | District extents when enabled | ±12 / Y −2…24 | -**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so a floor-level click still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so clicking the floor from it is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. The client’s **`ground_pick.gd`** only accepts ray hits whose surface **normal** is mostly **upward** (`MIN_WALKABLE_NORMAL_DOT_UP`), so **vertical faces** on the pedestal do not emit move targets—this complements server per-step limits and prevents “stair-stepping” up walls. +**Manual QA (Godot `main.tscn`):** **Green bumps** are **two random short cylinders** each run (~**8–14 cm** rise) and stay under default `MaxVerticalStep` from the dev spawn; **orange reject pedestal** top is ~**2.5 m** above floor so **`move-stream`** from the floor into the pedestal still yields **`vertical_step_exceeded`** (default `MaxVerticalStep` is **2.2 m**); the **physics ramp test block** top is **2 m** so streaming from it toward the floor is **accepted**. **Far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`** when horizontal limits are enabled. Request body (example):