fix(client): reliable NS-14 click-to-move without NavigationMesh

- Drive movement with horizontal steering + move_and_slide toward click goal
- Remove NavigationRegion3D / NavigationAgent3D; obstacle interaction is slide
- main.gd: raycast to walkable, Player.set_move_goal; viewport camera + mouse
- Scene: explicit physics layers/mask on floor, obstacle, player
- Docs: Godot 4.6 baseline, README + NS-14 plan + tech_stack; Godot .import/.uid

Made-with: Cursor
feature/ns-14-click-to-move
don 2026-03-29 22:08:25 -04:00
parent 343592541a
commit 64092566a4
11 changed files with 134 additions and 69 deletions

View File

@ -49,4 +49,4 @@ Connection (dev): host `localhost`, port `5432`, database `neon_sprawl`, user `n
### Run the client ### Run the client
Open the [`client/`](client/) folder in **Godot 4.2+** and run the main scene (see [`client/README.md`](client/README.md)). Open the [`client/`](client/) folder in **Godot 4.6** and run the main scene (see [`client/README.md`](client/README.md)).

View File

@ -1,6 +1,6 @@
# Neon Sprawl — Godot client # Neon Sprawl — Godot client
Open this **`client/`** directory as a project in **Godot 4.2+** (4.x recommended). Use the **standard** Godot build (not the .NET build)—client code is **GDScript** (`.gd`). Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible). Use the **standard** Godot build (not the .NET build)—client code is **GDScript** (`.gd`).
- 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).
@ -10,8 +10,8 @@ Open this **`client/`** directory as a project in **Godot 4.2+** (4.x recommende
The main scene includes a **client-only** click-to-move demo: 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**. - **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. - Movement is **direct horizontal steering** plus **`move_and_slide()`**: the capsule walks toward the click and **slides along** the center crate instead of pathfinding around it. (A hand-authored `NavigationMesh` was not reliable across Godot versions; **NavigationAgent3D** can return later for routed paths.)
- The avatar lives on **physics layer 2**; the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor. - 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.
This behavior is **temporary**: when authoritative movement and `MoveCommand` / `PositionState` exist, the client will follow server state instead of driving navigation locally. This behavior is **temporary**: when authoritative movement and `MoveCommand` / `PositionState` exist, the client will follow server state instead of driving navigation locally.
@ -19,7 +19,11 @@ This behavior is **temporary**: when authoritative movement and `MoveCommand` /
1. Run the main scene (**F5**). 1. Run the main scene (**F5**).
2. Click the floor: the avatar walks to the point. 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. 3. Click behind the center crate: the avatar **slides** against the crate (no nav mesh path around it in this build).
### Clicks still ignored?
In the **Game** dock, **Input** must be active (not the **2D** / **3D** scene-picking tools) so events go to the game. If Input is on and clicks still do nothing, pick rays must use the **viewports current camera** and **mouse position** (the script does this so the embedded Game view matches the ray).
## First run ## First run

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://clvcqwwuhaity"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@ -1,13 +1,23 @@
; Godot 4.x project — import this folder in the Godot project manager. ; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5 config_version=5
[animation]
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="Neon Sprawl" config/name="Neon Sprawl"
config/description="Neon Sprawl — prototype client (isometric MMO; planning docs in repo root)." config/description="Neon Sprawl — prototype client (isometric MMO; planning docs in repo root)."
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.2", "Forward Plus") config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[display] [display]
@ -15,6 +25,10 @@ config/icon="res://icon.svg"
window/size/viewport_width=1280 window/size/viewport_width=1280
window/size/viewport_height=720 window/size/viewport_height=720
[dotnet]
project/assembly_name="Neon Sprawl"
[rendering] [rendering]
textures/canvas_textures/default_texture_filter=0 textures/canvas_textures/default_texture_filter=0

View File

@ -1,20 +1,20 @@
[gd_scene load_steps=9 format=3 uid="uid://cyberpunkmmo_main"] [gd_scene format=3 uid="uid://dg2g1nd82lyxm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" uid="uid://bh04b3iify0hd" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"] [ext_resource type="Script" uid="uid://1jimgt3d4bjj" 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"] [sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(20, 0.2, 20) size = Vector3(20, 0.2, 20)
[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"] [sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(2, 2, 2) size = Vector3(20, 0.2, 20)
[sub_resource type="BoxMesh" id="BoxMesh_obstacle"] [sub_resource type="BoxMesh" id="BoxMesh_obstacle"]
size = Vector3(2, 2, 2) size = Vector3(2, 2, 2)
[sub_resource type="BoxShape3D" id="BoxShape3D_obstacle"]
size = Vector3(2, 2, 2)
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_player"]
radius = 0.4 radius = 0.4
height = 1.0 height = 1.0
@ -23,66 +23,50 @@ height = 1.0
radius = 0.4 radius = 0.4
height = 1.0 height = 1.0
[sub_resource type="NavigationMesh" id="NavigationMesh_frame"] [node name="Main" type="Node3D" unique_id=1358372723]
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") script = ExtResource("1_main")
[node name="World" type="Node3D" parent="."] [node name="World" type="Node3D" parent="." unique_id=1042212190]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="World"] [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) 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 shadow_enabled = true
[node name="Camera3D" type="Camera3D" parent="World"] [node name="Camera3D" type="Camera3D" parent="World" unique_id=1124088856]
transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.573576, -0.579228, 0.579228, 12, 10, 12) transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.573576, -0.579228, 0.579228, 12, 10, 12)
current = true current = true
fov = 50.0 fov = 50.0
[node name="Floor" type="StaticBody3D" parent="World" groups=["walkable"]] [node name="Floor" type="StaticBody3D" parent="World" unique_id=1800743112 groups=["walkable"]]
collision_layer = 1 collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor"] [node name="MeshInstance3D" type="MeshInstance3D" parent="World/Floor" unique_id=152652175]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
mesh = SubResource("BoxMesh_floor") mesh = SubResource("BoxMesh_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor"] [node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor" unique_id=409532142]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
shape = SubResource("BoxShape3D_floor") shape = SubResource("BoxShape3D_floor")
[node name="Obstacle" type="StaticBody3D" parent="World"] [node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763]
collision_layer = 1 collision_layer = 1
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle"] [node name="MeshInstance3D" type="MeshInstance3D" parent="World/Obstacle" unique_id=750473628]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
mesh = SubResource("BoxMesh_obstacle") mesh = SubResource("BoxMesh_obstacle")
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle"] [node name="CollisionShape3D" type="CollisionShape3D" parent="World/Obstacle" unique_id=1344302688]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
shape = SubResource("BoxShape3D_obstacle") shape = SubResource("BoxShape3D_obstacle")
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World"] [node name="Player" type="CharacterBody3D" parent="." unique_id=352931696]
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) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5, 0.9, -5)
collision_layer = 2 collision_layer = 2
collision_mask = 1 collision_mask = 1
script = ExtResource("2_player") script = ExtResource("2_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"] [node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
shape = SubResource("CapsuleShape3D_player") shape = SubResource("CapsuleShape3D_player")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Player"] [node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=2027034386]
mesh = SubResource("CapsuleMesh_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

View File

@ -3,20 +3,23 @@ extends Node3D
## NS-14: client-only click-to-move. Provisional until authoritative movement sync (server / MoveCommand). ## NS-14: client-only click-to-move. Provisional until authoritative movement sync (server / MoveCommand).
@onready var _camera: Camera3D = $World/Camera3D @onready var _camera: Camera3D = $World/Camera3D
@onready var _nav_agent: NavigationAgent3D = $Player/NavigationAgent3D @onready var _player: CharacterBody3D = $Player
func _unhandled_input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mb := event as InputEventMouseButton var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
_try_set_move_target(mb.position) _try_set_move_target(get_viewport().get_mouse_position())
func _try_set_move_target(screen_pos: Vector2) -> void: func _try_set_move_target(screen_pos: Vector2) -> void:
var origin: Vector3 = _camera.project_ray_origin(screen_pos) var cam: Camera3D = get_viewport().get_camera_3d()
var ray_dir: Vector3 = _camera.project_ray_normal(screen_pos) if cam == null:
cam = _camera
var origin: Vector3 = cam.project_ray_origin(screen_pos)
var ray_dir: Vector3 = cam.project_ray_normal(screen_pos)
var to: Vector3 = origin + ray_dir * 2000.0 var to: Vector3 = origin + ray_dir * 2000.0
var query := PhysicsRayQueryParameters3D.create(origin, to) var query := PhysicsRayQueryParameters3D.create(origin, to)
query.collision_mask = 1 query.collision_mask = 1
@ -25,5 +28,18 @@ func _try_set_move_target(screen_pos: Vector2) -> void:
if hit.is_empty(): if hit.is_empty():
return return
var collider: Variant = hit.get("collider") var collider: Variant = hit.get("collider")
if collider is Node and (collider as Node).is_in_group("walkable"): if not _collider_is_walkable(collider):
_nav_agent.target_position = hit.position return
var hit_pos: Variant = hit.get("position")
if hit_pos is Vector3:
_player.set_move_goal(hit_pos as Vector3)
func _collider_is_walkable(collider: Variant) -> bool:
var n: Node = collider as Node
while n:
if n.is_in_group("walkable"):
return true
n = n.get_parent()
return false

View File

@ -0,0 +1 @@
uid://bh04b3iify0hd

View File

@ -1,30 +1,30 @@
extends CharacterBody3D extends CharacterBody3D
## NS-14: drives CharacterBody3D toward NavigationAgent3D path. Client-only; not server-authoritative. ## NS-14: click-to-move using horizontal steering + move_and_slide (not NavigationAgent3D).
## Obstacles are handled by physics sliding, not pathfinding — see client README.
const MOVE_SPEED: float = 5.0 const MOVE_SPEED: float = 5.0
const ARRIVE_EPS: float = 0.35
@onready var _nav: NavigationAgent3D = $NavigationAgent3D var _goal: Vector3
func _ready() -> void: func _ready() -> void:
# Map sync: avoid querying paths before the navigation map is ready. _goal = global_position
await get_tree().physics_frame
_nav.target_position = global_position
func set_move_goal(world_pos: Vector3) -> void:
_goal = world_pos
_goal.y = global_position.y
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if _nav.is_navigation_finished(): var to_goal: Vector3 = _goal - global_position
velocity = Vector3.ZERO to_goal.y = 0.0
move_and_slide() if to_goal.length() <= ARRIVE_EPS:
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 velocity = Vector3.ZERO
else: else:
velocity = dir.normalized() * MOVE_SPEED velocity = to_goal.normalized() * MOVE_SPEED
velocity.y = 0.0
move_and_slide() move_and_slide()

View File

@ -0,0 +1 @@
uid://1jimgt3d4bjj

View File

@ -24,6 +24,8 @@
## Client: Godot 4 ## Client: Godot 4
The [`client/`](../../client/) Godot project targets **4.6** (`config/features` in `project.godot`). Older 4.x may work but 4.6 is the baseline for editor and manual testing.
**Why** **Why**
- Strong fit for solo iteration: fast reload, small install, no revenue cap. - Strong fit for solo iteration: fast reload, small install, no revenue cap.

View File

@ -26,9 +26,9 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [x] Clicking walkable ground moves the avatar to the destination without requiring WASD (mouse-only locomotion for this prototype). - [ ] 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). - [ ] 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. - [ ] README or an in-editor scene note states this behavior is provisional until authoritative sync lands.
## Technical approach ## Technical approach