Files
modbison/scripts/network_client.gd

309 lines
10 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 pending_loadout: Dictionary = {}
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()
if pending_loadout.is_empty():
var api := _get_api_client()
var cur_username: String = ""
var cur_user_id: int = 0
if api != null:
cur_username = str(api.get("current_username"))
cur_user_id = int(api.get("current_user_id"))
if not cur_username.is_empty():
var default_loadout: Dictionary = {"username": cur_username}
if cur_user_id > 0:
default_loadout["user_id"] = cur_user_id
pending_loadout = default_loadout
if not pending_loadout.is_empty():
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
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)
## Send the local player's avatar loadout to the server so it can be replicated
## to other clients.
func send_loadout(loadout_dict: Dictionary) -> void:
var copy: Dictionary = loadout_dict.duplicate(true)
var api := _get_api_client()
var cur_username: String = ""
var cur_user_id: int = 0
if api != null:
cur_username = str(api.get("current_username"))
cur_user_id = int(api.get("current_user_id"))
if not copy.has("username"):
if not cur_username.is_empty():
copy["username"] = cur_username
else:
copy["username"] = "Player_%d" % my_peer_id
if cur_user_id > 0 and not copy.has("user_id"):
copy["user_id"] = cur_user_id
pending_loadout = copy
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
func _get_api_client() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null