WIP checkpoint: Client-side position sync + prediction/reconciliation
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
class_name NetworkClient
|
||||
extends Node
|
||||
## Client-side networked player: input prediction + server reconciliation.
|
||||
##
|
||||
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
|
||||
## for the *local* player only -- remote-entity interpolation (§5.3) and full
|
||||
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
|
||||
## for this first pass; the server already only ever has one player in PC2's
|
||||
## single-peer testing, and this task's own description scopes it to "the
|
||||
## player controller built in the dependency task" (singular).
|
||||
##
|
||||
## RPC path-matching, the part that makes this actually work:
|
||||
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
|
||||
## *sending* node's NodePath on the caller's side. The server (scripts/
|
||||
## server.gd) is the SceneTree root node named "Server" when the dedicated
|
||||
## server runs scenes/server.tscn as its main scene, and it spawns each
|
||||
## connected player as a *child* of itself named "Player_<peer_id>"
|
||||
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
|
||||
## actually land on the right node, the client needs a LOCAL node tree at the
|
||||
## exact same paths -- /root/Server for _receive_snapshot (called by the
|
||||
## server on itself), and /root/Server/Player_<own_peer_id> for
|
||||
## rpc_request_input (a real method on the server's PlayerNode). Those local
|
||||
## nodes don't need to run any server logic -- they only need to exist at the
|
||||
## right path so Godot's RPC routing finds them. connect_to_server() builds
|
||||
## that structure once the peer id is known (only available after the ENet
|
||||
## handshake completes).
|
||||
##
|
||||
## Usage: instantiate directly (not an autoload -- this only exists for the
|
||||
## lifetime of an actual multiplayer session, not on every client launch):
|
||||
## var net := NetworkClient.new()
|
||||
## get_tree().root.add_child(net)
|
||||
## var ok := await net.connect_to_server("127.0.0.1", 7777)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
|
||||
# docs/architecture/networking.md §5.1 ("the same physics constants as the
|
||||
# server"). A mismatch here doesn't break anything outright, it just makes
|
||||
# reconciliation corrections more frequent/visible.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WALK_SPEED: float = 5.0
|
||||
const JUMP_VELOCITY: float = 4.5
|
||||
const GRAVITY: float = 9.8
|
||||
|
||||
## How far predicted and authoritative position can drift before snapping
|
||||
## rather than trusting the prediction was "close enough." Per §5.2.
|
||||
const RECONCILE_ERROR_THRESHOLD: float = 0.5
|
||||
|
||||
## How many past predicted states to keep for reconciliation replay. Real
|
||||
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
|
||||
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
|
||||
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
|
||||
const PREDICTION_BUFFER_SIZE: int = 120
|
||||
|
||||
signal reconciled(correction_distance: float)
|
||||
signal connected_to_server
|
||||
signal connection_failed
|
||||
|
||||
|
||||
var peer: ENetMultiplayerPeer
|
||||
var _server_root: Node # local /root/Server proxy
|
||||
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
|
||||
|
||||
var _seq: int = 0
|
||||
## Ring buffer of {seq, input, position, velocity} predicted at send time,
|
||||
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
|
||||
var _prediction_buffer: Array[Dictionary] = []
|
||||
|
||||
var position: Vector3 = Vector3.ZERO
|
||||
var velocity: Vector3 = Vector3.ZERO
|
||||
var _on_floor: bool = false
|
||||
|
||||
|
||||
func connect_to_server(host: String, port: int) -> bool:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(host, port)
|
||||
if err != OK:
|
||||
push_error("NetworkClient: create_client failed: %d" % err)
|
||||
connection_failed.emit()
|
||||
return false
|
||||
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(func():
|
||||
connection_failed.emit()
|
||||
)
|
||||
|
||||
for i in 300: # ~5s at 60fps
|
||||
await get_tree().process_frame
|
||||
var status := peer.get_connection_status()
|
||||
if status == MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return true
|
||||
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||
connection_failed.emit()
|
||||
return false
|
||||
connection_failed.emit()
|
||||
return false
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
|
||||
# Build the local proxy tree at the exact paths the server's own RPC
|
||||
# calls target -- see the class docstring for why this has to exist.
|
||||
_server_root = Node.new()
|
||||
_server_root.name = "Server"
|
||||
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
|
||||
get_tree().root.add_child(_server_root)
|
||||
_server_root.set("owner_client", self)
|
||||
|
||||
_player_proxy = Node.new()
|
||||
_player_proxy.name = "Player_%d" % my_id
|
||||
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
|
||||
_server_root.add_child(_player_proxy)
|
||||
|
||||
connected_to_server.emit()
|
||||
|
||||
|
||||
func disconnect_from_server() -> void:
|
||||
if peer:
|
||||
peer.close()
|
||||
if _server_root and is_instance_valid(_server_root):
|
||||
_server_root.queue_free()
|
||||
multiplayer.multiplayer_peer = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prediction -- called every physics frame by whatever drives input (a real
|
||||
# controller, or a test harness). Applies input locally immediately (so it
|
||||
# renders with zero perceived latency) and sends it to the server, per §5.1.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||
_apply_movement(delta, move_x, move_z, jump)
|
||||
|
||||
_seq += 1
|
||||
var record := {
|
||||
"seq": _seq,
|
||||
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
|
||||
"position": position,
|
||||
"velocity": velocity,
|
||||
}
|
||||
_prediction_buffer.append(record)
|
||||
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
|
||||
_prediction_buffer.pop_front()
|
||||
|
||||
if _player_proxy and is_instance_valid(_player_proxy):
|
||||
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
|
||||
|
||||
|
||||
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
|
||||
# intentionally without its ground-friction smoothing -- friction is a
|
||||
# server-authoritative-feel detail, not something worth risking a
|
||||
# prediction/server mismatch over in this first pass. Position still
|
||||
# reconciles against the server's real (frictioned) result every
|
||||
# snapshot, so this doesn't silently drift.
|
||||
if not _on_floor:
|
||||
velocity.y -= GRAVITY * delta
|
||||
|
||||
var direction := Vector3(move_x, 0.0, move_z)
|
||||
if direction.length() > 0.01:
|
||||
direction = direction.normalized()
|
||||
velocity.x = direction.x * WALK_SPEED
|
||||
velocity.z = direction.z * WALK_SPEED
|
||||
else:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
|
||||
if jump and _on_floor:
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
position += velocity * delta
|
||||
# Headless prediction has no collision shape of its own to test
|
||||
# is_on_floor() against -- treat "at or below the spawn-plane ground"
|
||||
# as grounded. A real render-side controller would swap this for the
|
||||
# actual CharacterBody3D.is_on_floor() of whatever visual node this
|
||||
# drives; this class only owns the *networking* half.
|
||||
if position.y <= 0.0:
|
||||
position.y = 0.0
|
||||
velocity.y = 0.0
|
||||
_on_floor = true
|
||||
else:
|
||||
_on_floor = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _handle_snapshot(snapshot_data: Dictionary) -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var players: Dictionary = snapshot_data.get("players", {})
|
||||
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
|
||||
# RPC payloads can arrive as String or int depending on the transport
|
||||
# encoding, so check both rather than assuming.
|
||||
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
|
||||
if my_state == null or not (my_state is Dictionary):
|
||||
return
|
||||
|
||||
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
|
||||
var acked_seq: int = snapshot_data.get("tick", 0)
|
||||
|
||||
# Find our own predicted position at (approximately) the acknowledged
|
||||
# tick -- the buffer is seq-ordered, so the first entry >= acked_seq is
|
||||
# the comparison point. If the server hasn't caught up to any of our
|
||||
# sent input yet (empty/very early buffer), nothing to reconcile.
|
||||
var predicted_entry: Dictionary = {}
|
||||
for entry in _prediction_buffer:
|
||||
if entry["seq"] >= acked_seq:
|
||||
predicted_entry = entry
|
||||
break
|
||||
if predicted_entry.is_empty():
|
||||
return
|
||||
|
||||
var error: float = (predicted_entry["position"] as Vector3).distance_to(authoritative_pos)
|
||||
if error <= RECONCILE_ERROR_THRESHOLD:
|
||||
return # Close enough -- prediction was correct, nothing to do.
|
||||
|
||||
# Correct: snap to the authoritative position, then replay every input
|
||||
# sent after the acknowledged tick so the visible result still reflects
|
||||
# input the server hasn't processed yet (§5.2 step 4).
|
||||
position = authoritative_pos
|
||||
velocity = Vector3.ZERO
|
||||
var replay_from_index := _prediction_buffer.find(predicted_entry) + 1
|
||||
for i in range(replay_from_index, _prediction_buffer.size()):
|
||||
var entry: Dictionary = _prediction_buffer[i]
|
||||
var inp: Dictionary = entry["input"]
|
||||
_apply_movement(1.0 / 30.0, inp.get("move_x", 0.0), inp.get("move_z", 0.0), inp.get("jump", false))
|
||||
entry["position"] = position
|
||||
entry["velocity"] = velocity
|
||||
|
||||
reconciled.emit(error)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
extends Node
|
||||
## Client-side proxy for the server's real PlayerNode (scripts/
|
||||
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
|
||||
## under the /root/Server proxy by NetworkClient._on_connected().
|
||||
##
|
||||
## rpc_request_input is the only one of these actually CALLED from this
|
||||
## side -- the real implementations all live on the server. But two things
|
||||
## force every @rpc method on the real PlayerNode to be mirrored here too,
|
||||
## not just the one this class uses:
|
||||
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
|
||||
## from the LOCAL script before it will even send a call, so the method
|
||||
## being called has to be declared locally with a matching annotation.
|
||||
## 2. Godot's multiplayer layer also 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 starts failing, not just the one
|
||||
## that's actually missing. So even rpc_request_interact/attack/
|
||||
## set_position/set_rotation, which this class never calls, have to be
|
||||
## declared here with matching annotations or rpc_request_input itself
|
||||
## breaks too.
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
|
||||
pass # Never invoked locally -- see the note above.
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_interact(_target_id: int) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_position(_pos: Vector3) -> void:
|
||||
pass
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func rpc_set_rotation(_rot: Vector3) -> void:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
@@ -0,0 +1,101 @@
|
||||
extends SceneTree
|
||||
## Real integration test for NetworkClient (client-side prediction +
|
||||
## reconciliation) against an actual dedicated server process.
|
||||
##
|
||||
## This does NOT boot its own server -- run scenes/server.tscn as a separate
|
||||
## process first (see docs/architecture/networking.md's ENet/port setup):
|
||||
## godot --headless --path . scenes/server.tscn &
|
||||
## then, from the same project directory:
|
||||
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
|
||||
##
|
||||
## Verifies: the client can actually connect over ENet, local prediction
|
||||
## moves the player immediately on input (no waiting for the server),
|
||||
## server snapshots actually arrive and update the client's authoritative
|
||||
## view, and a deliberately-desynced predicted position gets corrected by
|
||||
## reconciliation.
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
var net := NetworkClient.new()
|
||||
root.add_child(net)
|
||||
|
||||
var ok: bool = await net.connect_to_server("127.0.0.1", 7777)
|
||||
if not _check("connected to the real dedicated server", ok):
|
||||
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
|
||||
_finish()
|
||||
return
|
||||
|
||||
# Give the server a few frames to process the connection and spawn our
|
||||
# player node before we start sending input.
|
||||
for i in 10:
|
||||
await process_frame
|
||||
|
||||
# 1. Prediction: pressing "forward" should move the local position
|
||||
# immediately, without waiting for any server round trip.
|
||||
var pos_before: Vector3 = net.position
|
||||
for i in 10:
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
|
||||
await process_frame
|
||||
var pos_after_predict: Vector3 = net.position
|
||||
_check("local prediction moved the player without waiting for the server",
|
||||
pos_after_predict.distance_to(pos_before) > 0.1)
|
||||
|
||||
# 2. Snapshots actually arrive from the real server and get processed --
|
||||
# wait for at least one reconciliation-eligible snapshot by giving it
|
||||
# real time (server broadcasts at its own tick rate).
|
||||
var got_snapshot := false
|
||||
net.reconciled.connect(func(_err): got_snapshot = true)
|
||||
for i in 60:
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
|
||||
await process_frame
|
||||
# Reconciliation only actually fires on a real mismatch -- absence of a
|
||||
# large mismatch is a PASS outcome too (means prediction tracked the
|
||||
# server closely), so this alone isn't proof snapshots arrived. Check
|
||||
# that directly instead:
|
||||
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
|
||||
|
||||
# 3. Deliberately desync prediction from reality, then confirm the next
|
||||
# snapshot corrects it -- the real proof reconciliation logic works,
|
||||
# not just that it never had to fire.
|
||||
net.position = Vector3(9999, 9999, 9999)
|
||||
var desynced_pos := net.position
|
||||
var reconciled_this_round := false
|
||||
var correction_seen: float = -1.0
|
||||
var reconcile_handler := func(err): reconciled_this_round = true; correction_seen = err
|
||||
net.reconciled.connect(reconcile_handler)
|
||||
for i in 60:
|
||||
await process_frame
|
||||
if reconciled_this_round:
|
||||
break
|
||||
net.reconciled.disconnect(reconcile_handler)
|
||||
_check("a deliberately desynced position gets corrected by the next snapshot",
|
||||
reconciled_this_round and net.position.distance_to(desynced_pos) > 100.0)
|
||||
if reconciled_this_round:
|
||||
print(" correction distance was ~", correction_seen)
|
||||
|
||||
net.disconnect_from_server()
|
||||
_finish()
|
||||
|
||||
|
||||
func _check(description: String, condition: bool) -> bool:
|
||||
if condition:
|
||||
print(" PASS: ", description)
|
||||
passed += 1
|
||||
else:
|
||||
printerr(" FAIL: ", description)
|
||||
failed += 1
|
||||
return condition
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
|
||||
quit(0 if failed == 0 else 1)
|
||||
Reference in New Issue
Block a user