diff --git a/client/characters/character/fps_character_controller.gd b/client/characters/character/fps_character_controller.gd index 2d385b3..9bde5f0 100644 --- a/client/characters/character/fps_character_controller.gd +++ b/client/characters/character/fps_character_controller.gd @@ -88,6 +88,12 @@ var _input_sequence: int = 0 ## Cached input dictionary (avoids alloc per frame). var _input_dict: Dictionary = {} +## Prediction controller reference (set by ClientPrediction system). +## When non-null and prediction_enabled, the controller uses client-side +## prediction with local movement for instant feedback instead of +## reading position from the SimulationServer entity directly. +var _prediction: Node = null + ## Mouse capture state. var _mouse_captured: bool = false var _mouse_clicked_this_frame: bool = false @@ -193,6 +199,12 @@ func _unhandled_input(event: InputEvent) -> void: # --------------------------------------------------------------------------- func _physics_process(delta: float) -> void: + # PREDICTION HOOK: capture pre-input state snapshot. + # Must be called BEFORE any input processing or movement so the + # snapshot represents the state at the start of this tick. + if _prediction and _prediction.prediction_enabled: + _prediction.on_before_tick() + # 1. Capture input state var input_dir := _get_move_direction() var jump_pressed := Input.is_action_just_pressed(&"jump") @@ -249,19 +261,24 @@ func _physics_process(delta: float) -> void: if should_fire: _server.fire_weapon(entity_id) - # 6. Send input to simulation server - if _server != null and entity_id >= 0: - # Build input for SimulationServer (mutate cached dict to avoid alloc) - _input_dict["move_direction"] = input_dir - _input_dict["look_yaw"] = rad_to_deg(_yaw) - _input_dict["look_pitch"] = rad_to_deg(_pitch) - _input_dict["jump"] = jump_pressed - _input_dict["crouch"] = _crouch_active - _input_dict["sprint"] = _sprint_active - _input_dict["shoot"] = should_fire - _input_dict["aim"] = aim_pressed - _input_dict["input_sequence"] = _input_sequence + # 6. Build input dictionary (all modes — mutate cached dict to avoid alloc) + _input_dict["move_direction"] = input_dir + _input_dict["look_yaw"] = rad_to_deg(_yaw) + _input_dict["look_pitch"] = rad_to_deg(_pitch) + _input_dict["jump"] = jump_pressed + _input_dict["crouch"] = _crouch_active + _input_dict["sprint"] = _sprint_active + _input_dict["shoot"] = should_fire + _input_dict["aim"] = aim_pressed + _input_dict["input_sequence"] = _input_sequence + # 6b. Route input to appropriate handler. + if _prediction and _prediction.prediction_enabled: + # Client prediction: input sending is handled by the prediction + # controller's on_after_tick() call at the end of this function. + pass + elif _server != null and entity_id >= 0: + # Listen server: send to local SimulationServer. _server.apply_input(entity_id, _input_dict) # Check for hit feedback from last tick @@ -271,16 +288,20 @@ func _physics_process(delta: float) -> void: var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet _weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false)) - _input_sequence += 1 + _input_sequence += 1 - # 7. Read server state back (for local simulation / listen server) - # The SimulationServer.tick() call happens in the game manager / network layer. - # Here we optionally read the entity's canonical position from the server. - # NOTE: On a dedicated client, position comes from the network replication - # layer (server snapshot), not from direct server entity access. - # On a listen server (host+client same process) or in standalone mode, - # reading position directly from the server entity is correct. - if _server != null and entity_id >= 0: + # 7. Update position. + # Architecture: + # - Client prediction: do local CharacterBody3D movement (instant feedback), + # the prediction controller handles reconciliation when authoritative + # server state arrives. + # - Listen server: read authoritative position from SimulationServer entity. + # - Standalone (no server): fallback CharacterBody3D movement. + if _prediction and _prediction.prediction_enabled: + # Client prediction path — local movement for instant feedback. + _move_local(input_dir, delta, jump_pressed) + elif _server != null and entity_id >= 0: + # Listen server: read from SimulationServer entity. var entity = _server.get_entity(entity_id) if entity != null and entity.is_alive(): # Server position is ground truth — apply it to the visual body @@ -290,6 +311,12 @@ func _physics_process(delta: float) -> void: # Standalone mode: do local CharacterBody3D physics _move_local(input_dir, delta, jump_pressed) + # PREDICTION HOOK: post-tick — send input to server via RPC. + # Must be called AFTER local movement so the prediction controller + # can send the input that produced this tick's movement. + if _prediction and _prediction.prediction_enabled: + _prediction.on_after_tick(_input_dict) + # --------------------------------------------------------------------------- # Local movement (standalone / no-server fallback) @@ -395,6 +422,13 @@ func set_entity_id(id: int) -> void: entity_id = id +## Set the prediction controller for client-side prediction/reconciliation. +## The prediction node must have on_before_tick() / on_after_tick() methods. +## Typically a ClientPrediction instance added as a child or sibling. +func set_prediction(prediction_node: Node) -> void: + _prediction = prediction_node + + ## Get current crouch amount (0.0 = standing, 1.0 = fully crouched). func get_crouch_amount() -> float: return _crouch_current diff --git a/scripts/network/client_prediction.gd b/scripts/network/client_prediction.gd new file mode 100644 index 0000000..85704e2 --- /dev/null +++ b/scripts/network/client_prediction.gd @@ -0,0 +1,313 @@ +## 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 diff --git a/scripts/network/network_manager.gd b/scripts/network/network_manager.gd index fd7f081..4b3b9f8 100644 --- a/scripts/network/network_manager.gd +++ b/scripts/network/network_manager.gd @@ -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 # --------------------------------------------------------------------------- diff --git a/scripts/network/snapshot.gd b/scripts/network/snapshot.gd new file mode 100644 index 0000000..8202e62 --- /dev/null +++ b/scripts/network/snapshot.gd @@ -0,0 +1,60 @@ +## Snapshot — Lightweight world-state snapshot for client prediction & reconciliation. +## +## Stores a point-in-time record of a player's physical state. +## Used by ClientPrediction to build its ring buffer, send inputs, +## and reconcile against authoritative server state. +## +## Serialization: to_dict() / from_dict() for RPC transport over ENet. +class_name Snapshot +extends RefCounted + +# --------------------------------------------------------------------------- +# Fields +# --------------------------------------------------------------------------- +## Server tick / local tick number this snapshot corresponds to. +var timestamp: int = 0 + +## World-space position of the character's feet / body origin. +var position: Vector3 = Vector3.ZERO + +## Character body rotation as a quaternion (avoids gimbal lock in interpolation). +var rotation: Quaternion = Quaternion.IDENTITY + +## Linear velocity in world space. +var velocity: Vector3 = Vector3.ZERO + +## Whether the character was on the ground (is_on_floor()) at capture time. +var grounded: bool = false + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + +## Convert to a Dictionary suitable for RPC transfer. +## Arrays are used instead of Vector3/Quaternion because Godot's RPC +## serialization handles basic types more reliably. +func to_dict() -> Dictionary: + return { + "timestamp": timestamp, + "position": [position.x, position.y, position.z], + "rotation": [rotation.x, rotation.y, rotation.z, rotation.w], + "velocity": [velocity.x, velocity.y, velocity.z], + "grounded": grounded, + } + +## Deserialize from a Dictionary produced by to_dict(). +static func from_dict(data: Dictionary) -> Snapshot: + var s = Snapshot.new() + s.timestamp = data.get("timestamp", 0) + + var p: Array = data.get("position", [0.0, 0.0, 0.0]) + s.position = Vector3(p[0], p[1], p[2]) + + var r: Array = data.get("rotation", [0.0, 0.0, 0.0, 1.0]) + s.rotation = Quaternion(r[0], r[1], r[2], r[3]) + + var v: Array = data.get("velocity", [0.0, 0.0, 0.0]) + s.velocity = Vector3(v[0], v[1], v[2]) + + s.grounded = data.get("grounded", false) + return s