diff --git a/.cursor/settings.json b/.cursor/settings.json index 32c28fc..fc38bf8 100644 --- a/.cursor/settings.json +++ b/.cursor/settings.json @@ -2,6 +2,9 @@ "plugins": { "atlassian": { "enabled": true + }, + "linear": { + "enabled": true } } } diff --git a/client/README.md b/client/README.md index 668ca77..abfd7a0 100644 --- a/client/README.md +++ b/client/README.md @@ -15,15 +15,51 @@ Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** 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`**. +## Prototype district (NEON-29) + +The default play space is a **45 × 45** unit flat floor (~2 000 sq units, ~5× the pre-NEON-29 footprint). All geometry lives under `World/NavigationRegion3D` in `scenes/main.tscn`; `main.gd` rebakes the nav mesh at startup so the scene-file vertices are intentionally stale. + +### Props at a glance + +Elevated walkables use **distinct albedo tints** in `scenes/main.tscn` so screenshots and discussion can name the right prop (`Mat_terrace_platform_a`, `Mat_terrace_b_step`, etc.). The main **floor** slab uses neutral gray (`Mat_floor_slab`). **Occluders** keep the default mesh gray. + +| Node | Group | Tint | Size (m) | World XZ | Purpose | +|------|-------|------|----------|----------|---------| +| `Floor` | `walkable` | Neutral gray | 45 × 45 | (0, 0) | Main play surface | +| `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 | +| `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 | +| `TerracePlatformA` | `walkable` | **Teal** | 10 × 10, h=0.3 m | (13, −14) | Large SE pad; **three darker-teal approach treads per cardinal** (`TerracePlatformA_Approach` / `PAS_*`) — **1.0 m run** along the climb axis (~0.104 m rise each), wider than the **0.8 m** capsule diameter so the body is not wider than each tread | +| `TerraceStepB` | `walkable` | **Gold** | 6 × 3, h=0.3 m | (−15, 8.5) | NW approach step; **`TerraceStepB_Approach`** / `TSB_*` — three treads per **S/E/W** (darker gold), **1.0 m** run on NS arms | +| `TerracePlatformB` | `walkable` | **Violet** | 6 × 6, h=0.6 m | (−15, 13) | Upper NW terrace; **`TerracePlatformB_Approach`** / `TPB_*` — **six** treads per **N/E/W** (~0.104 m rise) for **0.6 m** to floor (**south** uses gold step, not violet treads) | +| `TerracePlatformC` | `walkable` | **Green** | 8 × 8, h=0.3 m | (14, 14) | NE pad; **`TerracePlatformC_Approach`** / `PCS_*` — same tread pattern as teal (`PAS_*`), darker green albedo | + +### 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. +- **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. +- 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. + ## Authoritative movement (NEO-7, NEO-11) 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. -**Tradeoff (prototype):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the pick’s **Y** is below the capsule **feet** by a small margin (smooth **stepped bumps**). Same-height floor clicks now stay on the baked **`NavigationAgent3D`** path instead of looking like a descend just because the capsule origin sits above the floor. **Automatic routing around tall obstacles on one click is still not guaranteed** for those true descend-bypass cases — 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 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. **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):** **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. 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 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**. - **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). - **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. @@ -34,8 +70,8 @@ With the game server running ([`server/README.md`](../server/README.md)), each v 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** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NEO-10 reject clicks still show the reject label and do **not** start a path. -5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged. +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). 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. @@ -63,9 +99,9 @@ The main scene includes a **prototype terminal** at the map center (same world * ## Movement prototype (NEO-5 → NEO-11) -**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion when following the mesh; it may steer **directly at the goal** in xz when the descend bypass applies (NEO-11). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile). +**`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 **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** 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. The pick ray uses **mask 1** only, so clicks pass through the avatar and hit the floor. ### Manual check (remnants) diff --git a/client/project.godot b/client/project.godot index fdae86a..5ab0b4b 100644 --- a/client/project.godot +++ b/client/project.godot @@ -68,6 +68,7 @@ dev_toggle_occluder_obstacle={ common/physics_ticks_per_second=120 3d/physics_engine="Jolt Physics" +3d/default_gravity=9.81 [rendering] diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e954510..ffe3f6d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -6,13 +6,12 @@ [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"] -[ext_resource type="Script" uid="uid://b8x7c4m3r5t8t2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"] +[ext_resource type="Script" uid="uid://caaj3ohikfdx2" path="res://scripts/isometric_follow_camera.gd" id="7_iso_cam"] [ext_resource type="Resource" path="res://resources/isometric_zoom_bands.tres" id="8_zoom_bands"] [ext_resource type="Resource" path="res://resources/isometric_occlusion_policy.tres" id="9_occ_pol"] +[ext_resource type="Script" uid="uid://dqm8s4k2h1xwy" path="res://scripts/prototype_smooth_hill_piece.gd" id="10_smooth_hill"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] -vertices = PackedVector3Array(-0.6999998, 0.25, -0.6999998, -0.10000038, 0.25, -0.6999998, -0.10000038, 0.25, -9.55, -9.55, 0.25, 0.05000019, -1, 0.25, 0.05000019, -9.55, 0.25, -9.55, 8.75, 0.25, -7.4500003, 8.75, 0.25, -6.4000006, 9.65, 0.25, -6.4000006, 9.65, 0.25, -9.55, 0.5, 0.25, -0.6999998, 6.6499996, 0.25, -5.2000003, 6.200001, 0.25, -5.5, 8.450001, 0.25, -7.75, 6.5, 0.25, -7.75, 6.200001, 0.25, -7.4500003, 7.1000004, 2.8000002, -6.8500004, 7.1000004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.1000004, 7.8500004, 2.8000002, -6.8500004, 7.25, 0.25, -6.7000003, 7.25, 0.25, -6.25, 7.700001, 0.25, -6.25, 7.700001, 0.25, -6.7000003, 8.75, 0.25, -5.5, 7.550001, 0.25, 3.8000002, 7.550001, 0.25, 5, 9.65, 0.25, 5, 7.25, 0.25, 3.5, 0.9499998, 0.25, 0.5, 0.5, 0.25, 0.8000002, 4.55, 0.25, 3.9499998, 4.8500004, 0.25, 3.5, 8.450001, 0.25, -5.2000003, 0.9499998, 0.25, -0.39999962, -0.6999998, 0.25, 0.8000002, 4.55, 0.25, 6.200001, 4.55, 0.25, 9.65, -9.55, 0.25, 9.65, 5.45, 2.2, 4.4000006, 5.45, 2.2, 5.6000004, 6.6499996, 2.2, 5.6000004, 6.6499996, 2.2, 4.4000006, 5.6000004, 0.25, 4.55, 5.6000004, 0.25, 5.45, 6.5, 0.25, 5.45, 6.5, 0.25, 4.55, 7.550001, 0.25, 6.200001, 7.25, 0.25, 6.5, 9.800001, 0.40000004, 9.800001, 4.8500004, 0.25, 6.5) -polygons = [PackedInt32Array(2, 1, 0), PackedInt32Array(4, 3, 0), PackedInt32Array(0, 3, 5), PackedInt32Array(0, 5, 2), PackedInt32Array(7, 6, 8), PackedInt32Array(8, 6, 9), PackedInt32Array(12, 11, 10), PackedInt32Array(9, 6, 13), PackedInt32Array(13, 14, 9), PackedInt32Array(9, 14, 2), PackedInt32Array(10, 1, 12), PackedInt32Array(12, 1, 15), PackedInt32Array(15, 1, 2), PackedInt32Array(2, 14, 15), PackedInt32Array(19, 18, 16), PackedInt32Array(16, 18, 17), PackedInt32Array(23, 22, 20), PackedInt32Array(20, 22, 21), PackedInt32Array(24, 7, 8), PackedInt32Array(27, 26, 25), PackedInt32Array(25, 28, 27), PackedInt32Array(27, 28, 24), PackedInt32Array(27, 24, 8), PackedInt32Array(32, 31, 29), PackedInt32Array(29, 31, 30), PackedInt32Array(28, 33, 24), PackedInt32Array(29, 34, 32), PackedInt32Array(32, 34, 28), PackedInt32Array(10, 11, 34), PackedInt32Array(34, 11, 33), PackedInt32Array(34, 33, 28), PackedInt32Array(30, 31, 35), PackedInt32Array(35, 31, 36), PackedInt32Array(36, 37, 35), PackedInt32Array(35, 37, 3), PackedInt32Array(3, 37, 38), PackedInt32Array(35, 3, 4), PackedInt32Array(42, 41, 39), PackedInt32Array(39, 41, 40), PackedInt32Array(46, 45, 43), PackedInt32Array(43, 45, 44), PackedInt32Array(47, 26, 27), PackedInt32Array(47, 27, 48), PackedInt32Array(48, 27, 49), PackedInt32Array(48, 49, 37), PackedInt32Array(37, 36, 50), PackedInt32Array(37, 50, 48)] geometry_collision_mask = 1 cell_size = 0.15 cell_height = 0.15 @@ -22,10 +21,13 @@ agent_radius = 0.4 agent_max_climb = 0.35 [sub_resource type="BoxMesh" id="BoxMesh_floor"] -size = Vector3(20, 0.2, 20) +size = Vector3(45, 0.2, 45) + +[sub_resource type="StandardMaterial3D" id="Mat_floor_slab"] +albedo_color = Color(0.5, 0.5, 0.52, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_floor"] -size = Vector3(20, 0.2, 20) +size = Vector3(45, 0.2, 45) [sub_resource type="BoxMesh" id="BoxMesh_terminal"] size = Vector3(0.9, 1, 0.4) @@ -60,6 +62,159 @@ size = Vector3(2, 2, 2) [sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"] size = Vector3(2, 2, 2) +[sub_resource type="BoxMesh" id="BoxMesh_obstacle_b"] +size = Vector3(3, 3, 3) + +[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle_b"] +size = Vector3(3, 3, 3) + +[sub_resource type="BoxMesh" id="BoxMesh_obstacle_c"] +size = Vector3(2, 4, 2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle_c"] +size = Vector3(2, 4, 2) + +[sub_resource type="BoxMesh" id="BoxMesh_obstacle_d"] +size = Vector3(3, 2, 3) + +[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle_d"] +size = Vector3(3, 2, 3) + +[sub_resource type="BoxMesh" id="BoxMesh_terrace_a"] +size = Vector3(10, 0.3, 10) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_a"] +albedo_color = Color(0.15, 0.62, 0.58, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_terrace_a"] +size = Vector3(10, 0.3, 10) + +[sub_resource type="BoxMesh" id="BoxMesh_pa_stair_ns"] +size = Vector3(9.2, 0.104, 1) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_a_ramp"] +albedo_color = Color(0.1, 0.48, 0.45, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_pa_stair_ns"] +size = Vector3(9.2, 0.104, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_pa_stair_we"] +size = Vector3(1, 0.104, 9.2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_pa_stair_we"] +size = Vector3(1, 0.104, 9.2) + +[sub_resource type="BoxMesh" id="BoxMesh_terrace_b_step"] +size = Vector3(6, 0.3, 3) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_b_step"] +albedo_color = Color(0.96, 0.72, 0.15, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_terrace_b_step"] +size = Vector3(6, 0.3, 3) + +[sub_resource type="BoxMesh" id="BoxMesh_terrace_b_platform"] +size = Vector3(6, 0.6, 6) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_b"] +albedo_color = Color(0.58, 0.32, 0.72, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_terrace_b_platform"] +size = Vector3(6, 0.6, 6) + +[sub_resource type="BoxMesh" id="BoxMesh_terrace_c"] +size = Vector3(8, 0.3, 8) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_c"] +albedo_color = Color(0.22, 0.68, 0.38, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_terrace_c"] +size = Vector3(8, 0.3, 8) + +[sub_resource type="BoxMesh" id="BoxMesh_pc_stair_ns"] +size = Vector3(7.2, 0.104, 1) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_c_ramp"] +albedo_color = Color(0.12, 0.48, 0.22, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_pc_stair_ns"] +size = Vector3(7.2, 0.104, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_pc_stair_we"] +size = Vector3(1, 0.104, 7.2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_pc_stair_we"] +size = Vector3(1, 0.104, 7.2) + +[sub_resource type="BoxMesh" id="BoxMesh_tsb_stair_ns"] +size = Vector3(6.2, 0.104, 1) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_b_step_ramp"] +albedo_color = Color(0.62, 0.45, 0.06, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_tsb_stair_ns"] +size = Vector3(6.2, 0.104, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_tsb_stair_we"] +size = Vector3(1, 0.104, 3.2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_tsb_stair_we"] +size = Vector3(1, 0.104, 3.2) + +[sub_resource type="BoxMesh" id="BoxMesh_tpb_stair_ns"] +size = Vector3(6.2, 0.104, 1) + +[sub_resource type="StandardMaterial3D" id="Mat_terrace_platform_b_ramp"] +albedo_color = Color(0.35, 0.18, 0.42, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_tpb_stair_ns"] +size = Vector3(6.2, 0.104, 1) + +[sub_resource type="BoxMesh" id="BoxMesh_tpb_stair_we"] +size = Vector3(1, 0.104, 6.2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_tpb_stair_we"] +size = Vector3(1, 0.104, 6.2) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_floor"] +size = Vector3(17.0, 0.2, 45) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_floor"] +size = Vector3(17.0, 0.2, 45) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_n"] +size = Vector3(7, 0.2, 20.048) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_n"] +size = Vector3(7, 0.2, 20.048) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_ext_fill_s"] +size = Vector3(7, 0.2, 20.432) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_ext_fill_s"] +size = Vector3(7, 0.2, 20.432) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_block"] +size = Vector3(4, 2, 4) + +[sub_resource type="StandardMaterial3D" id="Mat_physics_ramp_test"] +albedo_color = Color(0.95, 0.42, 0.38, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_block"] +size = Vector3(4, 2, 4) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_walk"] +size = Vector3(4.5, 0.15, 6.5) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_walk"] +size = Vector3(4.5, 0.15, 6.5) + +[sub_resource type="BoxMesh" id="BoxMesh_physics_ramp_test_steep"] +size = Vector3(2.5, 0.15, 4.5) + +[sub_resource type="BoxShape3D" id="BoxShape3D_physics_ramp_test_steep"] +size = Vector3(2.5, 0.15, 4.5) + [sub_resource type="BoxMesh" id="BoxMesh_marker"] size = Vector3(0.22, 0.35, 0.22) @@ -102,10 +257,12 @@ fov = 50.0 navigation_mesh = SubResource("NavigationMesh_district") [node name="Floor" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1800743112 groups=["walkable"]] +collision_mask = 3 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) mesh = SubResource("BoxMesh_floor") +surface_material_override/0 = SubResource("Mat_floor_slab") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Floor" unique_id=409532142] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) @@ -113,6 +270,7 @@ shape = SubResource("BoxShape3D_floor") [node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +collision_mask = 3 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] mesh = SubResource("BoxMesh_terminal") @@ -123,6 +281,7 @@ shape = SubResource("BoxShape3D_terminal") [node name="MoveRejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) +collision_mask = 3 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/MoveRejectPedestal" unique_id=1900022] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) @@ -135,6 +294,7 @@ shape = SubResource("BoxShape3D_move_reject_pedestal") [node name="MoveRejectFarPad" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900031 groups=["walkable"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0.06, 9) +collision_mask = 3 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/MoveRejectFarPad" unique_id=1900032] mesh = SubResource("BoxMesh_move_reject_farpad") @@ -145,6 +305,7 @@ shape = SubResource("BoxShape3D_move_reject_farpad") [node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763 groups=["occluder"]] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) +collision_mask = 3 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) @@ -154,6 +315,761 @@ mesh = SubResource("BoxMesh_obstacle") transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) shape = SubResource("BoxShape3D_obstacle") +[node name="ObstacleB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000001 groups=["occluder"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10, 0, 5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/ObstacleB" unique_id=3000002] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0) +mesh = SubResource("BoxMesh_obstacle_b") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/ObstacleB" unique_id=3000003] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0) +shape = SubResource("BoxShape3D_obstacle_b") + +[node name="ObstacleC" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000004 groups=["occluder"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, -12) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/ObstacleC" unique_id=3000005] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0) +mesh = SubResource("BoxMesh_obstacle_c") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/ObstacleC" unique_id=3000006] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0) +shape = SubResource("BoxShape3D_obstacle_c") + +[node name="ObstacleD" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000007 groups=["occluder"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 0, -16) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/ObstacleD" unique_id=3000008] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +mesh = SubResource("BoxMesh_obstacle_d") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/ObstacleD" unique_id=3000009] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +shape = SubResource("BoxShape3D_obstacle_d") + +[node name="TerracePlatformA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000010 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.012, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA" unique_id=3000011] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +mesh = SubResource("BoxMesh_terrace_a") +surface_material_override/0 = SubResource("Mat_terrace_platform_a") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA" unique_id=3000012] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +shape = SubResource("BoxShape3D_terrace_a") + +[node name="TerracePlatformA_Approach" type="Node3D" parent="World/NavigationRegion3D" unique_id=3100000] + +[node name="PAS_S1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100001 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.052, -20.4) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S1" unique_id=3100011] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S1" unique_id=3100012] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_S2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100002 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.156, -19.95) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S2" unique_id=3100021] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S2" unique_id=3100022] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_S3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100003 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.26, -19.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S3" unique_id=3100031] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_S3" unique_id=3100032] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_N1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100004 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.052, -6.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N1" unique_id=3100041] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N1" unique_id=3100042] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_N2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100005 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.156, -7.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N2" unique_id=3100051] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N2" unique_id=3100052] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_N3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100006 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 13, 0.26, -8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N3" unique_id=3100061] +mesh = SubResource("BoxMesh_pa_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_N3" unique_id=3100062] +shape = SubResource("BoxShape3D_pa_stair_ns") + +[node name="PAS_W1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100007 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.5, 0.052, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W1" unique_id=3100071] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W1" unique_id=3100072] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="PAS_W2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100008 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0.156, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W2" unique_id=3100081] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W2" unique_id=3100082] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="PAS_W3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100009 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.5, 0.26, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W3" unique_id=3100091] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_W3" unique_id=3100092] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="PAS_E1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100010 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 15.5, 0.052, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E1" unique_id=3100101] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E1" unique_id=3100102] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="PAS_E2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100013 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16.5, 0.156, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E2" unique_id=3100111] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E2" unique_id=3100112] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="PAS_E3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach" unique_id=3100014 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 17.5, 0.26, -14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E3" unique_id=3100121] +mesh = SubResource("BoxMesh_pa_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_a_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformA_Approach/PAS_E3" unique_id=3100122] +shape = SubResource("BoxShape3D_pa_stair_we") + +[node name="TerraceStepB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000013 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB" unique_id=3000014] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +mesh = SubResource("BoxMesh_terrace_b_step") +surface_material_override/0 = SubResource("Mat_terrace_b_step") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB" unique_id=3000015] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +shape = SubResource("BoxShape3D_terrace_b_step") + +[node name="TerracePlatformB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000016 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB" unique_id=3000017] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0) +mesh = SubResource("BoxMesh_terrace_b_platform") +surface_material_override/0 = SubResource("Mat_terrace_platform_b") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB" unique_id=3000018] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0) +shape = SubResource("BoxShape3D_terrace_b_platform") + +[node name="TerracePlatformC" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=3000019 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC" unique_id=3000020] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +mesh = SubResource("BoxMesh_terrace_c") +surface_material_override/0 = SubResource("Mat_terrace_platform_c") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC" unique_id=3000021] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.15, 0) +shape = SubResource("BoxShape3D_terrace_c") + +[node name="TerracePlatformC_Approach" type="Node3D" parent="World/NavigationRegion3D" unique_id=3150000] + +[node name="PCS_S1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150001 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.052, 8.6) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S1" unique_id=3150011] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S1" unique_id=3150012] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_S2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150002 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.156, 9.05) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S2" unique_id=3150021] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S2" unique_id=3150022] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_S3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150003 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.26, 9.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S3" unique_id=3150031] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_S3" unique_id=3150032] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_N1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150004 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.052, 19.4) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N1" unique_id=3150041] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N1" unique_id=3150042] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_N2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150005 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.156, 18.95) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N2" unique_id=3150051] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N2" unique_id=3150052] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_N3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150006 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 0.26, 18.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N3" unique_id=3150061] +mesh = SubResource("BoxMesh_pc_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_N3" unique_id=3150062] +shape = SubResource("BoxShape3D_pc_stair_ns") + +[node name="PCS_W1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150007 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.5, 0.052, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W1" unique_id=3150071] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W1" unique_id=3150072] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="PCS_W2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150008 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8.95, 0.156, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W2" unique_id=3150081] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W2" unique_id=3150082] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="PCS_W3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150009 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9.5, 0.26, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W3" unique_id=3150091] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_W3" unique_id=3150092] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="PCS_E1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150010 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 19.4, 0.052, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E1" unique_id=3150101] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E1" unique_id=3150102] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="PCS_E2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150013 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 18.95, 0.156, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E2" unique_id=3150111] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E2" unique_id=3150112] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="PCS_E3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach" unique_id=3150014 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 18.5, 0.26, 14) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E3" unique_id=3150121] +mesh = SubResource("BoxMesh_pc_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_c_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformC_Approach/PCS_E3" unique_id=3150122] +shape = SubResource("BoxShape3D_pc_stair_we") + +[node name="TerraceStepB_Approach" type="Node3D" parent="World/NavigationRegion3D" unique_id=3160000] + +[node name="TSB_S1" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160001 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.052, 5.6) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S1" unique_id=3160011] +mesh = SubResource("BoxMesh_tsb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S1" unique_id=3160012] +shape = SubResource("BoxShape3D_tsb_stair_ns") + +[node name="TSB_S2" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160002 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.156, 6.05) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S2" unique_id=3160021] +mesh = SubResource("BoxMesh_tsb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S2" unique_id=3160022] +shape = SubResource("BoxShape3D_tsb_stair_ns") + +[node name="TSB_S3" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160003 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.26, 6.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S3" unique_id=3160031] +mesh = SubResource("BoxMesh_tsb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_S3" unique_id=3160032] +shape = SubResource("BoxShape3D_tsb_stair_ns") + +[node name="TSB_W1" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160004 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.5, 0.052, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W1" unique_id=3160041] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W1" unique_id=3160042] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TSB_W2" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160005 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.05, 0.156, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W2" unique_id=3160051] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W2" unique_id=3160052] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TSB_W3" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160006 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -18.5, 0.26, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W3" unique_id=3160061] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_W3" unique_id=3160062] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TSB_E1" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160007 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.5, 0.052, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E1" unique_id=3160071] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E1" unique_id=3160072] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TSB_E2" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160008 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -11, 0.156, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E2" unique_id=3160081] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E2" unique_id=3160082] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TSB_E3" type="StaticBody3D" parent="World/NavigationRegion3D/TerraceStepB_Approach" unique_id=3160009 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -11.5, 0.26, 8.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E3" unique_id=3160091] +mesh = SubResource("BoxMesh_tsb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_b_step_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerraceStepB_Approach/TSB_E3" unique_id=3160092] +shape = SubResource("BoxShape3D_tsb_stair_we") + +[node name="TerracePlatformB_Approach" type="Node3D" parent="World/NavigationRegion3D" unique_id=3170000] + +[node name="TPB_N1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170001 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.052, 18.75) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N1" unique_id=3170011] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N1" unique_id=3170012] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_N2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170002 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.156, 18.3) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N2" unique_id=3170021] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N2" unique_id=3170022] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_N3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170003 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.26, 17.85) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N3" unique_id=3170031] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N3" unique_id=3170032] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_N4" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170004 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.364, 17.4) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N4" unique_id=3170041] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N4" unique_id=3170042] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_N5" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170005 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.468, 16.95) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N5" unique_id=3170051] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N5" unique_id=3170052] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_N6" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170006 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 0.572, 16.5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N6" unique_id=3170061] +mesh = SubResource("BoxMesh_tpb_stair_ns") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_N6" unique_id=3170062] +shape = SubResource("BoxShape3D_tpb_stair_ns") + +[node name="TPB_W1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170007 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20.75, 0.052, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W1" unique_id=3170071] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W1" unique_id=3170072] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_W2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170008 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20.3, 0.156, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W2" unique_id=3170081] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W2" unique_id=3170082] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_W3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170009 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.85, 0.26, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W3" unique_id=3170091] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W3" unique_id=3170092] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_W4" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170010 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.4, 0.364, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W4" unique_id=3170101] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W4" unique_id=3170102] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_W5" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170013 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -18.95, 0.468, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W5" unique_id=3170111] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W5" unique_id=3170112] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_W6" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170014 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -18.5, 0.572, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W6" unique_id=3170121] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_W6" unique_id=3170122] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E1" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170015 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.25, 0.052, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E1" unique_id=3170131] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E1" unique_id=3170132] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E2" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170016 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.7, 0.156, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E2" unique_id=3170141] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E2" unique_id=3170142] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E3" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170017 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.15, 0.26, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E3" unique_id=3170151] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E3" unique_id=3170152] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E4" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170018 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -10.6, 0.364, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E4" unique_id=3170161] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E4" unique_id=3170162] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E5" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170019 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -11.05, 0.468, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E5" unique_id=3170171] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E5" unique_id=3170172] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="TPB_E6" type="StaticBody3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach" unique_id=3170020 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -11.5, 0.572, 13) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E6" unique_id=3170181] +mesh = SubResource("BoxMesh_tpb_stair_we") +surface_material_override/0 = SubResource("Mat_terrace_platform_b_ramp") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/TerracePlatformB_Approach/TPB_E6" unique_id=3170182] +shape = SubResource("BoxShape3D_tpb_stair_we") + +[node name="PhysicsRampTestFloorExtension" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201001 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34.5, 0, 0) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201002] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, -0.19204712) +mesh = SubResource("BoxMesh_physics_ramp_test_floor") +surface_material_override/0 = SubResource("Mat_floor_slab") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtension" unique_id=4201003] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.5, -0.1, 0) +shape = SubResource("BoxShape3D_physics_ramp_test_floor") + +[node name="PhysicsRampTestFloorExtensionNorthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201050 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 12.284) +collision_layer = 1 +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201051] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +mesh = SubResource("BoxMesh_physics_ramp_ext_fill_n") +surface_material_override/0 = SubResource("Mat_floor_slab") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionNorthFill" unique_id=4201052] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +shape = SubResource("BoxShape3D_physics_ramp_ext_fill_n") + +[node name="PhysicsRampTestFloorExtensionSouthFill" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201053 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, -12.476) +collision_layer = 1 +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201054] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +mesh = SubResource("BoxMesh_physics_ramp_ext_fill_s") +surface_material_override/0 = SubResource("Mat_floor_slab") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestFloorExtensionSouthFill" unique_id=4201055] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +shape = SubResource("BoxShape3D_physics_ramp_ext_fill_s") + +[node name="PhysicsRampTestBlock" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201010 groups=["occluder"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 40, 0, 0) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestBlock" unique_id=4201011] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +mesh = SubResource("BoxMesh_physics_ramp_test_block") +surface_material_override/0 = SubResource("Mat_physics_ramp_test") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestBlock" unique_id=4201012] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +shape = SubResource("BoxShape3D_physics_ramp_test_block") + +[node name="PhysicsRampTestWalkRamp" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201020 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 0.948683, -0.316228, 0, 0.316228, 0.948683, 40, 1, 5) +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsRampTestWalkRamp" unique_id=4201021] +mesh = SubResource("BoxMesh_physics_ramp_test_walk") +surface_material_override/0 = SubResource("Mat_physics_ramp_test") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsRampTestWalkRamp" unique_id=4201022] +shape = SubResource("BoxShape3D_physics_ramp_test_walk") + +[node name="PhysicsSmoothHillTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201040 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 34, 0, 0) +collision_layer = 1 +collision_mask = 3 +script = ExtResource("10_smooth_hill") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201041] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothHillTest" unique_id=4201042] + +[node name="PhysicsSmoothDipTest" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=4201043 groups=["walkable"]] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 43, 0, 0) +collision_layer = 1 +collision_mask = 3 +script = ExtResource("10_smooth_hill") +kind = 1 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201044] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PhysicsSmoothDipTest" unique_id=4201045] + +[node name="PhysicsRampTestSteepRamp" type="StaticBody3D" parent="World" unique_id=4201030] +transform = Transform3D(-0.6, -0.8, 0, 0.8, 0.6, 0, 0, 0, 1, 42.75, 1, 0) +collision_layer = 1 +collision_mask = 3 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PhysicsRampTestSteepRamp" unique_id=4201031] +mesh = SubResource("BoxMesh_physics_ramp_test_steep") +surface_material_override/0 = SubResource("Mat_physics_ramp_test") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/PhysicsRampTestSteepRamp" unique_id=4201032] +shape = SubResource("BoxShape3D_physics_ramp_test_steep") + [node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003] script = ExtResource("6_rad") @@ -170,6 +1086,7 @@ surface_material_override/0 = SubResource("Mat_marker_base") [node name="Player" type="CharacterBody3D" parent="World" unique_id=352931696] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) collision_layer = 2 +collision_mask = 3 floor_max_angle = 0.872665 floor_snap_length = 0.22 safe_margin = 0.055 @@ -211,3 +1128,18 @@ offset_right = -12.0 offset_bottom = -8.0 grow_horizontal = 2 autowrap_mode = 3 + +[node name="PlayerPositionLabel" type="Label" parent="UICanvas" unique_id=9000003] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 220.0 +offset_bottom = 72.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 15 +text = "x: — +y: — +z: —" diff --git a/client/scripts/ground_pick.gd b/client/scripts/ground_pick.gd index 3f48957..d52b798 100644 --- a/client/scripts/ground_pick.gd +++ b/client/scripts/ground_pick.gd @@ -1,6 +1,8 @@ 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.) @@ -64,6 +66,17 @@ func _try_pick(screen_pos: Vector2) -> void: 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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index b01c91a..ce4946e 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -7,6 +7,15 @@ extends Node3D ## (see `isometric_follow_camera.gd`). ## Prototype: two random short bumps (sibling StaticBody3D under NavigationRegion3D; see ## `random_floor_bumps.gd`) before nav bake. +## Prototype: `PhysicsRampTest*` (+X extension: west slab ends before the dip; +## `PhysicsRampTestFloorExtensionNorthFill` / `SouthFill` restore walkable floor past the dip in ±Z; +## block; gentle ramp on **+Z**; steep on **+X**), plus `PhysicsSmoothHillTest` / +## `PhysicsSmoothDipTest` (HeightMap collision). +## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°) +## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is +## under `World` only (not nav source geometry). +## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel` +## (updated in `_physics_process` so it matches physics ticks). const MOVE_REJECT_MSG_SECONDS: float = 4.0 @@ -29,10 +38,12 @@ var _dev_obstacle_smoke: Node3D @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel +@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor func _ready() -> void: + set_physics_process(true) set_process_unhandled_key_input(true) await get_tree().process_frame _dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D @@ -54,6 +65,13 @@ func _ready() -> void: _radius_preview.call("setup_player", _player) +func _physics_process(_delta: float) -> void: + if not is_instance_valid(_player) or not is_instance_valid(_player_pos_label): + return + 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) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 5ecb78c..f112ff9 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -12,28 +12,210 @@ const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0 ## Idle: tighter angle to reduce vertex / vertical-face flicker (~35°). const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0 ## Physics ticks to keep moving floor angle after walk goal clears (arrival). -const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96 +## Post-walk window for wider [member floor_max_angle] (~0.4 s @ 120 Hz); shorter tightens idle +## contact sooner and helps the anchor latch feel snappy on flat / mild slopes. +const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 48 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 +## Hysteresis for nav-path column steering (see `_physics_process`). At exactly +## [member DIRECT_APPROACH_RADIUS] the old test flipped every frame: path nudges +## tangentially → [code]horiz_dist[/code] crosses the radius → direct vs path +## alternates → velocity sign flips (Jolt ping-pong while [code]has_goal[/code]). +const NAV_COLUMN_STEER_ENTER_DIST: float = 0.74 +const NAV_COLUMN_STEER_EXIT_DIST: float = 0.97 +## First path waypoint used for column steer must be at least this far in XZ. [method +## NavigationAgent3D.get_next_path_position] +## can sit ~4 cm from the capsule; Jolt slides ~4 cm/tick → the “toward next” vector flips 180° +## every frame +## (`vlat && ncol` NEON-16 traces). A 22 cm gate matches foot-scale probes and stays stable on +## treads. +const NAV_PATH_STEER_MIN_LOOKAHEAD: float = 0.22 +## If lookahead steering points more than ~105° from the click direction (XZ), use direct goal steer +## instead. +const NAV_PATH_STEER_MIN_GOAL_DOT: float = -0.25 +## When the new lookahead unit is almost opposite the last applied nav steer, slerp slowly — stops +## ±[code]MOVE_SPEED[/code] +## flips while [code]vlat && ncol[/code] (Jolt nudges XZ → different first waypoint past [member +## NAV_PATH_STEER_MIN_LOOKAHEAD]). +const NAV_PATH_STEER_SMOOTH_DOT: float = 0.22 +const NAV_PATH_STEER_SMOOTH_SLERP: float = 0.58 +const NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING: float = 0.16 +## While [code]vlat && ncol[/code], ledge peel + zero floor snap fight [method move_and_slide] on +## approach treads +## (3-cycle NEON-16 traces: same XZ triplet, Y/feet_y ping-pong). Suspend only when +## [code]vsep[/code] is +## **tread-scale** — if this matched tall-pad descents (~0.48 m), column steering stays true while +## [code]vsep[/code] +## shrinks from metres to zero and peel was off for the last ~half metre (orange block slow float). +## Just above **two** QA tread drops (~0.208 m) so double risers still suspend. +const WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP: float = 0.23 +## Fallback when [code]vlat[/code] has unlatched (feet and goal both near the same slab Y) but +## [code]ncol[/code] is false because [code]horiz_dist[/code] is past column hysteresis — peel + +## zero +## snap still oscillates on violet treads (NEON-16 ~y=0.92). Slightly below a single tall lip so +## shallow +## lips (~0.35–0.47 m) still peel vs [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP]. +const WALK_PEEL_SUSPEND_HORIZ_VERT_SEP: float = 0.46 +const WALK_PEEL_SUSPEND_HORIZ_DIST: float = 5.0 +## Broad [method _walk_peel_suspend_near_goal] (horiz + [member WALK_PEEL_SUSPEND_HORIZ_VERT_SEP]) +## is for +## tread-height wobble when XZ is already near the click. On **tall pads** (orange reject block, +## terraces), +## feet can stay tens of cm above the goal while [code]horiz_dist < 5[/code] — suspend then kills +## ledge peel +## for the whole last ~0.46 m of drop → [code]is_on_floor()[/code] + no [code]velocity.y[/code] +## feels like a slow float. +## Only allow that broad branch once the feet are within this of the goal surface **while +## descending**. +const WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL: float = 0.22 +## With [method _walk_peel_suspend_near_goal] (tread snap/peel stability), zero XZ [member velocity] +## when +## close in XZ but vertical arrival still unresolved — avoids band-edge flicker from a tight +## [code]vert_sep[/code] +## window. [member WALK_PEEL_SUSPEND_HORIZ_DIST] can be 5 m; cap XZ here so we never freeze long +## crosses. +const WALK_TREAD_FREEZE_HORIZ_MAX: float = 0.38 +## Freeze only while vertical error is in a “tread wobble” band (past arrival noise, not a big +## drop). +const WALK_TREAD_FREEZE_VERT_ERR_MAX: float = 0.18 +## [method _walk_has_close_floor_probe_below] often flips on stairs: next tread is ~0.104 m down but +## [member WALK_CONTINUATION_MAX_BELOW_FEET] is 0.078 m, so rays reject the hit → peel + zero snap +## alternate with full snap each tick (~y≈1.0 jitter). Require this many consecutive probe-fail +## frames +## before arming peel/snap aggression; [member WALK_DEEP_DESCENT_FEET_ABOVE_GOAL] still arms +## immediately. +const WALK_PEEL_PROBE_FALSE_FRAMES: int = 2 +## [member VERT_ARRIVE_EPS] is too tight on column stairs: feet oscillate ~±6 cm vs goal surface +## while XZ is +## already inside [member ARRIVE_EPS] → never [code]has_goal=false[/code], full [member MOVE_SPEED] +## path +## steer flips forever (looks like “idle” jitter; NEON-16 traces). +const WALK_COLUMN_NEAR_ARRIVE_VERT: float = 0.36 +## Only apply [member WALK_COLUMN_NEAR_ARRIVE_VERT] when vertical error is already in this band — +## avoids +## treating a **plaza / block** drop (feet still >20 cm above goal) as “column stair wobble” while +## [code]horiz_dist[/code] is inside column steering (NEON-29-style skate + slow float after XZ). +const WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR: float = 0.22 +## If [code]vlat && ncol[/code] and net displacement stays below this for [member +## WALK_COLUMN_STUCK_FRAMES], +## clear the walk goal — path lookahead limit cycle with no progress (backup if widened arrival is +## not enough). +const WALK_COLUMN_STUCK_FRAMES: int = 28 +const WALK_COLUMN_STUCK_MAX_DRIFT: float = 0.045 +const WALK_COLUMN_STUCK_HORIZ_MAX: float = 0.68 +## After [method _walk_clip_horizontal_velocity_against_vertical_contacts], Jolt can alternate which +## vertical face “wins” on stair/column seams → post-clip [code]velocity.xz[/code] flips ~180° at +## ~[code]MOVE_SPEED[/code] while [code]horiz_dist[/code] stays large ([code]ncol[/code] +## hysteresis). +## Dot below this vs last tick’s post-clip XZ → zero horizontal + brief suppress (NEO-14 traces). +const WALK_COL_SEAM_FLIP_DOT: float = -0.7 +## Treat post-clip speed as “full fight” when above this fraction of [member MOVE_SPEED]. +const WALK_COL_SEAM_SPEED_FRAC: float = 0.82 +## Physics ticks to hold [code]velocity.xz == 0[/code] after a seam flip (odd/even frame +## alternation). +const WALK_COL_SEAM_SUPPRESS_TICKS: int = 3 +## Hysteresis for [code]needs_vertical_routing[/code]: [code]feet_y[/code] wobbles with +## [code]move_and_slide[/code] on treads (~7 cm), so a single margin (see [member +## DESCEND_GOAL_Y_MARGIN]) +## can flip path vs direct steering every tick → velocity sign ping-pong while +## [code]has_goal[/code]. +const WALK_VERT_ROUTE_LATCH_ON_SEP: float = 0.10 +## Stay below [member WALK_VERT_ROUTE_LATCH_ON_SEP]. Old 0.038 unlatched while descending toward +## floor +## (vert_sep under ~4 cm) → [code]vlat[/code]/[code]ncol[/code] false → peel suspend never ran. +const WALK_VERT_ROUTE_LATCH_OFF_SEP: float = 0.085 const FLOOR_SNAP_MOVING: float = 0.32 ## Idle floor snap length (stronger to pin rim contacts). const FLOOR_SNAP_IDLE: float = 0.11 ## Below this floor-normal dot up, idle tick uses **moving** `floor_max_angle` (rim). const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968 +## Uniform walkable slopes (e.g. physics QA ramp ~18°): stable idle when not tread+riser ridged. +## Below ~cos(28°): treat as too steep for this fast-path (leave corrective / rim logic). +const IDLE_SLOPE_STABLE_MIN_UP_DOT: float = 0.88 ## Stable flat idle support: skip corrective idle motion when support is effectively level. -const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.9998 +## 0.998 (~3.6°): 0.999 still saw open-floor HUD drift — Jolt [code]get_floor_normal()[/code] often +## reports ~0.9991–0.9992 on a flat box, so the old 0.999 gate rarely latched the idle anchor and +## [code]move_and_slide[/code] + rim/bump nudges kept nudging XZ. +const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.998 +## While [member _idle_stable_latched], allow slightly tilted reported normals (tread/riser edge). +const STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT: float = 0.992 ## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z. -const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8 +const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 5 +## Consecutive [code]raw_stable[/code] ticks before first idle anchor latch. Two was safe but Jolt +## heightmaps / slopes often insert a one-frame false negative → ~1 s extra jitter before latch. +const STABLE_IDLE_ENTER_STREAK_FRAMES: int = 1 +## When not [code]raw_stable[/code] and not yet latched, decay the enter streak instead of zeroing +## (brief floor-normal flicker no longer wipes two good ticks). +const STABLE_IDLE_ENTER_MISS_DECAY: int = 1 +## Consecutive unstable-idle physics ticks before leaving latched stable idle (thin tread lip). +const STABLE_IDLE_UNLATCH_TICKS: int = 10 ## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**). const IDLE_RIM_SETTLE_STEP: float = 0.004 +## Used by tests and vertical routing checks (feet vs goal surface). const DESCEND_GOAL_Y_MARGIN: float = 0.06 +## Cap snap while dropping off a pad so Jolt cannot glue the capsule to the upper surface. +const DESCEND_LIP_SNAP_CAP: float = 0.1 ## Y lift when blocked by wall-ish slide but nav goal is higher (stepped bumps). const WALK_STEP_ASSIST_DELTA: float = 0.11 -## Skip assist when horizontal velocity already aligns enough with want-dir (corners slide slowly). -const WALK_STEP_ASSIST_MAX_FORWARD_DOT: float = 0.52 const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8 -## Capsule height 1.0 in scene: origin at center, feet ~ **`y - 0.5`**. +## Floor-snap length while step-climbing. Small enough that it cannot reach the lower floor +## (~0.11 m away after the first lift) but large enough to catch the step surface once the +## capsule clears the face (~0.03 m away after the third lift for a 0.3 m step). +const WALK_STEP_ASSIST_SNAP: float = 0.09 +## Do not step-assist when the goal surface is farther above the feet than this (meters). +## Matches [NavigationMesh] `agent_max_climb` in `main.tscn` so repeated lifts cannot bypass a +## two-level climb in one click (e.g. floor → **TerracePlatformB** violet deck at ~0.6 m — use +## **TerraceStepB** gold first, or a second click after standing on the step). +const WALK_STEP_ASSIST_MAX_SURFACE_DELTA: float = 0.35 +## Ray below capsule feet while walking: if [method CharacterBody3D.is_on_floor] stays true off a +## ledge (snap + lip contacts), we still need gravity toward the lower floor — without re-applying +## gravity for every “goal below feet” tick on a whole upper deck (see [member +## _apply_walk_air_gravity]). +## Down-ray length under the capsule foot disk while walking (~[member +## NavigationMesh.agent_max_climb]). +const WALK_SUPPORT_PROBE_DEPTH: float = 0.32 +const WALK_SUPPORT_PROBE_MIN_UP_DOT: float = 0.42 +## Ignore ray hits farther below the feet than this (m). Set just **above** QA tread rise (~0.104 m +## in +## [code]main.tscn[/code]) so the next tread usually counts as continuation — stabilizes debounced +## peel/snap +## on violet stairs. Must stay **below** ~0.25 m so a gold→floor (~0.3 m) void still rejects the +## slab hit +## (no horizontal skate off the deck). +const WALK_CONTINUATION_MAX_BELOW_FEET: float = 0.108 +## If capsule feet are farther above the click’s surface Y than this, always arm ledge peel (bypass +## probe streak). Keep this **just above** a single QA tread drop (~0.104 m) so stair treads still +## use +## [method _walk_has_close_floor_probe_below]; use a **small** value so tall drops (block ~2 m, +## terrace +## ~0.6 m) never exit this “deep” band until feet are almost on the goal surface — otherwise the +## last +## ~40 cm reverted to probe-only, lip [code]is_on_floor()[/code] + full snap could **horizontal +## skate** +## with almost no peel until XZ arrival. +const WALK_DEEP_DESCENT_FEET_ABOVE_GOAL: float = 0.12 +## [method get_gravity] can be ~0 with missing/odd project defaults; ledge peel + airborne walk +## still need a pull. +const WALK_FALLBACK_GRAVITY_Y: float = -9.81 +## Multiplier on project gravity for walk ledge peel, walk-airborne, and idle-airborne (arcade +## descent). +const PLAYER_VERTICAL_ACCEL_MULTIPLIER: float = 9.0 +## Scale [method _apply_walk_post_slide_ledge_peel] integration when feet are still far above a +## **lower** +## goal (block / terrace lip). Keeps peel sub-linear time from feeling like a hover; tread-sized +## drops stay ~1×. +const WALK_LEDGE_PEEL_BOOST_SEP_1: float = 0.28 +const WALK_LEDGE_PEEL_BOOST_SEP_2: float = 0.75 +const WALK_LEDGE_PEEL_BOOST_SEP_3: float = 1.35 +const WALK_LEDGE_PEEL_BOOST_MUL_1: float = 1.65 +const WALK_LEDGE_PEEL_BOOST_MUL_2: float = 2.75 +const WALK_LEDGE_PEEL_BOOST_MUL_3: float = 4.25 +## CapsuleShape3D in scene: height = 1.0 (cylinder portion), radius = 0.4. +## Total half-height from body origin to physical bottom = 0.5 + 0.4 = 0.9 +## (`CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS`). +## `CAPSULE_HALF_HEIGHT` alone (0.5) is the cylinder half; used only where that +## distinction matters (e.g. the descend-bypass feet estimate). const CAPSULE_HALF_HEIGHT: float = 0.5 const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload( "res://scripts/random_floor_bump_collision_constants.gd" @@ -51,14 +233,44 @@ var _has_walk_goal: bool = false var _auth_walk_goal: Vector3 = Vector3.ZERO var _floor_angle_loose_ticks: int = 0 var _step_assist_cooldown: int = 0 +## True while the player is actively climbing a step; suppresses the normal large floor +## snap so it cannot pull the capsule back to the lower surface between assist cycles. +var _step_assist_active: bool = false var _idle_anchor_active: bool = false var _idle_anchor_xz: Vector2 = Vector2.ZERO +var _idle_anchor_y: float = 0.0 +## Latched stable idle: stay on zero-vel + anchor through brief Jolt false negatives on treads. +var _idle_stable_latched: bool = false +var _idle_stable_unlatch_streak: int = 0 +var _idle_stable_enter_streak: int = 0 var _idle_manual_correction_ticks: int = 0 var _debug_last_idle_xz: Vector2 = Vector2.INF var _debug_idle_heartbeat: int = 0 var _debug_last_transform_xz: Vector2 = Vector2.INF var _debug_trace_frame: int = 0 - +## True while we use [method _set_horizontal_velocity_from_nav_path_or_goal] for vertical routing. +var _walk_nav_column_steering: bool = false +## Last horizontal nav-path steer unit (XZ); eases 180° waypoint switches (see [member +## NAV_PATH_STEER_SMOOTH_DOT]). +var _walk_nav_path_steer_hz: Vector3 = Vector3.ZERO +## Schmitt latch for vertical routing (see [member WALK_VERT_ROUTE_LATCH_ON_SEP]). +var _walk_vert_route_latched: bool = false +## GROUNDED [method move_and_slide] clears downward [code]velocity.y[/code] on floor; accumulate +## peel +## speed here and apply [code]global_position.y[/code] so terrace lips can actually drop. +var _walk_ledge_peel_vy: float = 0.0 +## Pre-[method move_and_slide] debounce for probe flicker (see [member +## WALK_PEEL_PROBE_FALSE_FRAMES]). +var _walk_peel_no_probe_streak: int = 0 +## True after streak / deep-descent check; drives zero snap and [method +## _apply_walk_post_slide_ledge_peel]. +var _walk_debounced_wants_ledge_peel: bool = false +var _walk_column_stuck_frame: int = 0 +var _walk_column_stuck_origin: Vector3 = Vector3.ZERO +## Post-clip XZ from last physics tick; used with [member WALK_COL_SEAM_FLIP_DOT] while [code]vlat +## && ncol[/code]. +var _walk_col_seam_prev_hz: Vector2 = Vector2.ZERO +var _walk_col_seam_suppress_ticks: int = 0 @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D @@ -75,17 +287,43 @@ func set_authoritative_nav_goal(world_pos: Vector3) -> void: _auth_walk_goal = world_pos _has_walk_goal = true _floor_angle_loose_ticks = 0 + _step_assist_active = false _idle_anchor_active = false _idle_manual_correction_ticks = 0 + _idle_stable_latched = false + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _walk_nav_column_steering = false + _walk_vert_route_latched = false + _walk_nav_path_steer_hz = Vector3.ZERO + _walk_ledge_peel_vy = 0.0 + _walk_peel_no_probe_streak = 0 + _walk_debounced_wants_ledge_peel = false + _walk_column_stuck_frame = 0 + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = 0 _nav_agent.set_target_position(world_pos) func clear_nav_goal() -> void: velocity = Vector3.ZERO + _walk_ledge_peel_vy = 0.0 _has_walk_goal = false _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP + _step_assist_active = false _idle_anchor_active = false _idle_manual_correction_ticks = 0 + _idle_stable_latched = false + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _walk_nav_column_steering = false + _walk_vert_route_latched = false + _walk_nav_path_steer_hz = Vector3.ZERO + _walk_peel_no_probe_streak = 0 + _walk_debounced_wants_ledge_peel = false + _walk_column_stuck_frame = 0 + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = 0 _nav_agent.set_target_position(global_position) @@ -107,33 +345,75 @@ func sync_navigation_agent_after_map_rebuild(nav_region: NavigationRegion3D) -> func snap_to_server(world_pos: Vector3) -> void: - global_position = world_pos + # 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 + # being placed underground (which, combined with a stale is_on_floor() returning true, + # would skip all corrective move_and_slide() calls and leave the capsule stuck). + var total_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + var settled := Vector3(world_pos.x, maxf(world_pos.y, total_half), world_pos.z) + global_position = settled velocity = Vector3.ZERO _has_walk_goal = false - _floor_angle_loose_ticks = 0 + _walk_ledge_peel_vy = 0.0 + _walk_peel_no_probe_streak = 0 + _walk_debounced_wants_ledge_peel = false + # Force corrective idle ticks so Jolt resolves any residual physics state before walking. + _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP _step_assist_cooldown = 0 + _step_assist_active = false _idle_anchor_active = false _idle_manual_correction_ticks = 0 - _nav_agent.set_target_position(world_pos) + _idle_stable_latched = false + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _walk_nav_column_steering = false + _walk_vert_route_latched = false + _walk_nav_path_steer_hz = Vector3.ZERO + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = 0 + _nav_agent.set_target_position(settled) reset_physics_interpolation() -func _step_assist_has_support() -> bool: +## Returns true when the capsule has something to stand on **or** is pressing against a step face +## in the direction of travel. `want_dir_xz` is the normalised horizontal goal direction. +## Using `is_on_wall()` as a blanket short-circuit was too broad: any floor-bump side-contact +## satisfied it, causing the assist to fire prematurely and then be unable to snap back. +func _step_assist_has_support(want_dir_xz: Vector3) -> bool: if is_on_floor(): return true + var w2 := Vector2(want_dir_xz.x, want_dir_xz.z) + var have_dir: bool = w2.length_squared() > 1e-8 + if have_dir: + w2 = w2.normalized() for i: int in get_slide_collision_count(): - if get_slide_collision(i).get_normal().y > 0.35: - return true + var n: Vector3 = get_slide_collision(i).get_normal() + if n.y > 0.35: + return true # upward-facing = floor support + # Wall-ish contact opposing forward direction = step face immediately ahead. + if have_dir and abs(n.y) < 0.52: + var n2 := Vector2(n.x, n.z) + if n2.length_squared() > 1e-8 and n2.normalized().dot(w2) < -0.2: + return true return false +## True when a vertical-ish contact opposes motion toward the goal. Jolt often reports a wall +## via `is_on_wall()` + `get_wall_normal()` at box lips while slide normals are ambiguous; the +## old slide-only loop missed head-on climbs onto e.g. TerracePlatformA (teal pad). func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool: - if is_on_wall(): - return true var w2 := Vector2(want_dir_xz.x, want_dir_xz.z) if w2.length_squared() < 1e-8: return false w2 = w2.normalized() + if is_on_wall(): + var wn: Vector3 = get_wall_normal() + var wnh := Vector2(wn.x, wn.z) + if wnh.length_squared() > 1e-10: + wnh = wnh.normalized() + # Wall pushes back against forward (not parallel skimming along the face). + if wnh.dot(w2) < -0.02: + return true for i: int in get_slide_collision_count(): var n: Vector3 = get_slide_collision(i).get_normal() if n.y > 0.52: @@ -142,11 +422,89 @@ func _step_assist_wallish_blocks(want_dir_xz: Vector3) -> bool: if n2.length_squared() < 1e-8: continue n2 = n2.normalized() - if n2.dot(w2) < -0.04: + if n2.dot(w2) < -0.02: return true return false +## Remove horizontal velocity into **vertical** contacts using last tick's slide normals (and +## [method get_wall_normal] when [method is_on_wall]). Capsule lips / floor seams often never set +## [method is_on_wall] while [method move_and_slide] still fights full [code]MOVE_SPEED[/code] into +## the face → jitter. Same plane test as [method _step_assist_wallish_blocks]: [code]dot < +## -0.02[/code]. +func _walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h: Vector3) -> void: + var v2 := Vector2(velocity.x, velocity.z) + for _iter: int in range(4): + var v_before: Vector2 = v2 + for i: int in get_slide_collision_count(): + var n: Vector3 = get_slide_collision(i).get_normal() + if n.y > 0.52: + continue + var n2 := Vector2(n.x, n.z) + if n2.length_squared() < 1e-10: + continue + n2 = n2.normalized() + var dot_v: float = v2.dot(n2) + if dot_v < -0.02: + v2 -= n2 * dot_v + if is_on_wall(): + var wn: Vector3 = get_wall_normal() + if wn.y <= 0.35: + var wnh := Vector2(wn.x, wn.z) + if wnh.length_squared() > 1e-10: + wnh = wnh.normalized() + var dot_w: float = v2.dot(wnh) + if dot_w < -0.02: + v2 -= wnh * dot_w + if v2.distance_squared_to(v_before) < 1e-12: + break + # Goal through a vertical blocker but only micro tangential slip survives → kill floaty XZ. + var want3 := Vector3(want_goal_h.x, 0.0, want_goal_h.z) + if want3.length_squared() > 1e-10 and _step_assist_wallish_blocks(want3.normalized()): + if v2.length_squared() < 0.0025: + v2 = Vector2.ZERO + velocity.x = v2.x + velocity.z = v2.y + + +## While [code]_walk_vert_route_latched && _walk_nav_column_steering[/code], seam contacts can make +## [method _walk_clip_horizontal_velocity_against_vertical_contacts] output ~opposite +## [code]velocity.xz[/code] +## on consecutive ticks at nearly [member MOVE_SPEED] (slide normal order / riser vs tread). +func _walk_damp_vert_route_column_seam_flip() -> void: + if not (_walk_vert_route_latched and _walk_nav_column_steering): + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = 0 + return + if _walk_col_seam_suppress_ticks > 0: + _walk_col_seam_suppress_ticks -= 1 + velocity.x = 0.0 + velocity.z = 0.0 + _walk_col_seam_prev_hz = Vector2.ZERO + return + var hz := Vector2(velocity.x, velocity.z) + var len2: float = hz.length_squared() + var thr: float = MOVE_SPEED * WALK_COL_SEAM_SPEED_FRAC + var thr2: float = thr * thr + if len2 < thr2 * 0.2: + if len2 > 1e-8: + _walk_col_seam_prev_hz = hz + else: + _walk_col_seam_prev_hz = Vector2.ZERO + return + var pl2: float = _walk_col_seam_prev_hz.length_squared() + if pl2 < thr2 * 0.16: + _walk_col_seam_prev_hz = hz + return + if hz.normalized().dot(_walk_col_seam_prev_hz.normalized()) < WALK_COL_SEAM_FLIP_DOT: + velocity.x = 0.0 + velocity.z = 0.0 + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = WALK_COL_SEAM_SUPPRESS_TICKS + else: + _walk_col_seam_prev_hz = hz + + func _step_assist_can_raise_by(dy: float) -> bool: if dy <= 0.0001: return false @@ -154,20 +512,26 @@ func _step_assist_can_raise_by(dy: float) -> bool: func _try_walk_step_assist() -> bool: - if not _has_walk_goal or _auth_walk_goal.y < global_position.y + 0.025: + if not _has_walk_goal: return false - if not _step_assist_has_support(): + # Use actual capsule bottom (cylindrical half-height + hemisphere radius) so the guard + # triggers whenever the goal surface is meaningfully above the player's feet — not the + # capsule centre, which sits ~0.9 m above the floor. + var actual_feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT - PLAYER_CAPSULE_RADIUS + if ( + _auth_walk_goal.y < actual_feet_y + 0.025 + or _auth_walk_goal.y > actual_feet_y + WALK_STEP_ASSIST_MAX_SURFACE_DELTA + ): return false var pos: Vector3 = global_position var to_h: Vector3 = Vector3(_auth_walk_goal.x - pos.x, 0.0, _auth_walk_goal.z - pos.z) if to_h.length_squared() < 0.03: return false var want: Vector3 = to_h.normalized() - var vel_h: Vector3 = Vector3(velocity.x, 0.0, velocity.z) - if not _step_assist_wallish_blocks(want) or vel_h.dot(want) > WALK_STEP_ASSIST_MAX_FORWARD_DOT: + if not (_step_assist_wallish_blocks(want) and _step_assist_has_support(want)): return false - var lift: float = minf(WALK_STEP_ASSIST_DELTA, _auth_walk_goal.y - global_position.y + 0.45) - lift = minf(lift, maxf(0.0, _auth_walk_goal.y + 0.55 - global_position.y)) + var target_center_y: float = _auth_walk_goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + var lift: float = clampf(target_center_y - global_position.y, 0.0, WALK_STEP_ASSIST_DELTA) if lift <= 0.002 or not _step_assist_can_raise_by(lift): return false global_position.y += lift @@ -180,18 +544,406 @@ func _after_walk_move_and_slide() -> void: if _step_assist_cooldown > 0: return if _try_walk_step_assist(): + _step_assist_active = true _step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS - floor_snap_length = FLOOR_SNAP_MOVING + # Reduced snap so the lower floor (0.11 m below lifted capsule) is out of range. + floor_snap_length = WALK_STEP_ASSIST_SNAP move_and_slide() -func _set_horizontal_velocity_toward(point: Vector3) -> void: +func _clear_step_assist_after_walk_move() -> void: + if not _step_assist_active: + return + if is_on_floor() and not is_on_wall(): + _step_assist_active = false + elif not is_on_floor() and get_slide_collision_count() == 0: + _step_assist_active = false + + +func _set_horizontal_velocity_toward( + point: Vector3, fallback_dir_xz: Vector3 = Vector3.ZERO +) -> void: var pos := global_position var dh: Vector3 = Vector3(point.x - pos.x, 0.0, point.z - pos.z) if dh.length_squared() < 1e-10: - dh = Vector3(1.0, 0.0, 0.0) - velocity = dh.normalized() * MOVE_SPEED - velocity.y = 0.0 + var fb2 := Vector2(fallback_dir_xz.x, fallback_dir_xz.z) + if fb2.length_squared() > 1e-10: + fb2 = fb2.normalized() + dh = Vector3(fb2.x, 0.0, fb2.y) + else: + dh = Vector3(1.0, 0.0, 0.0) + else: + dh = dh.normalized() + # Same tick as [method _apply_walk_air_gravity]: zeroing [code]velocity.y[/code] every frame while + # airborne capped fall to one gravity delta per tick (~constant sink rate off tall pads). + var vy_air: float = velocity.y + velocity = dh * MOVE_SPEED + velocity.y = 0.0 if is_on_floor() else vy_air + + +func _walk_probe_xz_offsets(move_dir_xz: Vector2) -> Array[Vector2]: + var offs: Array[Vector2] = [Vector2.ZERO] + var len2: float = move_dir_xz.length_squared() + var r: float = PLAYER_CAPSULE_RADIUS + if len2 > 1e-8: + var inv_len: float = 1.0 / sqrt(len2) + var f := Vector2(move_dir_xz.x * inv_len, move_dir_xz.y * inv_len) + var perp := Vector2(-f.y, f.x) + offs.append(f * r * 0.58) + offs.append(f * r * 0.30 + perp * r * 0.42) + offs.append(f * r * 0.30 - perp * r * 0.42) + else: + offs.append(Vector2(1.0, 0.0) * r * 0.52) + offs.append(Vector2(-1.0, 0.0) * r * 0.52) + offs.append(Vector2(0.0, 1.0) * r * 0.52) + offs.append(Vector2(0.0, -1.0) * r * 0.52) + return offs + + +func _walk_ray_hit_up_floor(space: PhysicsDirectSpaceState3D, off_xz: Vector2) -> bool: + var feet: Vector3 = ( + global_position - Vector3(0.0, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS, 0.0) + ) + var probe_from := Vector3(feet.x + off_xz.x, feet.y + 0.04, feet.z + off_xz.y) + var probe_end := Vector3( + feet.x + off_xz.x, feet.y - WALK_SUPPORT_PROBE_DEPTH, feet.z + off_xz.y + ) + var q := PhysicsRayQueryParameters3D.create(probe_from, probe_end) + q.collision_mask = collision_mask + q.exclude = [get_rid()] + var res: Dictionary = space.intersect_ray(q) + if res.is_empty(): + return false + if not res.has("normal") or not res.has("position"): + return false + var hit_y: float = (res["position"] as Vector3).y + if hit_y < feet.y - WALK_CONTINUATION_MAX_BELOW_FEET: + return false + var n: Vector3 = res["normal"] + return n.dot(Vector3.UP) > WALK_SUPPORT_PROBE_MIN_UP_DOT + + +## Horizontal bearing for walk ledge / continuation rays. Must not stay ~zero while descending: a +## floor click **under** the capsule ([code]want_goal_h ≈ 0[/code]) used to leave +## [code]walk_move_dir_xz[/code] +## empty so [method _walk_has_close_floor_probe_below] took the center-only fast path and never +## applied ledge gravity until XZ arrival. +func _resolve_walk_probe_dir_xz(feet_y: float, want_goal_h: Vector3) -> Vector2: + var pos := global_position + var to_g := Vector2(_auth_walk_goal.x - pos.x, _auth_walk_goal.z - pos.z) + var vxz := Vector2(velocity.x, velocity.z) + var wh := Vector2(want_goal_h.x, want_goal_h.z) + var descending: bool = _has_walk_goal and _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN + var out: Vector2 + if descending: + if to_g.length_squared() >= 1e-6: + out = to_g + elif wh.length_squared() >= 1e-8: + out = wh + else: + var picked_from_path: bool = false + var path: PackedVector3Array = _nav_agent.get_current_navigation_path() + var min_l2: float = NAV_PATH_STEER_MIN_LOOKAHEAD * NAV_PATH_STEER_MIN_LOOKAHEAD + for i: int in range(path.size()): + var d := Vector2(path[i].x - pos.x, path[i].z - pos.z) + if d.length_squared() >= min_l2: + out = d + picked_from_path = true + break + if not picked_from_path: + if to_g.length_squared() > 1e-12: + out = to_g + elif vxz.length_squared() >= 1e-8: + out = vxz + else: + out = Vector2(1.0, 0.0) + else: + if vxz.length_squared() < 1e-10: + out = wh + else: + out = vxz + return out + + +## Walkable support for **continued** forward motion within [member WALK_SUPPORT_PROBE_DEPTH]. +## Requires a hit under the foot origin **and** at least one sample **ahead** in [param move_dir_xz] +## (same construction as [method _walk_probe_xz_offsets]). If only the center hits while the leading +## foot is over a void, [method CharacterBody3D.is_on_floor] can stay true from a lip — returning +## true here would suppress gravity and keep moving snap until XZ arrival (ledge skate). +## When [param move_dir_xz] is ~zero (idle / no horizontal intent), center-only is enough. +func _walk_has_close_floor_probe_below(move_dir_xz: Vector2) -> bool: + var w3d := get_world_3d() + if w3d == null: + return true + var space: PhysicsDirectSpaceState3D = w3d.direct_space_state + if not _walk_ray_hit_up_floor(space, Vector2.ZERO): + return false + var len2: float = move_dir_xz.length_squared() + if len2 < 1e-10: + return true + var inv_len: float = 1.0 / sqrt(len2) + var md := Vector2(move_dir_xz.x * inv_len, move_dir_xz.y * inv_len) + for off2: Vector2 in _walk_probe_xz_offsets(move_dir_xz): + if off2.length_squared() < 1e-12: + continue + if off2.dot(md) > 0.0: + if _walk_ray_hit_up_floor(space, off2): + return true + return false + + +## Airborne walk ticks only. Do **not** add gravity just because the nav goal is below the feet — +## that stays true for the whole pad cross and poisons horizontal motion. +## Do apply gravity when on floor but no walkable surface lies under the foot disk (ledge void). +## With GROUNDED [method move_and_slide], [code]velocity.y[/code] clears on floor; terrace lips use +## [method _apply_walk_post_slide_ledge_peel] instead of gravity-on-velocity alone. +func _apply_walk_air_gravity( + delta: float, _feet_y: float, _move_dir_xz: Vector2 = Vector2.ZERO +) -> void: + if not is_on_floor(): + velocity += _player_vertical_accel(delta) * delta + + +func _walk_try_release_column_stuck_orbit(horiz_dist: float) -> bool: + if not _has_walk_goal or not is_on_floor(): + _walk_column_stuck_frame = 0 + return false + if not (_walk_vert_route_latched and _walk_nav_column_steering): + _walk_column_stuck_frame = 0 + return false + if horiz_dist > WALK_COLUMN_STUCK_HORIZ_MAX: + _walk_column_stuck_frame = 0 + return false + if _walk_column_stuck_frame == 0: + _walk_column_stuck_origin = global_position + _walk_column_stuck_frame += 1 + if _walk_column_stuck_frame < WALK_COLUMN_STUCK_FRAMES: + return false + var drift: float = global_position.distance_to(_walk_column_stuck_origin) + _walk_column_stuck_frame = 0 + if drift > WALK_COLUMN_STUCK_MAX_DRIFT: + return false + clear_nav_goal() + return true + + +func _walk_peel_suspend_near_goal(feet_y: float, horiz_dist: float) -> bool: + var vsep: float = absf(_auth_walk_goal.y - feet_y) + if ( + _walk_vert_route_latched + and _walk_nav_column_steering + and vsep < WALK_PEEL_COLUMN_SUSPEND_MAX_VSEP + ): + return true + var descending: bool = _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN + if descending and feet_y > _auth_walk_goal.y + WALK_PEEL_SUSPEND_BROAD_MAX_FEET_ABOVE_GOAL: + return false + return vsep <= WALK_PEEL_SUSPEND_HORIZ_VERT_SEP and horiz_dist <= WALK_PEEL_SUSPEND_HORIZ_DIST + + +func _walk_tread_freeze_horizontal_active(_feet_y: float, horiz_dist: float) -> bool: + if not is_on_floor() or not _has_walk_goal: + return false + if _auth_walk_goal.y > _feet_y + 0.04: + return false + if not _walk_peel_suspend_near_goal(_feet_y, horiz_dist): + return false + if horiz_dist > WALK_TREAD_FREEZE_HORIZ_MAX: + return false + var cap_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + var verr: float = vertical_arrival_error(_auth_walk_goal.y, global_position.y, cap_half) + return verr > VERT_ARRIVE_EPS and verr <= WALK_TREAD_FREEZE_VERT_ERR_MAX + + +func _walk_refresh_ledge_peel_debounce(feet_y: float, move_dir_xz: Vector2) -> void: + var probe_ok: bool = _walk_has_close_floor_probe_below(move_dir_xz) + var deep: bool = feet_y > _auth_walk_goal.y + WALK_DEEP_DESCENT_FEET_ABOVE_GOAL + if deep: + _walk_peel_no_probe_streak = WALK_PEEL_PROBE_FALSE_FRAMES + elif not probe_ok: + _walk_peel_no_probe_streak += 1 + else: + _walk_peel_no_probe_streak = 0 + _walk_debounced_wants_ledge_peel = ( + deep or _walk_peel_no_probe_streak >= WALK_PEEL_PROBE_FALSE_FRAMES + ) + + +## After [method move_and_slide], peel the capsule down when the authoritative goal is below the +## feet +## but the engine still reports floor contact (GROUNDED mode ate negative [code]velocity.y[/code]). +## Uses a small accumulated peel speed so displacement matches [code]∫ g dt[/code], not [code]g +## Δt[/code] (wrong units). +func _apply_walk_post_slide_ledge_peel( + delta: float, feet_y: float, _move_dir_xz: Vector2, horiz_dist: float +) -> void: + if not _has_walk_goal: + _walk_ledge_peel_vy = 0.0 + return + if not is_on_floor(): + _walk_ledge_peel_vy = 0.0 + return + # Do not use [member DESCEND_GOAL_Y_MARGIN] here: peel stopped while feet were still ~6 cm above + # the goal surface (slow float to rest at XZ). Stop only once vertical arrival matches. + var cap_half: float = CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + if vertical_arrival_error(_auth_walk_goal.y, global_position.y, cap_half) <= VERT_ARRIVE_EPS: + _walk_ledge_peel_vy = 0.0 + return + if _auth_walk_goal.y > feet_y + 0.04: + _walk_ledge_peel_vy = 0.0 + return + if _walk_peel_suspend_near_goal(feet_y, horiz_dist): + _walk_ledge_peel_vy = 0.0 + return + if not _walk_debounced_wants_ledge_peel: + _walk_ledge_peel_vy = 0.0 + return + # Debounce was computed pre-slide; do not re-probe here (post-slide rays re-flicker on treads). + var sep_below_feet: float = feet_y - _auth_walk_goal.y + var peel_boost: float = 1.0 + if sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_3: + peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_3 + elif sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_2: + peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_2 + elif sep_below_feet > WALK_LEDGE_PEEL_BOOST_SEP_1: + peel_boost = WALK_LEDGE_PEEL_BOOST_MUL_1 + _walk_ledge_peel_vy += _player_vertical_accel(delta).y * delta * peel_boost + var dy: float = _walk_ledge_peel_vy * delta + if absf(dy) > 1e-9: + # Raw [code]global_position[/code] edits may be overwritten vs Jolt after [method move_and_slide]. + move_and_collide(Vector3(0.0, dy, 0.0), false, safe_margin, false) + + +func _walk_floor_snap_length( + feet_y: float, + want_goal_h: Vector3, + _move_dir_xz: Vector2 = Vector2.ZERO, + horiz_dist: float = INF, +) -> float: + if _step_assist_active: + return WALK_STEP_ASSIST_SNAP + if _has_walk_goal: + # Strong moving snap + lip [code]is_on_floor()[/code] otherwise cancels gravity every tick. + if ( + is_on_floor() + and not _walk_peel_suspend_near_goal(feet_y, horiz_dist) + and _walk_debounced_wants_ledge_peel + ): + return 0.0 + if want_goal_h.length_squared() > 1e-10: + var want: Vector3 = want_goal_h.normalized() + # Climb: cap snap only for a **meaningful** rise (not shallow tread-to-tread), or we + # fight the approach stairs with snap ↔ assist oscillation. + if _auth_walk_goal.y > feet_y + 0.12 and _step_assist_wallish_blocks(want): + return WALK_STEP_ASSIST_SNAP + # Lip cap only while debounced peel is armed. Otherwise [code]feet_y[/code] wobble on treads + # crosses [code]feet_y > goal + 0.08[/code] / margins → 0.32 m vs 0.1 m snap flips each tick + # (~2–3 cm body Y jitter on green stairs, x≈19). + var descend_lip_geom: bool = ( + _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN and feet_y > _auth_walk_goal.y + 0.08 + ) + if ( + descend_lip_geom + and _walk_debounced_wants_ledge_peel + and not _walk_peel_suspend_near_goal(feet_y, horiz_dist) + ): + return minf(FLOOR_SNAP_MOVING, DESCEND_LIP_SNAP_CAP) + return FLOOR_SNAP_MOVING + + +## Descend only: relax `floor_block_on_wall` when pressed against a vertical face so Jolt can +## slide off terrace lips. Never when the goal is above the feet (climb) — avoids platform-wide +## toggling that broke horizontal motion. +func _apply_floor_block_for_descend_at_wall(feet_y: float) -> void: + floor_block_on_wall = true + if not _has_walk_goal: + return + if _auth_walk_goal.y >= feet_y - DESCEND_GOAL_Y_MARGIN: + return + if is_on_wall(): + floor_block_on_wall = false + return + # Terrace lip: floor normal tilts off UP when straddling an internal edge (teal pad, etc.). + if is_on_floor() and get_floor_normal().dot(Vector3.UP) < 0.992: + floor_block_on_wall = false + return + for i: int in range(get_slide_collision_count()): + var n: Vector3 = get_slide_collision(i).get_normal() + if absf(n.y) < 0.58: + floor_block_on_wall = false + return + + +## When the authoritative goal shares XZ with the capsule (e.g. terrace above), horizontal +## `full_to_goal` is zero and steering must follow the baked path toward a ramp/step, not +X. +func _set_horizontal_velocity_from_nav_path_or_goal(fallback_goal_xz: Vector3) -> void: + # `get_next_path_position()` alone still ping-pongs: the engine’s “next” point can sit within + # Jolt’s per-tick slide distance, so the horizontal seek vector flips 180° each tick. + # Scan the baked path for the first XZ waypoint at least [member NAV_PATH_STEER_MIN_LOOKAHEAD] + # away (unlike the old 5 cm scan). Reject segments that point opposite the click (XZ). + if _nav_agent.is_navigation_finished(): + _walk_nav_path_steer_hz = Vector3.ZERO + _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) + return + var pos := global_position + var min_l2: float = NAV_PATH_STEER_MIN_LOOKAHEAD * NAV_PATH_STEER_MIN_LOOKAHEAD + var steer_point: Vector3 = _auth_walk_goal + var path: PackedVector3Array = _nav_agent.get_current_navigation_path() + if path.size() > 0: + var chosen: bool = false + for i: int in range(path.size()): + var wp: Vector3 = path[i] + var dx: float = wp.x - pos.x + var dz: float = wp.z - pos.z + if dx * dx + dz * dz >= min_l2: + steer_point = wp + chosen = true + break + if not chosen: + steer_point = path[path.size() - 1] + var dh: Vector3 = Vector3(steer_point.x - pos.x, 0.0, steer_point.z - pos.z) + if dh.length_squared() < 1e-10: + _walk_nav_path_steer_hz = Vector3.ZERO + _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) + return + var dir: Vector3 = dh.normalized() + var gh: Vector3 = Vector3(fallback_goal_xz.x, 0.0, fallback_goal_xz.z) + if gh.length_squared() > 1e-10: + gh = gh.normalized() + if dir.dot(gh) < NAV_PATH_STEER_MIN_GOAL_DOT: + _walk_nav_path_steer_hz = Vector3.ZERO + _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) + return + var dir_hz := Vector3(dir.x, 0.0, dir.z) + if dir_hz.length_squared() < 1e-10: + _walk_nav_path_steer_hz = Vector3.ZERO + _set_horizontal_velocity_toward(_auth_walk_goal, fallback_goal_xz) + return + dir_hz = dir_hz.normalized() + if _walk_nav_path_steer_hz.length_squared() > 1e-10: + var prev := Vector3(_walk_nav_path_steer_hz.x, 0.0, _walk_nav_path_steer_hz.z).normalized() + var dotp: float = dir_hz.dot(prev) + var t: float = ( + NAV_PATH_STEER_SMOOTH_SLERP_OPPOSING + if dotp < NAV_PATH_STEER_SMOOTH_DOT + else NAV_PATH_STEER_SMOOTH_SLERP + ) + dir_hz = prev.slerp(dir_hz, t).normalized() + _walk_nav_path_steer_hz = dir_hz + var vy_air: float = velocity.y + velocity = dir_hz * MOVE_SPEED + velocity.y = 0.0 if is_on_floor() else vy_air + + +func _effective_gravity(_delta: float) -> Vector3: + var g: Vector3 = get_gravity() + if g.length_squared() < 0.25: + return Vector3(0.0, WALK_FALLBACK_GRAVITY_Y, 0.0) + return g + + +func _player_vertical_accel(_delta: float) -> Vector3: + return _effective_gravity(_delta) * PLAYER_VERTICAL_ACCEL_MULTIPLIER static func capsule_feet_y(body_origin_y: float, capsule_half_height: float) -> float: @@ -204,19 +956,83 @@ static func vertical_arrival_error( return absf(goal_y - capsule_feet_y(body_origin_y, capsule_half_height)) -func _physics_idle_tick() -> void: - velocity = Vector3.ZERO - floor_snap_length = FLOOR_SNAP_IDLE +func _physics_idle_tick(delta: float) -> void: + if is_on_floor(): + velocity = Vector3.ZERO + floor_snap_length = FLOOR_SNAP_IDLE + else: + # Ledge / post-arrival hover: without gravity, vy stays 0 and FLOOR_SNAP_IDLE (0.11 m) + # cannot reach the lower floor — the capsule idles in mid-air until a new walk goal runs. + velocity.x = 0.0 + velocity.z = 0.0 + velocity += _player_vertical_accel(delta) * delta + floor_snap_length = FLOOR_SNAP_MOVING move_and_slide() -static func idle_support_is_stable( - on_floor: bool, floor_normal: Vector3, _slide_normals: Array[Vector3], loose_ticks: int -) -> bool: - ## `_slide_normals` is reserved for future extra-contact analysis. - if not on_floor or loose_ticks > 0: +static func idle_slide_contacts_are_ridged(slide_normals: Array[Vector3]) -> bool: + var upish := 0 + var wallish := 0 + for n: Vector3 in slide_normals: + var ny: float = n.y + if ny > 0.65: + upish += 1 + # Steeper than ~70° from horizontal — shallow seam faces (ny≈0.4) are not “lip” walls. + elif ny < 0.35: + wallish += 1 + return upish >= 1 and wallish >= 1 + + +## [method idle_slide_contacts_are_ridged] is true on heightmap facet seams (up + shallow “wall” +## micro-normals) while the floor normal’s up component still sits in the smooth single-slope band. +## Treat +## that like a uniform ramp: do not run idle rim nudges / stair-lip stability against it (QA hill). +## Same for **nearly level** aggregate normals (hill crest / corner): facet pairs are not +## tread+riser. +static func idle_ridged_stair_lip_only(slide_normals: Array[Vector3], floor_up_dot: float) -> bool: + if not idle_slide_contacts_are_ridged(slide_normals): return false - if floor_normal.dot(Vector3.UP) < STABLE_IDLE_FLOOR_MIN_UP_DOT: + if floor_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT: + return false + if floor_up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT: + return false + return true + + +static func idle_support_is_stable( + on_floor: bool, + floor_normal: Vector3, + slide_normals: Array[Vector3], + loose_ticks: int, + min_flat_up_dot: float = STABLE_IDLE_FLOOR_MIN_UP_DOT, +) -> bool: + if not on_floor: + return false + var up_dot: float = floor_normal.dot(Vector3.UP) + var ridged: bool = idle_ridged_stair_lip_only(slide_normals, up_dot) + # Single-surface inclines (walk ramps): flatter than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] but not + # stair lip geometry — latch stable idle instead of endless corrective [method _physics_idle_tick]. + if ( + not ridged + and up_dot >= IDLE_SLOPE_STABLE_MIN_UP_DOT + and up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT + ): + return true + # Entering stable idle uses [member STABLE_IDLE_FLOOR_MIN_UP_DOT] (0.998). On stair treads, + # [code]get_floor_normal()[/code] often sits ~0.996–0.997 with a riser contact — never latches, + # so idle stays in corrective [method _maybe_idle_rim_settle_nudge] + [method _physics_idle_tick] + # forever (HUD jitter while **not** walking). Use the latched hold threshold (0.992) when ridged. + var required_up: float = min_flat_up_dot + if ridged and min_flat_up_dot >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 1e-6: + required_up = STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT + if up_dot < required_up: + return false + # Post-stop **ridged** slides (tread + riser) used to force unstable idle for the entire + # `loose_ticks` window even when the **floor** is level — e.g. idle on a flat approach tread + # hugging a vertical face stayed in corrective `move_and_slide` ~0.8 s (HUD X/Y/Z noise). + # Only demand rim settle when the reported floor is already shallow (lip straddle), not mere + # proximity to a riser on an otherwise flat tread. + if loose_ticks > 0 and ridged and up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT: return false return true @@ -229,8 +1045,15 @@ func _current_slide_normals() -> Array[Vector3]: func _stable_idle_support() -> bool: + var min_dot: float = ( + STABLE_IDLE_FLOOR_HOLD_MIN_UP_DOT if _idle_stable_latched else STABLE_IDLE_FLOOR_MIN_UP_DOT + ) return idle_support_is_stable( - is_on_floor(), get_floor_normal(), _current_slide_normals(), _floor_angle_loose_ticks + is_on_floor(), + get_floor_normal(), + _current_slide_normals(), + _floor_angle_loose_ticks, + min_dot ) @@ -238,9 +1061,10 @@ func _hold_idle_anchor() -> void: var pos := global_position if not _idle_anchor_active: _idle_anchor_xz = Vector2(pos.x, pos.z) + _idle_anchor_y = pos.y _idle_anchor_active = true return - global_position = Vector3(_idle_anchor_xz.x, pos.y, _idle_anchor_xz.y) + global_position = Vector3(_idle_anchor_xz.x, _idle_anchor_y, _idle_anchor_xz.y) func _debug_trace_idle_state(tag: String) -> void: @@ -293,11 +1117,21 @@ func _debug_trace_transform(tag: String) -> void: and tag != "physics" ): return + var extra: String = "" + if _has_walk_goal: + var fy: float = capsule_feet_y( + global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + ) + var vsep: float = absf(_auth_walk_goal.y - fy) + extra = ( + " feet_y=%s vert_sep=%.4f vlat=%s ncol=%s" + % [fy, vsep, _walk_vert_route_latched, _walk_nav_column_steering] + ) print( ( ( "NEO-14 transform trace [%s] frame=%d pos=%s vel=%s has_goal=%s " - + "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s" + + "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s%s" ) % [ tag, @@ -310,6 +1144,7 @@ func _debug_trace_transform(tag: String) -> void: _stable_idle_support(), _idle_anchor_active, _idle_anchor_xz, + extra, ] ) ) @@ -322,24 +1157,20 @@ func _notification(what: int) -> void: func _idle_ridged_floor_contacts() -> bool: - var upish := 0 - var wallish := 0 - for i: int in get_slide_collision_count(): - var ny: float = get_slide_collision(i).get_normal().y - if ny > 0.65: - upish += 1 - elif ny < 0.45: - wallish += 1 - return upish >= 1 and wallish >= 1 + return idle_slide_contacts_are_ridged(_current_slide_normals()) func _maybe_idle_rim_settle_nudge() -> bool: if get_slide_collision_count() < 1: return false - var shallow_floor: bool = ( - is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT - ) - if not shallow_floor and not _idle_ridged_floor_contacts(): + var floor_up_dot: float = get_floor_normal().dot(Vector3.UP) + var shallow_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT + var ridged: bool = idle_ridged_stair_lip_only(_current_slide_normals(), floor_up_dot) + # Uniform ramps (one sloped floor, no tread+riser) still read “shallow” vs + # [member IDLE_RIM_MIN_FLOOR_UP_DOT]; rim nudges slide along the ramp every tick (jitter). + if shallow_floor and not ridged: + return false + if not shallow_floor and not ridged: return false var h := Vector3.ZERO if shallow_floor: @@ -438,28 +1269,46 @@ func _snap_capsule_upright() -> void: global_transform = Transform3D(Basis.IDENTITY, p) -func _physics_process(_delta: float) -> void: - _debug_trace_frame += 1 - var use_loose_floor_angle: bool = _has_walk_goal or _floor_angle_loose_ticks > 0 - if not _has_walk_goal: - var floor_up_dot: float = get_floor_normal().dot(Vector3.UP) - var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT - if shallow_idle_floor or _idle_ridged_floor_contacts(): - use_loose_floor_angle = true - floor_max_angle = deg_to_rad( - FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG - ) - if not _has_walk_goal: - if _stable_idle_support(): - _idle_manual_correction_ticks = 0 - velocity = Vector3.ZERO - floor_snap_length = FLOOR_SNAP_IDLE - _hold_idle_anchor() - _debug_trace_idle_state("stable") - _debug_trace_transform("physics") - _snap_capsule_upright() - return - if _idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS: +func _physics_process_no_walk_goal(delta: float) -> void: + floor_block_on_wall = true + var raw_stable: bool = _stable_idle_support() + if raw_stable: + _idle_stable_unlatch_streak = 0 + if not _idle_stable_latched: + _idle_stable_enter_streak += 1 + if _idle_stable_enter_streak >= STABLE_IDLE_ENTER_STREAK_FRAMES: + _idle_stable_latched = true + else: + _idle_stable_enter_streak = 0 + elif _idle_stable_latched: + var crest_quiet: bool = ( + is_on_floor() + and get_floor_normal().dot(Vector3.UP) >= STABLE_IDLE_FLOOR_MIN_UP_DOT - 0.001 + ) + if raw_stable or crest_quiet: + _idle_stable_unlatch_streak = 0 + else: + _idle_stable_unlatch_streak += 1 + if _idle_stable_unlatch_streak >= STABLE_IDLE_UNLATCH_TICKS: + _idle_stable_latched = false + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _idle_anchor_active = false + else: + _idle_stable_enter_streak = maxi( + 0, _idle_stable_enter_streak - STABLE_IDLE_ENTER_MISS_DECAY + ) + if _idle_stable_latched: + _idle_manual_correction_ticks = 0 + velocity = Vector3.ZERO + floor_snap_length = FLOOR_SNAP_IDLE + _hold_idle_anchor() + _debug_trace_idle_state("stable") + _debug_trace_transform("physics") + _snap_capsule_upright() + return + if _idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS: + if is_on_floor(): velocity = Vector3.ZERO floor_snap_length = FLOOR_SNAP_IDLE if _floor_angle_loose_ticks > 0: @@ -470,73 +1319,165 @@ func _physics_process(_delta: float) -> void: _snap_capsule_upright() return _idle_anchor_active = false - _physics_idle_tick() - if _apply_idle_manual_correction(): - _idle_manual_correction_ticks += 1 - else: - _idle_manual_correction_ticks = 0 + _physics_idle_tick(delta) if _floor_angle_loose_ticks > 0: _floor_angle_loose_ticks -= 1 - _debug_trace_idle_state("corrective") + _debug_trace_idle_state("budgeted") _debug_trace_transform("physics") _snap_capsule_upright() return + _idle_anchor_active = false + _physics_idle_tick(delta) + if _apply_idle_manual_correction(): + _idle_manual_correction_ticks += 1 + else: + _idle_manual_correction_ticks = 0 + if _floor_angle_loose_ticks > 0: + _floor_angle_loose_ticks -= 1 + _debug_trace_idle_state("corrective") + _debug_trace_transform("physics") + _snap_capsule_upright() + + +func _physics_process(delta: float) -> void: + _debug_trace_frame += 1 + # Do not tie wide floor angle to `_floor_angle_loose_ticks` alone: flat walks that kept a wide + # angle for the whole loose window made `move_and_slide` + idle snap jitter XZ. Use moving max + # angle only while walking or when idle support is shallow / ridged. + var use_loose_floor_angle: bool = _has_walk_goal + if not _has_walk_goal: + var floor_up_dot: float = get_floor_normal().dot(Vector3.UP) + var shallow_idle_floor: bool = is_on_floor() and floor_up_dot < IDLE_RIM_MIN_FLOOR_UP_DOT + # Raw facet ridging reads true on a heightmap **crest** while [code]get_floor_normal()[/code] is + # already ~level; widening [member floor_max_angle] there makes [method move_and_slide] hunt + # forever (HUD jitter on the pink hill tip). + if ( + (shallow_idle_floor or idle_slide_contacts_are_ridged(_current_slide_normals())) + and floor_up_dot < STABLE_IDLE_FLOOR_MIN_UP_DOT + ): + use_loose_floor_angle = true + floor_max_angle = deg_to_rad( + FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG + ) + if not _has_walk_goal: + _physics_process_no_walk_goal(delta) + return var full_to_goal: Vector3 = _auth_walk_goal - global_position var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length() - var vert_err: float = vertical_arrival_error( - _auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT + var want_goal_h: Vector3 = Vector3(full_to_goal.x, 0.0, full_to_goal.z) + # Feet + vertical routing before arrival: column stairs keep [code]vert_err[/code] above + # [member VERT_ARRIVE_EPS] while XZ is inside [member ARRIVE_EPS]. Widen vertical tolerance only + # for [code]vlat && ncol[/code] on floor (see [member WALK_COLUMN_NEAR_ARRIVE_VERT]). + var feet_y: float = capsule_feet_y( + global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS ) - if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS: + var vert_sep: float = absf(_auth_walk_goal.y - feet_y) + if not _walk_vert_route_latched: + if vert_sep > WALK_VERT_ROUTE_LATCH_ON_SEP: + _walk_vert_route_latched = true + else: + if vert_sep < WALK_VERT_ROUTE_LATCH_OFF_SEP: + _walk_vert_route_latched = false + var needs_vertical_routing: bool = _walk_vert_route_latched + if not needs_vertical_routing: + _walk_nav_column_steering = false + elif horiz_dist <= NAV_COLUMN_STEER_ENTER_DIST: + _walk_nav_column_steering = true + elif horiz_dist >= NAV_COLUMN_STEER_EXIT_DIST: + _walk_nav_column_steering = false + var vert_err: float = vertical_arrival_error( + _auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + ) + var vert_ok: float = VERT_ARRIVE_EPS + if ( + is_on_floor() + and _walk_vert_route_latched + and _walk_nav_column_steering + and vert_err < WALK_COLUMN_NEAR_ARRIVE_GATE_VERT_ERR + ): + vert_ok = maxf(VERT_ARRIVE_EPS, WALK_COLUMN_NEAR_ARRIVE_VERT) + if horiz_dist <= ARRIVE_EPS and vert_err <= vert_ok: velocity = Vector3.ZERO _has_walk_goal = false + _walk_ledge_peel_vy = 0.0 + _walk_peel_no_probe_streak = 0 + _walk_debounced_wants_ledge_peel = false + floor_block_on_wall = true _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP + _step_assist_active = false _idle_anchor_active = false _idle_manual_correction_ticks = 0 + _idle_stable_latched = false + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _walk_nav_column_steering = false + _walk_vert_route_latched = false + _walk_col_seam_prev_hz = Vector2.ZERO + _walk_col_seam_suppress_ticks = 0 _nav_agent.set_target_position(global_position) - _physics_idle_tick() + _physics_idle_tick(delta) + if _stable_idle_support(): + _idle_stable_latched = true + _idle_stable_unlatch_streak = 0 + _idle_stable_enter_streak = 0 + _idle_manual_correction_ticks = 0 + velocity = Vector3.ZERO + floor_snap_length = FLOOR_SNAP_IDLE + _hold_idle_anchor() + _debug_trace_idle_state("arrival_stable") + _debug_trace_transform("physics") + _snap_capsule_upright() + return if _apply_idle_manual_correction(): _idle_manual_correction_ticks += 1 _debug_trace_transform("physics") _snap_capsule_upright() return + if _walk_try_release_column_stuck_orbit(horiz_dist): + floor_block_on_wall = true + _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP + _physics_idle_tick(delta) + _debug_trace_transform("physics") + _snap_capsule_upright() + return + _idle_manual_correction_ticks = 0 - var nav_map: RID = _nav_agent.get_navigation_map() - if NavigationServer3D.map_get_iteration_id(nav_map) == 0: - _set_horizontal_velocity_toward(_auth_walk_goal) - floor_snap_length = FLOOR_SNAP_MOVING - move_and_slide() - _after_walk_move_and_slide() - _debug_trace_transform("physics") - _snap_capsule_upright() - return + _apply_floor_block_for_descend_at_wall(feet_y) - var feet_y: float = capsule_feet_y(global_position.y, CAPSULE_HALF_HEIGHT) - if _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN: - _set_horizontal_velocity_toward(_auth_walk_goal) - floor_snap_length = FLOOR_SNAP_MOVING - move_and_slide() - _after_walk_move_and_slide() - _debug_trace_transform("physics") - _snap_capsule_upright() - return - - if horiz_dist <= DIRECT_APPROACH_RADIUS: - _set_horizontal_velocity_toward(_auth_walk_goal) + # Horizontal motion toward the **authoritative** click target (XZ) by default. Use the baked + # path only while **airborne** with column vertical routing (`on_floor=false` + `vlat && ncol`): + # mid-air the next waypoint can carry tangential intent around a column. On **floor**, the same + # path sampling on a narrow nav ribbon + shallow rim normals still flipped ±[code]MOVE_SPEED[/code] + # every tick (Z seam traces: [code]ncol=true[/code] stable [code]on_floor[/code], ~4 cm Z wobble). + # Shallow on-floor column approach uses direct goal steer plus + # [method _walk_clip_horizontal_velocity_against_vertical_contacts]. + if needs_vertical_routing and _walk_nav_column_steering and not is_on_floor(): + _set_horizontal_velocity_from_nav_path_or_goal(want_goal_h) else: - var next_path_position: Vector3 = _nav_agent.get_next_path_position() - var to_next_h: Vector3 = Vector3( - next_path_position.x - global_position.x, 0.0, next_path_position.z - global_position.z - ) - if to_next_h.length_squared() > 0.0025: - _set_horizontal_velocity_toward(next_path_position) - else: - _set_horizontal_velocity_toward(_auth_walk_goal) + _walk_nav_path_steer_hz = Vector3.ZERO + _set_horizontal_velocity_toward(_auth_walk_goal, want_goal_h) - floor_snap_length = FLOOR_SNAP_MOVING + _walk_clip_horizontal_velocity_against_vertical_contacts(want_goal_h) + _walk_damp_vert_route_column_seam_flip() + + if _walk_tread_freeze_horizontal_active(feet_y, horiz_dist): + velocity.x = 0.0 + velocity.z = 0.0 + + var walk_move_dir_xz: Vector2 = _resolve_walk_probe_dir_xz(feet_y, want_goal_h) + _walk_refresh_ledge_peel_debounce(feet_y, walk_move_dir_xz) + + _apply_walk_air_gravity(delta, feet_y, walk_move_dir_xz) + floor_snap_length = _walk_floor_snap_length(feet_y, want_goal_h, walk_move_dir_xz, horiz_dist) move_and_slide() + var feet_after: float = capsule_feet_y( + global_position.y, CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS + ) + _apply_walk_post_slide_ledge_peel(delta, feet_after, walk_move_dir_xz, horiz_dist) _after_walk_move_and_slide() + _clear_step_assist_after_walk_move() _debug_trace_transform("physics") _snap_capsule_upright() diff --git a/client/scripts/prototype_smooth_hill_piece.gd b/client/scripts/prototype_smooth_hill_piece.gd new file mode 100644 index 0000000..f5fb451 --- /dev/null +++ b/client/scripts/prototype_smooth_hill_piece.gd @@ -0,0 +1,124 @@ +extends StaticBody3D +## Procedural smooth grid for the physics ramp strip: raised hill or sunken dip. +## Uses HeightMapShape3D for collision (CharacterBody3D tunnels through ConcavePolygonShape3D +## trimesh). +## Keep the extension slab (`PhysicsRampTestFloorExtension`) short of this body's xz footprint so a +## dip is not covered by a flat floor collider/render. + +enum PieceKind { HILL, DIP } + +@export var kind: PieceKind = PieceKind.HILL +@export var footprint_x: float = 7.0 +@export var footprint_z: float = 4.5 +@export var amplitude: float = 0.42 +@export var subdiv_x: int = 36 +@export var subdiv_z: int = 22 +## Slight Y offset so the rim is not coplanar with the slab (reduces z-fighting with the floor). +@export var base_lift: float = 0.022 + + +func _ready() -> void: + collision_layer = 1 + _build_mesh_and_visual() + _build_heightmap_collision() + + +func _build_mesh_and_visual() -> void: + var mi: MeshInstance3D = get_node_or_null("MeshInstance3D") as MeshInstance3D + if mi == null: + push_error("prototype_smooth_hill_piece: expected MeshInstance3D child.") + return + + var sx: int = maxi(3, subdiv_x) + var sz: int = maxi(3, subdiv_z) + var half_x: float = footprint_x * 0.5 + var half_z: float = footprint_z * 0.5 + var height_sign := 1.0 if kind == PieceKind.HILL else -1.0 + + var verts := PackedVector3Array() + verts.resize((sx + 1) * (sz + 1)) + var uvs := PackedVector2Array() + uvs.resize(verts.size()) + + var idx := 0 + for j in sz + 1: + var v: float = float(j) / float(sz) + var z: float = lerpf(-half_z, half_z, v) + for i in sx + 1: + var u: float = float(i) / float(sx) + var x: float = lerpf(-half_x, half_x, u) + var envelope: float = sin(PI * u) * sin(PI * v) + var y: float = base_lift + height_sign * amplitude * envelope + verts[idx] = Vector3(x, y, z) + uvs[idx] = Vector2(u, v) + idx += 1 + + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + for j in sz: + for i in sx: + var a: int = j * (sx + 1) + i + var b: int = a + 1 + var c: int = a + (sx + 1) + var d: int = c + 1 + for tri in [[a, c, b], [b, c, d]]: + st.set_uv(uvs[tri[0]]) + st.add_vertex(verts[tri[0]]) + st.set_uv(uvs[tri[1]]) + st.add_vertex(verts[tri[1]]) + st.set_uv(uvs[tri[2]]) + st.add_vertex(verts[tri[2]]) + st.generate_normals() + var mesh: ArrayMesh = st.commit() + mi.mesh = mesh + mi.material_override = _qa_material_for_kind() + + +func _build_heightmap_collision() -> void: + var cs: CollisionShape3D = get_node_or_null("CollisionShape3D") as CollisionShape3D + if cs == null: + push_error("prototype_smooth_hill_piece: expected CollisionShape3D child.") + return + + var sx: int = maxi(3, subdiv_x) + var height_sign := 1.0 if kind == PieceKind.HILL else -1.0 + + var mw: int = maxi(3, sx + 1) + var md: int = maxi(3, subdiv_z + 1) + var s: float = footprint_x / float(mw - 1) + while (md - 1) * s < footprint_z - 0.0001 and md < 512: + md += 1 + + var data := PackedFloat32Array() + data.resize(mw * md) + for j in md: + var v: float = float(j) / float(md - 1) if md > 1 else 0.0 + for i in mw: + var u: float = float(i) / float(mw - 1) if mw > 1 else 0.0 + var envelope: float = sin(PI * u) * sin(PI * v) + var y_world: float = base_lift + height_sign * amplitude * envelope + data[j * mw + i] = y_world / s + + var hm := HeightMapShape3D.new() + hm.map_width = mw + hm.map_depth = md + hm.map_data = data + cs.shape = hm + cs.scale = Vector3(s, s, s) + cs.position = Vector3.ZERO + + +func _qa_material_for_kind() -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.cull_mode = BaseMaterial3D.CULL_DISABLED + m.roughness = 0.45 + m.metallic = 0.0 + m.emission_enabled = true + m.emission_energy_multiplier = 0.85 + if kind == PieceKind.HILL: + m.albedo_color = Color(0.98, 0.12, 0.42) + m.emission = Color(0.45, 0.02, 0.15) + else: + m.albedo_color = Color(0.08, 0.35, 0.98) + m.emission = Color(0.02, 0.12, 0.45) + return m diff --git a/client/scripts/prototype_smooth_hill_piece.gd.uid b/client/scripts/prototype_smooth_hill_piece.gd.uid new file mode 100644 index 0000000..386ddfb --- /dev/null +++ b/client/scripts/prototype_smooth_hill_piece.gd.uid @@ -0,0 +1 @@ +uid://dqm8s4k2h1xwy diff --git a/client/test/player_idle_support_test.gd b/client/test/player_idle_support_test.gd new file mode 100644 index 0000000..2221afa --- /dev/null +++ b/client/test/player_idle_support_test.gd @@ -0,0 +1,75 @@ +# Idle-support stability tests for res://scripts/player.gd (static helpers). +extends GdUnitTestSuite + +const PLAYER_SCRIPT: Script = preload("res://scripts/player.gd") + + +func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0) + assert_that(stable).is_true() + + +func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP] + # Must be steeper than [member STABLE_IDLE_FLOOR_MIN_UP_DOT] (0.998) *and* below + # [member IDLE_SLOPE_STABLE_MIN_UP_DOT] (0.88), or the ramp short-circuit treats support as stable. + var tilted_floor: Vector3 = Vector3(0.5, 0.82, 0.0).normalized() + assert_that(tilted_floor.dot(Vector3.UP)).is_less( + PLAYER_SCRIPT.get("IDLE_SLOPE_STABLE_MIN_UP_DOT") + ) + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, tilted_floor, slide_normals, 0) + assert_that(stable).is_false() + + +func test_idle_support_is_stable_min_flat_up_dot_parameter() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP] + var floor_n: Vector3 = Vector3(0.05, 0.99875, 0.0).normalized() + var strict: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.999) + var holdish: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 0, 0.992) + assert_that(strict).is_false() + assert_that(holdish).is_true() + + +func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0) + assert_that(stable).is_true() + + +func test_idle_support_is_stable_true_when_loose_ticks_and_ridged_but_floor_still_level() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12) + assert_that(stable).is_true() + + +func test_idle_support_is_stable_false_when_loose_ticks_and_ridged_and_shallow_floor() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(1.0, 0.0, 0.0)] + # Normal must be outside the ramp-stable band [IDLE_SLOPE_STABLE_MIN_UP_DOT, IDLE_RIM) or + # `idle_ridged_stair_lip_only` is false and the ramp short-circuit returns stable. Use a tread + # tilt above the rim dot but still below the ridged hold threshold (0.992). + var floor_n: Vector3 = Vector3(0.14, 0.98, 0.0).normalized() + var up_dot: float = floor_n.dot(Vector3.UP) + assert_that(up_dot).is_greater_equal(PLAYER_SCRIPT.get("IDLE_RIM_MIN_FLOOR_UP_DOT") as float) + assert_that(up_dot).is_less(PLAYER_SCRIPT.get("STABLE_IDLE_FLOOR_MIN_UP_DOT") as float) + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, floor_n, slide_normals, 12) + assert_that(stable).is_false() + + +func test_idle_support_is_stable_true_when_loose_ticks_but_flat_open_support() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12) + assert_that(stable).is_true() + + +func test_idle_support_is_stable_true_when_loose_ticks_and_shallow_seam_not_ridged() -> void: + var n2: Vector3 = Vector3(0.0, 0.42, sqrt(1.0 - 0.42 * 0.42)).normalized() + var slide_normals: Array[Vector3] = [Vector3.UP, n2] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12) + assert_that(stable).is_true() + + +func test_idle_support_is_stable_false_when_not_on_floor() -> void: + var slide_normals: Array[Vector3] = [Vector3.UP] + var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0) + assert_that(stable).is_false() diff --git a/client/test/player_idle_support_test.gd.uid b/client/test/player_idle_support_test.gd.uid new file mode 100644 index 0000000..32ab622 --- /dev/null +++ b/client/test/player_idle_support_test.gd.uid @@ -0,0 +1 @@ +uid://b7k2idle8support1test diff --git a/client/test/player_test.gd b/client/test/player_test.gd index 50aee13..9fe91ad 100644 --- a/client/test/player_test.gd +++ b/client/test/player_test.gd @@ -48,36 +48,18 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void: assert_that(nav.target_position).is_equal(p.global_position) -func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void: - var slide_normals: Array[Vector3] = [Vector3.UP] - var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0) - assert_that(stable).is_true() - - -func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void: - var slide_normals: Array[Vector3] = [Vector3.UP] - var stable: bool = PLAYER_SCRIPT.idle_support_is_stable( - true, Vector3(0.02, 0.995, 0.0).normalized(), slide_normals, 0 - ) - assert_that(stable).is_false() - - -func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void: - var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()] - var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0) - assert_that(stable).is_true() - - -func test_idle_support_is_stable_false_when_loose_ticks_active() -> void: - var slide_normals: Array[Vector3] = [Vector3.UP] - var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12) - assert_that(stable).is_false() - - -func test_idle_support_is_stable_false_when_not_on_floor() -> void: - var slide_normals: Array[Vector3] = [Vector3.UP] - var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0) - assert_that(stable).is_false() +func test_nav_goal_lifecycle_resets_vert_route_latch() -> void: + var p := _make_player() + p.set_authoritative_nav_goal(Vector3(1.0, 0.0, 2.0)) + p.set("_walk_vert_route_latched", true) + p.clear_nav_goal() + assert_that(p.get("_walk_vert_route_latched")).is_false() + p.set("_walk_vert_route_latched", true) + p.set_authoritative_nav_goal(Vector3(3.0, 0.0, 4.0)) + assert_that(p.get("_walk_vert_route_latched")).is_false() + p.set("_walk_vert_route_latched", true) + p.snap_to_server(Vector3(0.0, 0.9, 0.0)) + assert_that(p.get("_walk_vert_route_latched")).is_false() func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void: @@ -89,7 +71,7 @@ func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void: p.global_position = Vector3(1.2, 0.7, 2.3) p.call("_hold_idle_anchor") - assert_that(p.global_position).is_equal(Vector3(1.0, 0.7, 2.0)) + assert_that(p.global_position).is_equal(Vector3(1.0, 0.5, 2.0)) func test_snap_to_server_clears_idle_anchor() -> void: @@ -119,6 +101,39 @@ func test_vertical_arrival_error_stays_large_for_origin_height_only_match() -> v assert_that(absf(err - 0.5)).is_less(0.000001) +func test_arrival_vert_err_large_for_step_goal_from_floor() -> void: + # Player at floor height (body Y=0.9). Goal at step surface (Y=0.3). + # Using actual capsule bottom (CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS) the error + # must exceed VERT_ARRIVE_EPS so the arrival check does NOT fire prematurely. + var goal_y := 0.3 + var body_y := 0.9 + var full_half: float = ( + ( + PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float + + PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS") + ) + as float + ) + var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half) + assert_that(err).is_greater(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float) + + +func test_arrival_vert_err_zero_when_standing_on_step() -> void: + # Player body centred at goal_y + total_half_height (i.e. actually standing on the step). + # Error must be within VERT_ARRIVE_EPS so arrival fires correctly once the player is up. + var goal_y := 0.3 + var full_half: float = ( + ( + PLAYER_SCRIPT.get("CAPSULE_HALF_HEIGHT") as float + + PLAYER_SCRIPT.get("PLAYER_CAPSULE_RADIUS") + ) + as float + ) + var body_y: float = goal_y + full_half + var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_y, full_half) + assert_that(err).is_less_equal(PLAYER_SCRIPT.get("VERT_ARRIVE_EPS") as float) + + func test_flat_floor_goal_is_not_below_feet_height_margin() -> void: var goal_y := 0.0 var body_origin_y := 0.4 @@ -127,3 +142,8 @@ func test_flat_floor_goal_is_not_below_feet_height_margin() -> void: goal_y < PLAYER_SCRIPT.capsule_feet_y(body_origin_y, 0.5) - descend_margin ) assert_that(below_feet_margin).is_false() + + +func test_step_assist_max_surface_delta_matches_navigation_mesh_agent_max_climb() -> void: + # Keep in sync with NavigationMesh `agent_max_climb` in `scenes/main.tscn`. + assert_that(PLAYER_SCRIPT.get("WALK_STEP_ASSIST_MAX_SURFACE_DELTA") as float).is_equal(0.35) diff --git a/docs/plans/NEO-22-implementation-plan.md b/docs/plans/NEO-22-implementation-plan.md new file mode 100644 index 0000000..7283e0d --- /dev/null +++ b/docs/plans/NEO-22-implementation-plan.md @@ -0,0 +1,96 @@ +# NEO-22 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-22 | +| **Title** | Retire prototype click-to-move; ship WASD locomotion on the client | +| **Linear** | [NEO-22](https://linear.app/neon-sprawl/issue/NEO-22) | +| **Parent** | [E1.M1 InputAndMovementRuntime](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4) (or current movement project) | +| **Supersedes / deprecates (prototype)** | [NEO-5](NEO-5-implementation-plan.md), [NEO-11](NEO-11-implementation-plan.md) click-to-move surface; [NEO-14](NEO-14-implementation-plan.md) seam/jitter mitigations tied to nav + goal steering | + +## Goal, scope, and out-of-scope + +**Goal:** Replace **mouse click → ground pick → `MoveCommand` → nav path → `player.gd` goal steering** with **keyboard WASD** (and optional gamepad later) as the **primary** locomotion input in the Godot prototype. Movement should feel predictable on stepped geometry without the column / nav-ribbon / slide-normal fight that click-to-move accumulated. + +**In scope** + +- **Input:** `project.godot` actions (or code) for **W/A/S/D**; read them in a thin place (`main.gd` or a small `locomotion_input.gd`) and drive the player’s **horizontal** intent each physics tick. +- **Locomotion model:** `CharacterBody3D` + `move_and_slide()` with velocity derived from **wish direction × speed**, camera-relative or world-relative (decide in [Decisions](#decisions)); **no `NavigationAgent3D` path goal** for routine walking unless a later slice reintroduces hybrid “click to mark + auto-run” explicitly. +- **Remove / disable** left-click ground pick as the default move trigger (`ground_pick.gd` → `main.gd` → `PositionAuthorityClient.submit_move_target`). Either delete the flow for this slice or gate it behind a dev flag documented as off by default. +- **Server / authority:** Resolve how WASD maps to **NEO-7 `MoveCommand`** and **authoritative position** (see [Open questions](#open-questions--risks)). Minimum acceptable outcomes are spelled in acceptance criteria — pick one path and document it in `client/README.md` + decomposition. +- **Cleanup:** Delete or drastically narrow **NEO-11 / NEO-14 / NEO-16** workaround blocks in `player.gd` (column seam damp, nav column steering, idle trace scaffolding tied to click goals) **only** after WASD path is stable; prefer one commit that switches input then a follow-up that deletes dead code. +- **Docs:** Update `client/README.md`, `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md`, and this plan’s checklist when shipped. + +**Out of scope** + +- Full **client prediction + server reconciliation** for FPS-grade netcode (unless explicitly pulled into this story by product). +- **Click-to-interact** changes (NEO-12-style interaction) except where click conflicts with removed move binding (document if unchanged). +- **Occlusion / camera** feature work (NEO-17/20) beyond what’s needed so WASD direction feels correct with the follow camera. +- **Production** navmesh tooling for NPCs — only player move input changes here. + +## Acceptance criteria checklist + +- [ ] **WASD** moves the avatar on the default **`main.tscn`** floor with **no** left-click requirement; speed and direction are stable on flat ground and on **stepped** QA props without the prior ±`MOVE_SPEED` seam oscillation at column/stair corners (subjective + optional `debug_idle_trace` spot-check). +- [ ] **Left-click** no longer starts a walk by default (or is clearly dev-only and off in normal play, documented). +- [ ] **Authoritative position** still converges with the server: document whether **continuous** sync, **throttled** `MoveCommand`, or **temporary client-only** prototype applies — and add the minimal client/server change so CI + manual steps do not lie. +- [ ] **Cold boot `snap_to_server`** still works; no spawn underground / stale goal regressions. +- [ ] **NEO-10** rejection UX: if clicks are removed, another path still exercises reject payloads in dev (automated test or documented console-only command) **or** clicks remain only for “set goal” debug, not default locomotion. +- [ ] **GdUnit** (or existing harness) covers at least one pure helper for wish-dir / speed clamp if logic is non-trivial. + +## Technical approach + +1. **Linear + branch:** Issue **NEO-22** exists in Backlog; on kickoff set **In Progress** and branch `neo-22-retire-click-to-move-implement-wasd-locomotion-godot-client` (or team naming). +2. **Authority model (blocking):** Decide with product/server owner: + - **A)** Client integrates WASD locally only for prototype; server receives **rate-limited** position or move deltas (may need new endpoint — likely **out of this slice** unless already planned), or + - **B)** Keep **discrete `MoveCommand`** but drive target from **keyboard-sampled “stick to last intent”** (awkward), or + - **C)** Server accepts a **stream** of small moves (largest change — schedule separately). + Document chosen option in README before merging locomotion feel work. +3. **Input layer:** Map WASD to `Vector2` in screen/camera space → world XZ tangent to `Camera3D` basis (same pattern as many third-person controllers); normalize; zero when no keys. +4. **`player.gd` refactor:** Replace `_has_walk_goal` / `_auth_walk_goal` steering path with **wish velocity** from parent or injected each `_physics_process`; keep gravity, floor snap policy, and `snap_to_server` entry points. Strip `NavigationAgent3D` dependency for default walk if decision says so (agent node can stay in scene unused until a later hybrid story). +5. **Remove click wiring:** Disconnect `target_chosen` → `submit_move_target` in `main.gd` for default build; optionally keep `ground_pick.gd` for debug “teleport goal” behind `OS.is_debug_build()` flag. +6. **Tests + manual:** Run README manual path; add unit test for direction math if extracted. + +## Decisions + +| Topic | Choice | Rationale | +|------|--------|-----------| +| **Issue id** | **NEO-22** | Created in Linear 2026-04-16; plan filename matches this key. | +| **Camera vs world steering** | **TBD in kickoff** — default recommendation: **camera-relative XZ** | Matches isometric follow rig; document if world-axis is chosen instead. | +| **NavAgent** | Default **off** for routine WASD in this slice | Removes nav-ribbon + column latch class of bugs; re-add only with explicit hybrid design. | + +## Files to add + +| Path | Purpose | +|------|---------| +| None required. | Prefer extending existing scripts unless `locomotion_input.gd` (or similar) is needed to keep `main.gd` thin per `godot-client-script-organization`. If extracted, add here and list in “Files to modify” as N/A for that line. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/project.godot` | Declare **`move_forward` / `move_back` / `move_left` / `move_right`** (or reuse Godot defaults) bound to WASD. | +| `client/scripts/main.gd` | Stop wiring click pick to `submit_move_target` by default; optionally forward WASD intent to player/authority per chosen model. | +| `client/scripts/ground_pick.gd` | Disable default `_input` pick **or** gate behind export/debug; update header comments (NS-16 lineage). | +| `client/scripts/player.gd` | Replace goal/nav horizontal steering with WASD-driven velocity; delete obsolete seam/nav state once stable. | +| `client/scripts/position_authority_client.gd` | Align with authority model (throttle, stream, or document client-only prototype). | +| `client/scenes/main.tscn` | Only if `NavigationAgent3D` or nodes are removed/changed or input singleton paths change. | +| `client/README.md` | Replace “click-move” manual checks with WASD + server steps; note deprecation of click-to-move prototype. | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | Snapshot: click-to-move retired in favor of WASD for prototype; link this plan. | +| `docs/plans/NEO-11-implementation-plan.md` | Add one-line “superseded for locomotion input by NEO-22” in header or Resolution if policy is to annotate predecessors. | + +## Tests + +| File | Coverage | +|------|----------| +| `client/test/player_test.gd` | **Change:** remove or replace tests that assumed `_has_walk_goal` / nav steering; add tests for any extracted **wish-direction** or speed clamp helper. | +| `client/test/position_authority_client_test.gd` | **Change:** align with new submit/sync cadence if `PositionAuthorityClient` changes. | +| **Manual (required)** | README path: boot with server, WASD on flat + stepped geometry, `snap_to_server`, and whichever NEO-10 rejection path remains after click removal. | + +## Open questions / risks + +- **Server contract:** Today’s **`MoveCommand`** is **discrete target**-shaped ([NEO-7](NEO-7-implementation-plan.md)). WASD needs **continuous** intent — either extend the protocol, rate-limit many small commands, or temporarily diverge prototype authority. **This is the main schedule risk**; do not merge feel-only client changes without an explicit written decision in README + this plan. +- **Interaction vs pick:** Confirm **E** / interaction does not rely on the same mouse button in a way that breaks when pick is disabled. +- **Gamepad:** Out of scope unless trivial (`Input.get_vector`); note for follow-up story. +- **Jira naming:** If the team uses **NEON-*** keys elsewhere, align Linear/Git subject lines per [linear-git-naming](../../.cursor/rules/jira-git-naming.md). diff --git a/docs/plans/NEON-29-implementation-plan.md b/docs/plans/NEON-29-implementation-plan.md new file mode 100644 index 0000000..68c200d --- /dev/null +++ b/docs/plans/NEON-29-implementation-plan.md @@ -0,0 +1,130 @@ +# NEON-29 Implementation Plan — Expand prototype client district for camera/nav stress QA + +## Story reference + +- **Key:** [NEON-29](https://neon-sprawl.atlassian.net/browse/NEON-29) +- **Title:** E1.M2: Expand prototype client district for camera/nav stress QA +- **Parent epic:** [NEON-1 — Epic 1 — Core Player Runtime](https://neon-sprawl.atlassian.net/browse/NEON-1) +- **Labels:** E1, E1.M2, client-qa-district, decomposition + +--- + +## Goal, scope, and out-of-scope + +### Goal + +Provide a **~5× footprint** (order-of-magnitude) prototype play space versus the current district, with **more obstacles** and **height-changing regions** (steps, terraces, bumps) so day-to-day client work can stress **motion, framing, and readability** for the camera, navigation, ground pick, and interaction systems. + +### Scope + +- `client/scenes/main.tscn`: expand walkable floor / nav source geometry; add props that participate in `NavigationRegion3D` bake and `walkable` picking as today; include elevated terrace/step platforms for vertical variation. +- `client/README.md`: short runbook note covering district scale, intent, and any new designer-facing limits. +- Optional: pointer in `docs/decomposition/modules/E1_M2_IsometricCameraController.md` that this geography is the default stress playground for client stories. + +### Out of scope + +- New camera features (zoom bands NEON-26, occlusion NEON-27) — geography may help test them, but this story does not implement them. +- Server schema or MoveCommand contract changes unless existing NEON-7 step/bounds defaults visibly break on the larger map (in that case: document + minimal config tune only). +- Production art pass or zone graph / Epic 4 content. + +--- + +## Acceptance criteria checklist + +- [ ] Playable area is **materially larger** (~5× order of magnitude vs pre-change footprint) with **additional obstacles** and **vertical variation** usable with click-to-move. +- [ ] **NavigationRegion3D** bakes successfully at startup; long clicks behave per existing NEON-8 tradeoffs (document if new geometry exposes new edge cases). +- [ ] **Ground pick** and `walkable` grouping remain correct on new surfaces. +- [ ] README updated for anyone running F5 + server. + +--- + +## Technical approach + +### Current baseline + +The current `main.tscn` district is a single **20 × 20** unit flat floor (`BoxMesh_floor`, placed at Y = -0.1), giving an approximate playable area of ~400 sq units. Static props: + +- `PrototypeTerminal` (0.9 × 1 × 0.4) at origin +- `MoveRejectPedestal` (1.5 × 2.5 × 1.5) at (7.5, 0, -6.5) +- `MoveRejectFarPad` (2.5 × 0.12 × 2.5) at (9, 0.06, 9) +- `Obstacle` (2 × 2 × 2, `occluder` group) at (6, 0, 5) +- Player spawn at (-5, 0.9, -5) + +The `NavigationMesh` stored in the scene file is intentionally stale; `main.gd` calls `bake_navigation_mesh(false)` at startup so the live mesh always reflects actual collision geometry. + +### Expansion plan + +**Floor size:** Replace the 20 × 20 floor with a **45 × 45** unit floor, giving ~2025 sq units (~5× of 400). Keep floor mesh center at origin (X=0, Z=0) so spawn position and existing props stay roughly central. + +**Additional obstacles (nav stress + camera occlusion):** Add 3–4 extra `StaticBody3D` nodes in the `occluder` group, distributed across the expanded area — positioned so the follow camera must work around varying angles. + +**Vertical variation (terraces / steps):** Add 2–3 raised terrace platforms as `StaticBody3D` nodes in the `walkable` group, elevated ~0.5–2.5 m above the main floor, with ramp/step approach geometry. These are `StaticBody3D` siblings of `Floor` under `NavigationRegion3D` so the runtime bake includes them as nav sources. Each terrace: + - Has its own `CollisionShape3D` (BoxShape3D). + - Is in the `walkable` group so ground-pick raycasts hit it correctly. + - Has a small `StaticBody3D` ramp or step approach (also `walkable`) so `NavigationAgent3D` can route onto it within the NEON-8 step/climb parameters (`agent_max_climb = 0.35` in existing NavMesh settings — ramp grade must respect this, or use multi-step approach). + +**MoveReject props:** Keep `MoveRejectPedestal` and `MoveRejectFarPad` in place — they exercise NEON-7 reject behavior and must remain reachable. Shift `MoveRejectFarPad` outward if needed to stay within the expanded floor bounds and remain "far." + +**NavigationMesh settings:** No changes to agent parameters (height=1.0, radius=0.4, max_climb=0.35, cell_size=0.15) — they remain aligned with the player capsule per NEON-8. The stale baked mesh in the scene will update at runtime. The `geometry_collision_mask = 1` stays, so all collision-layer-1 bodies under the region contribute. + +**NEON-7 edge case check:** With a larger floor the server's `step_size` / `MaxDistance` defaults may now allow clicks that would previously have been out-of-range. Document in the README if this changes observed reject behavior; no server code change unless a new reject case is broken (not just shifted in range). + +--- + +## Files to add + +None. All geometry lives in the existing scene file; no new GDScript logic is required. + +--- + +## Files to modify + +| File | Rationale | +|------|-----------| +| `client/scenes/main.tscn` | Replace 20×20 floor with 45×45; add terrace/step platforms and extra obstacle props; update pre-baked `NavigationMesh` vertices (will be overwritten at runtime, but keeping a reasonable approximation avoids editor warnings). | +| `client/README.md` | Add district runbook note: new footprint, vertical variation, intent as stress playground, updated manual-check geometry references. | +| `docs/decomposition/modules/E1_M2_IsometricCameraController.md` | Optional lightweight pointer that this expanded geography is the default QA map for E1.M2 camera stories. | + +--- + +## Tests + +No new automated GDScript tests are added for this story — there is no new GDScript logic. This story is pure scene geometry. + +**Manual verification replaces automated tests:** + +1. **F5** with server running: confirm player spawns and nav bake completes without errors in Output. +2. Click-to-move across the expanded floor, up/down each terrace, and through obstacles — confirm `NavigationAgent3D` routes and `move_and_slide` behaves per NEON-8 tradeoffs. +3. Ground-pick regression: click behind each new `occluder`-tagged obstacle with player in and out of occlusion fade range (per NEON-30 manual check in README). +4. Confirm `MoveRejectPedestal` and `MoveRejectFarPad` still reject correctly. +5. Follow camera: walk to far corners of the expanded floor — confirm `IsometricFollowCamera` tracks smoothly without snapping or losing the player (snap_distance not tripped by longer walks). +6. Interaction radius: walk near `PrototypeTerminal` to confirm range indicators still work correctly. + +--- + +## Decisions + +- **No horizontal step limit (resolved):** The server's `MaxHorizontalStep = 18 m` was too restrictive for the expanded 45 × 45 m floor (diagonal ≈ 63 m). Added `HorizontalStepEnabled` bool to `MovementValidationOptions` (defaults `false`), matching the `DistrictBoundsEnabled` pattern. The horizontal check is now skipped by default; the property remains available to opt back in via config. `MoveRejectFarPad` no longer exercises a horizontal range reject; it retains its walkable surface for vertical-step regression only. +- **Step assist guard/lift bug fixed (resolved):** `_try_walk_step_assist` in `player.gd` compared the goal's surface Y against the **capsule centre** (`global_position.y ≈ 0.9`) instead of the actual capsule bottom (`global_position.y − CAPSULE_HALF_HEIGHT − PLAYER_CAPSULE_RADIUS = 0.0`). This disabled the assist for any surface below ≈ 0.925 m — including all three terrace platforms. The lift formula used offsets of 0.45/0.55 instead of the full total half-height (0.9), giving zero or negative lift. Fixed: guard now uses `actual_feet_y`; lift targets `goal.y + CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` via `clampf`. +- **Arrival check using wrong capsule reference (resolved):** `vertical_arrival_error` was called with `CAPSULE_HALF_HEIGHT = 0.5`, giving code-feet at `body_y − 0.5 = 0.4` at floor level. Step surfaces (Y = 0.3) are only 0.1 m from that reference. When the Jolt physics engine nudges the capsule body down to ~0.8 m (bottom hemisphere contacts the step edge), code-feet becomes 0.3 — matching the step surface Y and making `vert_err = 0 ≤ VERT_ARRIVE_EPS`. If the player is also within `ARRIVE_EPS = 0.35 m` horizontally, the arrival check fires immediately, clearing the nav goal before the capsule has moved. Fixed: pass `CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9) to `vertical_arrival_error` so actual feet = `body_y − 0.9`. Step goal vert_err is now 0.3 >> 0.055 from floor height; arrival fires correctly only once the step assist has lifted the capsule onto the surface (body_y = 1.2, vert_err ≈ 0). Two regression tests added. +- **snap_to_server places capsule underground (resolved):** The server stores the raw surface Y from the client's click (e.g. 0.6 for a terrace platform), not the capsule body-centre Y (which should be 0.6 + 0.9 = 1.5). `snap_to_server` was setting `global_position.y` directly to this surface Y, putting the capsule bottom at −0.3 m. With `_floor_angle_loose_ticks = 0` and a stale `is_on_floor() = true`, the idle path's `_stable_idle_support()` check passed and held the player without calling `move_and_slide()`, preventing Jolt from resolving the penetration. Fixed: clamp `global_position.y ≥ CAPSULE_HALF_HEIGHT + PLAYER_CAPSULE_RADIUS` (= 0.9 m) in `snap_to_server`; also set `_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP` (96 ticks) to force corrective idle physics before the next walk goal. +- **Step assist undone by floor snap (resolved):** Each `_try_walk_step_assist()` cycle lifts the capsule 0.11 m. The prior `floor_snap_length = FLOOR_SNAP_MOVING = 0.32 m` meant the snap always reached the lower floor (0.11 m below the lifted bottom), pulling the capsule straight back down every tick. The player "nudged and stopped" because each lift was immediately cancelled. Fixed: added `WALK_STEP_ASSIST_SNAP = 0.09 m` constant (below 0.11 m so the snap cannot reach the previous floor, but above 0.03 m so it catches the step surface once cleared) and a `_step_assist_active` boolean flag. During active climbing, `floor_snap_length` is set to `WALK_STEP_ASSIST_SNAP` in both `_after_walk_move_and_slide()` and the main physics loop. The flag is cleared when the player lands cleanly on a floor without wall contact (`is_on_floor() and not is_on_wall()`), or when the nav goal is cleared/snapped. +- **Terrace B descend / climb (resolved, iterated):** Scene lifts and **descend-bypass** bee-lines caused regressions (stuck climbs, lip oscillation, wrong-direction slides). **Removed the descend bypass:** vertical moves (e.g. platform ↔ floor) use the **baked nav path + step assist** only. **Walk gravity** is **`!is_on_floor()`** again — never “goal below feet” while still walking on an upper surface (that applied gravity across the whole **TerracePlatformB** deck and destroyed horizontal motion). **`velocity.y`** is cleared by the horizontal steer each tick as before. +- **Lip stuck (floor→platform / platform→floor):** QA screenshots for this were on **`TerracePlatformA`** (10 × 10 large SE pad, **0.3 m** single step — not the NW **Terrace B** step/platform pair). Step assist required **`vel_h.dot(want) ≤ 0.52`**, so a **head-on** push into the vertical face (dot ≈ 1) **never** triggered the lift. **Removed** that gate. For **platform→floor**, **`floor_block_on_wall = false`** only when the nav goal is **below** the feet **and** **`is_on_wall()`** or a **wall-ish slide normal** exists, so open deck walking stays stable. +- **Teal pad still stuck (follow-up):** Step assist used **slide normals only**; Jolt often exposes the lip via **`is_on_wall()`** / **`get_wall_normal()`** instead — re-added a **directional** wall check (`wnh.dot(want) < -0.02`). Descend relax now also triggers on **shallow floor normal** (`get_floor_normal().dot(UP) < 0.992`) and slightly wider vertical-ish slide band. **`TerracePlatformA`** root **Y = 0.012 m** breaks **coplanarity** with the main floor without the larger gap that hurt **TerraceStepB** approach. +- **Nav waypoint steering dropped (movement):** Following **`get_next_path_position()`** by default caused **near-zero** horizontal progress under **Jolt** + **120 Hz** (waypoint advance / finished edge cases), read as universal **stuck** movement even with the server up. **`player.gd`** now **bee-lines in XZ** toward **`_auth_walk_goal`** and uses path-based steer only when **`needs_vertical_routing`** (Schmitt **`WALK_VERT_ROUTE_LATCH_*`**) **and** **`NAV_COLUMN_STEER_*`** hysteresis **and** **`is_on_floor()`** (mid-air column steer caused **180° XZ flips** while falling). **`_set_horizontal_velocity_from_nav_path_or_goal`** scans **`get_current_navigation_path()`** for the first waypoint **≥ `NAV_PATH_STEER_MIN_LOOKAHEAD` (0.22 m)** in XZ, with a **goal-direction dot** guard — raw **`get_next_path_position()`** and a **5 cm** scan both sat inside Jolt’s per-tick slide and could flip velocity **180°** each tick. +- **Walk ledge / void gravity (iterated):** **`is_on_floor()`** + **`floor_snap_length`** could keep the capsule **glued** while crossing void to a lower click. **Fix:** **multi-sample** down rays under the **foot disk** (offsets along **move direction**; depth ~**0.32**); apply gravity when **none** hit; **`floor_snap_length = 0`** that tick so snap does not cancel **`velocity.y`**. Dropped a **floor-normal** gate that skipped the probe on **lip** contacts. +- **Step assist vs `agent_max_climb`:** Nav **`agent_max_climb = 0.35`** only constrained mesh links; **bee-line + chained step assist** could still climb **TerracePlatformB** (~**0.6 m** from floor) in one click. **`_try_walk_step_assist`** now returns false when **`_auth_walk_goal.y > feet + WALK_STEP_ASSIST_MAX_SURFACE_DELTA`** (**0.35**, same as the mesh). +- **Teal cardinal / tread shake (rollback):** Aggressive tweaks (**0.16** lift, **1**-tick cooldown, **`descending_stall`** toggling **`floor_block_on_wall`**, **seam-based** step-assist clear, **walk-stall nav replan**) caused **oscillation** on the small approach treads. **Reverted** to **`WALK_STEP_ASSIST_DELTA = 0.11`**, **`WALK_STEP_ASSIST_COOLDOWN_TICKS = 8`**, **no** stall replan, **no** `descending_stall` branch, **no** seam-based assist clear — only the original clear (clean floor without wall, or airborne with no slides). **Climb** wallish snap cap applies only when **`goal.y > feet_y + 0.12`** so shallow tread-to-tread motion is not capped to assist snap every frame. +- **Teal pad — geometry wins:** Script-only mitigations still failed in practice; **`TerracePlatformA_Approach`** adds **twelve** `walkable` **`StaticBody3D`** treads (three per cardinal) with **~0.104 m** rise each, **darker teal** albedo, flush to the platform lip — nav + `CharacterBody3D` use **sloped contact** instead of fighting a **single vertical face**. **Follow-up:** tread **run** was **0.45 m** along the climb axis while the capsule **diameter is 0.8 m** — the body was wider than each tread, causing straddle / stuck motion when **entering** and blocking horizontal progress toward the lip when **exiting**. Treads widened to **1.0 m** run with positions re-centered. **`NavigationAgent3D`** `path_desired_distance` / `target_desired_distance` were briefly lowered to **0.22** but that caused **floor** movement to **stall**; **reverted to 0.35**. + +## Approach treads (district convention) + +- **Shipped in scene:** **`TerracePlatformC_Approach`** (`PCS_*`, teal-style **3×4** cardinals, **7.2 m** NS / **WE** span), **`TerraceStepB_Approach`** (`TSB_*`, **S/E/W** only — north meets violet), **`TerracePlatformB_Approach`** (`TPB_*`, **N/E/W**, **six** ~**0.104 m** risers for **0.6 m** deck height; south remains **gold step**). **Smooth ramps** for other props can follow the same rise/run idea without new `player.gd` logic. +- **Idle jitter on thin treads / flat stops:** (1) **`idle_support_is_stable`**: post-stop **`loose_ticks` + ridged** blocks stable idle only when **`floor_normal.dot(UP) < IDLE_RIM_MIN_FLOOR_UP_DOT`** — flat tread + vertical riser (e.g. **TerraceStepB_Approach** corner) no longer spends ~0.8 s in corrective idle. (2) **`use_loose_floor_angle`** not tied to **`loose_ticks` alone**. (3) **`STABLE_IDLE_FLOOR_MIN_UP_DOT` 0.998** (was 0.999 — Jolt flat-floor normals often ~0.9991+ so the anchor rarely latched and **`move_and_slide`** + nudges drifted HUD XZ); **`STABLE_IDLE_ENTER_STREAK_FRAMES` 2**; **`STABLE_IDLE_UNLATCH_TICKS` 10**; ridged **wallish ny < 0.35**; **stable latch + Y anchor**. (4) **Arrival** stable shortcut (immediate latch, no enter streak). (5) **Nav column steer** hysteresis. (6) **`needs_vertical_routing` Schmitt latch**; **`debug_idle_trace`** walking extras. +- **Not in this pass:** **`PrototypeTerminal`**, **MoveReject** props, **runtime random bumps** — add geometry when those need climbable access. + +## Open questions / risks + +- **NavMesh bake time:** A 45×45 floor with several terraces will bake more polygons than the 20×20 baseline. Expected to be negligible for `bake_navigation_mesh(false)` on desktop, but monitor Output for a perceptible pause. +- **Agent climb on terraces:** `agent_max_climb = 0.35` limits single-step height. Each terrace transition is 0.30 m; verify in Godot editor nav mesh debug overlay before shipping. diff --git a/gdlintrc b/gdlintrc index eb2e165..4fcc846 100644 --- a/gdlintrc +++ b/gdlintrc @@ -32,7 +32,8 @@ 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]+)* -max-file-lines: 1000 +# `player.gd` is a single CharacterBody3D controller (~1.4k lines); keep headroom until it splits. +max-file-lines: 1600 max-line-length: 100 max-public-methods: 20 max-returns: 6 diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs index 4f4ab9a..643c1fa 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs @@ -115,7 +115,11 @@ public class MoveCommandApiTests { b.ConfigureTestServices(services => { - services.PostConfigure(o => o.MovementValidation.MaxHorizontalStep = 1.0); + services.PostConfigure(o => + { + o.MovementValidation.HorizontalStepEnabled = true; + o.MovementValidation.MaxHorizontalStep = 1.0; + }); }); }); var client = factory.CreateClient(); diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs index 7614c42..4d45c09 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs @@ -5,9 +5,11 @@ namespace NeonSprawl.Server.Tests.Game.PositionState; public class MoveCommandValidationTests { - private static MovementValidationOptions Rules(double maxH, double maxV, bool bounds = false) => + private static MovementValidationOptions Rules( + double maxH, double maxV, bool bounds = false, bool horizontalEnabled = true) => new() { + HorizontalStepEnabled = horizontalEnabled, MaxHorizontalStep = maxH, MaxVerticalStep = maxV, DistrictBoundsEnabled = bounds, @@ -63,6 +65,15 @@ public class MoveCommandValidationTests Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); } + [Fact] + public void TryValidate_ShouldAllow_WhenHorizontalDisabled() + { + var from = new PositionSnapshot(0, 0, 0, 0); + var to = new PositionVector { X = 1000, Y = 0, Z = 1000 }; + Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code)); + Assert.Equal("", code); + } + [Fact] public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled() { diff --git a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs index ef75be9..1cadafa 100644 --- a/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs +++ b/server/NeonSprawl.Server/Game/PositionState/MoveCommandValidation.cs @@ -18,13 +18,16 @@ public static class MoveCommandValidation { reasonCode = ""; - var dx = to.X - from.X; - var dz = to.Z - from.Z; - var horizontal = Math.Sqrt(dx * dx + dz * dz); - if (horizontal > rules.MaxHorizontalStep) + if (rules.HorizontalStepEnabled) { - reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded; - return false; + var dx = to.X - from.X; + var dz = to.Z - from.Z; + var horizontal = Math.Sqrt(dx * dx + dz * dz); + if (horizontal > rules.MaxHorizontalStep) + { + reasonCode = MoveCommandReasonCodes.HorizontalStepExceeded; + return false; + } } var dy = Math.Abs(to.Y - from.Y); diff --git a/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs index 51111e1..94d64ed 100644 --- a/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/MovementValidationOptions.cs @@ -3,11 +3,15 @@ namespace NeonSprawl.Server.Game.PositionState; /// Per-command movement sanity limits (NS-19). Bound from Game:MovementValidation. public sealed class MovementValidationOptions { - /// Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit. + /// When true, enforces . False (default) means no horizontal limit. + public bool HorizontalStepEnabled { get; set; } + + /// Max horizontal displacement on X/Z in one move (meters). Inclusive at the limit. + /// Only applied when is true. public double MaxHorizontalStep { get; set; } = 18.0; /// Max absolute delta on Y in one move (meters). Inclusive at the limit. - public double MaxVerticalStep { get; set; } = 1.25; + public double MaxVerticalStep { get; set; } = 2.2; /// When true, target must lie inside the axis-aligned district box (inclusive min/max). public bool DistrictBoundsEnabled { get; set; } diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index e09c7f9..18e0bca 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -14,8 +14,9 @@ "Z": -5 }, "MovementValidation": { + "HorizontalStepEnabled": false, "MaxHorizontalStep": 18, - "MaxVerticalStep": 1.25, + "MaxVerticalStep": 2.2, "DistrictBoundsEnabled": false } } diff --git a/server/README.md b/server/README.md index f63f2db..63f378d 100644 --- a/server/README.md +++ b/server/README.md @@ -70,11 +70,11 @@ Unknown player ids return **404**. Full zone sync / replication is still out of | Config key | Meaning | Default (see `appsettings.json`) | |------------|---------|-----------------------------------| | `Game:MovementValidation:MaxHorizontalStep` | Max XZ displacement per command (m); inclusive at limit | `18` | -| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `1.25` | +| `Game:MovementValidation:MaxVerticalStep` | Max absolute Y delta per command (m); inclusive | `2.2` | | `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 yields **`vertical_step_exceeded`**; **far pad** is beyond default horizontal reach for **`horizontal_step_exceeded`**. 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 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. Request body (example):