f6d69545c9
Add client-side prediction (t_p1_pred) with: - scripts/network/snapshot.gd — lightweight snapshot resource with to_dict/from_dict serialization for RPC. Stores position (Vector3), rotation (Quaternion), velocity (Vector3), grounded (bool). - scripts/network/client_prediction.gd — prediction/reconciliation controller. 64-entry ring buffer of local snapshots, sends inputs to server each physics tick (128Hz, ENet channel 0), detects misprediction on server state arrival, rewinds and re-applies unconfirmed inputs. Also supports remote player interpolation. - scripts/network/network_manager.gd — new RPC endpoints for client prediction: send_client_input (client->server, ch 0) and send_server_state (server->client, ch 1). New signals for routing. - client/characters/character/fps_character_controller.gd — prediction hooks in _physics_process: on_before_tick() captures pre-input snapshot, on_after_tick() sends input to server. Client prediction path uses local movement (instant feedback) instead of reading from SimulationServer entity. Architecture: Each tick: client applies input → predicts new state locally → sends input to server → server returns authoritative state → client compares and reconciles if mismatch.
202 lines
7.5 KiB
GDScript
202 lines
7.5 KiB
GDScript
## NetworkManager — ENet Transport + Player Replication Singleton
|
|
##
|
|
## Autoload that wraps Godot 4's ENetMultiplayerPeer and provides
|
|
## player spawn/despawn broadcasting to all connected clients.
|
|
## Server creates RPC broadcasts; clients receive and emit signals.
|
|
##
|
|
## Architecture:
|
|
## server mode → start_server(port) → ENetMultiplayerPeer server
|
|
## client mode → join_server(host,port)→ ENetMultiplayerPeer client
|
|
## replication → _broadcast_spawn_player / _broadcast_despawn_player
|
|
## (server→all clients RPC)
|
|
##
|
|
## Channels (3-lane layout per Phase 0 research):
|
|
## 0 unreliable-ordered → 128Hz input / transform deltas
|
|
## 1 reliable-ordered → game events, spawn, damage, chat
|
|
## 2 unreliable → telemetry / VOIP metadata
|
|
|
|
extends Node
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
signal server_started(port: int)
|
|
signal server_stopped()
|
|
signal player_connected(peer_id: int)
|
|
signal player_disconnected(peer_id: int)
|
|
signal connection_succeeded()
|
|
signal connection_failed(error_message: String)
|
|
|
|
# --- Player replication signals (emitted on all peers after RPC broadcast) ---
|
|
signal remote_player_spawned(peer_id: int, pos: Vector3)
|
|
signal remote_player_despawned(peer_id: int)
|
|
|
|
# --- Client prediction signals ---
|
|
## Emitted on the server when a client sends input. payload: {peer_id, tick, input_dict}
|
|
signal client_input_received(peer_id: int, tick: int, input_dict: Dictionary)
|
|
## Emitted on the client when the server sends authoritative state.
|
|
## entity_id: the simulation entity this state belongs to.
|
|
signal server_state_received(entity_id: int, snapshot_dict: Dictionary)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
const DEFAULT_PORT: int = 34197
|
|
const CHANNELS: int = 3 # 0=input, 1=events, 2=telemetry
|
|
|
|
# Max clients is read from ServerConfig at start_server() time.
|
|
# This default is used before ServerConfig is available.
|
|
var max_clients: int = 16
|
|
|
|
# Channel indices
|
|
enum Chan {
|
|
INPUT = 0,
|
|
EVENTS = 1,
|
|
TELEMETRY = 2,
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
var is_server: bool = false : get = _is_server
|
|
var is_client: bool = false : get = _is_client
|
|
var peer: ENetMultiplayerPeer = null
|
|
|
|
func _is_server() -> bool:
|
|
return is_server
|
|
|
|
func _is_client() -> bool:
|
|
return is_client
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server API
|
|
# ---------------------------------------------------------------------------
|
|
## Start a dedicated server on [port].
|
|
## Uses max_clients (which should be set from ServerConfig before calling).
|
|
## Returns OK or ERR_* on failure.
|
|
func start_server(port: int = DEFAULT_PORT) -> Error:
|
|
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
|
|
stop()
|
|
|
|
# If ServerConfig is available, use it for max_clients
|
|
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
|
|
max_clients = ServerConfig.max_players
|
|
|
|
peer = ENetMultiplayerPeer.new()
|
|
peer.set_bind_ip("*")
|
|
|
|
var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0)
|
|
if err != OK:
|
|
peer = null
|
|
return err
|
|
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected)
|
|
|
|
is_server = true
|
|
server_started.emit(port)
|
|
print("[NetworkManager] Server started on port %d" % port)
|
|
return OK
|
|
|
|
## Stop the server / disconnect.
|
|
func stop() -> void:
|
|
if not peer:
|
|
return
|
|
|
|
if is_server:
|
|
multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected)
|
|
multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected)
|
|
|
|
peer.close()
|
|
multiplayer.multiplayer_peer = null
|
|
peer = null
|
|
is_server = false
|
|
is_client = false
|
|
server_stopped.emit()
|
|
print("[NetworkManager] Stopped")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client API
|
|
# ---------------------------------------------------------------------------
|
|
## Connect to a remote server.
|
|
func join_server(host: String, port: int = DEFAULT_PORT) -> Error:
|
|
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
|
|
stop()
|
|
|
|
peer = ENetMultiplayerPeer.new()
|
|
var err: Error = peer.create_client(host, port, CHANNELS, 0, 0)
|
|
if err != OK:
|
|
peer = null
|
|
return err
|
|
|
|
multiplayer.multiplayer_peer = peer
|
|
connection_succeeded.emit()
|
|
print("[NetworkManager] Connecting to %s:%d ..." % [host, port])
|
|
|
|
is_client = true
|
|
return OK
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Player Replication RPCs (broadcast server → all clients)
|
|
# ---------------------------------------------------------------------------
|
|
## Server calls this when a new player joins.
|
|
## Broadcasts to all clients so they can create a visual player node.
|
|
@rpc("authority", "call_local", "reliable")
|
|
func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void:
|
|
if not multiplayer.is_server():
|
|
print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
|
|
remote_player_spawned.emit(peer_id, pos)
|
|
|
|
## Server calls this when a player leaves.
|
|
@rpc("authority", "call_local", "reliable")
|
|
func broadcast_despawn_player(peer_id: int) -> void:
|
|
if not multiplayer.is_server():
|
|
print("[NetworkManager] Client received despawn: peer=%d" % peer_id)
|
|
remote_player_despawned.emit(peer_id)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client Prediction RPCs (Phase 1 — client-side prediction)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Client → Server: send raw input for the given local tick.
|
|
## Called by ClientPrediction.on_after_tick() each physics tick.
|
|
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
|
|
@rpc("unreliable", "any_peer", "call_remote", Chan.INPUT)
|
|
func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var peer_id: int = multiplayer.get_remote_sender_id()
|
|
client_input_received.emit(peer_id, tick, input_dict)
|
|
|
|
|
|
## Server → Client: send authoritative entity snapshot for reconciliation.
|
|
## Called by server-side code (e.g. GameServer after each tick).
|
|
## entity_id identifies which simulation entity this state belongs to.
|
|
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
|
|
@rpc("unreliable", "authority", "call_remote", Chan.EVENTS)
|
|
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
|
|
if multiplayer.is_server():
|
|
return
|
|
server_state_received.emit(entity_id, snapshot_dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event handlers
|
|
# ---------------------------------------------------------------------------
|
|
func _on_peer_connected(id: int) -> void:
|
|
print("[NetworkManager] Peer connected: %d" % id)
|
|
player_connected.emit(id)
|
|
|
|
func _on_peer_disconnected(id: int) -> void:
|
|
print("[NetworkManager] Peer disconnected: %d" % id)
|
|
player_disconnected.emit(id)
|
|
|
|
func _process(_delta: float) -> void:
|
|
# Godot does internal ENet polling via MultiplayerAPI;
|
|
# explicit polling is reserved for future custom packet handling.
|
|
pass
|
|
|
|
func _exit_tree() -> void:
|
|
stop()
|