NEO-22: Remove click-to-move client and legacy move POST/verify
Delete ground_pick and GroundPick wiring; PositionAuthorityClient now only boot GET and move-stream POST. main.gd snaps on authoritative position only. Update client/server READMEs, cursor rule example, NEO-22 plan note, and GdUnit authority tests (stream 400 + boot resync).pull/42/head
parent
76ff2cddac
commit
a4095cb9bf
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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,30 +40,30 @@ 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, NEO-22)
|
||||
|
||||
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. **Boot** `GET` and **debug** click-move **verify** `GET` still emit **`authoritative_position_received`** so **`main.gd`** can **`snap_to_server`** once or set a **nav walk goal**. **`POST /game/players/{id}/move`** remains for **debug** click-to-move when running a **debug** Godot build (`ground_pick.gd` only listens in **`OS.is_debug_build()`**): it still does **POST** + **`GET`** verify and sets a **nav walk goal** on the player.
|
||||
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.
|
||||
|
||||
**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 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.
|
||||
**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`).
|
||||
|
||||
|
|
@ -73,11 +73,10 @@ With the game server running ([`server/README.md`](../server/README.md)), **WASD
|
|||
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. **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. **Debug build only — click-to-move:** **Left-click** the floor: the client **POST**s **`/move`**, then **GET**s position; the capsule **walks** toward that verified target with **nav** assist where the old prototype applies. Server-rejected clicks show the reject label.
|
||||
6. Walk to the **orange reject pedestal** and try to climb it with WASD / jumps as applicable; **`move-stream`** should return **`vertical_step_exceeded`** when a requested step exceeds **`MaxVerticalStep`**. In a **debug** build, click the **pedestal top** to confirm the same **`vertical_step_exceeded`** path via **`/move`**. From the **physics ramp test block** top (**2 m**), clicking the floor should **succeed**. The `MoveRejectFarPad` (blue pad at (9, 9)) remains a normal walkable surface.
|
||||
7. 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).
|
||||
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)
|
||||
|
||||
|
|
@ -89,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).
|
||||
|
|
@ -153,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`**, **`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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,126 +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:
|
||||
# NEO-22: click-to-move is dev-only; WASD is default locomotion.
|
||||
if OS.is_debug_build():
|
||||
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
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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-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 (see `player.gd` / `position_authority_client.gd`).
|
||||
## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal.
|
||||
## 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
|
||||
|
|
@ -56,7 +56,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
|
||||
|
|
@ -76,8 +75,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")
|
||||
)
|
||||
|
|
@ -127,12 +124,9 @@ func _locomotion_wish_world_xz_from_camera() -> Vector3:
|
|||
return w.normalized()
|
||||
|
||||
|
||||
func _on_target_chosen(world: Vector3) -> void:
|
||||
_authority.call("submit_move_target", world)
|
||||
|
||||
|
||||
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
||||
if apply_as_snap:
|
||||
if not apply_as_snap:
|
||||
return
|
||||
var force: bool = _authority_force_snap_next
|
||||
_authority_force_snap_next = false
|
||||
var p: Vector3 = _player.global_position
|
||||
|
|
@ -141,23 +135,18 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
|||
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
|
||||
)
|
||||
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)
|
||||
else:
|
||||
_player.set_authoritative_nav_goal(world)
|
||||
|
||||
|
||||
func _on_move_rejected(reason_code: String) -> void:
|
||||
_authority_force_snap_next = true
|
||||
# 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.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
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).
|
||||
## NEO-22: `submit_stream_targets` POSTs `move-stream` (batched small moves); no VERIFY GET. A 200
|
||||
## response does not emit `authoritative_position_received` (ack-only; avoids RTT snap-back).
|
||||
## `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)
|
||||
|
|
@ -12,7 +11,7 @@ signal move_rejected(reason_code: String)
|
|||
|
||||
const MOVE_STREAM_MAX_PER_REQUEST: int = 12
|
||||
|
||||
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET, STREAM_POST }
|
||||
enum Phase { BOOT_GET, STREAM_POST }
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
|
|
@ -22,9 +21,6 @@ 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
|
||||
|
|
@ -51,14 +47,6 @@ func sync_from_server() -> void:
|
|||
_request_get()
|
||||
|
||||
|
||||
func submit_move_target(world: Vector3) -> void:
|
||||
if _busy:
|
||||
_pending_move_target = world
|
||||
return
|
||||
_pending_move_target = null
|
||||
_start_move_post(world)
|
||||
|
||||
|
||||
## 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:
|
||||
|
|
@ -81,28 +69,6 @@ func _try_flush_pending() -> void:
|
|||
var latest: Vector3 = _stream_queue[_stream_queue.size() - 1]
|
||||
_stream_queue.clear()
|
||||
_start_stream_post([latest])
|
||||
return
|
||||
if _pending_move_target is Vector3:
|
||||
var w: Vector3 = _pending_move_target as Vector3
|
||||
_pending_move_target = null
|
||||
_start_move_post(w)
|
||||
|
||||
|
||||
func _start_move_post(world: Vector3) -> void:
|
||||
_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},
|
||||
}
|
||||
var body := JSON.stringify(payload)
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, body)
|
||||
if err != OK:
|
||||
push_warning("PositionAuthorityClient: POST failed to start (%s)" % err)
|
||||
_busy = false
|
||||
_try_flush_pending()
|
||||
|
||||
|
||||
func _start_stream_post(batch: Array) -> void:
|
||||
|
|
@ -164,30 +130,6 @@ func _on_request_completed(
|
|||
if response_code == 200:
|
||||
_emit_position_from_response(text, true)
|
||||
_try_flush_pending()
|
||||
Phase.POST_MOVE:
|
||||
if response_code == 400:
|
||||
_emit_move_rejection_if_present(text)
|
||||
_busy = false
|
||||
_try_flush_pending()
|
||||
return
|
||||
if response_code != 200:
|
||||
push_warning(
|
||||
"PositionAuthorityClient: move POST unexpected code %s" % response_code
|
||||
)
|
||||
_busy = false
|
||||
_try_flush_pending()
|
||||
return
|
||||
_phase = Phase.VERIFY_GET
|
||||
_request_get()
|
||||
Phase.VERIFY_GET:
|
||||
_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()
|
||||
Phase.STREAM_POST:
|
||||
if response_code == 400:
|
||||
_emit_move_rejection_if_present(text)
|
||||
|
|
@ -204,14 +146,12 @@ func _on_request_completed(
|
|||
_busy = false
|
||||
_try_flush_pending()
|
||||
return
|
||||
# Queue was cleared when the POST started; nothing to drop.
|
||||
_stream_in_flight_count = 0
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
# 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. Boot GET and
|
||||
# click-move VERIFY still drive snaps / nav goals.
|
||||
# roughly one RTT — snapping caused visible rubber-banding during WASD.
|
||||
pass
|
||||
_try_flush_pending()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -75,33 +75,29 @@ func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
|
|||
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
|
||||
|
||||
|
||||
func test_move_post_400_emits_move_rejected() -> void:
|
||||
func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
|
||||
var mock := MockHttpTransport.new()
|
||||
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, '{"reasonCode":"vertical_step_exceeded"}')
|
||||
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":0,"y":0.9,"z":0}}')
|
||||
var c := _make_client(mock)
|
||||
monitor_signals(c)
|
||||
c.submit_move_target(Vector3(1.0, 2.0, 3.0))
|
||||
c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)])
|
||||
await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
|
||||
await 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:
|
||||
func test_move_stream_post_400_without_reason_emits_unknown() -> void:
|
||||
var mock := MockHttpTransport.new()
|
||||
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 400, "{}")
|
||||
mock.enqueue(HTTPRequest.RESULT_SUCCESS, 200, '{"position":{"x":1,"y":0.9,"z":1}}')
|
||||
var c := _make_client(mock)
|
||||
monitor_signals(c)
|
||||
c.submit_move_target(Vector3.ZERO)
|
||||
c.submit_stream_targets([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
|
||||
"authoritative_position_received", Vector3(1.0, 0.9, 1.0), true
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -99,3 +99,7 @@
|
|||
- **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.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
|
|||
|
||||
**`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** 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.
|
||||
**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.
|
||||
|
||||
|
|
@ -76,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):
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue