diff --git a/Assets/Mirror/Components/Experimental.meta b/Assets/Mirror/Components/Experimental.meta new file mode 100644 index 00000000000..262dcce154d --- /dev/null +++ b/Assets/Mirror/Components/Experimental.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: efe674d41d954e5a86bb1359a77b2e8c +timeCreated: 1741636295 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController.meta b/Assets/Mirror/Components/Experimental/NetworkPlayerController.meta new file mode 100644 index 00000000000..d9481e2f130 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c1487901ba2a4c27a39535c380476be8 +timeCreated: 1731968059 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs b/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs new file mode 100644 index 00000000000..9062ad430ff --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs @@ -0,0 +1,4 @@ +namespace System.Runtime.CompilerServices{ + internal static class IsExternalInit{ + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs.meta b/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs.meta new file mode 100644 index 00000000000..d76b9ead93a --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/IsExternalInit.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0f687b67d300421fa5f5e29b0d9c9f03 +timeCreated: 1733953055 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs new file mode 100644 index 00000000000..80ce52f00a7 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs @@ -0,0 +1,912 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Debug = UnityEngine.Debug; + +#nullable enable +namespace Mirror.Components.Experimental{ + public abstract class NetworkPlayerControllerBase : NetworkBehaviour, INetworkedItem{ + private const int HistoryBufferSize = 2048; // Equal to tick rollover counter for ease of use (no modulus needed) + + /* Public variables and settings */ + + #region Public variables and settings + + // Functions for comparing additional states and inputs for network optimization. + // Each function accepts two byte arrays: + // - For change compare functions: the first is the previous additional state/inputs, the second is the new additional state/inputs. + // Returns a byte array representing the differences; an empty array indicates no changes. + public Func? AdditionalInputsChangeCompare; + public Func? AdditionalStateChangeCompare; + + // Functions for overriding additional states and inputs for network optimization. + // Each function accepts two byte arrays: + // - For override functions: the first is the base additional state/inputs, the second is the override state/input. + // Returns a byte array representing the additional state/inputs after the modifications. + public Func? AdditionalInputsOverride; + public Func? AdditionalStateOverride; + + [Header("Physics Execution Settings")] [Tooltip("Affects when the character is executed during physics tick (higher = earlier)")] + public int executionPriority = 1000; + + [Header("Compensation options")] + [Min(1)] + [Tooltip("Minimum past ticks data to attach to current tick data when packet loss is detected.\n\n" + + "Note: With higher packet loss, the compensation increases on its own.")] + public int minCompensationTicks = 1; + + [Header("Inputs synchronization Options")] + [Min(1)] + [Tooltip("Defines how often to send a full input set to ensure accurate compensation and prevent desynchronization.")] + public int inputsSyncModulus = 24; + + [Tooltip("Will send current and previous tick inputs to avoid desync on single packet loss.")] + public bool durableInputs = false; + + [Header("State synchronization Options")] [Min(1)] [Tooltip("Defines how often to send absolute state updates.")] + public int stateSyncModulus = 24; + + [Tooltip("Whether to send state changes.")] + public bool sendStateChanges = true; + + [Tooltip("Will send current and previous tick states to avoid desync on single packet loss.")] + public bool durableStates = false; + + [Header("Reconciliation Options")] [SerializeField, Tooltip("Reconcile when position on the server does not match local position history.")] + internal SyncInterpolateOptions reconcileBy = SyncInterpolateOptions.Position | SyncInterpolateOptions.Rotation | SyncInterpolateOptions.Velocity; + + [Min(0)] [Tooltip("Starts reconciliation this many ticks before desync detection. Prevents failures caused by stale component state from previous ticks.")] + public int extraReconcileTicks = 0; + + [Tooltip("Adjusts player state to correct minor desync errors without triggering full reconciliation, preventing cumulative error build-up.")] + public bool softMicroAdjustments = true; + + [Header("Reconciliation Debug Options")] [Tooltip("Show reconcile events in console.")] + public bool showReconciliationLog = true; + + [Tooltip("Show soft reconcile events in console.")] + public bool showSoftReconciliationLog = true; + + #endregion + + /* Private variables */ + + #region Private variables + + // (Server) Set of client connection IDs when set client will receive normal data otherwise sync data + private readonly HashSet _syncedClients = new HashSet(); + + // (Server/Client) Flag signaling that the player is ready to execute and send inputs + private bool _isPlayerReady = false; + + // (Client) Flag set by OnResetNetworkState and cleared at tick end that signals first tick of reconciliation + private bool _isPendingStateReset = false; + + // (Server/Client) The tick at which local player synchronization ends + private int _localPlayerSyncEnd = 0; + + // (Server/Client) The tick at which PlayerStart() will be executed + private int _playerStartTick = int.MaxValue; + + // (Server/Client) For local players: prevents reconcile checks; for remote players: prevents snapping to server position + private bool _isSimulating = false; + + // Tick after which simulation ends (for local players, the next tick; for remote players, when server tick equals client tick) + private int _simulationEndTick = 0; + + // Tick after which soft reconciliation adjustments can resume + private int _softReconcileLock = 0; + + // (Server) Tick at which to force a full state sync regardless of modulus condition + private int _forceStateSyncTick = 0; + + // Inputs and states of the player recorded locally + private readonly NetworkPlayerInputs[] _playerInputsHistory = new NetworkPlayerInputs[HistoryBufferSize]; + private readonly NetworkPlayerState[] _playerStateHistory = new NetworkPlayerState[HistoryBufferSize]; + + // Inputs and states of the received player data from client to server and server to client + private readonly NetworkPlayerInputs[] _receivedPlayerInputs = new NetworkPlayerInputs[HistoryBufferSize]; + private readonly NetworkPlayerState[] _receivedPlayerStates = new NetworkPlayerState[HistoryBufferSize]; + + // Queued inputs and state data to be sent between server and client + private readonly NetworkPlayerInputs[] _inputsSendQueue = new NetworkPlayerInputs[HistoryBufferSize]; + private readonly NetworkPlayerState[] _stateSendQueue = new NetworkPlayerState[HistoryBufferSize]; + + // Whether position desynchronization will trigger a reconcile event + private bool _reconcileByPosition => (reconcileBy & SyncInterpolateOptions.Position) == SyncInterpolateOptions.Position; + + // Whether rotation desynchronization will trigger a reconcile event + private bool _reconcileByRotation => (reconcileBy & SyncInterpolateOptions.Rotation) == SyncInterpolateOptions.Rotation; + + // Whether velocity desynchronization will trigger a reconcile event + private bool _reconcileByVelocity => (reconcileBy & SyncInterpolateOptions.Velocity) == SyncInterpolateOptions.Velocity; + + [Flags] + public enum SyncInterpolateOptions{ + Position = 1 << 0, + Rotation = 1 << 1, + Velocity = 1 << 2, + } + + #endregion + + /* Network Item Registration */ + + #region Network Item Registration + + /// + /// Registers the network entity in the system when the object is enabled, + /// ensuring it participates in network physics updates. + /// + protected virtual void OnEnable() => NetworkPhysicsEntity.AddNetworkEntity(this, executionPriority); + + /// + /// Unregisters the network entity from the system when the object is disabled, + /// cleaning up resources and preventing unnecessary updates. + /// + protected virtual void OnDisable() => NetworkPhysicsEntity.RemoveNetworkEntity(this); + + /// + /// Unregisters the network entity from the system when the object is destroyed, + /// cleaning up resources and preventing unnecessary updates. + /// + protected virtual void OnDestroy() => NetworkPhysicsEntity.RemoveNetworkEntity(this); + + #endregion + + /* Required methods */ + + #region Required methods + + /* Inputs */ + + /// Retrieves the current player inputs. + public abstract NetworkPlayerInputs GetPlayerInputs(); + + /// Sets the current player inputs. + public abstract void SetPlayerInputs(NetworkPlayerInputs inputs); + + /// Resets the player inputs at the first tick of reconciliation. Called at first reconcile tick. + public abstract void ResetPlayerInputs(NetworkPlayerInputs inputs); + + /* States */ + + /// Retrieves the current player state. + public abstract NetworkPlayerState GetPlayerState(); + + /// Applies the specified player state. + public abstract void SetPlayerState(NetworkPlayerState state); + + /// Resets the player state at the first tick of reconciliation. Called at first reconcile tick. + public abstract void ResetPlayerState(NetworkPlayerState state); + + #endregion + + /* Overridable methods */ + + #region Overridable methods + + /// Called when the player takes control of the character, either locally or remotely. + public virtual void PlayerStart() { + } + + /// Called during reconciliation checks to allow implementing custom logic. Return true if reconciliation should be triggered. + protected virtual bool CustomReconcileCheck(NetworkPlayerState localState, NetworkPlayerState remoteState) { + // Overridable method to allow for custom reconcile requests + return false; + } + + /// Called to reset network state; override to implement custom reset logic. + public virtual void ResetNetworkState() { + } + + /// Called before reconciliation begins. + public virtual void BeforeNetworkReconcile() { + } + + /// Called after reconciliation has concluded. + public void AfterNetworkReconcile() { + } + + /// Called before the network update begins. + public virtual void BeforeNetworkUpdate(int deltaTicks, float deltaTime) { + } + + /// Called before a network tick is executed, similar to Update. + public virtual void NetworkUpdate(int deltaTicks, float deltaTime) { + } + + /// Called after a network tick has been executed. + public virtual void AfterNetworkUpdate(int deltaTicks, float deltaTime) { + } + + #endregion + + /* Utility Methods */ + + #region Utility Methods + + /// Forces a full state synchronization on the next update cycle. + [Server] + protected void ForceStateSync() => _forceStateSyncTick = NetworkTick.ServerAbsoluteTick; + + /// + /// Forces the local player into simulation for the next client tick, + /// temporarily disabling reconciliation checks. + /// Necessary when colliding with or affecting server-controlled objects. + /// + [Client] + protected void SimulateNextTick() { + _isSimulating = true; + _simulationEndTick = NetworkTick.IncrementTick(NetworkTick.ClientTick, 1); + } + + /// Indicates whether the player is synchronized and ready (replaces Start()). + protected bool IsPlayerReady => _isPlayerReady; + + /// Indicates whether the player is running a local simulation or is synchronized with the server. + protected bool IsSimulating => _isSimulating; + + #endregion + + /* Player Synchronization methods */ + + #region Player Synchronization methods + + /// Ensures that PlayerStart is called even after client synchronization is complete. + public override void OnStartClient() { + if (isServer) return; // ignore client on the hosted server + + // Either the server or the player signaled to create a player; in either case, reconciliation is not needed. + if (NetworkTick.IsSynchronized && isLocalPlayer) + _playerStartTick = NetworkTick.IncrementTick(NetworkTick.ClientTick, 1); + } + + /// + /// Synchronizes the client with the server: + /// for the local player, duplicates server inputs and sets a sync endpoint; + /// for remote players, notifies the server and sets the start tick for accurate reconciliation. + /// + public void OnNetworkSynchronized() { + // Server has network sync at the beginning so we skip this for the host. + if (isServer) _playerStartTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, 1); + // If not server and not local player, send a reliable end sync request. + else if (!isLocalPlayer) ClientSynchronizedCmd(); + else { + // For the local player, mark the current client tick as the sync endpoint. + // This ensures inputs are sent until the server tick reaches or exceeds this value and set waiting for network start + _localPlayerSyncEnd = NetworkTick.ClientAbsoluteTick; + _playerStartTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, 1); + } + } + + /// Checks if network start conditions are met and initiates PlayerStart(). + private void CheckForPlayerStart() { + var compareTick = isLocalPlayer ? NetworkTick.CurrentTick : NetworkTick.ServerTick; + + // If the server and clients are in sync, fire network start. + if (_playerStartTick != int.MaxValue && NetworkTick.SubtractTicks(compareTick, _playerStartTick) >= 0) { + _isPlayerReady = true; + PlayerStart(); + } + } + + /// Sets the remote player's start tick using the earliest valid tick found in the input list. + private void SetRemotePlayerStart(List inputsList) { + // Find the earliest tick that has a tick number attached. + foreach (var inputs in inputsList) + // Ensure the data actually contains valid ticks. + if (inputs.TickNumber.HasValue) { + var tick = inputs.TickNumber.Value; + if (_playerStartTick == int.MaxValue || NetworkTick.SubtractTicks(tick, _playerStartTick) < 0) _playerStartTick = tick; + // Only care about the earliest tick. + break; + } + } + + #endregion + + /* Tick simulation and update handling */ + + #region Tick simulation and update handling + + /// + /// Server-side update method that records the player's state and input history for the current tick. + /// It retrieves the current server tick, saves the player's state at that tick, and if the player is ready, + /// stores local input data directly or overlays remote inputs onto the previous tick's inputs. + /// + /// The number of ticks elapsed since the last update. + /// The time in seconds elapsed since the last update. + [Server] + private void OnServerPlayerUpdate(int deltaTicks, float deltaTime) { + var serverTick = NetworkTick.ServerTick; + + // Record player state in history. + _playerStateHistory[serverTick] = GetPlayerStateWithTick(serverTick); + + if (_isPlayerReady) { + // For the local player, simply save history to send to remote clients. + if (isLocalPlayer) _playerInputsHistory[serverTick] = GetPlayerInputsWithTick(serverTick); + // For remote players, overlay received changes on the previous inputs. + else { + var previousTick = NetworkTick.IncrementTick(serverTick, -1); + _playerInputsHistory[serverTick] = _playerInputsHistory[previousTick] + .OverrideInputsWith(_receivedPlayerInputs[serverTick], serverTick, AdditionalInputsOverride); + } + } + } + + /// Handles the local player's update on the client side each tick. + /// The number of ticks elapsed since the last update. + /// The time in seconds elapsed since the last update. + /// + /// - If the client is in reconciliation mode, it rebuilds the received data using the latest server state. + /// - If there is no pending state reset, it updates the player state history by overlaying the received state. + /// - When the player is ready and not reconciling, it records the current player inputs, queues them for sending, + /// sends them to the server with packet loss compensation, and checks if a reconciliation is required. + /// + [Client] + private void OnClientLocalPlayerUpdate(int deltaTicks, float deltaTime) { + var currentTick = NetworkTick.CurrentTick; + + // Record player state in history if not pending reset. + if (!_isPendingStateReset) _playerStateHistory[currentTick] = GetPlayerStateWithTick(currentTick); + + if (_isPlayerReady && !NetworkTick.IsReconciling) { + _playerInputsHistory[currentTick] = GetPlayerInputsWithTick(currentTick); + AddPlayerInputsToSendQueue(); + SmartSendInputsToServer(NetworkTick.ServerAbsoluteTick <= _localPlayerSyncEnd, NetworkTick.ClientToServerPacketLossCompensation); + CheckIfReconcile(); + } + } + + /// + /// Updates the remote player's input and state history on the client. + /// If not in a state reset, it updates the state history using received data—allowing deviation if simulating—and synchronizes input history. + /// + /// The number of ticks elapsed since the last update. + /// The time in seconds elapsed since the last update. + [Client] + private void OnClientRemotePlayerUpdate(int deltaTicks, float deltaTime) { + var serverTick = NetworkTick.ServerTick; + + // Update history states with received inputs unless during reset phase. + if (!_isPendingStateReset) + // If simulating we want to let the character deviate rather than snap it to server position. + _playerStateHistory[serverTick] = _isSimulating + ? GetPlayerStateWithTick(serverTick) // If simulating, allow deviation. + : GetPlayerStateWithTick(serverTick).OverrideStateWith(_receivedPlayerStates[serverTick], serverTick, AdditionalStateOverride); + + // Update history inputs with received inputs. + _playerInputsHistory[serverTick] = _receivedPlayerInputs[serverTick]; + } + + /// + /// Applies the stored player state and inputs for the current tick. + /// If a network state reset is pending, resets state and inputs; otherwise, sets them normally. + /// + private void ApplyStatesAndInputs() { + var targetTick = isLocalPlayer ? NetworkTick.CurrentTick : NetworkTick.ServerTick; + + if (_isPendingStateReset) { + ResetPlayerState(_playerStateHistory[targetTick]); + if (_isPlayerReady) ResetPlayerInputs(_playerInputsHistory[targetTick]); + } + else { + SetPlayerState(_playerStateHistory[targetTick]); + if (_isPlayerReady) SetPlayerInputs(_playerInputsHistory[targetTick]); + } + } + + /// + /// Updates player state depending on whether this instance is server or client, and whether the player is local or remote. + /// Then handles sending or receiving data. + /// + public void OnBeforeNetworkUpdate(int deltaTicks, float deltaTime) { + if (!_isPlayerReady) CheckForPlayerStart(); + + if (_isPendingStateReset) BeforeNetworkReconcile(); + else BeforeNetworkUpdate(deltaTicks, deltaTime); + + if (isServer) OnServerPlayerUpdate(deltaTicks, deltaTime); + else { + RebuildReceivedData(NetworkTick.ServerTick); + if (isLocalPlayer) OnClientLocalPlayerUpdate(deltaTicks, deltaTime); + else OnClientRemotePlayerUpdate(deltaTicks, deltaTime); + } + + ApplyStatesAndInputs(); + } + + /// + /// Executes the network update cycle: runs custom logic, sends data to clients on the server, + /// and checks for simulation end on the client. + /// + /// Elapsed ticks since the last update. + /// Elapsed time in seconds since the last update. + public void OnNetworkUpdate(int deltaTicks, float deltaTime) { + // Call custom player logic before sending data. + NetworkUpdate(deltaTicks, deltaTime); + + // Server sends data to all connected clients. + if (isServer) SendDataToClients(); + + // Check if simulation should end on the client. + else CheckSimulationEnd(); + } + + /// Executes post-update logic and clears outdated tick data. + /// Number of ticks advanced since the last update. + /// Time elapsed since the last update. + public void OnAfterNetworkUpdate(int deltaTicks, float deltaTime) { + AfterNetworkUpdate(deltaTicks, deltaTime); + ClearPastTickData(); + } + + #endregion + + /* Reconciliation checks and logic */ + + #region Reconciliation checks and logic + + /// + /// Rebuilds the received input and state data for the specified tick by overlaying the current tick's values + /// on the previous tick's data. If state changes are enabled and valid, the state is updated similarly. + /// + private void RebuildReceivedData(int rebuildTick) { + var previousServerTick = NetworkTick.IncrementTick(rebuildTick, -1); + + // Assume no input changes if not sent; overlay current inputs on previous tick. + _receivedPlayerInputs[rebuildTick] = _receivedPlayerInputs[previousServerTick] + .OverrideInputsWith(_receivedPlayerInputs[rebuildTick], rebuildTick, AdditionalInputsOverride); + + // If sending precise server changes, overlay state changes. + if (sendStateChanges) + _receivedPlayerStates[rebuildTick] = _receivedPlayerStates[previousServerTick] + .OverrideStateWith(_receivedPlayerStates[rebuildTick], rebuildTick, AdditionalStateOverride); + } + + /// + /// Client-only method that resets the player’s network state at the specified tick by overriding input/state with received data, + /// then triggers reconciliation. + /// + [Client] + public void OnResetNetworkState() { + var targetTick = isLocalPlayer ? NetworkTick.CurrentTick : NetworkTick.ServerTick; + + // Override inputs and states with the most updated received data. + _playerInputsHistory[targetTick] = _playerInputsHistory[targetTick] + .OverrideInputsWith(_receivedPlayerInputs[targetTick], targetTick, AdditionalInputsOverride); + _playerStateHistory[targetTick] = + _playerStateHistory[targetTick].OverrideStateWith(_receivedPlayerStates[targetTick], targetTick, AdditionalStateOverride); + + // Call virtual BeforeReconcile function to allow developers to implement custom logic like storing position for smoothing desync + ResetNetworkState(); + + // Set pending reset flag to suspend state collection and apply reset methods. + _isPendingStateReset = true; + } + + /// + /// Client-only method that compares the latest local and remote player states. + /// If significant discrepancies are found, queues a full reconcile request; otherwise, applies a soft adjustment. + /// + [Client] + private void CheckIfReconcile() { + var targetTick = NetworkTick.ServerTick; + var remoteState = _receivedPlayerStates[targetTick]; + var localState = _playerStateHistory[targetTick]; + // Avoid reconcile check when already reconciling, simulating, or lacking valid data. + if (_isSimulating || !_receivedPlayerStates[targetTick].HasTick || !_playerStateHistory[targetTick].HasTick) return; + + // we dont use the custom compare functions we only care about position, rotation and velocity + var changes = _playerStateHistory[targetTick].GetChangedStateComparedTo(_receivedPlayerStates[targetTick]); + if (changes.HasValue && ( + (_reconcileByPosition && changes.Value.Position.HasValue) || + (_reconcileByRotation && changes.Value.Rotation.HasValue) || + (_reconcileByVelocity && changes.Value.BaseVelocity.HasValue) || + CustomReconcileCheck(localState, localState.OverrideStateWith(remoteState, targetTick, AdditionalStateOverride)) + )) { + // Request reconcile from the faulty tick (including extra ticks for compensation). + NetworkPhysicsController.RequestReconcileFromTick(NetworkTick.IncrementTick(targetTick, -extraReconcileTicks)); + // Log reconcile debug information if enabled. + if (showReconciliationLog) { + var reconcileSources = new List(); + if (_reconcileByPosition && changes.Value.Position.HasValue) reconcileSources.Add("Position"); + if (_reconcileByRotation && changes.Value.Rotation.HasValue) reconcileSources.Add("Rotation"); + if (_reconcileByVelocity && changes.Value.BaseVelocity.HasValue) reconcileSources.Add("Velocity"); + Debug.Log("Reconcile from tick " + targetTick + " because (" + string.Join(", ", reconcileSources) + ")"); + } + } + else if (softMicroAdjustments && _softReconcileLock < NetworkTick.ServerAbsoluteTick) { + // Check for any state differences, even if minor. + // we dont use the custom compare functions we only care about position, rotation and velocity + changes = _playerStateHistory[targetTick].GetChangedStateComparedTo(_receivedPlayerStates[targetTick], true); + if (changes.HasValue && (changes.Value.Position.HasValue || changes.Value.BaseVelocity.HasValue || changes.Value.Rotation.HasValue)) { + // Wait until after the updated tick to avoid compounding errors. + _softReconcileLock = NetworkTick.ClientAbsoluteTick; + + // Calculate deviations for adjustment. + Vector3? positionDeviation = changes.Value.Position.HasValue ? remoteState.Position - localState.Position : null; + Vector3? velocityDeviation = changes.Value.BaseVelocity.HasValue ? remoteState.BaseVelocity - localState.BaseVelocity : null; + Quaternion? rotationDeviation = changes.Value.Rotation.HasValue ? GetRotationDeviation(remoteState.Rotation, localState.Rotation) : null; + + // Apply deviations to the current state. we only want to override with the deviation adjustments + var originalState = _playerStateHistory[NetworkTick.ClientTick]; + _playerStateHistory[NetworkTick.ClientTick] = originalState.OverrideStateWith(new NetworkPlayerState() { + Position = originalState.Position + positionDeviation, + BaseVelocity = originalState.BaseVelocity + velocityDeviation, + Rotation = originalState.Rotation.HasValue && rotationDeviation.HasValue + ? (originalState.Rotation.Value * rotationDeviation.Value).normalized + : null + }); + + // Log soft reconcile events if enabled. + if (showSoftReconciliationLog) { + var reconcileSources = new List(); + if (changes.Value.Position.HasValue) reconcileSources.Add("Position"); + if (changes.Value.Rotation.HasValue) reconcileSources.Add("Rotation"); + if (changes.Value.BaseVelocity.HasValue) reconcileSources.Add("Velocity"); + Debug.Log("Soft adjusted tick " + NetworkTick.ClientTick + " because (" + string.Join(", ", reconcileSources) + ")"); + } + } + } + } + + #endregion + + /* Network Senders */ + + #region Network Senders + + /// + /// Sends input and state updates to all remote clients. + /// Inputs are synced incrementally every tick, with periodic full syncs for accuracy and packet loss compensation. + /// State updates occur at set intervals or when compensation requires a full sync. + /// + [Server] + private void SendDataToClients() { + var serverTick = NetworkTick.ServerAbsoluteTick; + var isStateFullSyncTick = serverTick % stateSyncModulus == 0 || serverTick == _forceStateSyncTick; + + AddPlayerInputsToSendQueue(); + if (sendStateChanges || isStateFullSyncTick) AddPlayerStatesToSendQueue(isStateFullSyncTick); + + foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values) { + // Skip the local server connection. And connections that are not yet active + if (conn == NetworkServer.localConnection) continue; + + // Get this clients packet loss compensation + // Determine if the client is still synchronizing. + var isClientSynchronizing = !_syncedClients.Contains(conn.connectionId); + var clientCompensation = NetworkTick.Server.GetClientToServerCompensation(conn.connectionId); + + // Ensure this client is not synchronizing, if client is synchronizing ensure full state + SmartSendStatesToClient(conn, isClientSynchronizing, clientCompensation); + if (_isPlayerReady) SmartSendInputsToClient(conn, isClientSynchronizing, clientCompensation); + } + } + + /// + /// Sends client inputs to the server with packet loss compensation. + /// Uses a full sync sequence if synchronizing; otherwise, sends incremental input changes. + /// + [Client] + private void SmartSendInputsToServer(bool isSynchronizing, int compensation = 0) { + // Calculate additional past ticks for packet loss compensation. + var additionalPastTicks = durableInputs || compensation > 0 ? Math.Max(compensation, minCompensationTicks) : 0; + var inputsToSend = isSynchronizing ? GetPlayerInputsSyncSequence(additionalPastTicks) : GetPlayerInputsSendSequence(additionalPastTicks); + + SendInputsToServer(inputsToSend); + } + + /// + /// Sends client input updates from the server to a specific client with packet loss compensation. + /// Uses a full sync sequence during synchronization; otherwise, sends incremental input updates. + /// + [Server] + private void SmartSendInputsToClient(NetworkConnectionToClient conn, bool isSynchronizing, int compensation = 0) { + // get inputs to send with the correct compensation for packet loss + var additionalPastTicks = durableInputs || compensation > 0 ? Math.Max(compensation, minCompensationTicks) : 0; + var inputsToSend = isSynchronizing ? GetPlayerInputsSyncSequence(additionalPastTicks) : GetPlayerInputsSendSequence(additionalPastTicks); + + SendInputsToClient(conn, inputsToSend); + } + + /// + /// Sends state updates from the server to a specific client with packet loss compensation. + /// Uses a full sync sequence when synchronizing; otherwise, sends incremental state changes. + /// + [Server] + private void SmartSendStatesToClient(NetworkConnectionToClient conn, bool isSynchronizing, int compensation = 0) { + // get inputs to send with the correct compensation for packet loss + var additionalPastTicks = durableStates || compensation > 0 ? Math.Max(compensation, minCompensationTicks) : 0; + var statesToSend = isSynchronizing ? GetPlayerStateSyncSequence(additionalPastTicks) : GetPlayerStatesSendSequence(additionalPastTicks); + + SendStatesToClient(conn, statesToSend); + } + + /// + /// Queues player inputs for sending. Uses a full input set on sync ticks (when absolute tick % inputsSyncModulus == 0) + /// or only the delta compared to the previous tick. + /// + private void AddPlayerInputsToSendQueue() { + var currentTick = NetworkTick.CurrentTick; + var isSendAbsoluteInputs = NetworkTick.CurrentAbsoluteTick % inputsSyncModulus == 0; + + // If the inputs are not set, return. + if (!_playerInputsHistory[currentTick].HasTick) return; + + // Use absolute inputs or send only changes compared to the previous tick. + var queuedInputs = isSendAbsoluteInputs + ? _playerInputsHistory[currentTick] + : _playerInputsHistory[currentTick] + .GetChangedInputsComparedTo(_playerInputsHistory[NetworkTick.IncrementTick(currentTick, -1)], AdditionalInputsChangeCompare); + + // If changes exist, add them to the send queue. + if (queuedInputs.HasValue) _inputsSendQueue[currentTick] = queuedInputs.Value; + } + + /// + /// Queues player state for sending. Sends the full state when isSendAbsoluteState is true; otherwise, + /// sends only the changes (delta) compared to the previous tick. + /// + private void AddPlayerStatesToSendQueue(bool isSendAbsoluteState) { + var currentTick = NetworkTick.CurrentTick; + + // if the state is not set we can return + if (!_playerStateHistory[currentTick].HasTick) return; + + var queuedState = isSendAbsoluteState + ? _playerStateHistory[currentTick] + : _playerStateHistory[currentTick] + .GetChangedStateComparedTo(_playerStateHistory[NetworkTick.IncrementTick(currentTick, -1)], false, AdditionalStateChangeCompare); + + if (queuedState.HasValue) _stateSendQueue[currentTick] = queuedState.Value; + } + + /// Retrieves queued player inputs over the compensation window starting from (CurrentTick - additionalPastInputsCount). + private List GetPlayerInputsSendSequence(int additionalPastInputsCount) { + var inputsSequence = new List(); + var offsetTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, -additionalPastInputsCount); + + // Add queued inputs for each tick within the compensation window. + for (var i = 0; i <= additionalPastInputsCount; i++) { + if (_inputsSendQueue[offsetTick].HasTick) inputsSequence.Add(_inputsSendQueue[offsetTick]); + offsetTick = NetworkTick.IncrementTick(offsetTick, 1); + } + + return inputsSequence; + } + + /// Retrieves queued player states over the compensation window starting from (CurrentTick - additionalPastInputsCount). + private List GetPlayerStatesSendSequence(int additionalPastInputsCount) { + var statesSequence = new List(); + var offsetTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, -additionalPastInputsCount); + + // Add queued states for each tick within the compensation window. + for (var i = 0; i <= additionalPastInputsCount; i++) { + if (_stateSendQueue[offsetTick].HasTick) statesSequence.Add(_stateSendQueue[offsetTick]); + offsetTick = NetworkTick.IncrementTick(offsetTick, 1); + } + + return statesSequence; + } + + /// Retrieves a sync sequence of player inputs starting with an absolute input, then only changed inputs for subsequent ticks. + private List GetPlayerInputsSyncSequence(int additionalPastInputsCount) { + var inputsSequence = new List(); + var offsetTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, -additionalPastInputsCount); + + // Ensure the first tick is absolute. + if (_playerInputsHistory[offsetTick].HasTick) inputsSequence.Add(_playerInputsHistory[offsetTick]); + + // Send only changes after the first full sync. + var previousTick = offsetTick; + for (var i = 0; i < additionalPastInputsCount; i++) { + offsetTick = NetworkTick.IncrementTick(offsetTick, 1); + if (_playerInputsHistory[offsetTick].HasTick) { + var changes = _playerInputsHistory[offsetTick].GetChangedInputsComparedTo(_playerInputsHistory[previousTick], AdditionalInputsChangeCompare); + if (changes.HasValue) inputsSequence.Add(changes.Value); + } + + previousTick = offsetTick; + } + + return inputsSequence; + } + + /// Retrieves a sync sequence of player states starting with an absolute state, then only changes compared to the previous tick. + private List GetPlayerStateSyncSequence(int additionalPastInputsCount) { + var stateSequence = new List(); + var offsetTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, -additionalPastInputsCount); + + // Ensure the first tick is absolute. + if (_playerStateHistory[offsetTick].HasTick) stateSequence.Add(_playerStateHistory[offsetTick]); + + // We want to send only changes after the first full sync, the peer can reconstruct them by overlaying one on top of another during execution + var previousTick = offsetTick; + for (var i = 0; i < additionalPastInputsCount; i++) { + offsetTick = NetworkTick.IncrementTick(offsetTick, 1); + if (_playerStateHistory[offsetTick].HasTick) { + var changes = _playerStateHistory[offsetTick].GetChangedStateComparedTo(_playerStateHistory[previousTick], false, AdditionalStateChangeCompare); + if (changes.HasValue) stateSequence.Add(changes.Value); + } + + previousTick = offsetTick; + } + + return stateSequence; + } + + #endregion + + /* Network Data Receivers */ + + #region Network Data Recievers + + /// Marks the client as synchronized by adding its connection ID to the synced clients set. + [Command(channel = Channels.Reliable, requiresAuthority = false)] + private void ClientSynchronizedCmd(NetworkConnectionToClient connectionToClient = null) { + if (connectionToClient is not null) _syncedClients.Add(connectionToClient.connectionId); + } + + /// Processes input updates from a client by updating the received inputs history and setting the remote player's start tick if needed. + [Server] + private void OnInputsFromClient(List inputsList, int connectionId) { + foreach (var inputs in inputsList) + if (inputs.TickNumber.HasValue) + _receivedPlayerInputs[inputs.TickNumber.Value] = _receivedPlayerInputs[inputs.TickNumber.Value] + .OverrideInputsWith(inputs, inputs.TickNumber.Value, AdditionalInputsOverride); + + if (!_isPlayerReady) { + _syncedClients.Add(connectionId); + SetRemotePlayerStart(inputsList); + } + } + + /// Processes input updates from the server by updating the received inputs history and setting the remote player's start tick if not yet set. + [Client] + private void OnInputsFromServer(List inputsList) { + foreach (var inputs in inputsList) + if (inputs.TickNumber.HasValue) + _receivedPlayerInputs[inputs.TickNumber.Value] = _receivedPlayerInputs[inputs.TickNumber.Value] + .OverrideInputsWith(inputs, inputs.TickNumber.Value, AdditionalInputsOverride); + + if (!_isPlayerReady) SetRemotePlayerStart(inputsList); + } + + /// Processes state updates from the server by updating the received states history. + [Client] + private void OnStateFromServer(List statesList) { + foreach (var state in statesList) + if (state.TickNumber.HasValue) + _receivedPlayerStates[state.TickNumber.Value] = + _receivedPlayerStates[state.TickNumber.Value].OverrideStateWith(state, state.TickNumber.Value, AdditionalStateOverride); + } + + #endregion + + /* Network Senders Abstractions */ + + #region Network Senders Abstractions + + /// Sends one or multiple input updates from client to server, optimizing bandwidth. + [Client] + private void SendInputsToServer(List inputs) { + // Optimize bandwidth by sending a single update if only one input differs, or a sequence if multiple do. + if (inputs.Count == 1) CmdSendInputsToServer(inputs[0]); + else if (inputs.Count > 1) CmdSendInputsSequenceToServer(inputs); + // default send none + } + + /// Sends one or multiple input updates from server to a specific client, optimizing bandwidth. + [Server] + private void SendInputsToClient(NetworkConnectionToClient conn, List inputs) { + // Optimize bandwidth by sending a single update if only one state differs, or a sequence if multiple do. + if (inputs.Count == 1) RpcSendInputsToClient(conn, inputs[0]); + if (inputs.Count > 1) RpcSendInputsSequenceToClient(conn, inputs); + // default send none + } + + /// Sends one or multiple state updates from server to a specific client, optimizing bandwidth. + [Server] + private void SendStatesToClient(NetworkConnectionToClient conn, List states) { + // Optimize bandwidth by sending a single update if only one state differs, or a sequence if multiple do. + if (states.Count == 1) RpcSendStatesToClient(conn, states[0]); + else if (states.Count > 1) RpcSendStatesSequenceToClient(conn, states); + // default send none + } + + #endregion + + /* Network Receivers Abstractions */ + + #region Network Receivers Abstractions + + /// Sends a single input from the client to the server. + [Command(channel = Channels.Unreliable, requiresAuthority = true)] + private void CmdSendInputsToServer(NetworkPlayerInputs inputs, NetworkConnectionToClient connectionToClient = null) => + OnInputsFromClient(new List { inputs }, connectionToClient?.connectionId ?? 0); + + /// Sends a sequence of inputs from the client to the server. + [Command(channel = Channels.Unreliable, requiresAuthority = true)] + private void CmdSendInputsSequenceToServer(List inputsSequence, NetworkConnectionToClient connectionToClient = null) => + OnInputsFromClient(inputsSequence, connectionToClient?.connectionId ?? 0); + + /// Sends a single input from the server to a specific client. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcSendInputsToClient(NetworkConnection target, NetworkPlayerInputs inputs) => OnInputsFromServer(new List { inputs }); + + /// Sends a sequence of inputs from the server to a specific client. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcSendInputsSequenceToClient(NetworkConnection target, List inputsSequence) => OnInputsFromServer(inputsSequence); + + /// Sends a single state from the server to a specific client. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcSendStatesToClient(NetworkConnection target, NetworkPlayerState state) => OnStateFromServer(new List { state }); + + /// Sends a sequence of states from the server to a specific client. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcSendStatesSequenceToClient(NetworkConnection target, List stateSequence) => OnStateFromServer(stateSequence); + + #endregion + + /* Helper Functions */ + + #region Helper Functions + + /// Ends simulation when the current server tick exceeds the simulation end tick. + private void CheckSimulationEnd() { + if (_isSimulating && NetworkTick.SubtractTicks(NetworkTick.ServerTick, _simulationEndTick) > 0) _isSimulating = false; + } + + /// Retrieves and updates player inputs for the specified tick. + private NetworkPlayerInputs GetPlayerInputsWithTick(int tick) { + var inputs = GetPlayerInputs(); + inputs.TickNumber = tick; + return inputs; + } + + /// Retrieves the player state for the specified tick by comparing with the previous state to avoid jitter. + private NetworkPlayerState GetPlayerStateWithTick(int currentTick) { + var newState = GetPlayerState(); + + var previousTick = NetworkTick.IncrementTick(currentTick, -1); + var changes = newState.GetChangedStateComparedTo(_playerStateHistory[previousTick], false, AdditionalStateChangeCompare) ?? new NetworkPlayerState(); + var stableState = _playerStateHistory[previousTick].OverrideStateWith(changes, currentTick, AdditionalStateOverride); + + return stableState; + } + + /// Clears old historical data for predicted and received player inputs and states, and resets the pending reset flag. + private void ClearPastTickData() { + var clearTick = NetworkTick.IncrementTick(NetworkTick.CurrentTick, -1000); + // Reset predicted inputs and state data for the old tick. + _playerInputsHistory[clearTick] = new NetworkPlayerInputs(); + _playerStateHistory[clearTick] = new NetworkPlayerState(); + + // Reset received inputs and state data for the old tick. + _receivedPlayerInputs[clearTick] = new NetworkPlayerInputs(); + _receivedPlayerStates[clearTick] = new NetworkPlayerState(); + + // Reset send queues for the old tick. + _inputsSendQueue[clearTick] = new NetworkPlayerInputs(); + _stateSendQueue[clearTick] = new NetworkPlayerState(); + + // Clear pending reset flag. + _isPendingStateReset = false; + } + + /// Calculates the deviation between two rotations and returns the shortest rotation path. + private Quaternion? GetRotationDeviation(Quaternion? q1, Quaternion? q2) { + if (!q1.HasValue || !q2.HasValue) return null; + + Quaternion deviation = Quaternion.Inverse(q2.Value) * q1.Value; + // Negate the quaternion to get the equivalent rotation via shorter path otherwise return the deviation + return deviation.w < 0 ? new Quaternion(-deviation.x, -deviation.y, -deviation.z, -deviation.w) : deviation; + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs.meta b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs.meta new file mode 100644 index 00000000000..47f10796427 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerControllerBase.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0b24b898ac8d4e579cc4a89e9bd8e4fd +timeCreated: 1734295672 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs new file mode 100644 index 00000000000..7f3c6e14e3e --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs @@ -0,0 +1,410 @@ +using System.Runtime.CompilerServices; // do not remove, required to add init support for net4.9 or lower +using System.Collections.Generic; +using System.Linq; +using System; +using UnityEngine; + +#nullable enable +namespace Mirror.Components.Experimental{ + public struct NetworkPlayerInputs{ + /// Initializes a new instance of the struct. Optionally takes defaults to initialize the instance. + /// Default inputs to copy values from, or null for default initialization. + public NetworkPlayerInputs(NetworkPlayerInputs? defaults = null) { + _tickNumber = defaults?._tickNumber; + _serverTickOffset = defaults?._serverTickOffset; + _movementVector = defaults?._movementVector; + _joystickVector = defaults?._joystickVector; + _mouseVectorX = defaults?._mouseVectorX; + _mouseVectorY = defaults?._mouseVectorY; + _additionalInputs = defaults?._additionalInputs; + } + + /// + /// Initializes a new instance of the struct with specific values. + /// This private constructor allows internal use for creating instances with selected fields. + /// + /// The tick number to set. + /// The server tick offset, or null if unset. + /// The movement vector, or null if unset. + /// The joystick vector, or null if unset. + /// The X component of the mouse vector, or null if unset. + /// The Y component of the mouse vector, or null if unset. + /// The additional inputs, or null if unset. + private NetworkPlayerInputs(int? tickNumber, byte? serverTickOffset, ushort? movementVector, ushort? joystickVector, ushort? mouseVectorX, + ushort? mouseVectorY, ReadOnlyMemory? additionalInputs) { + _tickNumber = tickNumber; + _serverTickOffset = serverTickOffset; + _movementVector = movementVector; + _joystickVector = joystickVector; + _mouseVectorX = mouseVectorX; + _mouseVectorY = mouseVectorY; + _additionalInputs = additionalInputs; + } + + /* Inputs Compare and Extend methods */ + + #region Inputs Compare and Extend methods + + /// + /// Compares the current NetworkPlayerInputs with another and returns the differences as a new instance. + /// Only fields that differ will be set in the returned instance; others will remain null. + /// + /// The other NetworkPlayerInputs to compare with. + /// Custom compare function for additional inputs. Empty byte[] means no changes. + /// A new NetworkPlayerInputs instance with only differing values, or null if there are no differences. + public NetworkPlayerInputs? GetChangedInputsComparedTo(NetworkPlayerInputs inputs, Func? manualCompare = null) { + // Compare each field and set values that differ, leaving others as null + byte? serverTickOffset = _serverTickOffset != inputs._serverTickOffset ? _serverTickOffset : null; + ushort? movementVector = _movementVector != inputs._movementVector ? _movementVector : null; + ushort? joystickVector = _joystickVector != inputs._joystickVector ? _joystickVector : null; + + // If any of these are different we need to send both + bool mouseVectorDiff = _mouseVectorX != inputs._mouseVectorX || _mouseVectorY != inputs._mouseVectorY; + ushort? mouseVectorX = mouseVectorDiff ? _mouseVectorX : null; + ushort? mouseVectorY = mouseVectorDiff ? _mouseVectorY : null; + + // If we have additional inputs on both copare and compare to sides and we have custom comparer we need to pass it to comparing + ReadOnlyMemory? additionalInputs = manualCompare is not null && _additionalInputs.HasValue && inputs._additionalInputs.HasValue + ? (manualCompare(inputs._additionalInputs.Value.ToArray(), _additionalInputs.Value.ToArray()) is var diff && diff.Length > 0 ? diff : null) + : (!ByteArraysEqual(_additionalInputs, inputs._additionalInputs) ? _additionalInputs : null); + + // If no differences exist, return null + return serverTickOffset is null && movementVector is null && joystickVector is null && + mouseVectorX is null && mouseVectorY is null && additionalInputs is null + ? null + : new NetworkPlayerInputs( + tickNumber: _tickNumber, + serverTickOffset: serverTickOffset, + movementVector: movementVector, + joystickVector: joystickVector, + mouseVectorX: mouseVectorX, + mouseVectorY: mouseVectorY, + additionalInputs: additionalInputs + ); + } + + /// + /// Creates a new NetworkPlayerInputs instance by overriding non-null fields from the given `overrides` instance. + /// Fields that are null in `overrides` remain unchanged from the current instance. + /// + /// The NetworkPlayerInputs instance providing the overriding values. + /// If specified will use this value on the result. + /// If specified will use this to override additional inputs. + /// A new NetworkPlayerInputs instance with fields overridden by non-null values from `overrides`. + public NetworkPlayerInputs OverrideInputsWith(NetworkPlayerInputs overrides, int? overrideTick = null, Func? manualOverride = null) + => new( + tickNumber: overrideTick ?? overrides._tickNumber ?? _tickNumber, + serverTickOffset: overrides._serverTickOffset ?? _serverTickOffset, + movementVector: overrides._movementVector ?? _movementVector, + joystickVector: overrides._joystickVector ?? _joystickVector, + mouseVectorX: overrides._mouseVectorX ?? _mouseVectorX, + mouseVectorY: overrides._mouseVectorY ?? _mouseVectorY, + additionalInputs: manualOverride is not null && _additionalInputs.HasValue && overrides._additionalInputs.HasValue + ? (manualOverride(_additionalInputs.Value.ToArray(), overrides._additionalInputs.Value.ToArray()) is var diff && diff.Length > 0 ? diff : null) + : overrides._additionalInputs ?? _additionalInputs + ); + + #endregion + + /* Tick Number Handling */ + + #region Tick Number Handling + + // Stores the validated tick number for the player's inputs. + private int? _tickNumber; + + /// Represents the tick number for the player's inputs, validated to be within the range [0, 2047]. + /// Thrown if the tick number is outside the range [0, 2047]. + public int? TickNumber { + get => _tickNumber; + set => _tickNumber = value is null or < 0 or > 2047 + ? throw new ArgumentOutOfRangeException(nameof(value), $"Invalid TickNumber: {value}. It must be between 0 and 2047.") + : value % 2048; + } + + /// Returns weather the tick number is set or not. + public bool HasTick => _tickNumber.HasValue; + + #endregion + + /* Server Tick Offset handling */ + + #region Server Tick Offset handling + + // Stores the server tick offset as a byte, representing a value between 0 and 255. + private readonly byte? _serverTickOffset; + + /// Represents the server tick offset, ensuring it is within the range of 0 to 255. Throws an exception if the value is out of range. + /// Thrown if the X or Y components are outside the range [-1, 1]. + public readonly int? ServerTickOffset { + get => _serverTickOffset; + init => _serverTickOffset = value is null + ? _serverTickOffset + : value is < 0 or > 255 + ? throw new ArgumentOutOfRangeException(nameof(value), $"Invalid ServerTickOffset: {value}. It must be between 0 and 255.") + : (byte)value; + } + + #endregion + + /* Movement Vector2 Normals handling */ + + #region Movement Vector2 Normals handling + + // Stores the movement input as a serialized Vector2. + private readonly ushort? _movementVector; + + /// Represents the movement input as a Vector2 with components clamped to the range [-1, 1]. + /// Thrown if the X or Y components are outside the range [-1, 1]. + public readonly Vector2? MovementVector { + get => _movementVector is not null + ? DecompressUshortNormalsToVector2(_movementVector.Value) + : null; + init => _movementVector = value is null + ? _movementVector + : value.Value.x is < -1 or > 1 || value.Value.y is < -1 or > 1 + ? throw new ArgumentOutOfRangeException(nameof(value), $"Invalid MovementVector: {value}. Components must be between -1 and 1.") + : CompressVector2NormalsToUshort(value.Value); + } + + #endregion + + /* Joystick Vector2 Normals handling */ + + #region Joystick Vector2 Normals handling + + // Stores the joystick vector as a normalized Vector2. + private readonly ushort? _joystickVector; + + /// Represents the joystick input as a normalized Vector2 with components in the range [-1, 1]. + /// Thrown if the vector components are outside the range [-1, 1]. + public readonly Vector2? JoystickVector { + get => _joystickVector is not null + ? DecompressUshortNormalsToVector2(_joystickVector.Value) + : null; + init => _joystickVector = value is null + ? _joystickVector + : value.Value.x is < -1 or > 1 || value.Value.y is < -1 or > 1 + ? throw new ArgumentOutOfRangeException(nameof(value), $"Invalid JoystickVector: {value}. Components must be between -1 and 1.") + : CompressVector2NormalsToUshort(value.Value); + } + + #endregion + + /* Mouse Vector2 handling */ + + #region Mouse Vector2 handling + + // Stores the mouse vector components as half-precision floats for efficient storage. + private readonly ushort? _mouseVectorX; + private readonly ushort? _mouseVectorY; + + /// Represents the mouse input as a Vector2, with components stored as half-precision floats. + public readonly Vector2? MouseVector { + get => _mouseVectorX.HasValue && _mouseVectorY.HasValue + ? new Vector2(Mathf.HalfToFloat(_mouseVectorX.Value), Mathf.HalfToFloat(_mouseVectorY.Value)) + : null; + init { + _mouseVectorX = value is null ? _mouseVectorX : Mathf.FloatToHalf(value.Value.x); + _mouseVectorY = value is null ? _mouseVectorY : Mathf.FloatToHalf(value.Value.y); + } + } + + #endregion + + /* Additional inputs handling */ + + #region Additional inputs handling + + //Stores additional inputs defined by the developer, allowing custom byte data. + private ReadOnlyMemory? _additionalInputs; + + /// Stores additional inputs defined by the developer, allowing custom byte data. Empty byte arrays are not allowed. + /// Thrown if the byte array is empty. + public ReadOnlyMemory? AdditionalInputs { + get => _additionalInputs; + init => _additionalInputs = value is null + ? _additionalInputs + : value is { Length: 0 } + ? throw new ArgumentException("AdditionalInputs cannot be an empty byte array.", nameof(value)) + : value; + } + + #endregion + + /* Serialization and Deserialization */ + + #region Serialization and DeserializatioMyRegion + + /// Serializes the NetworkPlayerInputs by encoding presence of inputs in a 16-bit header and writes relevant fields conditionally. + /// The NetworkWriter to write to. + /// The NetworkPlayerInputs to serialize. + public static void WriteNetworkPlayerInputs(NetworkWriter writer, NetworkPlayerInputs inputs) { + // Ensure tick number is set; otherwise, we may send the wrong tick number here + if (inputs._tickNumber is null) + throw new InvalidOperationException("WriteNetworkPlayerInputs.TickNumber must be set before serialization."); + + // Create header of 16 bits ( 5 bits for payload and 11 bits for the tick number ) + ushort header = 0; + // First 5 bits represent the presence (null or non-null) of specific inputs + if (inputs._serverTickOffset is not null) header |= (1 << 0); // Bit 0: ServerTickOffset presence + if (inputs._movementVector is not null) header |= (1 << 1); // Bit 1: MovementVector presence + if (inputs._joystickVector is not null) header |= (1 << 2); // Bit 2: JoystickVector presence + if (inputs._mouseVectorX is not null && inputs._mouseVectorY is not null) header |= (1 << 3); // Bit 3: MouseVector presence + if (inputs._additionalInputs is not null) header |= (1 << 4); // Bit 4: AdditionalInputs non-empty + + // Next 11 bits represent the tick number (masking to ensure only lower 11 bits are used) + header |= (ushort)((inputs._tickNumber & 0x7FF) << 5); + + //Write header first + writer.WriteUShort(header); + + // Write server tick offset if its not null + if (inputs._serverTickOffset is not null) + writer.WriteByte(inputs._serverTickOffset.Value); + + // Write compressed movement vector if its not null + if (inputs._movementVector is not null) + writer.WriteUShort(inputs._movementVector.Value); + + // Write compressed joystick vector if its not null + if (inputs._joystickVector is not null) + writer.WriteUShort(inputs._joystickVector.Value); + + // Write Half (fp16) mouse vector if its not null + if (inputs._mouseVectorX is not null && inputs._mouseVectorY is not null) { + writer.WriteUShort(inputs._mouseVectorX.Value); + writer.WriteUShort(inputs._mouseVectorY.Value); + } + + // Write additional inputs bytes + if (inputs._additionalInputs is not null) + writer.WriteBytesAndSize(inputs._additionalInputs.Value.ToArray(), 0, inputs._additionalInputs.Value.Length); + } + + + /// Deserializes NetworkPlayerInputs by reading a 16-bit header to determine which fields are present and reads them conditionally. + /// The NetworkReader to read from. + /// A deserialized instance of NetworkPlayerInputs. + public static NetworkPlayerInputs ReadNetworkPlayerInputs(NetworkReader reader) { + // Read the header first + ushort header = reader.ReadUShort(); + + // Extract presence bits from the header + bool hasServerTickOffset = (header & (1 << 0)) != 0; // Bit 0: ServerTickOffset presence + bool hasMovementVector = (header & (1 << 1)) != 0; // Bit 1: MovementVector presence + bool hasJoystickVector = (header & (1 << 2)) != 0; // Bit 2: JoystickVector presence + bool hasMouseVector = (header & (1 << 3)) != 0; // Bit 3: MouseVector presence + bool hasAdditionalInputs = (header & (1 << 4)) != 0; // Bit 4: AdditionalInputs non-empty + + // Extract the tick number from the header (last 11 bits) + int tickNumber = (header >> 5) & 0x7FF; + + // Initialize fields + byte? serverTickOffset = hasServerTickOffset ? reader.ReadByte() : null; + ushort? movementVector = hasMovementVector ? reader.ReadUShort() : null; + ushort? joystickVector = hasJoystickVector ? reader.ReadUShort() : null; + ushort? mouseVectorX = hasMouseVector ? reader.ReadUShort() : null; + ushort? mouseVectorY = hasMouseVector ? reader.ReadUShort() : null; + + // Compiler nonsense requires me to use explicit if-else to avoid getting byte[0] instead of null + ReadOnlyMemory? additionalInputs; + if (hasAdditionalInputs) + additionalInputs = new ReadOnlyMemory(reader.ReadBytesAndSize()); + else + additionalInputs = null; + + // Construct and return the NetworkPlayerInputs object + return new NetworkPlayerInputs( + tickNumber: tickNumber, + serverTickOffset: serverTickOffset, + movementVector: movementVector, + joystickVector: joystickVector, + mouseVectorX: mouseVectorX, + mouseVectorY: mouseVectorY, + additionalInputs: additionalInputs + ); + } + + #endregion + + + /* Utility functions */ + + #region Utility functions + + /// Compares two byte arrays for equality, including handling null values. + /// The first ReadOnlyMemory? to compare, can be null. + /// The second ReadOnlyMemory? to compare, can be null. + /// Comparison by value + public static bool ByteArraysEqual(in ReadOnlyMemory? byteArray1, in ReadOnlyMemory? byteArray2) { + // If both are null, they are equal + if (byteArray1 == null && byteArray2 == null) return true; + + // If one is null but not the other, they are not equal + if (byteArray1 == null || byteArray2 == null) return false; + + return byteArray1.Value.Span.SequenceEqual(byteArray2.Value.Span); + } + + /// Decompresses a back into normalized Vector2 values (X and Y). + /// The compressed value. + /// A tuple containing the normalized Vector2 values in the range [-1, 1]. + public static Vector2 DecompressUshortNormalsToVector2(ushort compressedValue) { + // Extract byteX and byteY from the compressed ushort + byte byteX = (byte)(compressedValue >> 8); + byte byteY = (byte)(compressedValue & 0xFF); + // Convert byte values back to normalized Vector2 in the range [-1, 1] + return new Vector2() { x = (byteX / 127f) - 1f, y = (byteY / 127f) - 1f }; + } + + + /// Compresses normalized Vector2 values (X and Y) into a single . + /// The normalized X and Y axis values in the range [-1, 1]. + /// A representing the compressed Vector2 values. + public static ushort CompressVector2NormalsToUshort(Vector2 vector) { + // Scale and shift values from [-1, 1] to [0, 254] + byte byteX = (byte)((Mathf.Clamp(vector.x, -1f, 1f) + 1f) * 127f); + byte byteY = (byte)((Mathf.Clamp(vector.y, -1f, 1f) + 1f) * 127f); + // Combine byteX and byteY into a single ushort + return (ushort)((byteX << 8) | byteY); + } + + #endregion + } + + // Serializers for the NetworkPlayerInputs Struct + public static class NetworkPlayerInputsSerializer{ + public static void WriteNetworkPlayerInputs(this NetworkWriter writer, NetworkPlayerInputs value) { + NetworkPlayerInputs.WriteNetworkPlayerInputs(writer, value); + } + + public static NetworkPlayerInputs ReadNetworkPlayerInputs(this NetworkReader reader) { + return NetworkPlayerInputs.ReadNetworkPlayerInputs(reader); + } + + public static void WriteNetworkPlayerInputsListArray(this NetworkWriter writer, NetworkPlayerInputs[] value) { + if (value.Length > 255) + throw new ArgumentOutOfRangeException(nameof(value), "NetworkPlayerInputs[]: Max supported length is 255"); + writer.WriteByte((byte)value.Length); + Array.ForEach(value, item => NetworkPlayerInputs.WriteNetworkPlayerInputs(writer, item)); + } + + public static NetworkPlayerInputs[] ReadNetworkPlayerInputsArray(this NetworkReader reader) => + Enumerable.Range(0, reader.ReadByte()) + .Select(_ => NetworkPlayerInputs.ReadNetworkPlayerInputs(reader)) + .ToArray(); + + public static void WriteNetworkPlayerInputsListList(this NetworkWriter writer, List value) { + if (value.Count > 255) + throw new ArgumentOutOfRangeException(nameof(value), "List: Max supported length is 255"); + writer.WriteByte((byte)value.Count); + value.ForEach(item => NetworkPlayerInputs.WriteNetworkPlayerInputs(writer, item)); + } + + public static List ReadNetworkPlayerInputsList(this NetworkReader reader) => + Enumerable.Range(0, reader.ReadByte()) + .Select(_ => NetworkPlayerInputs.ReadNetworkPlayerInputs(reader)) + .ToList(); + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs.meta b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs.meta new file mode 100644 index 00000000000..24c387b8299 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerInputs.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2211aaeb9db94477bbf7378c640b0ca2 +timeCreated: 1733696353 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs new file mode 100644 index 00000000000..a4af245d32b --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs @@ -0,0 +1,357 @@ +using System.Runtime.CompilerServices; // do not remove, required to add init support for net4.9 or lower +using System.Collections.Generic; +using System.Linq; +using System; +using UnityEngine; + +#nullable enable +namespace Mirror.Components.Experimental{ + public struct NetworkPlayerState{ + /// Initializes a new instance of the struct. Optionally takes defaults to initialize the instance. + /// Default to copy values from, or null for default initialization. + public NetworkPlayerState(NetworkPlayerState? defaults = null) { + _tickNumber = defaults?._tickNumber; + _parent = defaults?._parent; + _parentId = defaults?._parentId; + _additionalState = defaults?._additionalState; + Position = defaults?.Position; + BaseVelocity = defaults?.BaseVelocity; + Rotation = defaults?.Rotation; + } + + /// + /// Initializes a new instance of the struct with the specified values. + /// This private constructor allows internal use for creating instances with selected fields. + /// + /// The validated tick number for the player's state, or null if unset. + /// The parent instance associated with this state, or null if none. + /// The unique identifier of the parent object, or null if none. + /// The player's position, or null if unset. + /// The player's base velocity, or null if unset. + /// The player's rotation, or null if unset. + /// Additional custom state data, or null if unset. + private NetworkPlayerState(int? tickNumber, NetworkIdentity? parent, uint? parentId, Vector3? position, Vector3? baseVelocity, Quaternion? rotation, + ReadOnlyMemory? additionalState) { + _tickNumber = tickNumber; + _parent = parent; + _parentId = parentId; + Position = position; + BaseVelocity = baseVelocity; + Rotation = rotation; + _additionalState = additionalState; + } + + /* State Compare and Extend methods */ + + #region State Compare and Extend methods + + /// + /// Creates a new state instance containing only the fields that have changed compared to the given state. + /// If no fields differ, returns null. + /// + /// The other NetworkPlayerState to compare with. + /// When to use compare value by bits ( any change ). + /// Custom compare function for additional state. Empty byte[] means no changes. + /// A new NetworkPlayerState instance with only differing values, or null if there are no differences. + public NetworkPlayerState? GetChangedStateComparedTo(NetworkPlayerState state, bool bitwise = false, Func? manualCompare = null) { + // Compare items that can be sent via network + Vector3? position; + Vector3? baseVelocity; + Quaternion? rotation; + + // Decide weather to compare it bit by bit or allow unity to compare with built in threshold + if (bitwise) { + position = !Vector3BitwiseEqual(Position, state.Position) ? Position : null; + baseVelocity = !Vector3BitwiseEqual(BaseVelocity, state.BaseVelocity) ? BaseVelocity : null; + rotation = !QuaternionBitwiseEqual(Rotation, state.Rotation) ? Rotation : null; + } + else { + position = Position != state.Position ? Position : null; + baseVelocity = BaseVelocity != state.BaseVelocity ? BaseVelocity : null; + rotation = Rotation != state.Rotation ? Rotation : null; + } + + // Ensure to align the parent NetworkIdentity instance with the changed parent state + uint? parentId = _parentId != state._parentId ? _parentId : null; + NetworkIdentity? parent = parentId is not null ? _parent : null; + + // If we have additional inputs on both copare and compare to sides and we have custom comparer we need to pass it to comparing + ReadOnlyMemory? additionalState = manualCompare is not null && _additionalState.HasValue && state._additionalState.HasValue + ? (manualCompare(state._additionalState.Value.ToArray(), _additionalState.Value.ToArray()) is var diff && diff.Length > 0 ? diff : null) + : (!ByteArraysEqual(_additionalState, state._additionalState) ? _additionalState : null); + + // If no changes in the network data we return null otherwise we create a new state with the changes + return parentId is null && position is null && baseVelocity is null && rotation is null && additionalState is null + ? null + : new NetworkPlayerState( + tickNumber: _tickNumber, + parent: parent, + parentId: parentId, + position: position, + baseVelocity: baseVelocity, + rotation: rotation, + additionalState: additionalState + ); + } + + /// + /// Creates a new NetworkPlayerState instance by overriding non-null fields from the given `overrides` instance. + /// Fields that are null in `overrides` remain unchanged from the current instance. + /// + /// The NetworkPlayerState instance providing the overriding values. + /// If specified will use this value on the result. + /// If specified will use this to override additional state. + /// A new NetworkPlayerState instance with fields overridden by non-null values from `overrides`. + public NetworkPlayerState OverrideStateWith(NetworkPlayerState overrides, int? overrideTick = null, Func? manualOverride = null) + => new( + tickNumber: overrideTick ?? overrides._tickNumber ?? _tickNumber, + parent: overrides._parentId is not null ? overrides._parent : _parent, + parentId: overrides._parentId ?? _parentId, + position: overrides.Position ?? Position, + baseVelocity: overrides.BaseVelocity ?? BaseVelocity, + rotation: overrides.Rotation ?? Rotation, + additionalState: manualOverride is not null && _additionalState.HasValue && overrides._additionalState.HasValue + ? (manualOverride(_additionalState.Value.ToArray(), overrides._additionalState.Value.ToArray()) is var diff && diff.Length > 0 ? diff : null) + : overrides._additionalState ?? _additionalState + ); + + #endregion + + /* Tick Number Handling */ + + #region Tick Number Handling + + // Stores the validated tick number for the player's state. + private int? _tickNumber; + + /// Represents the tick number for the player's state, validated to be within the range [0, 2047]. + /// Thrown if the tick number is outside the range [0, 2047]. + public int? TickNumber { + get => _tickNumber; + set => _tickNumber = value is null or < 0 or > 2047 + ? throw new ArgumentOutOfRangeException(nameof(value), $"Invalid TickNumber: {value}. It must be between 0 and 2047.") + : value % 2048; + } + + /// Returns weather the tick number is set or not. + public bool HasTick => _tickNumber.HasValue; + + #endregion + + /* Physics state handling */ + + #region Physics state handling + + /// Represents the player's position in the world. This value does not undergo compression and is directly used as-is. + public readonly Vector3? Position { get; init; } + + /// Represents the player's base velocity in the world. This value does not undergo compression and is directly used as-is. + public readonly Vector3? BaseVelocity { get; init; } + + /// Represents the player's rotation in the world. This value does not undergo compression and is directly used as-is. + public readonly Quaternion? Rotation { get; init; } + + #endregion + + /* Additional state handling */ + + #region Additional state handling + + //Stores additional state defined by the developer, allowing custom byte data. + private readonly ReadOnlyMemory? _additionalState; + + /// Stores additional state defined by the developer, allowing custom byte data. Empty byte arrays are not allowed. + /// Thrown if the byte array is empty. + public readonly ReadOnlyMemory? AdditionalState { + get => _additionalState; + init => _additionalState = value is null + ? _additionalState + : value is { Length: 0 } + ? throw new ArgumentException("AdditionalState cannot be an empty byte array.", nameof(value)) + : value; + } + + #endregion + + /* Parent state handling */ + + #region Parent state handling + + // This is here to ensure we dont do expensive search every time we want to return the NetworkIdentity parent instance + private readonly NetworkIdentity? _parent; + + // Actual parentId to send over the network, null means no change and 0 means no-parent. + private readonly uint? _parentId; + + /// Indicates whether a parent is set. Returns true if the parent is either assigned or unassigned; otherwise, false. + public readonly bool IsParentSet => _parentId is not null; + + /// Gets or sets the parent . When set, the associated parent ID is also cached. + public readonly NetworkIdentity? Parent { + get => _parent; + init => (_parent, _parentId) = (value, value?.netId ?? 0); + } + + #endregion + + /* Serialization and Deserialization */ + + #region Serialization and DeserializatioMyRegion + + /// Serializes the NetworkPlayerState by encoding presence of state in a 16-bit header and writes relevant fields conditionally. + /// The NetworkWriter to write to. + /// The NetworkPlayerState to serialize. + public static void WriteNetworkPlayerState(NetworkWriter writer, NetworkPlayerState state) { + // Ensure tick number is set; otherwise, we may send the wrong tick number here + if (state._tickNumber is null) + throw new InvalidOperationException("WriteNetworkPlayerState.TickNumber must be set before serialization."); + + // Create header of 16 bits ( 5 bits for payload and 11 bits for the tick number ) + ushort header = 0; + // First 5 bits represent the presence (null or non-null) of specific state + if (state._parentId is not null) header |= (1 << 0); // Bit 0: Parent presence + if (state.Position is not null) header |= (1 << 1); // Bit 1: Position presence + if (state.BaseVelocity is not null) header |= (1 << 2); // Bit 2: BaseVelocity presence + if (state.Rotation is not null) header |= (1 << 3); // Bit 3: Rotation presence + if (state._additionalState is not null) header |= (1 << 4); // Bit 4: AdditionalState non-empty + + // Next 11 bits represent the tick number (masking to ensure only lower 11 bits are used) + header |= (ushort)((state._tickNumber & 0x7FF) << 5); + + //Write header first + writer.WriteUShort(header); + + // Write parent id if it's not null + if (state._parentId is not null) + writer.WriteUInt(state._parentId.Value); + + // Write position vector if it's not null + if (state.Position is not null) + writer.WriteVector3(state.Position.Value); + + // Write base velocity vector if it's not null + if (state.BaseVelocity is not null) + writer.WriteVector3(state.BaseVelocity.Value); + + // Write rotation quaternion if it's not null + if (state.Rotation is not null) + writer.WriteQuaternion(state.Rotation.Value); + + // Write additional state bytes + if (state._additionalState is not null) + writer.WriteBytesAndSize(state._additionalState.Value.ToArray(), 0, state._additionalState.Value.Length); + } + + /// Deserializes NetworkPlayerState by reading a 16-bit header to determine which fields are present and reads them conditionally. + /// The NetworkReader to read from. + /// A deserialized instance of NetworkPlayerState. + public static NetworkPlayerState ReadNetworkPlayerState(NetworkReader reader) { + // Read the header first + ushort header = reader.ReadUShort(); + // Extract presence bits from the header + bool hasParentId = (header & (1 << 0)) != 0; // Bit 0: ServerTickOffset presence + bool hasPosition = (header & (1 << 1)) != 0; // Bit 1: MovementVector presence + bool hasBaseVelocity = (header & (1 << 2)) != 0; // Bit 2: JoystickVector presence + bool hasRotation = (header & (1 << 3)) != 0; // Bit 3: MouseVector presence + bool hasAdditionalState = (header & (1 << 4)) != 0; // Bit 4: AdditionalState non-empty + // Extract the tick number from the header (last 11 bits) + int tickNumber = (header >> 5) & 0x7FF; + // extract the set data + uint? parentId = hasParentId ? reader.ReadUInt() : null; + Vector3? position = hasPosition ? reader.ReadVector3() : null; + Vector3? baseVelocity = hasBaseVelocity ? reader.ReadVector3() : null; + Quaternion? rotation = hasRotation ? reader.ReadQuaternion() : null; + // Compiler nonsense requires me to use explicit if-else to avoid getting byte[0] instead of null + ReadOnlyMemory? additionalState; + if (hasAdditionalState) + additionalState = new ReadOnlyMemory(reader.ReadBytesAndSize()); + else + additionalState = null; + // We want to fetch the parent here to prevent expensive get by id method later + NetworkIdentity? parent = parentId is not null && parentId != 0 ? Utils.GetSpawnedInServerOrClient(parentId.Value) : null; + return new NetworkPlayerState( + tickNumber: tickNumber, + parent: parent, + parentId: parentId, + position: position, + baseVelocity: baseVelocity, + rotation: rotation, + additionalState: additionalState + ); + } + + #endregion + + /*** Utility Functions ***/ + + #region Utility Functions + + private static bool Vector3BitwiseEqual(Vector3? v1, Vector3? v2) => + v1.HasValue && v2.HasValue + ? BitConverter.SingleToInt32Bits(v1.Value.x) == BitConverter.SingleToInt32Bits(v2.Value.x) + && BitConverter.SingleToInt32Bits(v1.Value.y) == BitConverter.SingleToInt32Bits(v2.Value.y) + && BitConverter.SingleToInt32Bits(v1.Value.z) == BitConverter.SingleToInt32Bits(v2.Value.z) + : v1 == v2; + + + private static bool QuaternionBitwiseEqual(Quaternion? q1, Quaternion? q2) => + q1.HasValue && q2.HasValue + ? BitConverter.SingleToInt32Bits(q1.Value.x) == BitConverter.SingleToInt32Bits(q2.Value.x) + && BitConverter.SingleToInt32Bits(q1.Value.y) == BitConverter.SingleToInt32Bits(q2.Value.y) + && BitConverter.SingleToInt32Bits(q1.Value.z) == BitConverter.SingleToInt32Bits(q2.Value.z) + && BitConverter.SingleToInt32Bits(q1.Value.w) == BitConverter.SingleToInt32Bits(q2.Value.w) + : q1 == q2; + + + /// Compares two byte arrays for equality, including handling null values. + /// The first ReadOnlyMemory? to compare, can be null. + /// The second ReadOnlyMemory? to compare, can be null. + /// Comparison by value + private static bool ByteArraysEqual(in ReadOnlyMemory? byteArray1, in ReadOnlyMemory? byteArray2) { + // If both are null, they are equal + if (byteArray1 == null && byteArray2 == null) return true; + + // If one is null but not the other, they are not equal + if (byteArray1 == null || byteArray2 == null) return false; + + return byteArray1.Value.Span.SequenceEqual(byteArray2.Value.Span); + } + + #endregion + } + + // Serializers for the NetworkPlayerState Struct + public static class NetworkPlayerStateSerializer{ + public static void WriteNetworkPlayerState(this NetworkWriter writer, NetworkPlayerState value) { + NetworkPlayerState.WriteNetworkPlayerState(writer, value); + } + + public static NetworkPlayerState ReadNetworkPlayerState(this NetworkReader reader) { + return NetworkPlayerState.ReadNetworkPlayerState(reader); + } + + public static void WriteNetworkPlayerStateListArray(this NetworkWriter writer, NetworkPlayerState[] value) { + if (value.Length > 255) + throw new ArgumentOutOfRangeException(nameof(value), "NetworkPlayerState[]: Max supported length is 255"); + writer.WriteByte((byte)value.Length); + Array.ForEach(value, item => NetworkPlayerState.WriteNetworkPlayerState(writer, item)); + } + + public static NetworkPlayerState[] ReadNetworkPlayerStateArray(this NetworkReader reader) => + Enumerable.Range(0, reader.ReadByte()) + .Select(_ => NetworkPlayerState.ReadNetworkPlayerState(reader)) + .ToArray(); + + public static void WriteNetworkPlayerStateListList(this NetworkWriter writer, List value) { + if (value.Count > 255) + throw new ArgumentOutOfRangeException(nameof(value), "List: Max supported length is 255"); + writer.WriteByte((byte)value.Count); + value.ForEach(item => NetworkPlayerState.WriteNetworkPlayerState(writer, item)); + } + + public static List ReadNetworkPlayerStateList(this NetworkReader reader) => + Enumerable.Range(0, reader.ReadByte()) + .Select(_ => NetworkPlayerState.ReadNetworkPlayerState(reader)) + .ToList(); + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs.meta b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs.meta new file mode 100644 index 00000000000..d7f706f663b --- /dev/null +++ b/Assets/Mirror/Components/Experimental/NetworkPlayerController/NetworkPlayerState.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9031959bfd46410b9f9581eb2593bdbb +timeCreated: 1733696360 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager.meta b/Assets/Mirror/Components/Experimental/TickManager.meta new file mode 100644 index 00000000000..1748900dd11 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d69f68a465494598a5c5d36a51f314d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs new file mode 100644 index 00000000000..a07ca74df57 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs @@ -0,0 +1,139 @@ +using UnityEngine; +using System; + +namespace Mirror.Components.Experimental{ + [DefaultExecutionOrder(-10)] + [DisallowMultipleComponent] + [AddComponentMenu("Network/Network Physics Controller")] + public class NetworkPhysicsController : MonoBehaviour{ + private readonly NetworkPhysicsEntity _physicsEntity = new NetworkPhysicsEntity(); + + // reconcile tick and request status + private static bool _pendingReconcile = false; + private static int _reconcileStartTick = 0; + + + /// + /// Callback action to handle tick-forwarding logic. + /// Allows external classes to define custom behavior when the tick advances. + /// + public Action TickForwardCallback; + + /// + /// Subscribable callback action to handle reset state logic. + /// Allows external classes to define custom behavior on reset state. + /// + public static event Action OnResetState; + + /// + /// Subscribable callback action to handle synchronized logic. + /// Allows external classes to define custom behavior on network synchronization. + /// + public static event Action OnSynchronized; + + /// Ensure that auto physics simulations are disabled. + void Awake() => Physics.autoSimulation = false; + + /// + /// Advances the game state by a specified number of ticks. + /// Invokes the TickForwardCallback to allow external classes to handle tick-forwarding logic. + /// Typically called with `deltaTicks` = 1 from RunSimulate. + /// + /// The number of ticks to forward. + public virtual void TickForward(int deltaTicks) { + TickForwardCallback?.Invoke(deltaTicks); + } + + /// + /// Called when network is synchronized. Invokes the if it is not null. + /// + public virtual void NetworkSynchronized() { + OnSynchronized?.Invoke(); + _physicsEntity.RunNetworkSynchronized(); + } + + /// + /// Called just before the reconcile process is performed. Invokes the if it is not null. + /// + public virtual void ResetNetworkState() { + // First, call all callbacks to let non-networked items reset their states before networked items. + // Otherwise, networked items might rely on out-of-sync world data (e.g., positions) if non-networked items aren’t reset first. + OnResetState?.Invoke(); + _physicsEntity.RunResetNetworkState(); + } + + /// + /// Executes a single physics simulation step for the given delta time. + /// Uses Unity's Physics.Simulate to perform the physics tick. + /// Typically called with Time.fixedDeltaTime. + /// + /// The time interval to simulate physics for. + public virtual void PhysicsTick(float deltaTime) { + Physics.Simulate(deltaTime); // Using Unity's built-in physics engine. + } + + /// + /// Runs the simulation for the specified number of delta ticks. + /// This method performs multiple steps of entity updates and physics ticks + /// to bring the simulation in sync with the latest tick count. + /// + /// The number of ticks to simulate forward. + /// Is current simulation a reconciliation. + public void RunSimulate(int deltaTicks, bool isReconciling = false) { + // ensure that the are ticks to execute in the first place + if (deltaTicks < 1) return; + + // execute the first tick + TickForward(1); + + // If reconciling, reset the network state before simulating. + if (isReconciling) ResetNetworkState(); + SimulateTick(); + + // run additional ticks iteration if any left skipping the first step since it was already executed + for (var step = 1; step < deltaTicks; step++) { + TickForward(1); + SimulateTick(); + } + + // If reconciling, run post-reconciliation actions. + if (isReconciling) _physicsEntity.RunAfterReconcile(); + } + + /// Performs a single simulation step by invoking pre-updates, network updates, physics simulation, and post-updates for one tick. + public void SimulateTick() { + var deltaTime = Time.fixedDeltaTime; + _physicsEntity.RunBeforeNetworkUpdates(1, deltaTime); + _physicsEntity.RunNetworkUpdates(1, deltaTime); + PhysicsTick(deltaTime); + _physicsEntity.RunAfterNetworkUpdates(1, deltaTime); + } + + /// Requests the reconciliation process to start from a specific tick (including the requested tick ) + /// The tick from which to start reconciliation. + public static void RequestReconcileFromTick(int reconcileStartTick) { + if (!_pendingReconcile || NetworkTick.SubtractTicks(_reconcileStartTick, reconcileStartTick) > 0) { + _pendingReconcile = true; + _reconcileStartTick = reconcileStartTick; // the +1 is important to include the faulty tick + } + } + + /// Requests the reconciliation process to start from a specific tick on an instance. + /// The tick from which to start reconciliation. + public void ReconcileFromTick(int reconcileStartTick) + => RequestReconcileFromTick(reconcileStartTick); + + /// Retrieves the tick number from which reconciliation should start. + /// The tick number from which to start reconciliation. + public int GetReconcileStartTick() => _reconcileStartTick; + + /// Is reconcile requested or not + public bool IsPEndingReconcile() => _pendingReconcile; + + /// Resets the reconciliation counter and pending flag, marking the reconciliation process as complete. + public void ResetReconcile() { + _reconcileStartTick = 0; + _pendingReconcile = false; + } + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs.meta new file mode 100644 index 00000000000..9e6d11c0cbd --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsController.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b36faa4b9565404b95ba6539a10fc47f +timeCreated: 1730317284 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs new file mode 100644 index 00000000000..f5fbed5ad90 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; + +namespace Mirror.Components.Experimental{ + /// + /// Interface representing a network item that requires updates at various stages of the network tick cycle. + /// Each method in this interface is intended to handle specific stages of the update process. + /// + public interface INetworkedItem{ + /// + /// Called when client and server are synchronized. + /// + void OnNetworkSynchronized(); + + /// + /// Called before the network reconciliation process begins, allowing the item to properly reset state to the last known good state. + /// + void OnResetNetworkState(); + + /// + /// Called after the network reconciliation process ends. + /// + void AfterNetworkReconcile() { + } + + /// + /// Called before the main network update, allowing the item to perform any necessary preparation or pre-update logic. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + void OnBeforeNetworkUpdate(int deltaTicks, float deltaTime) { + } + + /// + /// Called during the main network update, allowing the item to handle core updates related to network state, physics, or entity positioning. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + void OnNetworkUpdate(int deltaTicks, float deltaTime); + + /// + /// Called after the main network update, allowing the item to perform any necessary cleanup or post-update logic. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + void OnAfterNetworkUpdate(int deltaTicks, float deltaTime) { + } + } + + /// + /// Manages network update sequences for entities requiring tick-based adjustments. + /// + public class NetworkPhysicsEntity{ + /// Stores items requiring updates on each tick, as a list of tuples with priority and item. + private static readonly List<(int priority, INetworkedItem item)> NetworkItems = new List<(int, INetworkedItem)>(); + + /// Adds a network entity to the collection for updates and sorts by priority. + /// The network item implementing that requires tick updates. + /// The priority for the entity, with lower numbers indicating higher priority. + public static void AddNetworkEntity(INetworkedItem item, int priority = 0) { + // Add item to list of executables + NetworkItems.Add((priority, item)); + + // Fortunately, List.Sort() in C# uses a stable sorting algorithm so same priority remains in the same order and new items are added to the end + // [2-a, 1-a, 1-b, 0-a, 0-b, 0-c] + [1-c] => [2-a, 1-a, 1-b, 1-c, 0-a, 0-b, 0-c] + NetworkItems.Sort((x, y) => y.priority.CompareTo(x.priority)); + } + + /// Removes a network entity from the collection based on the item reference only. + /// The network item to remove. + public static void RemoveNetworkEntity(INetworkedItem item) { + NetworkItems.RemoveAll(entry => entry.item.Equals(item)); + } + + /// + /// Runs the AfterReconcile method on each network item in priority order. + /// This method is intended to signal reconcile complete. + /// + public void RunAfterReconcile() { + foreach (var (_, item) in NetworkItems) { + item.AfterNetworkReconcile(); + } + } + + /// + /// Runs the OnResetNetworkState method on each network item in priority order. + /// This method is intended to reset the network state before any updates are processed. + /// + public void RunResetNetworkState() { + foreach (var (_, item) in NetworkItems) { + item.OnResetNetworkState(); + } + } + + /// + /// Runs the OnNetworkSynchronized method on each network item in priority order. + /// This method is intended to signal that the network state is synchronized. + /// + public void RunNetworkSynchronized() { + foreach (var (_, item) in NetworkItems) { + item.OnNetworkSynchronized(); + } + } + + /// + /// Runs the OnBeforeNetworkUpdate method on each network item in priority order. + /// This method is intended to perform any necessary setup or pre-update logic before the main network updates are processed. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + public void RunBeforeNetworkUpdates(int deltaTicks, float deltaTime) { + foreach (var (_, item) in NetworkItems) { + item.OnBeforeNetworkUpdate(deltaTicks, deltaTime); + } + } + + /// + /// Runs the OnNetworkUpdate method on each network item in priority order. + /// This method executes the main network update logic for each item, handling any core updates needed for the network state or entity positions. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + public void RunNetworkUpdates(int deltaTicks, float deltaTime) { + foreach (var (_, item) in NetworkItems) { + item.OnNetworkUpdate(deltaTicks, deltaTime); + } + } + + /// + /// Runs the AfterNetworkUpdate method on each network item in priority order. + /// This method is intended for any necessary cleanup or post-update logic following the main network updates. + /// + /// The number of ticks since the last update. + /// The time elapsed since the last update in seconds. + public void RunAfterNetworkUpdates(int deltaTicks, float deltaTime) { + foreach (var (_, item) in NetworkItems) { + item.OnAfterNetworkUpdate(deltaTicks, deltaTime); + } + } + } +} diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs.meta new file mode 100644 index 00000000000..7d5484a568d --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkPhysicsEntity.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8b08c8ad7b0840fdb96784accdc66787 +timeCreated: 1730400815 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs b/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs new file mode 100644 index 00000000000..11d56cddf04 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; + +namespace Mirror.Components.Experimental{ + public class NetworkTick{ + /*** Private Definitions ***/ + + #region Private Definitions + + // Current state flags + private static bool _isServer = false; + private static bool _isSynchronizing = false; + private static bool _isSynchronized = false; + private static bool _isReconciling = false; + + // Internal tick counters + private static int _clientTick = 0; + private static int _serverTick = 0; + private static int _absoluteClientTick = 0; + private static int _absoluteServerTick = 0; + + // Packet loss compensation ticks + private static int _clientToServerPacketLossCompensation = 0; + private static int _serverToClientPacketLossCompensation = 0; + + // Holds server-only data for tracking tick compensations for incoming and outgoing packets. + private static readonly Dictionary ServerToClientCompensations = new(); + private static readonly Dictionary ClientToServerCompensations = new(); + + #endregion + + /* Utility */ + + #region Utility + + /// Resets NetworkTick to initial state. + public void Reset() { + _isServer = false; + _isSynchronizing = false; + _isSynchronized = false; + _isReconciling = false; + _clientTick = 0; + _serverTick = 0; + _absoluteClientTick = 0; + _absoluteServerTick = 0; + _clientToServerPacketLossCompensation = 0; + _serverToClientPacketLossCompensation = 0; + ServerToClientCompensations.Clear(); + ClientToServerCompensations.Clear(); + } + + #endregion + + + /*** SERVER ONLY METHODS ***/ + + #region SERVER ONLY METHODS + + /// Provides server-only methods for retrieving compensation values. + public static class Server{ + /// Gets the server-to-client compensation for the specified connection ID. Throws an exception if called from a non-server context. + public static int GetServerToClientCompensation(int connectionId) => _isServer + ? ServerToClientCompensations.GetValueOrDefault(connectionId, 0) + : throw new InvalidOperationException("Server.GetServerToClientCompensation is server-only and cannot be accessed on the client."); + + /// Gets the client-to-server compensation for the specified connection ID. Throws an exception if called from a non-server context. + public static int GetClientToServerCompensation(int connectionId) => _isServer + ? ClientToServerCompensations.GetValueOrDefault(connectionId, 0) + : throw new InvalidOperationException("Server.GetServerToClientCompensation is server-only and cannot be accessed on the client."); + } + + /// Sets the server-to-client compensation value based on connection ID. Throws an exception if called from a non-server context. + public void ServerSetServerToClientCompensation(int connectionId, int compensation) => + ServerToClientCompensations[connectionId] = _isServer + ? Math.Max(compensation, 0) + : throw new InvalidOperationException("ServerSetServerToClientCompensation is server-only."); + + /// Sets the client-to-server compensation value based on connection ID. Throws an exception if called from a non-server context. + public void ServerSetClientToServerCompensation(int connectionId, int compensation) => + ClientToServerCompensations[connectionId] = _isServer + ? Math.Max(compensation, 0) + : throw new InvalidOperationException("ServerSetServerToClientCompensation is server-only."); + + #endregion + + /*** CLIENT ONLY METHODS ***/ + + #region CLIENT ONLY METHODS + + /// Gets the client-to-server packet loss compensation ticks. Client-only: This cant be accessed on the server. + /// Thrown if accessed on the server. + public static int ClientToServerPacketLossCompensation { + get { + if (_isServer) throw new InvalidOperationException("ClientToServerPacketLossCompensation is client-only and cannot be accessed on the server."); + return _clientToServerPacketLossCompensation; + } + } + + /// Gets the server-to-client packet loss compensation ticks. Client-only: This cant be accessed on the server. + /// Thrown if accessed on the server. + public static int ServerToClientPacketLossCompensation { + get { + if (_isServer) throw new InvalidOperationException("ServerToClientPacketLossCompensation is client-only and cannot be accessed on the server."); + return _serverToClientPacketLossCompensation; + } + } + + /// + /// Sets the client-to-server packet loss compensation ticks, allowing the client to define the number of compensation ticks based on detected packet loss. + /// Client-only: This method should not be called on the server. + /// + /// The number of compensation ticks to set. + public void SetClientToServerPacketLossCompensation(int compensationTicks) { + if (_isServer) throw new InvalidOperationException("SetClientToServerPacketLossCompensation is client-only and cannot be accessed on the server."); + _clientToServerPacketLossCompensation = compensationTicks; + } + + /// + /// Sets the server-to-client packet loss compensation ticks, allowing the client to define the number of compensation ticks based on detected packet loss. + /// Client-only: This method should not be called on the server. + /// + /// The number of compensation ticks to set. + public void SetServerToClientPacketLossCompensation(int compensationTicks) { + if (_isServer) throw new InvalidOperationException("SetServerToClientPacketLossCompensation is client-only and cannot be accessed on the server."); + _serverToClientPacketLossCompensation = compensationTicks; + } + + #endregion + + /*** Static Status Getters ***/ + + #region Static Status Getters + + /// Gets a value indicating whether the current instance is a server. + public static bool IsServer => _isServer; + + /// Gets a value indicating whether the client is synchronizing with the server. + public static bool IsSynchronizing => _isSynchronizing; + + /// Gets a value indicating whether the client is synchronized with the server. + public static bool IsSynchronized => _isSynchronized; + + /// Gets a value indicating whether the system is reconciling ticks. + public static bool IsReconciling => _isReconciling; + + #endregion + + /*** Static Tick Getters ***/ + + #region Static Tick Getters + + /// Gets the current tick count based on whether the instance is a server or client. + public static int CurrentTick => _isServer ? _serverTick : _clientTick; + + /// Gets the current absolute tick count based on whether the instance is a server or client. + public static int CurrentAbsoluteTick => _isServer ? _absoluteServerTick : _absoluteClientTick; + + /// Gets the client tick count. + public static int ClientTick => _clientTick; + + /// Gets the client absolute tick count. + public static int ClientAbsoluteTick => _absoluteClientTick; + + /// Gets the server tick count. + public static int ServerTick => _serverTick; + + /// Gets the server tick count. + public static int ServerAbsoluteTick => _absoluteServerTick; + + #endregion + + /*** Instance Getters ***/ + + #region Instance Getters + + /// Checks if the client is in the process of synchronizing with the server. + public bool GetIsSynchronizing() => _isSynchronizing; + + /// Checks if the client is currently synchronized with the server. + public bool GetIsSynchronized() => _isSynchronized; + + /// Gets the current client tick value. + public int GetClientTick() => _clientTick; + + /// Gets the absolute tick value for the client. + public int GetClientAbsoluteTick() => _absoluteClientTick; + + /// Gets the current client tick value. + public int GetServerTick() => _serverTick; + + /// Gets the absolute tick value for the server. + public int GetServerAbsoluteTick() => _absoluteServerTick; + + #endregion + + /*** Instance Status Setters ***/ + + #region Instance Status Setters + + /// Sets the server status of the current instance. + public void SetIsServer(bool isServer) => _isServer = isServer; + + /// Sets the synchronization status between client and server. + public void SetSynchronized(bool isSynchronized) => _isSynchronized = isSynchronized; + + /// Sets the synchronization status between client and server. + public void SetSynchronizing(bool isSynchronizing) => _isSynchronizing = isSynchronizing; + + /// Sets the reconciling status. + public void SetReconciling(bool reconciling) => _isReconciling = reconciling; + + #endregion + + /*** Instance Tick Setters ***/ + + #region Instance Tick Setters + + /// Sets a new tick value for the client. + public void SetClientTick(int newTick) => _clientTick = newTick; + + /// Sets a new absolute tick value for the client. + public void SetClientAbsoluteTick(int newAbsoluteTick) => _absoluteClientTick = newAbsoluteTick; + + /// Sets a new tick value for the server. + public void SetServerTick(int newTick) => _serverTick = newTick; + + /// Sets a new absolute tick value for the server. + public void SetServerAbsoluteTick(int newAbsoluteTick) => _absoluteServerTick = newAbsoluteTick; + + #endregion + + /*** Instance Tick Modifiers ***/ + + #region Instance Tick Modifiers + + /// Increments the client tick by a specified amount, wrapping to 11 bits. + public void IncrementClientTick(int increment) => _clientTick = (_clientTick + increment) & 0b11111111111; + + /// Increments the client's absolute tick by a specified amount. + public void IncrementClientAbsoluteTick(int increment) => _absoluteClientTick += increment; + + /// Increments the server tick by a specified amount, wrapping to 11 bits. + public void IncrementServerTick(int increment) => _serverTick = (_serverTick + increment) & 0b11111111111; + + /// Increments the server's absolute tick by a specified amount. + public void IncrementServerAbsoluteTick(int increment) => _absoluteServerTick += increment; + + #endregion + + /*** Useful Bitwise Functions ***/ + + #region Useful Bitwise Functions + + /// + /// Combines a fiveBits and tick counter into a single value. This is used to optimize network traffic by packing two values into one. + /// + /// The fiveBits value (should be within 5 bits). + /// The tick counter value (should be within 11 bits). + /// A combined containing both the fiveBits and tick counter. + public static ushort CombineBitsTick(int fiveBits, int tick) { + // Ensure the fiveBits is within 5 bits and tickCounter within 11 bits + fiveBits &= 0x1F; // Mask to keep only the lowest 5 bits + tick &= 0x7FF; // Mask to keep only the lowest 11 bits + return (ushort)((fiveBits << 11) | tick); // Shift fiveBits left by 11 bits and combine with tickCounter + } + + /// + /// Splits a combined fiveBits and tick counter value back into its individual components. + /// + /// The combined value. + /// A tuple containing the fiveBits and tick counter. + public static (int fiveBits, int tickCounter) SplitCombinedBitsTick(ushort combined) { + var fiveBits = (combined >> 11) & 0x1F; // Extract the 5-bit fiveBits by shifting right and masking + var tickCounter = combined & 0x7FF; // Extract the 11-bit tick counter by masking the lower 11 bits + return (fiveBits, tickCounter); + } + + /// + /// Calculates the minimal difference between two ticks, accounting for wraparound (ex: SubtractTicks(2040, 2) => 10). + /// This helps in correctly comparing tick counts in a circular tick range. + /// + /// The first tick value. + /// The second tick value. + /// The minimal difference between the two ticks. + public static int SubtractTicks(int tickOne, int tickTwo) { + var delta = (tickOne - tickTwo + 2048) % 2048; + if (delta >= 1024) delta -= 2048; + return delta; + } + + /// + /// Increments a tick value by a specified amount, wrapping around within a 2047 tick range. + /// This function ensures that tick values stay within a defined range by handling wraparound correctly. + /// + /// The initial tick value. + /// The amount to increment the tick by. + /// The incremented tick value, wrapped within the 2047 range. + public static int IncrementTick(int tick, int increment) { + return (tick + increment) & 0b11111111111; + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs.meta new file mode 100644 index 00000000000..402d11bc2c6 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkTick.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e4b45d54032d4018af1450222e8e7eef +timeCreated: 1730317658 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs b/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs new file mode 100644 index 00000000000..1fe82a2cd04 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs @@ -0,0 +1,849 @@ +using System; +using UnityEngine; +using System.Collections; +using System.Collections.Generic; + + +namespace Mirror.Components.Experimental{ + // Ensure we run first and that there is only one instance present. + [DefaultExecutionOrder(-10)] + [DisallowMultipleComponent] + [AddComponentMenu("Network/Network Tick Manager")] + public class NetworkTickManager : NetworkBehaviour{ + /*** Struct Definitions ***/ + + #region Struct Definitions + + /// + /// Represents data sent from the client to track its current state. + /// + private struct ClientData{ + public int ClientNonce; + public int ClientTick; + public RunningMax ClientToServerLoss; + public int SentPackets; + } + + /// + /// Represents a server response that includes synchronization data for the client tick and packet loss information. + /// + private struct ServerPong{ + public ushort ServerTickWithNonce; // 5 bits for nonce + 11 bits for the tick + public ushort ClientTickWithLoss; // 5 bits for packet loss value + 11 bits for the tick + } + + /// + /// Represents a server response with an absolute tick count to help the client synchronize with the server more accurately. + /// + private struct AbsoluteServerPong{ + public ushort ServerTickWithNonce; // 5 bits for nonce + 11 bits for the tick + public ushort ClientTickWithLoss; // 5 bits for packet loss value + 11 bits for the tick + public int AbsoluteServerTick; // Absolute server tick count to sync the client + } + + /// + /// Represents a client request sent to the server, including the client tick and a unique nonce for tracking. + /// + private struct ClientPing{ + public ushort ClientTickWithNonce; // 5 bits for nonce + 11 bits for the tick + } + + #endregion + + /*** Public Definitions ***/ + + #region Public Definitions + + [Header("Client Prediction Settings")] + [Min(1)] + [Tooltip("The minimum tick difference required between the client and server when the client's tick data arrives on the server." + + "If the difference is less than this value, the client must adjust to stay synchronized.")] + public int minClientRunaway = 1; + + [Min(1)] + [Tooltip( + "The allowable tick difference range added on top of the minimum client runaway." + + "This defines how much further ahead the client can be from the server beyond the minimum before needing adjustment.")] + public int acceptableClientRunawayRange = 1; + + [Tooltip("Enables an additional client adjustment for sudden ping spikes.")] + public bool pingSpikeCompensation = true; + + [Header("Server State Replay Settings")] + [Min(1)] + [Tooltip( + "The minimum tick difference required between the server and client when the client replays server states." + + "If the difference is less than this value, the client must adjust the server replay tick.")] + public int minServerRunaway = 1; + + [Min(1)] + [Tooltip("The allowable tick difference range added on top of the minimum server runaway." + + "Defines how much further ahead the received server state can be from the server replay beyond the minimum before needing adjustment.")] + public int acceptableServerRunawayRange = 1; + + [Header("Deviation measurements and timings:")] [Tooltip("The amount of seconds to use for packet loss calculation")] [Min(5)] + public int packetLossSampleTime = 15; + + [Min(1)] + [Tooltip("The duration in seconds over which deviation data is collected before adjusting the client or server states to acceptable runaway values.")] + public int calculationSeconds = 2; + + [Min(10)] + [Tooltip("The longer duration in seconds over which deviation data is collected before adjusting the client or server states to minimum runaway values.")] + public int longCalculationSeconds = 30; + + [Tooltip("Tick compensation when packet loss is present: \ncompensation = consecutive lost packets * factor")] [Range(1, 3)] + public float packetLossCompensationFactor = 1.5f; + + [Header("Absolute tick sync settings:")] + [Min(1)] + [Tooltip("How often to verify absolute tick between the server and the client (this can happen if desync is larger than 1024 ticks)")] + public int absoluteTickSyncIntervalSeconds = 10; + + [Min(10)] [Tooltip("How many ticks to send the absolute server tick for the clients to sync. Cant be 1 because this packet can get lost in transit.")] + public int absoluteTickSyncHandshakeTicks = 10; + + [Header("Physics Controller:")] public NetworkPhysicsController physicsController; + + #endregion + + /*** Private Definitions ***/ + + #region Private Definitions + + // Local clients list on the server for efficient communication + private readonly Dictionary _clients = new Dictionary(); + + // Instance of NetworkTick used for managing and changing the tick counters + private readonly NetworkTick _networkTick = new NetworkTick(); + + // Actual runaway metrics - adjusted based on network conditions ( aka packet losses ) + private int _internalClientRunaway = 0; + private int _internalServerRunaway = 0; + private int _internalMinClientRunaway = 0; + private int _internalMinServerRunaway = 0; + + // Running minimum counters used to avoid adjusting too fast and oscillating back and forth + private RunningMin _clientRunningMin; + private RunningMin _serverRunningMin; + private RunningMin _clientLongRunningMin; + private RunningMin _serverLongRunningMin; + + // Client side packet loss tracker for packets from the server + private RunningMax _receivePacketLoss; + private int _lastSentServerToClientCompensation; + + // Server and Client last nonce values - used to dettect packet losses + private int _serverNonce = 0; + private int _clientNonce = 0; + + // Client side last client tick received from the server to avoid lengthy adjustments + private int _lastRemoteClientTick = 0; + + // Absolute tick synch status + private bool _isAbsoluteTickSynced = false; + + // Modulus ( % modulus == 0 -> send absolute tick to clients ) based on absoluteTickSyncIntervalSeconds and tick rate + private int _absoluteServerTickModulus = 0; + + // Adjustment flags and variables used to ensure only one adjustment is taking place at a time. + private bool _capturedOffsets = false; + private bool _isAdjusting = false; + private int _adjustmentEndTick = 0; + private bool _tickManagerStarted = false; + + // Make sure NetworkPhysicsController is attached to the tick manager + protected new virtual void OnValidate() { + base.OnValidate(); + physicsController = GetComponent(); + if (physicsController == null) { + throw new ArgumentException("Missing NetworkPhysicsController! please attach it to this entity"); + } + } + + public static NetworkTickManager singleton; + + void Awake() { + if (singleton != null && singleton != this) { + Destroy(gameObject); + return; + } + + singleton = this; + } + + #endregion + + /*** Server Startup and Setup ***/ + + #region Server Startup and Setup + + /// Resets the class state to new() state + private void ResetState() { + _clients.Clear(); + _networkTick.Reset(); + _internalClientRunaway = 0; + _internalServerRunaway = 0; + _internalMinClientRunaway = 0; + _internalMinServerRunaway = 0; + _clientRunningMin?.Reset(); + _serverRunningMin?.Reset(); + _clientLongRunningMin?.Reset(); + _serverLongRunningMin?.Reset(); + _receivePacketLoss?.Reset(); + _lastSentServerToClientCompensation = 0; + _serverNonce = 0; + _clientNonce = 0; + _lastRemoteClientTick = 0; + _isAbsoluteTickSynced = false; + _absoluteServerTickModulus = 0; + _capturedOffsets = false; + _isAdjusting = false; + _adjustmentEndTick = 0; + _tickManagerStarted = false; + } + + /// + /// Called when the server starts. Registers callbacks for client connection and disconnection events, + /// allowing the server to handle these events appropriately. + /// + [Server] + public override void OnStartServer() { + // Register callback for when clients connect/disconnect + NetworkServer.OnConnectedEvent += OnConnectedEventHandle; + NetworkServer.OnDisconnectedEvent += OnDisconnectedEventHandle; + StartServer(); + } + + /// + /// Called when the server stops. Unregisters callbacks for client connection and disconnection events + /// to ensure cleanup and avoid unintended event handling after the server has stopped. + /// Resets NetworkTickManager state to the beginning. + /// + [Server] + public override void OnStopServer() { + // Unregister callbacks when server stops + NetworkServer.OnConnectedEvent -= OnConnectedEventHandle; + NetworkServer.OnDisconnectedEvent -= OnDisconnectedEventHandle; + ResetState(); + } + + /// + /// Called when a client connects to the server. Initializes a new entry in the _clients dictionary for the connected client, + /// storing initial client data such as nonce, tick count, packet loss tracker, and sent packets. + /// + /// The network connection for the connected client. + [Server] + private void OnConnectedEventHandle(NetworkConnectionToClient conn) => _clients[conn.connectionId] = new ClientData() { + ClientNonce = 0, + ClientTick = 0, + ClientToServerLoss = new RunningMax(Mathf.RoundToInt(packetLossSampleTime / Time.fixedDeltaTime), 0), + SentPackets = 0 + }; + + + /// + /// Called when a client disconnects from the server. Removes the client entry from the _clients dictionary and clears NetworkTick + /// to free up resources and maintain an accurate list of active clients. + /// + /// The network connection for the disconnected client. + [Server] + private void OnDisconnectedEventHandle(NetworkConnectionToClient conn) => _clients.Remove(conn.connectionId); + + #endregion + + /*** Network Tick Start and Tick Initialization ***/ + + #region Network Tick Start and Tick Initialization + + /// Initializes server-specific settings when the server starts. + [Server] + private void StartServer() { + _tickManagerStarted = true; + _networkTick.SetIsServer(isServer); + physicsController.TickForwardCallback = OnTickForwardServer; + + // Set server tick modulus in ticks + _absoluteServerTickModulus = Mathf.RoundToInt(absoluteTickSyncIntervalSeconds / Time.fixedDeltaTime); + + // Set status to synchronized + _networkTick.SetSynchronized(true); + _networkTick.SetSynchronizing(false); + + // Delay the call until the next FixedUpdate cycle + StartCoroutine(DelayedNetworkSync()); + + // run the first tick to move things to position and prevent visual clutter + physicsController.RunSimulate(1); + } + + /// Ensures that the server has time to set up and start properly before firing sync events + private IEnumerator DelayedNetworkSync() { + yield return new WaitForFixedUpdate(); // Wait for the fixed update that spawns the character + yield return new WaitForEndOfFrame(); // Wait until end-of-frame so OnEnable and others complete + physicsController.NetworkSynchronized(); + } + + /// Initializes client-specific settings when the client starts. + [Client] + private void StartClient() { + _tickManagerStarted = true; + _networkTick.SetIsServer(isServer); + physicsController.TickForwardCallback = OnTickForwardClient; + + _internalMinClientRunaway = minClientRunaway; + _internalMinServerRunaway = minServerRunaway; + _internalClientRunaway = acceptableClientRunawayRange; + _internalServerRunaway = acceptableServerRunawayRange; + _clientRunningMin = new RunningMin(Mathf.RoundToInt(calculationSeconds / Time.fixedDeltaTime)); + _serverRunningMin = new RunningMin(Mathf.RoundToInt(calculationSeconds / Time.fixedDeltaTime)); + _clientLongRunningMin = new RunningMin(Mathf.RoundToInt(longCalculationSeconds / Time.fixedDeltaTime)); + _serverLongRunningMin = new RunningMin(Mathf.RoundToInt(longCalculationSeconds / Time.fixedDeltaTime)); + _receivePacketLoss = new RunningMax(Mathf.RoundToInt(packetLossSampleTime / Time.fixedDeltaTime), 0); + + // run the first tick to move things to position and prevent visual clutter + physicsController.RunSimulate(1); + } + + /// Advances the client's tick counters by the specified number of ticks. + /// Number of ticks to advance. + [Client] + private void OnTickForwardClient(int deltaTicks) { + _networkTick.IncrementClientTick(deltaTicks); + _networkTick.IncrementClientAbsoluteTick(deltaTicks); + _networkTick.IncrementServerTick(deltaTicks); + _networkTick.IncrementServerAbsoluteTick(deltaTicks); + } + + /// Advances the server's tick counters by the specified number of ticks. + /// Number of ticks to advance. + [Server] + private void OnTickForwardServer(int deltaTicks) { + _networkTick.IncrementServerTick(deltaTicks); + _networkTick.IncrementServerAbsoluteTick(deltaTicks); + } + + + /// Initializes the tick manager and sets up the physics controller based on whether it is running on the server or client. + public override void OnStartClient() { + if (!isServer) StartClient(); + } + + /// Resets NetworkTickManager state to the beginning. + public override void OnStopClient() => ResetState(); + + #endregion + + /*** Packet Loss Calculations ***/ + + #region Packet Loss Calculations + + /// Updates the client's packet loss compensation values based on the server's nonce and reported packet loss. + /// The nonce received from the server to detect packet loss. + /// The packet loss percentage reported by the server. + [Client] + private void UpdatePacketLossCompensation(int serverNonce, int sendPacketLoss) { + _receivePacketLoss.Add(_serverNonce > 0 ? CalculatePacketLoss(NextNonce(_serverNonce), serverNonce) : 0); + _serverNonce = serverNonce; + + // Calculate adjustments based on server and client packet loss factors + var sendCompensationTicks = CalculateTickCompensation(sendPacketLoss); + var receiveCompensationTicks = CalculateTickCompensation(_receivePacketLoss.CurrentMax); + + // Update NetworkTick with the compensation values for users to integrate compensations if needed + _networkTick.SetClientToServerPacketLossCompensation(sendCompensationTicks); + _networkTick.SetServerToClientPacketLossCompensation(receiveCompensationTicks); + + // Adjust internal tick min and max runaway values to compensate for packet losses + _internalMinClientRunaway = minClientRunaway + sendCompensationTicks; + _internalClientRunaway = acceptableClientRunawayRange + _internalMinClientRunaway + sendCompensationTicks; + _internalMinServerRunaway = minServerRunaway + receiveCompensationTicks; + _internalServerRunaway = acceptableServerRunawayRange + _internalMinServerRunaway + receiveCompensationTicks; + } + + #endregion + + /*** Synchronization functions ***/ + + #region Synchronization functions + + /// Sets or adjusts the client's absolute server tick values to maintain synchronization with the server. + /// The absolute tick count provided by the server. + /// The server's current tick value. + [Client] + private void SetAbsoluteTicks(int absoluteServerTick, int serverTick) { + if (!_isAbsoluteTickSynced) { + _isAbsoluteTickSynced = true; + _networkTick.SetServerTick(serverTick); + _networkTick.SetServerAbsoluteTick(absoluteServerTick); + return; + } + + var proposedServerAbsoluteTick = absoluteServerTick - NetworkTick.SubtractTicks(serverTick, _networkTick.GetServerTick()); + var absoluteTickDiff = proposedServerAbsoluteTick - _networkTick.GetServerAbsoluteTick(); + if (absoluteTickDiff != 0) { + _networkTick.IncrementServerAbsoluteTick(absoluteTickDiff); + _networkTick.IncrementClientAbsoluteTick(absoluteTickDiff); + } + } + + /// Initiates the synchronization process by aligning the client's and server's ticks. + /// The current tick value from the server. + /// The client's tick value as received by the server. + [Client] + private void SynchronizeStart(int serverTick, int clientTick) { + var oldServerTick = _networkTick.GetServerTick(); + _networkTick.IncrementServerTick(-_internalServerRunaway); + var newServerTick = _networkTick.GetServerTick(); + _networkTick.IncrementServerAbsoluteTick(NetworkTick.SubtractTicks(newServerTick, oldServerTick)); + _networkTick.SetClientTick(NetworkTick.IncrementTick(serverTick, + NetworkTick.SubtractTicks(_networkTick.GetClientTick(), clientTick) + _internalClientRunaway)); + _networkTick.SetClientAbsoluteTick(_networkTick.GetServerAbsoluteTick() + NetworkTick.SubtractTicks(_networkTick.GetClientTick(), newServerTick)); + _networkTick.SetSynchronizing(true); + SetAdjusting(_networkTick.GetClientTick()); + } + + /// Synchronizes the client's and server's ticks by applying necessary adjustments. + [Client] + private void Synchronize() { + var serverTickAdjustment = GetServerAdjustment(true); + var clientTickAdjustment = GetClientAdjustment(true); + + // Apply adjustments on current tick counters + _networkTick.IncrementClientTick(clientTickAdjustment); + _networkTick.IncrementClientAbsoluteTick(clientTickAdjustment); + _networkTick.IncrementServerTick(serverTickAdjustment); + _networkTick.IncrementServerAbsoluteTick(serverTickAdjustment); + + // Set status to synchronized + _networkTick.SetSynchronized(true); + _networkTick.SetSynchronizing(false); + + // ensure no additional adjustment happens until server confirms adjusted ticks + SetAdjusting(_networkTick.GetClientTick()); + + // Signal that the client and server are synchronized + physicsController.NetworkSynchronized(); + + // Simulate correct client prediction state + SimulateInitialTick(); + } + + #endregion + + /*** Handling Message from the Server ***/ + + #region Handling Message from the Server + + /// + /// Handles the server's pong response to maintain and adjust tick synchronization between the client and server. + /// This method updates packet loss compensation, ensures packets are processed in order, + /// and manages the client's synchronization state based on the received ticks. + /// Depending on whether the client is synchronized, in the process of synchronizing, or not yet synchronized, + /// it calculates necessary offsets and adjusts ticks to align with the server. + /// + /// Nonce value from the server for packet loss detection. + /// Current tick count from the server. + /// Packet loss percentage reported by the server. + /// Client's tick count as received by the server. + [Client] + private void HandleServerPong(int serverNonce, int serverTick, int sendLoss, int clientTick) { + _capturedOffsets = false; + UpdatePacketLossCompensation(serverNonce, sendLoss); + + // We want to avoid handling the same client tick from the server to improve accuracy otherwise we risk of repeating adjustments + if (!IsValidPacket(clientTick)) return; + + if (_networkTick.GetIsSynchronized()) { + if (_isAdjusting && NetworkTick.SubtractTicks(clientTick, _adjustmentEndTick) > 0) { + _isAdjusting = false; + ResetRunningMins(); + } + + // Calculate and deviations using the server info + CalculateOffsets(serverTick, clientTick); + return; + } + + if (_networkTick.GetIsSynchronizing()) { + // Since client tick is not yet synchronized and the server sends old tick we need to compare to server tick rather than client tick + if (NetworkTick.SubtractTicks(serverTick, _adjustmentEndTick) > 0) { + // We are not worried about being too much ahead at this point, we only care about being behind the execution on client or server + // So we calculate the minimum values and run the adjustment again + CalculateOffsets(serverTick, clientTick); + Synchronize(); + ResetRunningMins(); + } + + return; + } + + // Wait until we receive positive tick from server before starting the initial 2 step sync + if (clientTick > 0 && _isAbsoluteTickSynced) { + SynchronizeStart(serverTick, clientTick); + ResetRunningMins(); + } + } + + #endregion + + /*** Tick Adjustment Calculations ***/ + + #region Tick Adjustment Calculations + + /// Calculates the tick offsets between client and server, updating running minimums for synchronization adjustments. + /// The current tick value from the server. + /// The client's tick value as received by the server. + [Client] + private void CalculateOffsets(int serverTick, int clientTick) { + _capturedOffsets = true; + var clientTickOffset = NetworkTick.SubtractTicks(clientTick, serverTick); + var serverTickOffset = NetworkTick.SubtractTicks(serverTick, _networkTick.GetServerTick()); + _clientRunningMin.Add(clientTickOffset); + _clientLongRunningMin.Add(clientTickOffset); + _serverRunningMin.Add(serverTickOffset); + _serverLongRunningMin.Add(serverTickOffset); + } + + /// Determines the necessary tick adjustment for the client to maintain synchronization with the server. + /// If true, applies the full adjustment needed; otherwise, applies a minimal step. + /// The number of ticks to adjust the client's tick by. + [Client] + private int GetClientAdjustment(bool absolute = false) { + // If the server received client predicted tick bellow min thresh hold we need to adjust ourselves forward otherwise risking server not receiving inputs + if (_clientRunningMin.CurrentMin < _internalMinClientRunaway) + return -(_clientRunningMin.CurrentMin - _internalMinClientRunaway); + + // If the server received client predicted tick is too far into the future we want to slow down the client to reduce perceived latency + if (_clientRunningMin.IsFull && _clientRunningMin.CurrentMin > _internalClientRunaway) + return absolute ? -_clientRunningMin.CurrentMin : -1; + + // If the server received client predicted tick is stable but above the min requirement we can slow down the client to reduce perceived latency + if (_clientLongRunningMin.IsFull && _clientLongRunningMin.CurrentMin > _internalMinClientRunaway) + return absolute ? -_clientLongRunningMin.CurrentMin : -1; + return 0; + } + + /// Determines the necessary tick adjustment for the server to maintain synchronization with the client. + /// If true, applies the full adjustment needed; otherwise, applies a minimal step. + /// The number of ticks to adjust the server's tick by. + [Client] + private int GetServerAdjustment(bool absolute = false) { + // If the received server tick is behind the expected minimum we need to adjust our tick backwards + if (_serverRunningMin.CurrentMin < _internalMinServerRunaway) + return _serverRunningMin.CurrentMin - _internalMinServerRunaway; + + // If the received server tick is too far forward we need to reduce it to reduce latency + if (_serverRunningMin.IsFull && _serverRunningMin.CurrentMin > _internalServerRunaway) + return absolute ? _serverRunningMin.CurrentMin : 1; + + // If the received server tick is more than the minimum for an extended period of time its safe to reduce it to reduce latency + if (_serverLongRunningMin.IsFull && _serverLongRunningMin.CurrentMin > _internalMinServerRunaway) + return absolute ? _serverLongRunningMin.CurrentMin : 1; + return 0; + } + + /// Calculates adjusted tick values for synchronization, applying any necessary client or server tick adjustments. + /// The base number of ticks to advance. + /// The adjusted number of ticks to use for simulation. + [Client] + private int GetAdjustedTicks(int deltaTicks) { + // Get server adjustment value -n for higher ping (execute older server tick) and +n for lower ping ( execute more recent server ping ) + int serverAdjustment = GetServerAdjustment(); + // Get client prediction adjustment value -n for higher ping (predict further) and +n for lower ping ( reduce prediction ticks ) + int clientAdjustment = GetClientAdjustment(); + + // since we get the server packet first ( client packets have to do a round trip ) we want to assume that the traffic has worsened both ways + // from and to the server, so we adjust client as well to reduce or prevent reconciliation events. + if (pingSpikeCompensation && serverAdjustment > 1) clientAdjustment = Math.Max(GetClientAdjustment(), -serverAdjustment); + + // we cant pause of fast-forward the server tick so we adjust it immediatly + if (serverAdjustment != 0) { + _networkTick.IncrementServerTick(serverAdjustment); + _networkTick.IncrementServerAbsoluteTick(serverAdjustment); + } + + // If client or server are adjusting we need to wait for confirmation to avoid oscillating adjustments + if (clientAdjustment != 0 || serverAdjustment != 0) + SetAdjusting(NetworkTick.IncrementTick(_networkTick.GetClientTick(), deltaTicks + clientAdjustment)); + + // If the server and client adjustments differ, it indicates the tick values have diverged and require reconciliation. + // We also need to trigger a reset state event to ensure the server and client ticks remain sequential. + // This preserves full per-tick input data locally while only sending incremental changes across the network. + if (clientAdjustment != serverAdjustment) { + var delta = Mathf.Min(clientAdjustment - serverAdjustment, 0); + physicsController.ReconcileFromTick(NetworkTick.IncrementTick(_networkTick.GetClientTick(), delta)); + } + + return deltaTicks + clientAdjustment; + } + + #endregion + + /*** Tick Simulation Functions ***/ + + #region Tick Simulation Functions + + /// + /// After synchronization, if the client tick is ahead, rolls it back, replays physics, and + /// ensures the client’s prediction state is predicted correctly. + /// + private void SimulateInitialTick() { + // Get the clinet prediction tick count + current tick (1) in order to simulate the initial state accurately. + var clientPredictionTicks = NetworkTick.SubtractTicks(_networkTick.GetClientTick(), _networkTick.GetServerTick()) + 1; + + // validate that the server is not ahead of the client. + if (clientPredictionTicks > 0) { + // Reset tick to right before the requested reconcile tick. + OnTickForwardClient(-clientPredictionTicks); + // Run reconcile ticks to ensure correct initial prediction + _networkTick.SetReconciling(true); + physicsController.RunSimulate(clientPredictionTicks, true); + _networkTick.SetReconciling(false); + } + + // Ensure that no reconcile is mistakenly queued. + physicsController.ResetReconcile(); + } + + /// Checks for any required reconciliation due to state discrepancies and resimulates physics accordingly. + [Client] + private void CheckReconcile() { + if (physicsController.IsPEndingReconcile()) { + // Since tick us forwarded before simulation we need to ensure we get the requested tick included in the reconciled ticks. + var reconcileTicks = NetworkTick.SubtractTicks(_networkTick.GetClientTick(), physicsController.GetReconcileStartTick()) + 1; + // Ensure safety in case developer sends us future tick for... whatever reason + if (reconcileTicks > 0) { + // Reset tick to right before the requested reconcile tick. + OnTickForwardClient(-reconcileTicks); + // Run reconcile ticks + _networkTick.SetReconciling(true); + physicsController.RunSimulate(reconcileTicks, true); + _networkTick.SetReconciling(false); + } + + // Reset reconcile state + physicsController.ResetReconcile(); + } + } + + /// Updates the client's state each tick, handling synchronization, reconciliation, and physics simulation. + /// The number of ticks to advance. + [Client] + private void UpdateClient(int deltaTicks) { + // Adjust the delta ticks if not waiting for adjustment confirmation + var adjustedTicks = _capturedOffsets && !_isAdjusting ? GetAdjustedTicks(deltaTicks) : deltaTicks; + + // fix discrepancies cause by client tick adjustment + _networkTick.IncrementServerTick(deltaTicks - adjustedTicks); + _networkTick.IncrementServerAbsoluteTick(deltaTicks - adjustedTicks); + + // Check if need reconciling - if yes reconcile before executing the next ticks + CheckReconcile(); + + // Simulate ticks or skip if pause was requested + if (adjustedTicks > 0) + physicsController.RunSimulate(adjustedTicks); + } + + /// Handles physics simulation and synchronization updates on both the server and client each fixed frame. + public void FixedUpdate() { + if (!_tickManagerStarted) return; + // Handle FixedUpdate for deltaTicks + if (isServer) { + physicsController.RunSimulate(1); + SendUpdatesToAllClients(); + } + else { + // Keep pushing the tick counters forward until the client is synced with the server + if (!_networkTick.GetIsSynchronized()) + OnTickForwardClient(1); + else + UpdateClient(1); + ClientSendPing(); + } + } + + #endregion + + /*** Communication Functions ***/ + + #region Communication Functions + + /// Sends a ping to the server with the client's current tick and a nonce for packet loss detection. + [Client] + private void ClientSendPing() { + // Increase nonce by 1 but keep withing 5 bits of data [0-31] + _clientNonce = NextNonce(_clientNonce); + CmdPingServer(new ClientPing() { ClientTickWithNonce = NetworkTick.CombineBitsTick(_clientNonce, _networkTick.GetClientTick()) }); + + // 0-30 % are reported on change but 31 or higher are aggregated as just 31 ( more than 30% packet loss is extreme! ) + var compressedLoss = Math.Min(31, _receivePacketLoss.CurrentMax); + + // Only send if compensation value changed ( loss % can be volatile ) + var compensationTicks = CalculateTickCompensation(compressedLoss); + if (compensationTicks != _lastSentServerToClientCompensation) { + CmdUpdateServerToClientLoss((byte)compressedLoss); + _lastSentServerToClientCompensation = compensationTicks; + } + } + + /// Sends synchronization updates to all connected clients, including tick counts and packet loss information. + [Server] + private void SendUpdatesToAllClients() { + // Increase nonce by 1 but keep withing 5 bits of data [0-31] + _serverNonce = NextNonce(_serverNonce); + var absoluteServerTick = _networkTick.GetServerAbsoluteTick(); + var isSendAbsolute = (absoluteServerTick % _absoluteServerTickModulus) == 0; + var serverTickWithNonce = NetworkTick.CombineBitsTick(_serverNonce, _networkTick.GetServerTick()); + foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values) { + // If connection is on the same machine as the server we skip it. + if (conn == NetworkServer.localConnection) continue; + + if (_clients.TryGetValue(conn.connectionId, out ClientData clientData)) { + // 0-30 % are reported regularly but 31 or higher are aggregated as just 31 ( mor ethan 30% packet loss is extreme! ) + int compressedLoss = Math.Min(31, clientData.ClientToServerLoss.CurrentMax); + // Update client to server compensation ticks + _networkTick.ServerSetClientToServerCompensation(conn.connectionId, CalculateTickCompensation(compressedLoss)); + + if (isSendAbsolute || clientData.SentPackets < absoluteTickSyncHandshakeTicks) + // If requested by interval or during hand shake send absolute tick alongside tick information + RpcAbsoluteServerPong(conn, new AbsoluteServerPong() { + AbsoluteServerTick = absoluteServerTick, + ServerTickWithNonce = serverTickWithNonce, + ClientTickWithLoss = NetworkTick.CombineBitsTick( + compressedLoss, + clientData.ClientTick) + }); + else + // Send tick information with nonce and loss + RpcServerPong(conn, new ServerPong() { + ServerTickWithNonce = serverTickWithNonce, + ClientTickWithLoss = NetworkTick.CombineBitsTick( + compressedLoss, + clientData.ClientTick) + }); + + // Count how many packets were sent + clientData.SentPackets += 1; + _clients[conn.connectionId] = clientData; + } + } + } + + #endregion + + /*** Target RPC and Command callbacks ***/ + + #region Target RPC and Command callbacks + + /// Handles the server's response containing absolute tick synchronization data. + /// The client connection receiving the response. + /// The server's pong message with tick and nonce data. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcAbsoluteServerPong(NetworkConnectionToClient target, AbsoluteServerPong serverPong) { + var (serverNonce, serverTick) = NetworkTick.SplitCombinedBitsTick(serverPong.ServerTickWithNonce); + var (sendLoss, clientTick) = NetworkTick.SplitCombinedBitsTick(serverPong.ClientTickWithLoss); + SetAbsoluteTicks(serverPong.AbsoluteServerTick, serverTick); + HandleServerPong(serverNonce, serverTick, sendLoss, clientTick); + } + + /// Handles the server's standard response containing synchronization data. + /// The client connection receiving the response. + /// The server's pong message with tick and nonce data. + [TargetRpc(channel = Channels.Unreliable)] + private void RpcServerPong(NetworkConnectionToClient target, ServerPong serverPong) { + var (serverNonce, serverTick) = NetworkTick.SplitCombinedBitsTick(serverPong.ServerTickWithNonce); + var (sendLoss, clientTick) = NetworkTick.SplitCombinedBitsTick(serverPong.ClientTickWithLoss); + HandleServerPong(serverNonce, serverTick, sendLoss, clientTick); + } + + /// Receives ping messages from clients and updates their data on the server. + /// The ping message containing the client's tick and nonce. + /// The connection to the client sending the ping. + [Command(requiresAuthority = false, channel = Channels.Unreliable)] + private void CmdPingServer(ClientPing clientPing, NetworkConnectionToClient connectionToClient = null) { + if (connectionToClient is null || !_clients.TryGetValue(connectionToClient.connectionId, out var clientData)) return; + var (nonce, clientTick) = NetworkTick.SplitCombinedBitsTick(clientPing.ClientTickWithNonce); + + // Update packet loss, new tick and nonce + clientData.ClientToServerLoss.Add(CalculatePacketLoss(NextNonce(_clients[connectionToClient.connectionId].ClientNonce), nonce)); + clientData.ClientTick = clientTick; + clientData.ClientNonce = nonce; + + _clients[connectionToClient.connectionId] = clientData; + } + + /// Receives the client's reported server-to-client packet loss % and updates the server-side NetworkTick compensation accordingly. + [Command(requiresAuthority = false, channel = Channels.Reliable)] + private void CmdUpdateServerToClientLoss(byte serverToClientLoss, NetworkConnectionToClient connectionToClient = null) { + if (connectionToClient is null) return; + // We update the compensation base on loss % to compensation ticks, we calculate compensation on the server. (max 31%) + _networkTick.ServerSetServerToClientCompensation( + connectionId: connectionToClient.connectionId, + compensation: CalculateTickCompensation(Math.Min(31, (int)serverToClientLoss)) + ); + } + + #endregion + + /*** Helper Functions ***/ + + #region Helper Functions + + /// Calculate the difference in a looping space of 0-31. + /// This gives the number of packets skipped between the expected and received nonces. + /// + /// The expected nonce value. + /// The nonce value from the incoming packet. + private int CalculatePacketLoss(int expectedNonce, int receivedNonce) => (receivedNonce - expectedNonce + 32) % 32; + + /// Validates whether the incoming packet is newer than the last processed one to prevent out-of-order processing. + /// The tick value from the incoming packet. + /// True if the packet is valid and should be processed; otherwise, false. + [Client] + private bool IsValidPacket(int clientTick) { + var isValid = NetworkTick.SubtractTicks(clientTick, _lastRemoteClientTick) > 0; + _lastRemoteClientTick = clientTick; + return isValid; + } + + /// Marks the start of an adjustment period, during which tick synchronization adjustments are applied. + /// The client tick value when adjustments should stop. + [Client] + private void SetAdjusting(int adjustmentEndTick) { + _capturedOffsets = false; + _isAdjusting = true; + _adjustmentEndTick = adjustmentEndTick; + } + + /// Resets the running minimums used for calculating tick adjustments, clearing any accumulated data. + [Client] + private void ResetRunningMins() { + _capturedOffsets = false; + _clientRunningMin.Reset(); + _clientLongRunningMin.Reset(); + _serverRunningMin.Reset(); + _serverLongRunningMin.Reset(); + } + + /// + /// Generates the next nonce value by incrementing the current nonce. + /// The nonce is a looping 5-bit variable (0-31), ensuring it wraps around correctly when reaching 31. + /// + /// The starting nonce value to increment. + /// The next nonce value, wrapped to stay within the 5-bit range. + private static int NextNonce(int startNonce) => (startNonce + 1) & 0b11111; + + /// + /// Calculates the tick compensation based on packet loss and a predefined compensation factor. + /// This is used to adjust for lost packets, smoothing gameplay experience based on the + /// `packetLossCompensationFactor`. + /// + /// The packet loss percentage used to calculate compensation ticks. + /// The number of ticks to compensate based on the provided packet loss. + private int CalculateTickCompensation(float loss) => Mathf.CeilToInt(loss * packetLossCompensationFactor); + + #endregion + } +} diff --git a/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs.meta new file mode 100644 index 00000000000..953b434a860 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/NetworkTickManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eb2e49dca4a74e7baa4b2fc290e20730 +timeCreated: 1730317270 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs b/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs new file mode 100644 index 00000000000..d2a5018e737 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace Mirror.Components.Experimental{ + /// + /// A class that maintains a running maximum over a fixed-size sliding window of integers. + /// Provides efficient tracking of the maximum value as elements are added and removed from the window. + /// + public class RunningMax{ + // The fixed size of the sliding window + private readonly int _windowSize; + + // The default value to calculate the maximum from + private readonly int _defaultMin; + + // Queue to store the values in the sliding window + private readonly Queue _values; + + // Stores the current maximum value in the window + private int _currentMax; + + /// Gets the current maximum value in the sliding window. + public int CurrentMax => _currentMax; + + /// Gets the current count of elements in the sliding window. + public int Count => _values.Count; + + /// Checks if the sliding window is full. + public bool IsFull => _values.Count == _windowSize; + + /// Returns last added value. + public int Last => _values.ToArray()[_values.Count - 1]; + + /// Initializes a new instance of the class with a specified window size. + /// The maximum number of elements in the sliding window. + /// The default number to calculate from (defaults to int.MinValue). + public RunningMax(int windowSize = 100, int defaultMax = int.MinValue) { + _windowSize = windowSize > 0 ? windowSize : throw new ArgumentException("Sample packets must be greater than zero."); + _values = new Queue(windowSize); + _defaultMin = defaultMax; + _currentMax = _defaultMin; + } + + /// Resets the values and current maximum. + public void Reset() { + _currentMax = _defaultMin; + _values.Clear(); + } + + /// Recalculates the current maximum by iterating through the queue. Only called when necessary to avoid performance overhead. + private void UpdateCurrentMax() { + _currentMax = _defaultMin; + foreach (int value in _values) + if (value > _currentMax) + _currentMax = value; + } + + /// Adds a value to the sliding window. Updates the current maximum as needed. + /// The new value to add to the window. + public void Add(int value) { + _values.Enqueue(value); + if (value > _currentMax) + _currentMax = value; + // Check if exceeding the window size, if so then remove oldest item + if (_values.Count > _windowSize) { + int removedValue = _values.Dequeue(); + // Check oldest value is equal to maximum and is not equal to the new value we need to calculate the current maximum + if (removedValue == _currentMax && removedValue != value) + UpdateCurrentMax(); + } + } + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs.meta new file mode 100644 index 00000000000..6a1b953abbf --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/RunningMax.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f6844542ea2649048a8b9c13639ee95e +timeCreated: 1740349708 \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs b/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs new file mode 100644 index 00000000000..332f2eaa269 --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace Mirror.Components.Experimental{ + /// + /// A class that maintains a running minimum over a fixed-size sliding window of integers. + /// Provides efficient tracking of the minimum value as elements are added and removed from the window. + /// + public class RunningMin{ + // The fixed size of the sliding window + private readonly int _windowSize; + + // The default value to calculate the maximum from + private readonly int _defaultMax; + + // Queue to store the values in the sliding window + private readonly Queue _values; + + // Stores the current minimum value in the window + private int _currentMin; + + /// Gets the current minimum value in the sliding window. + public int CurrentMin => _currentMin; + + /// Gets the current count of elements in the sliding window. + public int Count => _values.Count; + + /// Checks if the sliding window is full. + public bool IsFull => _values.Count == _windowSize; + + /// Returns last added value. + public int Last => _values.ToArray()[_values.Count - 1]; + + /// Initializes a new instance of the class with a specified window size. + /// The maximum number of elements in the sliding window. + /// /// The default number to calculate from (defaults to int.MaxValue). + public RunningMin(int windowSize = 100, int defaultMin = int.MaxValue) { + _windowSize = windowSize > 0 ? windowSize : throw new ArgumentException("Sample packets must be greater than zero."); + _values = new Queue(windowSize); + _defaultMax = defaultMin; + _currentMin = _defaultMax; + } + + /// Resets the values and current minimum. + public void Reset() { + _currentMin = _defaultMax; + _values.Clear(); + } + + /// Recalculates the current minimum by iterating through the queue. Only called when necessary to avoid performance overhead. + private void UpdateCurrentMin() { + _currentMin = _defaultMax; + foreach (int value in _values) + if (value < _currentMin) + _currentMin = value; + } + + /// Adds a value to the sliding window. Updates the current minimum as needed. + /// The new value to add to the window. + public void Add(int value) { + _values.Enqueue(value); + if (value < _currentMin) + _currentMin = value; + // Check if exceeding the window size, if so then remove oldest item + if (_values.Count > _windowSize) { + int removedValue = _values.Dequeue(); + // Check oldest value is equal to minimum and is not equal to the new value we need to calculate the current minimum + if (removedValue == _currentMin && removedValue != value) + UpdateCurrentMin(); + } + } + } +} \ No newline at end of file diff --git a/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs.meta b/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs.meta new file mode 100644 index 00000000000..3d86e20d5cd --- /dev/null +++ b/Assets/Mirror/Components/Experimental/TickManager/RunningMin.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9671059f9b45471abcf30dd42fa2018e +timeCreated: 1730577873 \ No newline at end of file