diff --git a/client/README.md b/client/README.md index d217da7..484da7d 100644 --- a/client/README.md +++ b/client/README.md @@ -15,10 +15,12 @@ With the game server running ([`server/README.md`](../server/README.md)), each v **Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the pick’s **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed. -**Known issue:** visible **idle / rest jitter** on the avatar — follow-up in [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) (Tech Debt). +**NS-19 / 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 (NS-24):** **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`**). **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** after motion. 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`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), thin `scripts/main.gd` (nav bake on first frame, then wiring). -- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` calls **`bake_navigation_mesh(false)`** (main-thread bake) after one **`process_frame`**, then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. After moving floor or obstacles, re-bake in the editor if needed. +- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor. - **Inspector:** select **`PositionAuthorityClient`** on the main scene to set **`base_url`** (default `http://127.0.0.1:5253`) and **`dev_player_id`** (default `dev-local-1`, must match server `Game:DevPlayerId`). ### Manual check (NS-16 + NS-23) @@ -28,6 +30,7 @@ With the game server running ([`server/README.md`](../server/README.md)), each v 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` / NS-18 walk demo). 4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NS-19 reject clicks still show the reject label and do **not** start a path. 5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged. +6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate on the floor or 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. diff --git a/client/project.godot b/client/project.godot index 184d3c8..f798aec 100644 --- a/client/project.godot +++ b/client/project.godot @@ -37,6 +37,13 @@ interact={ project/assembly_name="Neon Sprawl" +[physics] + +3d/physics_engine="Jolt Physics" +common/physics_ticks_per_second=120 +common/physics_interpolation=false + [rendering] +anti_aliasing/quality/msaa_3d=2 textures/canvas_textures/default_texture_filter=0 diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 7a25b54..85ebb68 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -2,10 +2,21 @@ [ext_resource type="Script" uid="uid://bh04b3iify0hd" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" uid="uid://1jimgt3d4bjj" path="res://scripts/player.gd" id="2_player"] -[ext_resource type="Script" path="res://scripts/ground_pick.gd" id="3_ground"] -[ext_resource type="Script" path="res://scripts/position_authority_client.gd" id="4_auth"] -[ext_resource type="Script" path="res://scripts/interaction_request_client.gd" id="5_ix"] -[ext_resource type="Script" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"] +[ext_resource type="Script" uid="uid://v0xpntk6uumr" path="res://scripts/ground_pick.gd" id="3_ground"] +[ext_resource type="Script" uid="uid://ds5fkbscljkxi" path="res://scripts/position_authority_client.gd" id="4_auth"] +[ext_resource type="Script" uid="uid://bv0xprp660hib" path="res://scripts/interaction_request_client.gd" id="5_ix"] +[ext_resource type="Script" uid="uid://n3p4f3fky3nv" path="res://scripts/interaction_radius_indicators.gd" id="6_rad"] + +[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 +border_size = 0.15 +agent_height = 1.0 +agent_radius = 0.4 +agent_max_climb = 0.35 [sub_resource type="BoxMesh" id="BoxMesh_floor"] size = Vector3(20, 0.2, 20) @@ -13,20 +24,6 @@ size = Vector3(20, 0.2, 20) [sub_resource type="BoxShape3D" id="BoxShape3D_floor"] size = Vector3(20, 0.2, 20) -[sub_resource type="BoxMesh" id="BoxMesh_obstacle"] -size = Vector3(2, 2, 2) - -[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"] -size = Vector3(2, 2, 2) - -[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"] -radius = 0.4 -height = 1.0 - -[sub_resource type="CapsuleMesh" id="CapsuleMesh_player"] -radius = 0.4 -height = 1.0 - [sub_resource type="BoxMesh" id="BoxMesh_terminal"] size = Vector3(0.9, 1, 0.4) @@ -36,55 +33,47 @@ albedo_color = Color(0.28, 0.38, 0.45, 1) [sub_resource type="BoxShape3D" id="BoxShape3D_terminal"] size = Vector3(0.9, 1, 0.4) +[sub_resource type="BoxMesh" id="BoxMesh_move_reject_pedestal"] +size = Vector3(1.5, 2.5, 1.5) + +[sub_resource type="StandardMaterial3D" id="Mat_move_reject_pedestal"] +albedo_color = Color(0.92, 0.42, 0.12, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_move_reject_pedestal"] +size = Vector3(1.5, 2.5, 1.5) + +[sub_resource type="BoxMesh" id="BoxMesh_move_reject_farpad"] +size = Vector3(2.5, 0.12, 2.5) + +[sub_resource type="StandardMaterial3D" id="Mat_move_reject_farpad"] +albedo_color = Color(0.32, 0.38, 0.55, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_move_reject_farpad"] +size = Vector3(2.5, 0.12, 2.5) + +[sub_resource type="BoxMesh" id="BoxMesh_obstacle"] +size = Vector3(2, 2, 2) + +[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"] +size = Vector3(2, 2, 2) + [sub_resource type="BoxMesh" id="BoxMesh_marker"] size = Vector3(0.22, 0.35, 0.22) [sub_resource type="StandardMaterial3D" id="Mat_marker_base"] albedo_color = Color(0.15, 0.4, 1, 1) -[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_a"] -size = Vector3(1, 0.15, 1) +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"] +radius = 0.4 +height = 1.0 -[sub_resource type="BoxMesh" id="BoxMesh_ns19_bump_b"] -size = Vector3(0.85, 0.12, 0.85) +[sub_resource type="CapsuleMesh" id="CapsuleMesh_player"] +radius = 0.4 +height = 1.0 -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19a_frustum"] -points = PackedVector3Array(-0.72, -0.075, -0.72, 0.72, -0.075, -0.72, 0.72, -0.075, 0.72, -0.72, -0.075, 0.72, -0.5, 0.075, -0.5, 0.5, 0.075, -0.5, 0.5, 0.075, 0.5, -0.5, 0.075, 0.5) - -[sub_resource type="ConvexPolygonShape3D" id="Convex_ns19b_frustum"] -points = PackedVector3Array(-0.575, -0.06, -0.575, 0.575, -0.06, -0.575, 0.575, -0.06, 0.575, -0.575, -0.06, 0.575, -0.425, 0.06, -0.425, 0.425, 0.06, -0.425, 0.425, 0.06, 0.425, -0.425, 0.06, 0.425) - -[sub_resource type="BoxMesh" id="BoxMesh_ns19_pedestal"] -size = Vector3(1.5, 2.5, 1.5) - -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_pedestal"] -size = Vector3(1.5, 2.5, 1.5) - -[sub_resource type="BoxMesh" id="BoxMesh_ns19_farpad"] -size = Vector3(2.5, 0.12, 2.5) - -[sub_resource type="BoxShape3D" id="BoxShape3D_ns19_farpad"] -size = Vector3(2.5, 0.12, 2.5) - -[sub_resource type="StandardMaterial3D" id="Mat_ns19_bump"] -albedo_color = Color(0.22, 0.52, 0.32, 1) - -[sub_resource type="StandardMaterial3D" id="Mat_ns19_pedestal"] -albedo_color = Color(0.92, 0.42, 0.12, 1) - -[sub_resource type="StandardMaterial3D" id="Mat_ns19_farpad"] -albedo_color = Color(0.32, 0.38, 0.55, 1) - -[sub_resource type="NavigationMesh" id="NavigationMesh_district"] -geometry_parsed_geometry_type = 2 -geometry_collision_mask = 1 -geometry_source_geometry_mode = 0 -cell_size = 0.15 -cell_height = 0.15 -agent_radius = 0.4 -agent_height = 1.0 -agent_max_climb = 0.35 -border_size = 0.15 +[sub_resource type="StandardMaterial3D" id="Mat_player_capsule"] +specular_mode = 2 +albedo_color = Color(0.42, 0.52, 0.7, 1) [node name="Main" type="Node3D" unique_id=1358372723] script = ExtResource("1_main") @@ -93,6 +82,7 @@ script = ExtResource("1_main") [node name="DirectionalLight3D" type="DirectionalLight3D" parent="World" unique_id=201819776] transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 6, 0) +light_specular = 0.0 shadow_enabled = true [node name="Camera3D" type="Camera3D" parent="World" unique_id=1124088856] @@ -104,7 +94,6 @@ fov = 50.0 navigation_mesh = SubResource("NavigationMesh_district") [node name="Floor" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1800743112 groups=["walkable"]] -collision_layer = 1 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) @@ -116,7 +105,6 @@ 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_layer = 1 [node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002] mesh = SubResource("BoxMesh_terminal") @@ -125,6 +113,39 @@ surface_material_override/0 = SubResource("Mat_terminal") [node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700006] shape = SubResource("BoxShape3D_terminal") +[node name="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) + +[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) +mesh = SubResource("BoxMesh_move_reject_pedestal") +surface_material_override/0 = SubResource("Mat_move_reject_pedestal") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/MoveRejectPedestal" unique_id=1900023] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) +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) + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/MoveRejectFarPad" unique_id=1900032] +mesh = SubResource("BoxMesh_move_reject_farpad") +surface_material_override/0 = SubResource("Mat_move_reject_farpad") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/MoveRejectFarPad" unique_id=1900033] +shape = SubResource("BoxShape3D_move_reject_farpad") + +[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +mesh = SubResource("BoxMesh_obstacle") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Obstacle" unique_id=1344302688] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +shape = SubResource("BoxShape3D_obstacle") + [node name="InteractionMarkers" type="Node3D" parent="World" unique_id=1700003] script = ExtResource("6_rad") @@ -138,86 +159,27 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.95, 0.22, -1.05) mesh = SubResource("BoxMesh_marker") surface_material_override/0 = SubResource("Mat_marker_base") -[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable", "ns19_bump"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.4, 0.075, -2.6) -collision_layer = 1 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900002] -mesh = SubResource("BoxMesh_ns19_bump_a") -surface_material_override/0 = SubResource("Mat_ns19_bump") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003] -shape = SubResource("Convex_ns19a_frustum") - -[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable", "ns19_bump"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.85) -collision_layer = 1 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900012] -mesh = SubResource("BoxMesh_ns19_bump_b") -surface_material_override/0 = SubResource("Mat_ns19_bump") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013] -shape = SubResource("Convex_ns19b_frustum") - -[node name="NS19RejectPedestal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900021 groups=["walkable"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 7.5, 0, -6.5) -collision_layer = 1 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900022] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) -mesh = SubResource("BoxMesh_ns19_pedestal") -surface_material_override/0 = SubResource("Mat_ns19_pedestal") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19RejectPedestal" unique_id=1900023] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0) -shape = SubResource("BoxShape3D_ns19_pedestal") - -[node name="NS19FarPad" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900031 groups=["walkable"]] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0.06, 9) -collision_layer = 1 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900032] -mesh = SubResource("BoxMesh_ns19_farpad") -surface_material_override/0 = SubResource("Mat_ns19_farpad") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900033] -shape = SubResource("BoxShape3D_ns19_farpad") - -[node name="Obstacle" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1638845763] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 5) -collision_layer = 1 - -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Obstacle" unique_id=750473628] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) -mesh = SubResource("BoxMesh_obstacle") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Obstacle" unique_id=1344302688] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) -shape = SubResource("BoxShape3D_obstacle") - -[node name="Player" type="CharacterBody3D" parent="World/NavigationRegion3D" unique_id=352931696] +[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 = 1 floor_max_angle = 0.872665 -floor_snap_length = 0.28 -safe_margin = 0.045 +floor_snap_length = 0.22 +safe_margin = 0.055 script = ExtResource("2_player") -[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Player" unique_id=1695755590] +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Player" unique_id=1695755590] shape = SubResource("CapsuleShape3D_player") -[node name="NavigationAgent3D" type="NavigationAgent3D" parent="World/NavigationRegion3D/Player" unique_id=8880002] -avoidance_enabled = false +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="World/Player" unique_id=8880002] path_desired_distance = 0.35 target_desired_distance = 0.35 -path_height_offset = 0.0 radius = 0.4 -height = 1.0 -[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Player" unique_id=2027034386] +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Player" unique_id=2027034386] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.034, 0) +cast_shadow = 0 mesh = SubResource("CapsuleMesh_player") +surface_material_override/0 = SubResource("Mat_player_capsule") [node name="GroundPick" type="Node3D" parent="." unique_id=2500001] script = ExtResource("3_ground") @@ -232,7 +194,6 @@ script = ExtResource("5_ix") [node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] anchors_preset = 12 -anchor_left = 0.0 anchor_top = 1.0 anchor_right = 1.0 anchor_bottom = 1.0 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 6c8aaf1..4939620 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -3,6 +3,7 @@ extends Node3D ## NS-16: composes ground pick + server authority; see `ground_pick.gd` / ## `position_authority_client.gd`. NS-18: interaction POST + radius glow preview (see child nodes). ## NS-23: bakes `NavigationRegion3D` on startup; boot snap vs nav goal from authority signal. +## Prototype: two **random short bumps** (sibling `StaticBody3D` under `NavigationRegion3D`; see `random_floor_bumps.gd`) **before** nav bake. const MOVE_REJECT_MSG_SECONDS: float = 4.0 @@ -11,15 +12,18 @@ var _move_reject_msg_token: int = 0 @onready var _camera: Camera3D = $World/Camera3D @onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D -@onready var _player: CharacterBody3D = $World/NavigationRegion3D/Player +@onready var _player: CharacterBody3D = $World/Player @onready var _ground_pick: Node3D = $GroundPick @onready var _authority: Node = $PositionAuthorityClient @onready var _radius_preview: Node3D = $World/InteractionMarkers @onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel +@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor func _ready() -> void: await get_tree().process_frame + # Runtime load + .call avoids class_name / preload static resolution issues across Godot parses. + load("res://scripts/random_floor_bumps.gd").call("spawn_short_random_bumps", _floor) # Default `bake_navigation_mesh()` bakes on a background thread; sync bake ensures the map # exists before any `NavigationAgent3D` queries (see Godot navigation_using_navigationagents). _nav_region.bake_navigation_mesh(false) diff --git a/client/scripts/player.gd b/client/scripts/player.gd index 2d5078e..c480ba3 100644 --- a/client/scripts/player.gd +++ b/client/scripts/player.gd @@ -1,44 +1,94 @@ extends CharacterBody3D ## NS-23: Follow server move target on walkable geometry. +## NS-24: Idle — **`velocity = 0`**, **`floor_snap_length = FLOOR_SNAP_IDLE`** (~**11 cm**), **`move_and_slide()`** +## only. (Post-slide **ray Y lerp** was removed: on bump **rims** hit counts / spread can cross thresholds +## frame-to-frame and **fight** the solver.) Walking snap **`FLOOR_SNAP_MOVING`**. **`snap_to_server`** calls +## **`reset_physics_interpolation()`** when **`physics/common/physics_interpolation`** is on. +## **`project.godot`:** **`physics/3d/physics_engine`** = **Jolt Physics**; **`physics/common/physics_interpolation`** +## = **off** — when **on**, micro **position** changes on bump **rims** blend across frames and read as **ghosting / jitter**. +## **`Player`** under **`World`**. +## **Rendering (main.tscn / project):** curved default capsule + directional light → **specular highlight** +## and **floor z-fight** often read as “jitter” even when `global_position` is stable — player uses +## **no-specular** material, capsule mesh **Y** lift vs **`CapsuleShape3D`** (**z-fight** vs walkables), **no cast shadow** on mesh, **MSAA 3D**, **`light_specular=0`**. +## **Timing:** **120** physics TPS. Jitter that vanishes at **2× run speed** points at **render vs physics** cadence. ## -## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0. Height along ramps -## and steps comes from `move_and_slide` + floor_* on CharacterBody3D. Move **legality** (step, -## distance, bounds) is server MoveCommand only — not reimplemented here. +## Horizontal velocity toward nav waypoint or goal only; `velocity.y` stays 0 while walking. +## Move **legality** is server-only. ## ## Nav detours except when goal Y is below us: then xz toward goal (waypoints sit under bump rims). ## -## Idle: skip `move_and_slide()` when on floor and velocity is zero — repeated slide + floor snap -## causes visible micro-vibration at rest. +## **Upright + floor angle (NS-24):** **`_snap_capsule_upright()`** each tick after motion — **`Transform3D(Basis.IDENTITY, pos)`** +## so the capsule cannot **bank** on rim contacts. **`floor_max_angle`** is **looser while walking**; **stricter when idle** on +## flat floor; **`_floor_angle_loose_ticks`** after a walk **stops**. **Rim / straddle idle:** **moving** `floor_max_angle` when +## **`get_floor_normal()`** is **shallow** or **`_idle_ridged_floor_contacts()`** (slab + cylinder **edge**). After idle slide, +## **`_maybe_idle_rim_settle_nudge()`** + **`_maybe_idle_bump_proximity_escape()`** (uses **`random_floor_bump_mesh`** group mesh radius vs +## **`random_floor_bump_collision_constants.gd`** collision lip, including **cylinder wall** hug). Do **not** reset **`global_transform`** in **`_process`** — that can **desync** +## **`CharacterBody3D`** from the physics/render path and read as **ghosting**. **`NavigationAgent3D.avoidance_enabled`** = **false**. +## **`_try_walk_step_assist()`** — **Y nudge** when the nav goal is **higher**, **`test_move`** says there is **headroom**, +## we have **support** (floor or slide normal with upward component), a **wall-ish** slide opposes **XZ** toward the goal, +## and **horizontal motion** is **not** clearly advancing. A **second** **`move_and_slide()`** runs after a nudge to fix overlap +## (avoids “floating” off the bump edge). const MOVE_SPEED: float = 5.0 +## `is_on_floor` / snap: **moving** — seam / curved-side transitions onto NS-19 bumps (~original 50°). +const FLOOR_MAX_ANGLE_MOVING_DEG: float = 50.0 +## **Idle** — tighter to reject **vertex / vertical-face** flicker when stably at rest. +const FLOOR_MAX_ANGLE_IDLE_DEG: float = 35.0 +## Physics ticks to keep **moving** floor angle after **`_has_walk_goal`** becomes false (arrival / clear). +const FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP: int = 96 const ARRIVE_EPS: float = 0.35 const VERT_ARRIVE_EPS: float = 0.055 const DIRECT_APPROACH_RADIUS: float = 0.85 -## Descend bypass: compares auth goal Y (often floor **surface** from pick) to `global_position.y` -## (**mid-capsule**). True for most floor clicks — smooth bumps; skips nav (bee-line xz), so -## obstacles may need multi-click. Nav waypoints under bump rims gave almost no horizontal speed. +const FLOOR_SNAP_MOVING: float = 0.32 +## Idle snap — slightly **strong** to pin capsule on **rim** contacts without a second slide pass. +const FLOOR_SNAP_IDLE: float = 0.11 +## Idle on a **rim**: if **`get_floor_normal().y`** dot world up is **below** this, use **moving** `floor_max_angle` for that tick. +const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968 +## Per-tick horizontal pull when idle on a **rim** or **straddling** slab + bump (see **`_maybe_idle_rim_settle_nudge`**). +const IDLE_RIM_SETTLE_STEP: float = 0.004 const DESCEND_GOAL_Y_MARGIN: float = 0.06 +## Per-tick lift when **stuck** on a low vertical face while the nav goal is **higher** (NS-19 bump height ~0.15 m). +const WALK_STEP_ASSIST_DELTA: float = 0.11 +## If **`vel_h.dot(want) >` this**, we are making enough XZ progress — do not assist (corners may still slide slowly). +const WALK_STEP_ASSIST_MAX_FORWARD_DOT: float = 0.52 +const WALK_STEP_ASSIST_COOLDOWN_TICKS: int = 8 +## Capsule **`height`** in **`main.tscn`** (**1.0**) → origin at **center**; feet ≈ **`y - 0.5`**. +const CAPSULE_HALF_HEIGHT: float = 0.5 +const _random_floor_bump_collision: Script = preload( + "res://scripts/random_floor_bump_collision_constants.gd" +) +## Idle-only radial push off bump **lip** / **wall** (m/tick at **120** TPS). +const IDLE_BUMP_ESCAPE_STEP: float = 0.014 +## Player **`CapsuleShape3D.radius`** in **`main.tscn`** — wall contact can occur with axis distance **>** **`col_r`**. +const PLAYER_CAPSULE_RADIUS: float = 0.4 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 @onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D func _ready() -> void: + physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + _nav_agent.avoidance_enabled = false _nav_agent.set_target_position(global_position) + floor_block_on_wall = true func set_authoritative_nav_goal(world_pos: Vector3) -> void: _auth_walk_goal = world_pos _has_walk_goal = true + _floor_angle_loose_ticks = 0 _nav_agent.set_target_position(world_pos) func clear_nav_goal() -> void: velocity = Vector3.ZERO _has_walk_goal = false + _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP _nav_agent.set_target_position(global_position) @@ -46,7 +96,83 @@ func snap_to_server(world_pos: Vector3) -> void: global_position = world_pos velocity = Vector3.ZERO _has_walk_goal = false + _floor_angle_loose_ticks = 0 + _step_assist_cooldown = 0 _nav_agent.set_target_position(world_pos) + reset_physics_interpolation() + + +func _step_assist_has_support() -> bool: + if is_on_floor(): + return true + for i: int in get_slide_collision_count(): + if get_slide_collision(i).get_normal().y > 0.35: + return true + return false + + +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() + 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-8: + continue + n2 = n2.normalized() + if n2.dot(w2) < -0.04: + return true + return false + + +func _step_assist_can_raise_by(dy: float) -> bool: + if dy <= 0.0001: + return false + return not test_move(global_transform, Vector3(0.0, dy, 0.0), null, safe_margin) + + +func _try_walk_step_assist() -> bool: + if not _has_walk_goal: + return false + if _auth_walk_goal.y < global_position.y + 0.025: + return false + if not _step_assist_has_support(): + 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() + if not _step_assist_wallish_blocks(want): + return false + var vel_h: Vector3 = Vector3(velocity.x, 0.0, velocity.z) + if vel_h.dot(want) > WALK_STEP_ASSIST_MAX_FORWARD_DOT: + 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)) + if lift <= 0.002: + return false + if not _step_assist_can_raise_by(lift): + return false + global_position.y += lift + return true + + +func _after_walk_move_and_slide() -> void: + if _step_assist_cooldown > 0: + _step_assist_cooldown -= 1 + if _step_assist_cooldown > 0: + return + if _try_walk_step_assist(): + _step_assist_cooldown = WALK_STEP_ASSIST_COOLDOWN_TICKS + floor_snap_length = FLOOR_SNAP_MOVING + move_and_slide() func _set_horizontal_velocity_toward(point: Vector3) -> void: @@ -58,18 +184,133 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void: velocity.y = 0.0 -## `is_on_floor()` comes from the last `move_and_slide()`. Skipping slide while idle reuses that -## stale floor state — OK for static floors; revisit if walkable geometry moves. -func _skip_move_when_idle_on_floor() -> bool: - return is_on_floor() and velocity.length_squared() < 1e-12 +func _physics_idle_tick() -> void: + velocity = Vector3.ZERO + floor_snap_length = FLOOR_SNAP_IDLE + move_and_slide() + + +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 + + +func _maybe_idle_rim_settle_nudge() -> void: + if get_slide_collision_count() < 1: + return + 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(): + return + var h := Vector3.ZERO + if shallow_floor: + h = Vector3(get_floor_normal().x, 0.0, get_floor_normal().z) + if h.length_squared() < 1e-12: + for i: int in get_slide_collision_count(): + var n: Vector3 = get_slide_collision(i).get_normal() + if n.y < 0.5: + var nh: Vector3 = Vector3(n.x, 0.0, n.z) + if nh.length_squared() > h.length_squared(): + h = nh + if h.length_squared() < 1e-12: + return + var step: Vector3 = -h.normalized() * IDLE_RIM_SETTLE_STEP + if test_move(global_transform, step, null, safe_margin): + return + global_position += step + + +func _maybe_idle_bump_proximity_escape() -> void: + var feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT + for node: Node in get_tree().get_nodes_in_group( + _random_floor_bump_collision.RANDOM_FLOOR_BUMP_MESH_GROUP + ): + if not node is MeshInstance3D: + continue + var mi := node as MeshInstance3D + var mesh: Mesh = mi.mesh + if not mesh is CylinderMesh: + continue + var cyl := mesh as CylinderMesh + var c: Vector3 = mi.global_position + var r_mesh: float = cyl.top_radius + var h: float = cyl.height + var col_r: float = minf( + r_mesh + _random_floor_bump_collision.COLLISION_RADIUS_EXTRA, + _random_floor_bump_collision.COLLISION_RADIUS_MAX + ) + var dx: float = global_position.x - c.x + var dz: float = global_position.z - c.z + var d: float = sqrt(dx * dx + dz * dz) + if d < 1e-5: + continue + var bottom_y: float = c.y - h * 0.5 + var top_y: float = c.y + h * 0.5 + if feet_y < bottom_y - 0.22 or feet_y > top_y + 0.4: + continue + var radial: Vector3 = Vector3(dx / d, 0.0, dz / d) + var away: Vector3 = radial * IDLE_BUMP_ESCAPE_STEP + var wall_band_outer: float = col_r + PLAYER_CAPSULE_RADIUS + 0.22 + # Outside visual disc but inside fat collision — clear the invisible lip. + if d > r_mesh * 0.9 and d < col_r + 0.32: + if not test_move(global_transform, away, null, safe_margin): + global_position += away + return + # On / above the disc, hugging the **visual** rim — step onto open slab. + if feet_y >= bottom_y - 0.06 and feet_y <= top_y + 0.1 and d >= r_mesh * 0.86 and d <= r_mesh + 0.14: + if not test_move(global_transform, away, null, safe_margin): + global_position += away + return + # Floor beside **vertical** cylinder side: axis distance can exceed **`col_r`** while capsule still touches wall. + if ( + feet_y <= top_y + 0.14 + and d > r_mesh * 0.82 + and d < wall_band_outer + and (is_on_wall() or _idle_ridged_floor_contacts()) + ): + if not test_move(global_transform, away, null, safe_margin): + global_position += away + return + + +func _snap_capsule_upright() -> void: + # TODO(NS-24 follow-on): When the avatar has **facing yaw**, clear **pitch/roll** only — keep **Y** rotation; + # full **`Basis.IDENTITY`** would erase look direction. + var p: Vector3 = global_position + global_transform = Transform3D(Basis.IDENTITY, p) func _physics_process(_delta: float) -> void: + var use_loose_floor_angle: bool = ( + _has_walk_goal or _floor_angle_loose_ticks > 0 + ) + if not _has_walk_goal and ( + ( + is_on_floor() + and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT + ) + 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: - velocity = Vector3.ZERO - if _skip_move_when_idle_on_floor(): - return - move_and_slide() + _physics_idle_tick() + _maybe_idle_rim_settle_nudge() + _maybe_idle_bump_proximity_escape() + if _floor_angle_loose_ticks > 0: + _floor_angle_loose_ticks -= 1 + _snap_capsule_upright() return var full_to_goal: Vector3 = _auth_walk_goal - global_position @@ -78,21 +319,29 @@ func _physics_process(_delta: float) -> void: if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS: velocity = Vector3.ZERO _has_walk_goal = false + _floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP _nav_agent.set_target_position(global_position) - if _skip_move_when_idle_on_floor(): - return - move_and_slide() + _physics_idle_tick() + _maybe_idle_rim_settle_nudge() + _maybe_idle_bump_proximity_escape() + _snap_capsule_upright() return var nav_map: RID = _nav_agent.get_navigation_map() if NavigationServer3D.map_get_iteration_id(nav_map) == 0: _set_horizontal_velocity_toward(_auth_walk_goal) + floor_snap_length = FLOOR_SNAP_MOVING move_and_slide() + _after_walk_move_and_slide() + _snap_capsule_upright() return if _auth_walk_goal.y < global_position.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() + _snap_capsule_upright() return if horiz_dist <= DIRECT_APPROACH_RADIUS: @@ -107,4 +356,7 @@ func _physics_process(_delta: float) -> void: else: _set_horizontal_velocity_toward(_auth_walk_goal) + floor_snap_length = FLOOR_SNAP_MOVING move_and_slide() + _after_walk_move_and_slide() + _snap_capsule_upright() diff --git a/client/scripts/random_floor_bump_collision_constants.gd b/client/scripts/random_floor_bump_collision_constants.gd new file mode 100644 index 0000000..2e9ad4b --- /dev/null +++ b/client/scripts/random_floor_bump_collision_constants.gd @@ -0,0 +1,11 @@ +extends RefCounted + +## Shared **`CylinderShape3D.radius`** fudge vs mesh for procedural **random floor QA bumps** (`random_floor_bumps.gd`). +## Used by **`player.gd`** (idle lip escape). Change here only. +## (Movement/step validation UX was originally tracked in Jira **NS-19** — not part of this script’s name.) + +## **`MeshInstance3D.add_to_group`** for spawned bumps; **`player.gd`** queries this for proximity escape. +const RANDOM_FLOOR_BUMP_MESH_GROUP: String = "random_floor_bump_mesh" + +const COLLISION_RADIUS_EXTRA: float = 0.105 +const COLLISION_RADIUS_MAX: float = 0.82 diff --git a/client/scripts/random_floor_bump_collision_constants.gd.uid b/client/scripts/random_floor_bump_collision_constants.gd.uid new file mode 100644 index 0000000..e72b089 --- /dev/null +++ b/client/scripts/random_floor_bump_collision_constants.gd.uid @@ -0,0 +1 @@ +uid://x4bbwq1c8xe4 diff --git a/client/scripts/random_floor_bumps.gd b/client/scripts/random_floor_bumps.gd new file mode 100644 index 0000000..d1c68a8 --- /dev/null +++ b/client/scripts/random_floor_bumps.gd @@ -0,0 +1,123 @@ +extends RefCounted + +## Prototype QA: two short **cylinder** bumps, each on its own **`StaticBody3D`** **sibling** of **`Floor`** (same parent: +## **`NavigationRegion3D`**). Avoids **compound internal edges** (floor box + cylinder on one body), which caused strong +## idle jitter beside the bump. Meshes use group **`random_floor_bump_mesh`** (see **`random_floor_bump_collision_constants.gd`**). Loaded via **`main.gd`** **`load().call`**. +## **Collision radius > mesh radius** (lip before visual rim); values in **`random_floor_bump_collision_constants.gd`**. Spawn **before** `NavigationRegion3D.bake_navigation_mesh()`. + +const _random_floor_bump_collision: Script = preload( + "res://scripts/random_floor_bump_collision_constants.gd" +) + +const COUNT: int = 2 +const HEIGHT_MIN: float = 0.08 +const HEIGHT_MAX: float = 0.14 +const RADIUS_MIN: float = 0.36 +const RADIUS_MAX: float = 0.62 +const FLOOR_SINK: float = 0.008 +const XZ_RANGE: float = 8.15 +const MIN_PAIR_SEPARATION: float = 2.85 +const MAX_PLACE_ATTEMPTS: int = 64 +const EXCLUSION_MARGIN: float = 0.72 + +static func spawn_short_random_bumps(floor_node: StaticBody3D) -> void: + var rng := RandomNumberGenerator.new() + rng.randomize() + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.22, 0.52, 0.32, 1.0) + mat.roughness = 1.0 + mat.metallic = 0.0 + mat.specular_mode = 2 + + var placed_xz: Array[Vector2] = [] + for idx: int in COUNT: + var xz: Vector2 = Vector2.ZERO + var ok := false + for _a: int in MAX_PLACE_ATTEMPTS: + xz = Vector2( + rng.randf_range(-XZ_RANGE, XZ_RANGE), + rng.randf_range(-XZ_RANGE, XZ_RANGE) + ) + if _xz_in_exclusion(xz): + continue + var far_enough := true + for p: Vector2 in placed_xz: + if xz.distance_to(p) < MIN_PAIR_SEPARATION: + far_enough = false + break + if not far_enough: + continue + ok = true + break + if not ok: + push_warning("RandomFloorBumps: could not place bump %d after %d tries" % [idx, MAX_PLACE_ATTEMPTS]) + continue + placed_xz.append(xz) + var h: float = rng.randf_range(HEIGHT_MIN, HEIGHT_MAX) + var r: float = rng.randf_range(RADIUS_MIN, RADIUS_MAX) + _add_cylinder_bump(floor_node, xz.x, xz.y, h, r, mat, idx) + + +static func _xz_in_exclusion(xz: Vector2) -> bool: + var ex: Array[Vector2] = [ + Vector2(-5.0, -5.0), + Vector2(0.0, 0.0), + Vector2(6.0, 5.0), + Vector2(7.5, -6.5), + Vector2(9.0, 9.0), + ] + var rad: Array[float] = [2.65, 1.2, 2.25, 1.65, 1.85] + for i: int in ex.size(): + if xz.distance_to(ex[i]) < rad[i] + EXCLUSION_MARGIN: + return true + return false + + +static func _add_cylinder_bump( + floor_node: StaticBody3D, + x: float, + z: float, + height: float, + radius: float, + mat: StandardMaterial3D, + idx: int +) -> void: + var cy: float = height * 0.5 - FLOOR_SINK + var local_center := Vector3(x, cy, z) + var nav_parent := floor_node.get_parent() + if nav_parent == null: + push_error("RandomFloorBumps: Floor has no parent; cannot spawn bump") + return + + var bump_root := StaticBody3D.new() + bump_root.name = "RandomTestBump%d" % idx + bump_root.add_to_group("walkable") + bump_root.collision_layer = floor_node.collision_layer + bump_root.collision_mask = floor_node.collision_mask + nav_parent.add_child(bump_root) + bump_root.global_position = floor_node.to_global(local_center) + + var mesh_inst := MeshInstance3D.new() + mesh_inst.name = "RandomTestBump%d_mesh" % idx + mesh_inst.add_to_group(_random_floor_bump_collision.RANDOM_FLOOR_BUMP_MESH_GROUP) + var cm := CylinderMesh.new() + cm.height = height + cm.top_radius = radius + cm.bottom_radius = radius + cm.radial_segments = 24 + mesh_inst.mesh = cm + mesh_inst.set_surface_override_material(0, mat) + mesh_inst.position = Vector3.ZERO + bump_root.add_child(mesh_inst) + + var col := CollisionShape3D.new() + col.name = "RandomTestBump%d_shape" % idx + var csh := CylinderShape3D.new() + csh.height = height + csh.radius = minf( + radius + _random_floor_bump_collision.COLLISION_RADIUS_EXTRA, + _random_floor_bump_collision.COLLISION_RADIUS_MAX + ) + col.shape = csh + col.position = Vector3.ZERO + bump_root.add_child(col) diff --git a/client/scripts/random_floor_bumps.gd.uid b/client/scripts/random_floor_bumps.gd.uid new file mode 100644 index 0000000..f68554d --- /dev/null +++ b/client/scripts/random_floor_bumps.gd.uid @@ -0,0 +1 @@ +uid://dgpkx05lwefn8 diff --git a/docs/plans/NS-24-implementation-plan.md b/docs/plans/NS-24-implementation-plan.md new file mode 100644 index 0000000..4680d34 --- /dev/null +++ b/docs/plans/NS-24-implementation-plan.md @@ -0,0 +1,76 @@ +# NS-24 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NS-24 | +| **Title** | Godot client: eliminate CharacterBody3D idle / rest vibration | +| **Jira** | [NS-24](https://neon-sprawl.atlassian.net/browse/NS-24) | +| **Parent** | [NS-20 — Tech Debt](https://neon-sprawl.atlassian.net/browse/NS-20) | +| **Related** | [NS-23](https://neon-sprawl.atlassian.net/browse/NS-23) (path-follow baseline; idle jitter explicitly deferred here) | + +## Goal, scope, and out-of-scope + +**Goal:** **Stable rest pose** — no perceptible micro-vibration when horizontal velocity is zero, there is no active walk goal, and the avatar stands on walkable geometry, including prototype floors and **NS-19** stepped bumps. + +**In scope** + +- Diagnose and fix (or materially reduce) idle jitter on the prototype **`CharacterBody3D`** player driven by **`player.gd`** (`move_and_slide`, floor interaction, **`NavigationAgent3D`** coupling as relevant). +- Preserve **NS-23** behavior: walking, arrival at goal, **`snap_to_server`** on boot, descend bypass tradeoff, and **NS-19** validation UX on the server side (client must not regress those flows). +- Document non-obvious fixes in **`client/README.md`** and/or the **`player.gd`** header (per Jira). + +**Out of scope** + +- Full prediction/reconciliation, server-side nav, or redesign of **MoveCommand** / **PositionAuthorityClient** contracts. +- Replacing the capsule with a different locomotion stack unless a minimal inspector tweak is sufficient. + +## Acceptance criteria checklist + +- [x] At rest (no click-to-move goal, feet on floor), **no visible vibration** in the default prototype scene for **several seconds** of observation. +- [x] **Walking**, **arrival**, and **`snap_to_server`** still behave correctly; **no regression** on NS-19 bumps or the **NS-23** obstacle / descend-bypass tradeoff documented in **`client/README.md`**. +- [x] If a **scene** or **project** setting change is required, it is **checked in** and called out for designers (README or short comment in scene notes). + +## Resolution (shipped) + +**Jolt Physics** + **`physics_interpolation` off**; QA bumps on **separate `StaticBody3D`** bodies (no floor+bump compound internal edges); idle **`floor_max_angle`** loosened on rim/straddle; **rim settle** + **`random_floor_bump_mesh`** lip/wall escape; **`NavigationAgent3D.avoidance_enabled`** false, **`floor_block_on_wall`** true; **no `global_transform` in `_process`**; **visual** capsule **mesh Y lift** vs collision to reduce **z-fight** with bump tops. Shared bump collider fudge: **`client/scripts/random_floor_bump_collision_constants.gd`**. + +## Technical approach + +1. **Reproduce and isolate** in Godot 4.x editor: Remote **Transform** on the player (and camera parent if suspected), **Visible Collision Shapes**, and frame / physics profiler to see whether jitter is position, floor snap, or render-only. +2. **CharacterBody3D / physics** — iterate on documented levers: **`move_and_slide`** at idle with **`floor_snap_length = 0`**; **`safe_margin`**; compound **`Floor`** collision; **`physics/3d/physics_engine`** (**Jolt Physics** vs Godot Physics — Jolt targets internal-edge / compound-shape jitter); **`floor_stop_on_slope`**, **`motion_mode`**, **`up_direction`** if still needed. +3. **Navigation agent** — confirm **`NavigationAgent3D`** target updates at rest (`set_target_position(global_position)` on clear/arrive) do not trigger path churn each frame; adjust only if measurements show agent-driven motion. +4. **Camera / parenting** — rule out camera or child node noise if transform root is stable. +5. **Ship** the smallest change that clears the AC (code, scene, or project), then update **`client/README.md`** to remove or narrow the “Known issue” idle jitter line and describe the chosen pattern. + +**Shipped note:** Idle uses **non-zero** **`floor_snap_length`** (see **`player.gd`**) rather than zero snap from the investigation list; straddle/rim logic and bump geometry split were the main levers. Code review: **`docs/reviews/2026-04-05-NS-24.md`**. + +## Files to add + +| Path | Purpose | +|------|---------| +| `client/scripts/random_floor_bump_collision_constants.gd` | Single source for QA bump **`CylinderShape3D`** radius fudge vs mesh and **`RANDOM_FLOOR_BUMP_MESH_GROUP`** (shared by **`random_floor_bumps.gd`** and **`player.gd`** lip escape). | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scripts/player.gd` | Idle **`move_and_slide`** policy, walking snap, arrival path, bump lip escape (imports **`random_floor_bump_collision_constants.gd`**). | +| `client/scenes/main.tscn` | Player **`CharacterBody3D`** / **`NavigationAgent3D`** inspector properties, collision layers, or nav region only if diagnosis requires it (AC: designer-visible changes documented). | +| `client/project.godot` | Physics or project defaults (e.g. physics ticks, 3D defaults) only if required and safe for the whole project. | +| `client/README.md` | Replace **Known issue: idle / rest jitter** with the fix summary and any manual verification deltas. | + +## Tests + +| Action | What to cover | +|--------|----------------| +| **Manual (required)** | Run main scene with server as in **NS-16/NS-23** README steps: stand idle **10+ s** at spawn and after a move; click-move including **stepped bumps** (NS-19 props); cold boot **`snap_to_server`**; far/vertical rejects still show NS-19 UX; optional multi-click around **`Obstacle`** per NS-23 tradeoff. | +| **Automated** | **None** — no Godot/GDScript test harness in repo; server **xUnit** suites unchanged unless an unrelated regression appears (not expected). | + +## Open questions / risks + +**Resolved for prototype QA:** Primary causes were **compound internal edges** (mitigated by per-bump bodies), **physics/render** artifacts (**interpolation**, **`CharacterBody3D`** transform desync, **z-fight**), and **rim/wall** contacts (mitigated by idle policy + small escapes). **Remaining:** **`_snap_capsule_upright()`** uses full **identity basis** — when **yaw** facing is added, preserve **Y** rotation only (see **`TODO`** in **`player.gd`**). Moving platforms / uneven nav mesh not validated. + +## PR / review + +Cross-check [E1.M1 — InputAndMovementRuntime](../decomposition/modules/E1_M1_InputAndMovementRuntime.md), **`docs/plans/NS-23-implementation-plan.md`** (residual polish pointer), and **`client/README.md`** after changes. diff --git a/docs/reviews/2026-04-05-NS-24.md b/docs/reviews/2026-04-05-NS-24.md new file mode 100644 index 0000000..a12b222 --- /dev/null +++ b/docs/reviews/2026-04-05-NS-24.md @@ -0,0 +1,55 @@ +# Code review — NS-24 idle / rest stability (Godot client) + +**Date:** 2026-04-05 +**Scope:** Branch `NS-24-idle-vibration-fix` vs merge-base `3283f6d04c8724f6536ca591a2a4c8ca06ea7b61` (tracked as `origin/main` at review time). Issue **NS-24**; no PR URL supplied. +**Base:** `origin/main` @ `3283f6d04c8724f6536ca591a2a4c8ca06ea7b61` + +**Follow-up:** Review **suggestions** below are **done** (strikethrough + **Done.**); **nits** remain optional. + +## Verdict + +**Approve with nits** + +## Summary + +This branch delivers **NS-24** by hardening the prototype **`CharacterBody3D`** idle path (`player.gd`): dual **`floor_max_angle`** behavior, rim/straddle detection, idle nudges, bump proximity escape, **`floor_block_on_wall`**, **`NavigationAgent3D.avoidance_enabled = false`**, and **physics interpolation off** at project + player node level. It fixes **compound internal-edge** issues by spawning each QA bump as its **own `StaticBody3D`** (`random_floor_bumps.gd`), adds **Jolt** in **`project.godot`**, and reduces **z-fighting** via capsule **mesh Y lift** (visual-only vs collision shape). **`client/README.md`** and **`server/README.md`** (bump height copy) are updated. **Server contracts** (`MoveCommand` / `PositionState`) are not changed; risk is localized to client feel and QA geometry. Manual QA by the author reports **no remaining jitter** in exercised poses. + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NS-24-implementation-plan.md` | **Matches** — **Resolution**, checked AC, **Shipped note**, updated **Open questions** (`015ca13`). | +| `docs/plans/NS-23-implementation-plan.md` | **Matches** at intent level (client nav visual, server authority unchanged); no contract drift observed in the diff. | +| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches.** Prototype client locomotion and reconciliation surface remain consistent; changes are polish under “movement-loop” / QA, not authority or wire shape. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A** for code behavior; cited as the hub for module IDs. No register **Status** change required for this client-only jitter fix. | +| `docs/decomposition/modules/client_server_authority.md` | **N/A** (no change to who owns `PositionState` or move validation). | +| `client/README.md` | **Matches** NS-24 call to document the pattern and manual steps (idle 10+ s, bumps). | + +## Blocking issues + +*(none)* + +## Suggestions + +**All done.** + +1. ~~**Close the loop in `docs/plans/NS-24-implementation-plan.md`:**~~ **Done.** Check off acceptance criteria; add **Resolution** (shipped mitigations) and **Shipped note** under technical approach — `015ca13`. +2. ~~**DRY bump collision constants** (`BUMP_COLLISION_*` aligned across `player.gd` / `random_floor_bumps.gd`):~~ **Done.** `client/scripts/random_floor_bump_collision_constants.gd` + preload from both scripts; README references — `015ca13`, doc sync `87d8ee0` (file later renamed off **`ns19_*`** identifiers). +3. ~~**Future character yaw** (`_snap_capsule_upright()` full identity basis):~~ **Done.** `TODO(NS-24 follow-on)` on `_snap_capsule_upright()` in `player.gd`; plan **Open questions** — `015ca13`. + +## Nits + +- **`get_tree().get_nodes_in_group(...)`** on **`random_floor_bump_mesh`** each idle tick is fine at **two** bumps; if the group grows, cache references after spawn or guard cost. +- ~~**Commit history:**~~ **Done.** Branch history squashed to a **single** **`NS-24:`** commit before merge; no stray **`fix:`** / **`docs:`** subjects on the slice. + +## Verification + +Per **`NS-24-implementation-plan.md`** and **`client/README.md`**: + +1. Run **`main.tscn`** with server as in NS-16/NS-23 README steps. +2. **Idle 10+ s** at spawn and after a move (no click goal); confirm **no visible vibration** on slab and on **random green bumps** (new positions each run). +3. **Walking**, **arrival**, **`snap_to_server`** on cold boot; **NS-19** reject UX (pedestal / far pad) unchanged. +4. **Obstacle / descend** flows per NS-23 tradeoff (multi-click around tall obstacle if needed). +5. Spot-check **bump rim**, **cylinder side**, and **floor–bump** contact (prior jitter loci). + +**Automated:** None required for NS-24 (no Godot harness in repo). diff --git a/server/README.md b/server/README.md index d254523..f6855de 100644 --- a/server/README.md +++ b/server/README.md @@ -74,7 +74,7 @@ Unknown player ids return **404**. Full zone sync / replication is still out of | `Game:MovementValidation:DistrictBoundsEnabled` | Axis-aligned box on **target** (inclusive min/max) | `false` | | `Game:MovementValidation:DistrictMinX` … `DistrictMaxZ` | District extents when enabled | ±12 / Y −2…24 | -**Manual QA (Godot `main.tscn`):** **Green bumps** (~0.15 m rise) 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 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. Request body (example):