From 343592541a644008f9efc2fead44f2d80e94be7f Mon Sep 17 00:00:00 2001 From: don Date: Sun, 29 Mar 2026 21:19:52 -0400 Subject: [PATCH] feat(client): NS-14 click-to-move with NavigationAgent3D - Main scene: floor, obstacle, nav mesh, camera, CharacterBody3D + agent - Click-to-move on walkable group; pick mask avoids avatar self-hit - player.gd drives path following; README and plan AC updated Made-with: Cursor --- client/README.md | 16 +++++ client/scenes/main.tscn | 84 ++++++++++++++++++++++++- client/scripts/main.gd | 29 ++++++++- client/scripts/player.gd | 30 +++++++++ docs/plans/NS-14-implementation-plan.md | 6 +- 5 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 client/scripts/player.gd diff --git a/client/README.md b/client/README.md index b80d7cf..b05c16c 100644 --- a/client/README.md +++ b/client/README.md @@ -5,6 +5,22 @@ Open this **`client/`** directory as a project in **Godot 4.2+** (4.x recommende - Main scene: `scenes/main.tscn` (bootstrap `scripts/main.gd`). - Networking will use **WebSocket** or **TCP** to the C# server; authoritative logic stays on the server per [`docs/architecture/tech_stack.md`](../docs/architecture/tech_stack.md). +## Movement prototype (NS-14) + +The main scene includes a **client-only** click-to-move demo: + +- **Left-click** walkable ground (the large floor) to move the capsule avatar; **WASD is not required**. +- Paths use **NavigationAgent3D** over a hand-authored navigation mesh with a central obstacle, so the avatar **routes around** the block instead of sliding through it. +- The avatar lives on **physics layer 2**; the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. + +This behavior is **temporary**: when authoritative movement and `MoveCommand` / `PositionState` exist, the client will follow server state instead of driving navigation locally. + +### Manual check + +1. Run the main scene (**F5**). +2. Click the floor: the avatar walks to the point. +3. Click behind the center crate: the avatar walks around it via the nav mesh. + ## First run 1. Install [Godot 4.x](https://godotengine.org/download). diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 39b869a..d8b5dc6 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,6 +1,88 @@ -[gd_scene load_steps=2 format=3 uid="uid://cyberpunkmmo_main"] +[gd_scene load_steps=9 format=3 uid="uid://cyberpunkmmo_main"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] +[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_floor"] +size = Vector3(20, 0.2, 20) + +[sub_resource type="BoxMesh" id="BoxMesh_floor"] +size = Vector3(20, 0.2, 20) + +[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"] +size = Vector3(2, 2, 2) + +[sub_resource type="BoxMesh" id="BoxMesh_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="NavigationMesh" id="NavigationMesh_frame"] +vertices = PackedVector3Array(-10, 0.02, -10, -2, 0.02, -10, 2, 0.02, -10, 10, 0.02, -10, 10, 0.02, 10, 2, 0.02, 10, -2, 0.02, 10, -10, 0.02, 10, -2, 0.02, -2, 2, 0.02, -2, 2, 0.02, 2, -2, 0.02, 2) +polygons = [PackedInt32Array(0, 1, 6, 7), PackedInt32Array(1, 2, 9, 8), PackedInt32Array(2, 3, 4, 5), PackedInt32Array(11, 10, 5, 6)] +agent_height = 2.0 +agent_radius = 0.45 +agent_max_climb = 0.25 [node name="Main" type="Node3D"] script = ExtResource("1_main") + +[node name="World" type="Node3D" parent="."] + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="World"] +transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 6, 0) +shadow_enabled = true + +[node name="Camera3D" type="Camera3D" parent="World"] +transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.573576, -0.579228, 0.579228, 12, 10, 12) +current = true +fov = 50.0 + +[node name="Floor" type="StaticBody3D" parent="World" groups=["walkable"]] +collision_layer = 1 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +mesh = SubResource("BoxMesh_floor") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) +shape = SubResource("BoxShape3D_floor") + +[node name="Obstacle" type="StaticBody3D" parent="World"] +collision_layer = 1 + +[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +mesh = SubResource("BoxMesh_obstacle") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +shape = SubResource("BoxShape3D_obstacle") + +[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World"] +navigation_mesh = SubResource("NavigationMesh_frame") + +[node name="Player" type="CharacterBody3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5) +collision_layer = 2 +collision_mask = 1 +script = ExtResource("2_player") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) +shape = SubResource("CapsuleShape3D_player") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="Player"] +mesh = SubResource("CapsuleMesh_player") + +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="Player"] +path_desired_distance = 0.35 +target_desired_distance = 0.4 +path_height_offset = 0.05 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e45a4d4..aa0a81b 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,4 +1,29 @@ extends Node3D -func _ready() -> void: - print("Neon Sprawl client bootstrap (Godot). Connect to game server TBD.") +## NS-14: client-only click-to-move. Provisional until authoritative movement sync (server / MoveCommand). + +@onready var _camera: Camera3D = $World/Camera3D +@onready var _nav_agent: NavigationAgent3D = $Player/NavigationAgent3D + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + _try_set_move_target(mb.position) + + + +func _try_set_move_target(screen_pos: Vector2) -> void: + var origin: Vector3 = _camera.project_ray_origin(screen_pos) + var ray_dir: Vector3 = _camera.project_ray_normal(screen_pos) + var to: Vector3 = origin + ray_dir * 2000.0 + var query := PhysicsRayQueryParameters3D.create(origin, to) + query.collision_mask = 1 + var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state + var hit: Dictionary = space.intersect_ray(query) + if hit.is_empty(): + return + var collider: Variant = hit.get("collider") + if collider is Node and (collider as Node).is_in_group("walkable"): + _nav_agent.target_position = hit.position diff --git a/client/scripts/player.gd b/client/scripts/player.gd new file mode 100644 index 0000000..958e610 --- /dev/null +++ b/client/scripts/player.gd @@ -0,0 +1,30 @@ +extends CharacterBody3D + +## NS-14: drives CharacterBody3D toward NavigationAgent3D path. Client-only; not server-authoritative. + +const MOVE_SPEED: float = 5.0 + +@onready var _nav: NavigationAgent3D = $NavigationAgent3D + + +func _ready() -> void: + # Map sync: avoid querying paths before the navigation map is ready. + await get_tree().physics_frame + _nav.target_position = global_position + + + +func _physics_process(_delta: float) -> void: + if _nav.is_navigation_finished(): + velocity = Vector3.ZERO + move_and_slide() + return + + var next: Vector3 = _nav.get_next_path_position() + var dir: Vector3 = next - global_position + dir.y = 0.0 + if dir.length() < 0.05: + velocity = Vector3.ZERO + else: + velocity = dir.normalized() * MOVE_SPEED + move_and_slide() diff --git a/docs/plans/NS-14-implementation-plan.md b/docs/plans/NS-14-implementation-plan.md index 0c6a742..df8709b 100644 --- a/docs/plans/NS-14-implementation-plan.md +++ b/docs/plans/NS-14-implementation-plan.md @@ -26,9 +26,9 @@ ## Acceptance criteria checklist -- [ ] Clicking walkable ground moves the avatar to the destination without requiring WASD (mouse-only locomotion for this prototype). -- [ ] Movement stops at obstacles when using navigation; **or** if using a direct move-to approach instead, the limitation is documented (README and/or plan). -- [ ] README or an in-editor scene note states this behavior is provisional until authoritative sync lands. +- [x] Clicking walkable ground moves the avatar to the destination without requiring WASD (mouse-only locomotion for this prototype). +- [x] Movement stops at obstacles when using navigation; **or** if using a direct move-to approach instead, the limitation is documented (README and/or plan). +- [x] README or an in-editor scene note states this behavior is provisional until authoritative sync lands. ## Technical approach