235 lines
9.5 KiB
GDScript
235 lines
9.5 KiB
GDScript
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)
|
|
|
|
# Real limitation, noted explicitly rather than silently faked: the
|
|
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
|
|
# not currently echo back "last input seq processed for this peer" --
|
|
# its `tick` field is the server's own internal simulation tick counter,
|
|
# a completely different clock from this client's per-input `_seq`. True
|
|
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
|
|
# "re-simulate any inputs that occurred after the server's tick") needs
|
|
# that ack echoed back per-peer -- a real, scoped follow-up, not
|
|
# something to fake here. Until then, reconcile against the MOST RECENT
|
|
# prediction (not a tick-matched one): still a real error measurement
|
|
# and a real correction, just without precise replay-from-exact-tick.
|
|
if _prediction_buffer.is_empty():
|
|
return
|
|
var latest_entry: Dictionary = _prediction_buffer[-1]
|
|
|
|
var error: float = (latest_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. Without a real ack-seq
|
|
# from the server there's no reliable "replay inputs after this point"
|
|
# window to recompute, so the buffer is cleared and prediction resumes
|
|
# fresh from the corrected position on the next input -- still visibly
|
|
# responsive (no waiting for a round trip before the *next* input
|
|
# applies), just without this pass replaying in-flight inputs.
|
|
position = authoritative_pos
|
|
velocity = Vector3.ZERO
|
|
_prediction_buffer.clear()
|
|
|
|
reconciled.emit(error)
|
|
|