NS-23: NavigationAgent3D path-follow with server move authority
parent
1f4e3c49f6
commit
0ac059fa3b
|
|
@ -9,20 +9,21 @@ Open this **`client/`** directory as a project in **Godot 4.6** (4.x compatible)
|
|||
|
||||
Do not grow an all-in-one **`main.gd`**. The main scene root should **compose** child nodes and scripts; put picking, server sync, and similar features in **dedicated scripts** (typically under `scripts/`) and connect via **signals** or small APIs. Use **Autoloads** only when several scenes truly need the same global. Full guidance for contributors and tooling: [`.cursor/rules/godot-client-script-organization.md`](../.cursor/rules/godot-client-script-organization.md).
|
||||
|
||||
## Authoritative movement (NS-16)
|
||||
## Authoritative movement (NS-16, NS-23)
|
||||
|
||||
With the game server running ([`server/README.md`](../server/README.md)), the client sends a **`MoveCommand`** over HTTP and **snaps** the avatar to the server’s **`PositionState`** after a follow-up **`GET`**.
|
||||
With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NS-16/19); the client **walks** along a **baked navigation mesh** (`NavigationRegion3D` + **`NavigationAgent3D`** on the player) toward that verified position instead of teleporting on the **`GET`** (NS-23). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
|
||||
|
||||
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify), thin `scripts/main.gd` wiring.
|
||||
- **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`** so **`bake_navigation_mesh()`** (called from `main.gd` after one **`process_frame`**) picks up floor, NS-19 props, and the **`Obstacle`** cube. After moving floor or obstacles, open the scene in the editor and **Bake NavigationMesh** on the region if you need to refresh cached mesh data.
|
||||
- **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`).
|
||||
- On startup, **`sync_from_server()`** runs once so the capsule matches the server before you click.
|
||||
|
||||
### Manual check (NS-16)
|
||||
### Manual check (NS-16 + NS-23)
|
||||
|
||||
1. From repo root: `cd server/NeonSprawl.Server && dotnet run` (note the URL/port, usually `http://localhost:5253`).
|
||||
2. If the port differs, set **`base_url`** on **`PositionAuthorityClient`** accordingly (e.g. `http://127.0.0.1:5253`).
|
||||
3. Open the client in Godot and run the main scene (**F5**). The player should jump 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 POSTs the target, then GETs position; the avatar **snaps** to the authoritative coordinates (no client-side walk to the click).
|
||||
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** around **`Obstacle`** toward the authoritative target. 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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -39,16 +40,16 @@ The main scene includes a **prototype terminal** at the map center (same world *
|
|||
2. **F5** in Godot: default spawn is out of range of the terminal; markers should stay **dim**. **Click-move** toward the center until markers **brighten** (within **3** m on the floor plane).
|
||||
3. Press **E** (input action **`interact`** in `project.godot`): Output should show **`allowed=true`** when markers glow, **`allowed=false`** with **`reasonCode=out_of_range`** when dim (if you walk back out). Interaction uses **`_input`**, not `_unhandled_input`, so keys register reliably in the embedded **Game** dock; click the game view if the editor had focus elsewhere.
|
||||
|
||||
## Movement prototype (NS-14, legacy local steering)
|
||||
## Movement prototype (NS-14 → NS-23)
|
||||
|
||||
The **`CharacterBody3D`** still uses horizontal steering + **`move_and_slide()`** in **`player.gd`**, but **NS-16** drives placement via **`snap_to_server()`** after each successful sync, so you will mostly see **snapping**, not walking to the click. Obstacle sliding from NS-14 applies only if local goals diverge from server snaps in future work.
|
||||
**`player.gd`** uses **`NavigationAgent3D.get_next_path_position()`** + **`move_and_slide()`** for horizontal motion (NS-23). **`snap_to_server()`** remains for **boot** (and would apply for any future hard reconcile).
|
||||
|
||||
- The avatar is on **physics layer 2** with **mask 1** (floor + obstacle on layer 1); the pick ray uses **mask 1** so clicks pass through the avatar and hit the floor.
|
||||
|
||||
### Manual check (NS-14 remnants)
|
||||
### Manual check (remnants)
|
||||
|
||||
1. Run the main scene (**F5**) **with server running** for NS-16 behavior above.
|
||||
2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for NS-16.
|
||||
1. Run the main scene (**F5**) **with server running** for movement behavior above.
|
||||
2. Without the server, startup sync does not apply authoritative position; behavior is undefined for this repo’s intended demo—**run the server** for the demo path.
|
||||
|
||||
### Clicks still ignored?
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,17 @@ 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 = 4294967295
|
||||
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
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1358372723]
|
||||
script = ExtResource("1_main")
|
||||
|
||||
|
|
@ -86,21 +97,24 @@ transform = Transform3D(0.819152, -0.40558, 0.40558, 0, 0.707107, 0.707107, -0.5
|
|||
current = true
|
||||
fov = 50.0
|
||||
|
||||
[node name="Floor" type="StaticBody3D" parent="World" unique_id=1800743112 groups=["walkable"]]
|
||||
[node name="NavigationRegion3D" type="NavigationRegion3D" parent="World" unique_id=8880001]
|
||||
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/Floor" unique_id=152652175]
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/Floor" unique_id=152652175]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||
mesh = SubResource("BoxMesh_floor")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/Floor" unique_id=409532142]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/Floor" unique_id=409532142]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.1, 0)
|
||||
shape = SubResource("BoxShape3D_floor")
|
||||
|
||||
[node name="PrototypeTerminal" type="StaticBody3D" parent="World" unique_id=1700001]
|
||||
[node name="PrototypeTerminal" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1700001]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/PrototypeTerminal" unique_id=1700002]
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NavigationRegion3D/PrototypeTerminal" unique_id=1700002]
|
||||
mesh = SubResource("BoxMesh_terminal")
|
||||
surface_material_override/0 = SubResource("Mat_terminal")
|
||||
|
||||
|
|
@ -117,61 +131,61 @@ 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" unique_id=1900001 groups=["walkable"]]
|
||||
[node name="NS19BumpA" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900001 groups=["walkable"]]
|
||||
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/NS19BumpA" unique_id=1900002]
|
||||
[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/NS19BumpA" unique_id=1900003]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpA" unique_id=1900003]
|
||||
shape = SubResource("BoxShape3D_ns19_bump_a")
|
||||
|
||||
[node name="NS19BumpB" type="StaticBody3D" parent="World" unique_id=1900011 groups=["walkable"]]
|
||||
[node name="NS19BumpB" type="StaticBody3D" parent="World/NavigationRegion3D" unique_id=1900011 groups=["walkable"]]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.85, 0.06, -4.1)
|
||||
collision_layer = 1
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="World/NS19BumpB" unique_id=1900012]
|
||||
[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/NS19BumpB" unique_id=1900013]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19BumpB" unique_id=1900013]
|
||||
shape = SubResource("BoxShape3D_ns19_bump_b")
|
||||
|
||||
[node name="NS19RejectPedestal" type="StaticBody3D" parent="World" unique_id=1900021 groups=["walkable"]]
|
||||
[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/NS19RejectPedestal" unique_id=1900022]
|
||||
[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/NS19RejectPedestal" unique_id=1900023]
|
||||
[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" unique_id=1900031 groups=["walkable"]]
|
||||
[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/NS19FarPad" unique_id=1900032]
|
||||
[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/NS19FarPad" unique_id=1900033]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="World/NavigationRegion3D/NS19FarPad" unique_id=1900033]
|
||||
shape = SubResource("BoxShape3D_ns19_farpad")
|
||||
|
||||
[node name="Obstacle" type="StaticBody3D" parent="World" unique_id=1638845763]
|
||||
[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/Obstacle" unique_id=750473628]
|
||||
[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/Obstacle" unique_id=1344302688]
|
||||
[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")
|
||||
|
||||
|
|
@ -184,6 +198,14 @@ script = ExtResource("2_player")
|
|||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1695755590]
|
||||
shape = SubResource("CapsuleShape3D_player")
|
||||
|
||||
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="Player" unique_id=8880002]
|
||||
avoidance_enabled = false
|
||||
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="Player" unique_id=2027034386]
|
||||
mesh = SubResource("CapsuleMesh_player")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,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.
|
||||
|
||||
const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
|||
var _move_reject_msg_token: int = 0
|
||||
|
||||
@onready var _camera: Camera3D = $World/Camera3D
|
||||
@onready var _nav_region: NavigationRegion3D = $World/NavigationRegion3D
|
||||
@onready var _player: CharacterBody3D = $Player
|
||||
@onready var _ground_pick: Node3D = $GroundPick
|
||||
@onready var _authority: Node = $PositionAuthorityClient
|
||||
|
|
@ -17,6 +19,8 @@ var _move_reject_msg_token: int = 0
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
await get_tree().process_frame
|
||||
_nav_region.bake_navigation_mesh()
|
||||
_ground_pick.set("fallback_camera", _camera)
|
||||
_ground_pick.connect("target_chosen", Callable(self, "_on_target_chosen"))
|
||||
_authority.connect(
|
||||
|
|
@ -32,11 +36,16 @@ func _on_target_chosen(world: Vector3) -> void:
|
|||
_authority.call("submit_move_target", world)
|
||||
|
||||
|
||||
func _on_authoritative_position(world: Vector3) -> void:
|
||||
_player.snap_to_server(world)
|
||||
func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
|
||||
if apply_as_snap:
|
||||
_player.snap_to_server(world)
|
||||
else:
|
||||
_player.set_authoritative_nav_goal(world)
|
||||
|
||||
|
||||
func _on_move_rejected(reason_code: String) -> void:
|
||||
# Rejected POST never reached VERIFY_GET, so no new nav target was applied; do not clear an
|
||||
# in-flight path from a prior successful move.
|
||||
push_warning("Move rejected: %s" % reason_code)
|
||||
_move_reject_msg_token += 1
|
||||
var token: int = _move_reject_msg_token
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
extends CharacterBody3D
|
||||
|
||||
## NS-14: click-to-move using horizontal steering + move_and_slide (not NavigationAgent3D).
|
||||
## Obstacles are handled by physics sliding, not pathfinding — see client README.
|
||||
## NS-23: `NavigationAgent3D` path-follow for click-to-move visuals. Server owns `PositionState` /
|
||||
## `MoveCommand` (NS-16, NS-19); boot snap vs post-verify nav goal from `main.gd`.
|
||||
|
||||
const MOVE_SPEED: float = 5.0
|
||||
const ARRIVE_EPS: float = 0.35
|
||||
|
||||
var _goal: Vector3
|
||||
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_goal = global_position
|
||||
_nav_agent.target_position = global_position
|
||||
|
||||
|
||||
func set_move_goal(world_pos: Vector3) -> void:
|
||||
_goal = world_pos
|
||||
_goal.y = global_position.y
|
||||
## After successful move POST + GET: walk to server target without teleporting.
|
||||
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
|
||||
_nav_agent.target_position = world_pos
|
||||
|
||||
|
||||
## NS-16: snap to authoritative server position; clears velocity so `move_and_slide` does not fight
|
||||
## the sync.
|
||||
## NS-19 rejection or cancel pending path without moving the body.
|
||||
func clear_nav_goal() -> void:
|
||||
velocity = Vector3.ZERO
|
||||
_nav_agent.target_position = global_position
|
||||
|
||||
|
||||
## Boot (and any hard reconcile): teleport to server position and reset agent.
|
||||
func snap_to_server(world_pos: Vector3) -> void:
|
||||
global_position = world_pos
|
||||
velocity = Vector3.ZERO
|
||||
_goal = world_pos
|
||||
_nav_agent.target_position = world_pos
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
var to_goal: Vector3 = _goal - global_position
|
||||
to_goal.y = 0.0
|
||||
if to_goal.length() <= ARRIVE_EPS:
|
||||
if _nav_agent.is_navigation_finished():
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
var next_path_position: Vector3 = _nav_agent.get_next_path_position()
|
||||
var to_next: Vector3 = next_path_position - global_position
|
||||
to_next.y = 0.0
|
||||
if to_next.length_squared() < 0.0001:
|
||||
velocity = Vector3.ZERO
|
||||
else:
|
||||
velocity = to_goal.normalized() * MOVE_SPEED
|
||||
velocity = to_next.normalized() * MOVE_SPEED
|
||||
velocity.y = 0.0
|
||||
move_and_slide()
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ extends Node
|
|||
|
||||
## NS-16: POST MoveCommand then GET PositionState; emits authoritative world position for the
|
||||
## avatar. NS-19: failed validation (HTTP 400 + rejection body) emits `move_rejected`.
|
||||
## NS-23: second signal arg — `true` = boot snap, `false` = post-move (nav goal, no teleport).
|
||||
## (No `class_name` so headless/CI can load the project before `.godot` import exists.)
|
||||
|
||||
signal authoritative_position_received(world: Vector3)
|
||||
signal authoritative_position_received(world: Vector3, apply_as_snap: bool)
|
||||
signal move_rejected(reason_code: String)
|
||||
|
||||
enum Phase { BOOT_GET, POST_MOVE, VERIFY_GET }
|
||||
|
|
@ -79,7 +80,7 @@ func _on_request_completed(
|
|||
Phase.BOOT_GET:
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
_emit_position_from_response(text)
|
||||
_emit_position_from_response(text, true)
|
||||
Phase.POST_MOVE:
|
||||
if response_code == 400:
|
||||
_emit_move_rejection_if_present(text)
|
||||
|
|
@ -96,7 +97,7 @@ func _on_request_completed(
|
|||
Phase.VERIFY_GET:
|
||||
_busy = false
|
||||
if response_code == 200:
|
||||
_emit_position_from_response(text)
|
||||
_emit_position_from_response(text, false)
|
||||
|
||||
|
||||
func _emit_move_rejection_if_present(json_text: String) -> void:
|
||||
|
|
@ -110,7 +111,7 @@ func _emit_move_rejection_if_present(json_text: String) -> void:
|
|||
move_rejected.emit("unknown")
|
||||
|
||||
|
||||
func _emit_position_from_response(json_text: String) -> void:
|
||||
func _emit_position_from_response(json_text: String, apply_as_snap: bool) -> void:
|
||||
var parsed: Variant = JSON.parse_string(json_text)
|
||||
if parsed == null:
|
||||
return
|
||||
|
|
@ -124,4 +125,4 @@ func _emit_position_from_response(json_text: String) -> void:
|
|||
var x: float = float(pos.get("x", 0.0))
|
||||
var y: float = float(pos.get("y", 0.0))
|
||||
var z: float = float(pos.get("z", 0.0))
|
||||
authoritative_position_received.emit(Vector3(x, y, z))
|
||||
authoritative_position_received.emit(Vector3(x, y, z), apply_as_snap)
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ Contract readiness is tracked in the [module dependency register](module_depende
|
|||
|
||||
## Implementation snapshot
|
||||
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client submits move and snaps to server after GET ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17).
|
||||
- **In progress:** **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow baseline while keeping **`MoveCommand`** / **`GET`** authority ([NS-23](../../plans/NS-23-implementation-plan.md)).
|
||||
- **Done (prototype):** Server-side authoritative **`PositionState`** read + **`MoveCommand`** apply (HTTP JSON v1, snap-to-target, `sequence` increments); **NS-19** server-side step + optional district bounds validation with **`reasonCode`** rejections ([NS-19](../../plans/NS-19-implementation-plan.md), `MoveCommandValidation`, [server README — Move command](../../../server/README.md#move-command-ns-16-ns-19)); default **in-memory** store; optional **PostgreSQL** persistence when `ConnectionStrings:NeonSprawl` is set ([NS-17](../../plans/NS-17-implementation-plan.md)); Godot client **`POST`/`GET`** move flow ([NS-15](../../plans/NS-15-implementation-plan.md), [NS-16](../../plans/NS-16-implementation-plan.md), `server/NeonSprawl.Server/Game/PositionState/`, `client/scripts/`). **NS-23** — client **`NavigationRegion3D` / `NavigationAgent3D`** path-follow for click-to-move visuals while server authority unchanged ([NS-23](../../plans/NS-23-implementation-plan.md); [client README](../../../client/README.md#authoritative-movement-ns-16-ns-23)). **`InteractionRequest`** + server-side horizontal range check ([NS-18](../../plans/NS-18-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`, [server README — Interaction](../../../server/README.md#interaction-ns-18)). See [server README — Position persistence](../../../server/README.md#position-persistence-ns-17).
|
||||
- **Not yet:** Prediction/reconciliation, full Epic 1 Slice 1 movement loop and telemetry.
|
||||
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
|
||||
5. **Godot client** — **Split scripts by concern** (required repo policy: [godot-client-script-organization.md](../../.cursor/rules/godot-client-script-organization.md)); do **not** grow a monolithic `main.gd` with pick + HTTP + wiring.
|
||||
- **`ground_pick.gd`** on a new **`Node3D`** child (e.g. `GroundPick`): owns walkable **ray pick** (logic moved out of NS-14 `main.gd`). Exposes **`target_chosen(Vector3)`** when the user clicks valid ground. **`main.gd`** sets **`fallback_camera`** (or equivalent) in `_ready`.
|
||||
- **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3)`** so **`main`** only snaps the player.
|
||||
- **`position_authority_client.gd`** on a new **`Node`** child: `@export` **base URL** (default `http://127.0.0.1:5253`, match `launchSettings.json`) and **dev player id** (`dev-local-1`). Creates **`HTTPRequest`** in **`_ready`** via **`add_child`** (not as a node hand-placed in `main.tscn`). Flow: **`POST`** `MoveCommand`, then **`GET`** position (tests must show **GET** reflects apply). Emits **`authoritative_position_received(Vector3, bool)`** (NS-23: second arg = boot snap vs nav goal) so **`main`** snaps on boot and path-follows after move verify.
|
||||
- **`main.gd`:** Thin composer only—connect signals, call **`sync_from_server()`** on run, delegate pick and HTTP to the two child scripts.
|
||||
- Align **client README** with NS-14 note: server authority path replaces pure local steering for this demo.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls).
|
||||
- [ ] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout).
|
||||
- [ ] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice.
|
||||
- [x] Clicking a valid floor target produces **continuous motion** along a plausible path around static obstacles in the prototype scene (not sliding through walls).
|
||||
- [x] Authoritative position still **converges** with the server (`MoveCommand` + `GET`); no regression on NS-19 rejection UX (`reasonCode` / label timeout).
|
||||
- [x] Short note in **`server/README.md`** and/or **client script header**: client nav vs server authority for this slice.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ Unknown player ids return **404**. Full zone sync / replication is still out of
|
|||
|
||||
**`POST /game/players/{id}/move`** with JSON body applies a v1 **MoveCommand**: authoritative position **snaps** to `target` immediately; `sequence` in responses increases by one per successful apply.
|
||||
|
||||
**Client navigation (NS-23):** The Godot prototype uses a **baked navigation mesh** only for **presentation**—path-follow around static geometry. The server does **not** simulate navmesh; it validates **straight-line** step limits (NS-19) from the **last authoritative position** to the **requested target**. Cheating or divergent paths are out of scope for this slice.
|
||||
|
||||
**NS-19 validation (reject-only):** Before applying, the server checks the move against **`Game:MovementValidation`**. Limits use **horizontal** distance on **X/Z** only and **|ΔY|** separately (see `MoveCommandValidation`). Unknown players return **404** before validation.
|
||||
|
||||
| Config key | Meaning | Default (see `appsettings.json`) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue