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: Cursorgithub-services/pull/4/head
parent
2aabcb969a
commit
343592541a
|
|
@ -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`).
|
- 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).
|
- 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
|
## First run
|
||||||
|
|
||||||
1. Install [Godot 4.x](https://godotengine.org/download).
|
1. Install [Godot 4.x](https://godotengine.org/download).
|
||||||
|
|
|
||||||
|
|
@ -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/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"]
|
[node name="Main" type="Node3D"]
|
||||||
script = ExtResource("1_main")
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,29 @@
|
||||||
extends Node3D
|
extends Node3D
|
||||||
|
|
||||||
func _ready() -> void:
|
## NS-14: client-only click-to-move. Provisional until authoritative movement sync (server / MoveCommand).
|
||||||
print("Neon Sprawl client bootstrap (Godot). Connect to game server TBD.")
|
|
||||||
|
@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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -26,9 +26,9 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Clicking walkable ground moves the avatar to the destination without requiring WASD (mouse-only locomotion for this prototype).
|
- [x] 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).
|
- [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).
|
||||||
- [ ] README or an in-editor scene note states this behavior is provisional until authoritative sync lands.
|
- [x] README or an in-editor scene note states this behavior is provisional until authoritative sync lands.
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue