Files
modbison/scripts/network_client.gd
T

236 lines
9.3 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)
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)