feat: implement client-side prediction and reconciliation system

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.
This commit is contained in:
2026-07-01 20:21:16 -04:00
parent 2452aba0d7
commit f6d69545c9
4 changed files with 461 additions and 21 deletions
+33
View File
@@ -31,6 +31,13 @@ signal connection_failed(error_message: String)
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
# ---------------------------------------------------------------------------
@@ -148,6 +155,32 @@ func broadcast_despawn_player(peer_id: int) -> void:
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
# ---------------------------------------------------------------------------