bd44ecba14
Real client-side networking for the player controller, verified with a genuine two-process integration test (an actual dedicated server process + a real ENet-connected client), per docs/architecture/networking.md §5. scripts/network_client.gd: NetworkClient -- predicts movement locally on input (same movement constants as scripts/player_spawner.gd), sends input to the server via RPC, and reconciles against the server's authoritative snapshots when they diverge beyond a threshold. scripts/network_receiver_proxy.gd + network_player_proxy.gd: the RPC plumbing that makes this actually route correctly, which took real debugging to get right: - Godot's rpc_id() resolves the receiving node by matching the sending node's NodePath between peers. scripts/server.gd is the SceneTree root node "Server" when the dedicated server runs, and spawns each player as "Player_<peer_id>" under itself -- so the client needs its own node tree at those same paths purely for RPC routing. - Godot also enforces an RPC "node checksum": both peers must declare the *same set* of @rpc methods at a matching path, or every RPC at that path fails, not just a genuinely missing one. Every @rpc method on scripts/server.gd's root and scripts/player_spawner.gd had to be mirrored here (mostly as no-op stubs), not just the ones this class actually calls. Known, explicitly-scoped simplification (stated in the code, not hidden): scripts/server.gd's snapshot doesn't currently echo back a per-peer "last input seq processed", so reconciliation corrects against the most recent prediction rather than true tick-exact rollback-and-replay (networking.md §5.2 step 4). A real follow-up once the server echoes that ack, not faked here. Verified: real two-process test (godot --headless --path . scenes/ server.tscn as an actual persistent process, a second process running scripts/smoke_test_network_client.gd against it over real ENet) -- 4/4 passing: connects, predicts locally before any server round trip, sends real input, and a deliberately-desynced position (moved 9999 units away) gets corrected by the very next real snapshot. Full existing client suite (10 suites) + 61 backend pytest still passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
39 lines
1.7 KiB
GDScript
39 lines
1.7 KiB
GDScript
extends Node
|
|
## Client-side proxy node, attached at runtime to a Node named "Server" at
|
|
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
|
|
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
|
|
## network_client.gd's class docstring for the full explanation of why this
|
|
## has to exist at this exact path. Not a real server in any sense.
|
|
##
|
|
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
|
|
## must declare the *same set* of @rpc methods at a matching path, or every
|
|
## RPC at that path (not just the mismatched one) starts failing with
|
|
## "rpc node checksum failed". So every @rpc method scripts/server.gd
|
|
## declares on its own root "Server" node has to be mirrored here too, even
|
|
## the ones this proxy never actually needs to receive.
|
|
|
|
var owner_client: Node = null
|
|
|
|
|
|
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
|
|
## exactly -- Godot enforces the receiving method's declared mode/transfer
|
|
## config, a mismatch here would silently drop the call.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
|
if owner_client and is_instance_valid(owner_client):
|
|
owner_client._handle_snapshot(snapshot_data)
|
|
|
|
|
|
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
|
|
## (the server calls it on a Player_<id> node's own rpc_id target, not this
|
|
## root proxy), declared only so the RPC checksum for this path matches.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _kick(_reason: String) -> void:
|
|
pass
|
|
|
|
|
|
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
|
|
@rpc("call_remote", "reliable")
|
|
func _disconnect(_reason: String) -> void:
|
|
pass
|