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.
314 lines
12 KiB
GDScript
314 lines
12 KiB
GDScript
## ClientPrediction — Client-side prediction & reconciliation for networked FPS.
|
|
##
|
|
## Architecture (tick loop):
|
|
## 1. on_before_tick() → captures a local-world Snapshot into a 64-entry
|
|
## ring buffer BEFORE input is applied.
|
|
## 2. The character controller runs its normal local movement (predicted
|
|
## simulation — instant feedback).
|
|
## 3. on_after_tick() → sends the raw input to the server via ENet
|
|
## channel 0 and advances the local tick counter.
|
|
## 4. When a server state snapshot arrives, the controller reconciles:
|
|
## a) Compares predicted state with authoritative state at that tick.
|
|
## b) If mismatch detected → emits state_mispredicted(delta).
|
|
## c) Rewinds to confirmed state, re-applies all unconfirmed inputs,
|
|
## then emits reconciled().
|
|
##
|
|
## The same node also supports INTERPOLATION MODE for remote players
|
|
## (those not owned by this peer). See set_interpolate_mode().
|
|
##
|
|
## Server-side input queue:
|
|
## This script also contains server-side logic for receiving client
|
|
## inputs and making them available to GameServer via consume_pending_inputs().
|
|
##
|
|
## Dependencies:
|
|
## - NetworkManager autoload (for RPC relay)
|
|
## - Snapshot (data container)
|
|
## - FPSCharacterController (linked via setup())
|
|
extends Node
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
const BUFFER_SIZE: int = 64
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
## Emitted when a misprediction is detected during reconciliation.
|
|
## delta_position: Vector3 — the position difference (server - predicted).
|
|
signal state_mispredicted(delta_position: Vector3)
|
|
|
|
## Emitted after successful reconciliation (state corrected).
|
|
signal reconciled()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State — Mode
|
|
# ---------------------------------------------------------------------------
|
|
## If true, this instance is actively predicting (client-side local player).
|
|
var prediction_enabled: bool = false
|
|
|
|
## If true, this instance is interpolating a remote player's position.
|
|
var interpolate_mode: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State — Prediction (local player)
|
|
# ---------------------------------------------------------------------------
|
|
## Local tick counter, incremented each physics tick.
|
|
var local_tick: int = 0
|
|
|
|
## Ring buffer of snapshots taken BEFORE input each tick.
|
|
## Indexed by tick % BUFFER_SIZE.
|
|
var _snapshot_buffer: Array[Snapshot] = []
|
|
|
|
## Pending (unconfirmed) inputs, keyed by local_tick.
|
|
## Each value is a Dictionary matching the FPSCharacterController input format.
|
|
var _pending_inputs: Dictionary = {}
|
|
|
|
## Last confirmed snapshot received from the server.
|
|
var _last_confirmed_snapshot: Snapshot = null
|
|
|
|
## Tick of the last confirmed snapshot.
|
|
var _last_confirmed_tick: int = -1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State — Interpolation (remote players)
|
|
# ---------------------------------------------------------------------------
|
|
var _prev_snapshot: Snapshot = null
|
|
var _next_snapshot: Snapshot = null
|
|
var _interp_fraction: float = 0.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# References
|
|
# ---------------------------------------------------------------------------
|
|
var _controller: FPSCharacterController = null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
func _init() -> void:
|
|
_snapshot_buffer.resize(BUFFER_SIZE)
|
|
|
|
func _ready() -> void:
|
|
if not multiplayer.is_server() and NetworkManager:
|
|
# Client instances: listen for authoritative server state.
|
|
NetworkManager.server_state_received.connect(_on_client_state_received)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API — setup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Link this prediction node to its FPSCharacterController.
|
|
func setup(controller: FPSCharacterController) -> void:
|
|
_controller = controller
|
|
if not multiplayer.is_server():
|
|
prediction_enabled = true
|
|
print("[ClientPrediction] Prediction active for entity %d" % controller.entity_id)
|
|
|
|
## Switch to interpolation mode (for remote player representations).
|
|
func set_interpolate_mode(enabled: bool) -> void:
|
|
interpolate_mode = enabled
|
|
if enabled:
|
|
prediction_enabled = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prediction hooks (called by FPSCharacterController._physics_process)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## MUST be called at the start of _physics_process, BEFORE any input
|
|
## processing or movement. Captures the current character state into
|
|
## the snapshot ring buffer.
|
|
func on_before_tick() -> void:
|
|
if _controller == null or interpolate_mode or not prediction_enabled:
|
|
return
|
|
|
|
var snap: Snapshot = _capture_snapshot(local_tick)
|
|
_snapshot_buffer[local_tick % BUFFER_SIZE] = snap
|
|
_pending_inputs[local_tick] = null # placeholder, filled by on_after_tick
|
|
|
|
## MUST be called at the end of _physics_process, AFTER local movement
|
|
## and input processing. Stores the applied input, sends it to the
|
|
## server via RPC, and advances the local tick.
|
|
func on_after_tick(input_dict: Dictionary) -> void:
|
|
if _controller == null or interpolate_mode or not prediction_enabled:
|
|
return
|
|
|
|
# Store the input for potential reconciliation.
|
|
_pending_inputs[local_tick] = input_dict.duplicate()
|
|
|
|
# Send input to the server via NetworkManager RPC (ENet channel 0).
|
|
if NetworkManager and NetworkManager.has_method(&"send_client_input"):
|
|
NetworkManager.send_client_input.rpc_id(1, local_tick, input_dict)
|
|
|
|
local_tick += 1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshot capture
|
|
# ---------------------------------------------------------------------------
|
|
func _capture_snapshot(tick: int) -> Snapshot:
|
|
var s = Snapshot.new()
|
|
s.timestamp = tick
|
|
s.position = _controller.global_position
|
|
s.rotation = _controller.global_transform.basis.get_rotation_quaternion()
|
|
s.velocity = _controller.velocity
|
|
s.grounded = _controller.is_on_floor()
|
|
return s
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server state handling (called when authoritative state arrives)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Handle a server state snapshot: detect misprediction and reconcile.
|
|
func on_server_state(server_snapshot: Snapshot) -> void:
|
|
if prediction_enabled:
|
|
_reconcile_from_server(server_snapshot)
|
|
elif interpolate_mode:
|
|
_store_interp_snapshot(server_snapshot)
|
|
|
|
func _reconcile_from_server(server_snapshot: Snapshot) -> void:
|
|
var server_tick: int = server_snapshot.timestamp
|
|
|
|
# Ignore stale / out-of-order state.
|
|
if server_tick <= _last_confirmed_tick:
|
|
return
|
|
|
|
# 1. Retrieve our predicted snapshot for this tick.
|
|
var predicted: Snapshot = _snapshot_buffer[server_tick % BUFFER_SIZE]
|
|
|
|
if predicted != null and predicted.timestamp == server_tick:
|
|
# 2. Compare predicted vs authoritative state.
|
|
var delta: Vector3 = server_snapshot.position - predicted.position
|
|
if delta.length_squared() > 0.0001:
|
|
state_mispredicted.emit(delta)
|
|
|
|
# 3. Update last confirmed state.
|
|
_last_confirmed_snapshot = server_snapshot
|
|
_last_confirmed_tick = server_tick
|
|
|
|
# 4. Rewind and re-predict.
|
|
_reconcile(server_snapshot, server_tick)
|
|
|
|
## Core reconciliation: rewind to the confirmed server state, then
|
|
## re-apply every unconfirmed input that was sent after that tick.
|
|
func _reconcile(confirmed: Snapshot, confirmed_tick: int) -> void:
|
|
if _controller == null:
|
|
return
|
|
|
|
# Collect pending ticks that are strictly after the confirmed tick.
|
|
var pending_ticks: Array[int] = []
|
|
for tick in _pending_inputs.keys():
|
|
if tick > confirmed_tick:
|
|
pending_ticks.append(tick)
|
|
pending_ticks.sort()
|
|
|
|
if pending_ticks.is_empty():
|
|
# Nothing to re-apply — snap directly to confirmed state.
|
|
_apply_snapshot(confirmed)
|
|
reconciled.emit()
|
|
return
|
|
|
|
# Rewind to the confirmed state.
|
|
_apply_snapshot(confirmed)
|
|
|
|
# Re-apply every pending input in tick order.
|
|
var tick_delta: float = 1.0 / 128.0 # matches the 128 Hz tick rate
|
|
for tick in pending_ticks:
|
|
var input_dict: Dictionary = _pending_inputs.get(tick, {})
|
|
if input_dict.is_empty():
|
|
continue
|
|
_simulate_input(input_dict, tick_delta)
|
|
|
|
reconciled.emit()
|
|
|
|
## Apply a snapshot directly to the character controller (rewind step).
|
|
func _apply_snapshot(snap: Snapshot) -> void:
|
|
if _controller == null:
|
|
return
|
|
|
|
_controller.global_position = snap.position
|
|
_controller.global_transform.basis = Basis(snap.rotation)
|
|
_controller.velocity = snap.velocity
|
|
|
|
## Re-simulate a single tick of input on the character controller.
|
|
## Uses the controller's own _move_local() for identical physics.
|
|
## NOTE: crouch and sprint state are not re-applied during reconciliation
|
|
## because they are frame-state toggles; the server state already accounts
|
|
## for them. Only movement direction and jump matter for position correction.
|
|
func _simulate_input(input_dict: Dictionary, delta: float) -> void:
|
|
if _controller == null:
|
|
return
|
|
|
|
var move_dir: Vector3 = input_dict.get("move_direction", Vector3.ZERO)
|
|
var jump: bool = input_dict.get("jump", false)
|
|
var yaw_deg: float = input_dict.get("look_yaw", 0.0)
|
|
|
|
# Re-apply look yaw so movement is oriented correctly.
|
|
_controller.rotation.y = deg_to_rad(yaw_deg)
|
|
|
|
# Re-run local movement (this calls move_and_slide internally).
|
|
_controller._move_local(move_dir, delta, jump)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Remote player interpolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _store_interp_snapshot(snap: Snapshot) -> void:
|
|
_prev_snapshot = _next_snapshot
|
|
_next_snapshot = snap
|
|
_interp_fraction = 0.0
|
|
|
|
## Advance interpolation for a remote player. Call each _process frame.
|
|
func advance_interpolation(delta: float) -> void:
|
|
if not interpolate_mode or _controller == null:
|
|
return
|
|
if _prev_snapshot == null or _next_snapshot == null:
|
|
return
|
|
|
|
_interp_fraction += delta * 128.0 # tick rate
|
|
if _interp_fraction >= 1.0:
|
|
_apply_snapshot(_next_snapshot)
|
|
_prev_snapshot = _next_snapshot
|
|
_next_snapshot = null
|
|
return
|
|
|
|
# Smooth interpolation between snapshots.
|
|
_controller.global_position = _prev_snapshot.position.lerp(
|
|
_next_snapshot.position, _interp_fraction
|
|
)
|
|
_controller.global_transform.basis = Basis(_prev_snapshot.rotation).slerp(
|
|
Basis(_next_snapshot.rotation), _interp_fraction
|
|
)
|
|
_controller.velocity = _prev_snapshot.velocity.lerp(
|
|
_next_snapshot.velocity, _interp_fraction
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client-side state reception
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Client-side: called when NetworkManager emits server_state_received.
|
|
## Converts the raw dict into a Snapshot and passes it to the
|
|
## reconciliation pipeline.
|
|
func _on_client_state_received(entity_id: int, snapshot_dict: Dictionary) -> void:
|
|
# Route to the correct prediction controller based on entity_id.
|
|
if _controller == null or _controller.entity_id != entity_id:
|
|
return
|
|
|
|
var snap: Snapshot = Snapshot.from_dict(snapshot_dict)
|
|
on_server_state(snap)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utility
|
|
# ---------------------------------------------------------------------------
|
|
## Reset all prediction state (call on respawn / round restart).
|
|
func reset() -> void:
|
|
_snapshot_buffer = []
|
|
_snapshot_buffer.resize(BUFFER_SIZE)
|
|
_pending_inputs.clear()
|
|
_last_confirmed_snapshot = null
|
|
_last_confirmed_tick = -1
|
|
local_tick = 0
|
|
_prev_snapshot = null
|
|
_next_snapshot = null
|
|
_interp_fraction = 0.0
|