261 lines
8.6 KiB
GDScript
261 lines
8.6 KiB
GDScript
class_name NetworkClient
|
|
extends Node
|
|
## Client-side multiplayer: ENet connection, local-player input prediction +
|
|
## server reconciliation (with input replay), and remote-player state for the
|
|
## game to render.
|
|
##
|
|
## RPC path-matching: Godot resolves an rpc_id() call's target by matching the
|
|
## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree
|
|
## root named "Server"; it spawns each peer's PlayerNode as a child named
|
|
## "Player_<peer_id>". So the client needs local nodes at /root/Server (for the
|
|
## server-initiated _receive_snapshot) and /root/Server/Player_<own_id> (the
|
|
## target of rpc_request_input). connect_to_server() builds that once the peer
|
|
## id is known.
|
|
##
|
|
## Usage:
|
|
## var net := NetworkClient.new()
|
|
## get_tree().root.add_child(net)
|
|
## if await net.connect_to_server(host, port):
|
|
## # each physics frame, from the local player controller:
|
|
## net.predict_and_send(delta, move_x, move_z, jump, body_yaw)
|
|
## # read net.position / net.get_remote_states() to render
|
|
|
|
# Movement constants -- MUST match scripts/server.gd and player_spawner.gd.
|
|
const WALK_SPEED := 5.0
|
|
const JUMP_VELOCITY := 4.5
|
|
const GRAVITY := 9.8
|
|
|
|
## Predicted vs authoritative drift beyond this (metres) snaps rather than
|
|
## trusting the prediction.
|
|
const RECONCILE_ERROR_THRESHOLD := 0.35
|
|
|
|
## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any
|
|
## realistic LAN RTT.
|
|
const PREDICTION_BUFFER_SIZE := 120
|
|
|
|
signal reconciled(correction_distance: float)
|
|
signal connected_to_server
|
|
signal connection_failed
|
|
signal server_disconnected
|
|
|
|
var peer: ENetMultiplayerPeer
|
|
var my_peer_id: int = 0
|
|
var _server_root: Node
|
|
var _player_proxy: Node
|
|
|
|
var _seq: int = 0
|
|
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
|
|
## oldest first. Entries are dropped as the server acks their seq.
|
|
var _prediction_buffer: Array[Dictionary] = []
|
|
|
|
## Local predicted player state.
|
|
var position: Vector3 = Vector3.ZERO
|
|
var velocity: Vector3 = Vector3.ZERO
|
|
var _on_floor: bool = true
|
|
|
|
## peer_id -> latest authoritative state dict from the most recent snapshot,
|
|
## for every peer that isn't us. The game reads this to render other players.
|
|
var _remote_states: Dictionary = {}
|
|
## Our own latest authoritative state from the most recent snapshot.
|
|
var _own_state: Dictionary = {}
|
|
var _last_snapshot_tick: int = -1
|
|
|
|
|
|
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.server_disconnected.connect(func(): server_disconnected.emit())
|
|
|
|
for _i in 300: # ~5 s at 60 fps
|
|
await get_tree().process_frame
|
|
match peer.get_connection_status():
|
|
MultiplayerPeer.CONNECTION_CONNECTED:
|
|
_on_connected()
|
|
return true
|
|
MultiplayerPeer.CONNECTION_DISCONNECTED:
|
|
connection_failed.emit()
|
|
return false
|
|
connection_failed.emit()
|
|
return false
|
|
|
|
|
|
func _on_connected() -> void:
|
|
my_peer_id = multiplayer.get_unique_id()
|
|
|
|
_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_peer_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()
|
|
if multiplayer:
|
|
multiplayer.multiplayer_peer = null
|
|
_prediction_buffer.clear()
|
|
_remote_states.clear()
|
|
|
|
|
|
func is_connected_to_server() -> bool:
|
|
return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prediction -- call every physics frame from the local player controller.
|
|
# Applies input locally immediately (zero perceived latency) and sends it.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void:
|
|
_apply_movement(delta, move_x, move_z, jump)
|
|
|
|
_seq += 1
|
|
_prediction_buffer.append({
|
|
"seq": _seq,
|
|
"move_x": move_x, "move_z": move_z, "jump": jump,
|
|
"delta": delta,
|
|
"position": position, "velocity": velocity,
|
|
})
|
|
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",
|
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
|
|
|
|
|
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
|
if not _on_floor:
|
|
velocity.y -= GRAVITY * delta
|
|
elif jump:
|
|
velocity.y = JUMP_VELOCITY
|
|
|
|
var dir := Vector3(move_x, 0.0, move_z)
|
|
if dir.length() > 0.01:
|
|
dir = dir.normalized()
|
|
velocity.x = dir.x * WALK_SPEED
|
|
velocity.z = dir.z * WALK_SPEED
|
|
else:
|
|
velocity.x = 0.0
|
|
velocity.z = 0.0
|
|
|
|
position += velocity * delta
|
|
# Headless prediction has no collision shape; treat the spawn plane as the
|
|
# floor. A render-side controller drives the real CharacterBody3D and reads
|
|
# `position` back for reconciliation only.
|
|
if position.y <= 0.0:
|
|
position.y = 0.0
|
|
velocity.y = 0.0
|
|
_on_floor = true
|
|
else:
|
|
_on_floor = false
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reconciliation -- _ReceiverProxy calls this when a snapshot arrives.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _handle_snapshot(snap: Dictionary) -> void:
|
|
var snap_tick: int = snap.get("tick", 0)
|
|
if snap_tick <= _last_snapshot_tick:
|
|
return # Stale / out-of-order (unreliable channel).
|
|
_last_snapshot_tick = snap_tick
|
|
|
|
var players: Dictionary = snap.get("players", {})
|
|
|
|
# --- Remote players ---
|
|
_remote_states.clear()
|
|
for key in players:
|
|
var pid := int(key)
|
|
if pid == my_peer_id:
|
|
continue
|
|
_remote_states[pid] = players[key]
|
|
|
|
# --- Local player: reconcile ---
|
|
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
|
|
if not (mine is Dictionary):
|
|
return
|
|
_own_state = mine
|
|
var auth_pos: Vector3 = mine.get("position", position)
|
|
var auth_vel: Vector3 = mine.get("velocity", velocity)
|
|
var acked_seq: int = int(mine.get("last_seq", 0))
|
|
|
|
# Drop inputs the server has already applied.
|
|
while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq:
|
|
_prediction_buffer.pop_front()
|
|
|
|
# Re-simulate the still-in-flight inputs from the authoritative state.
|
|
var replay_pos := auth_pos
|
|
var replay_vel := auth_vel
|
|
var replay_floor := replay_pos.y <= 0.0
|
|
for entry in _prediction_buffer:
|
|
var d: float = entry["delta"]
|
|
if not replay_floor:
|
|
replay_vel.y -= GRAVITY * d
|
|
elif entry["jump"]:
|
|
replay_vel.y = JUMP_VELOCITY
|
|
var dir := Vector3(entry["move_x"], 0.0, entry["move_z"])
|
|
if dir.length() > 0.01:
|
|
dir = dir.normalized()
|
|
replay_vel.x = dir.x * WALK_SPEED
|
|
replay_vel.z = dir.z * WALK_SPEED
|
|
else:
|
|
replay_vel.x = 0.0
|
|
replay_vel.z = 0.0
|
|
replay_pos += replay_vel * d
|
|
if replay_pos.y <= 0.0:
|
|
replay_pos.y = 0.0
|
|
replay_vel.y = 0.0
|
|
replay_floor = true
|
|
else:
|
|
replay_floor = false
|
|
|
|
var error := position.distance_to(replay_pos)
|
|
if error > RECONCILE_ERROR_THRESHOLD:
|
|
position = replay_pos
|
|
velocity = replay_vel
|
|
_on_floor = replay_floor
|
|
reconciled.emit(error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Remote-player state for rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most
|
|
## recent snapshot. Read this each frame to spawn/update remote avatars.
|
|
func get_remote_states() -> Dictionary:
|
|
return _remote_states
|
|
|
|
|
|
## Our own latest authoritative state from the most recent snapshot, or {}.
|
|
func get_own_server_state() -> Dictionary:
|
|
return _own_state
|
|
|
|
|
|
## Send an input frame to the server WITHOUT running NetworkClient's own
|
|
## flat-plane prediction -- for callers that predict locally with their own
|
|
## collision-aware CharacterBody3D (scripts/game.gd) and only want the server
|
|
## as an authority/correction backstop.
|
|
func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void:
|
|
_seq += 1
|
|
if _player_proxy and is_instance_valid(_player_proxy):
|
|
_player_proxy.rpc_id(1, "rpc_request_input",
|
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|