diff --git a/SSMP/Game/Client/ClientManager.cs b/SSMP/Game/Client/ClientManager.cs index 6941af05..893a7caa 100644 --- a/SSMP/Game/Client/ClientManager.cs +++ b/SSMP/Game/Client/ClientManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using GlobalEnums; using Steamworks; using SSMP.Animation; @@ -143,10 +142,11 @@ internal class ClientManager : IClientManager { private Vector3 _lastPosition; /// - /// Stopwatch to keep track of the last time that the position of the local player object was updated. - /// Used to throttle position updates to 60 Hz maximum to avoid having to update the packet every frame. + /// Seconds accumulated since the last local player position update, driven by + /// . Used to throttle position updates to 60 Hz maximum to avoid + /// having to update the packet every frame. /// - private readonly Stopwatch _lastPositionStopwatch; + private float _timeSinceLastPosition; /// /// Keeps track of the last updated scale of the local player object. @@ -168,6 +168,11 @@ internal class ClientManager : IClientManager { /// private bool _sceneHostDetermined; + /// + /// GameObject hosting the that drives per-frame networking. + /// + private GameObject? _networkTickObject; + #endregion #region IClientManager properties @@ -245,8 +250,6 @@ ModSettings modSettings var clientApi = new ClientApi(this, _commandManager, uiManager, netClient, eventAggregator); _addonManager = new ClientAddonManager(clientApi, _modSettings); - - _lastPositionStopwatch = new Stopwatch(); } #region Internal client-manager methods @@ -320,7 +323,10 @@ private void RegisterHooks() { SceneManager.activeSceneChanged += OnSceneChange; CustomHooks.HeroControllerStartAction += OnHeroControllerStart; - EventHooks.HeroControllerUpdate += OnHeroControllerUpdate; + // Drive networking from a dedicated MonoBehaviour so it ticks regardless of Time.timeScale. + _networkTickObject = new GameObject("SSMP_NetworkTick"); + Object.DontDestroyOnLoad(_networkTickObject); + _networkTickObject.AddComponent().OnTick = OnNetworkTick; CustomHooks.AfterEnterSceneHeroTransformed += OnEnterScene; @@ -348,7 +354,11 @@ private void DeregisterHooks() { SceneManager.activeSceneChanged -= OnSceneChange; CustomHooks.HeroControllerStartAction -= OnHeroControllerStart; - EventHooks.HeroControllerUpdate -= OnHeroControllerUpdate; + // Tear down the network tick driver. + if (_networkTickObject != null) { + Object.Destroy(_networkTickObject); + _networkTickObject = null; + } CustomHooks.AfterEnterSceneHeroTransformed -= OnEnterScene; @@ -1185,45 +1195,42 @@ private void OnSceneChange(Scene oldScene, Scene newScene) { } /// - /// Callback method on the HeroController#Update method. + /// Per-frame network tick. Runs every Unity frame regardless of pause state. /// - /// The HeroController instance. - private void OnHeroControllerUpdate(HeroController self) { - // Ignore player position updates on non-gameplay scenes - var currentSceneName = SceneUtil.GetCurrentSceneName(); - if (SceneUtil.IsNonGameplayScene(currentSceneName)) { - return; - } - + private void OnNetworkTick() { // If we are not connected, there is nothing to send to if (!_netClient.IsConnected) { return; } // Update all remote player interpolations in one centralized loop. + // Uses unscaled delta time so interpolation keeps progressing while the game is paused. // We also pass the latest measured RTT so the interpolator can adapt to ping. - _playerManager.UpdateInterpolations(Time.deltaTime, _netClient.UpdateManager.AverageRtt); + _playerManager.UpdateInterpolations(Time.unscaledDeltaTime, _netClient.UpdateManager.AverageRtt); + + // The remaining work samples the local hero, so skip it when the hero isn't present + // (non-gameplay scenes, or before the hero has spawned). + if (HeroController.instance == null || SceneUtil.IsNonGameplayScene(SceneUtil.GetCurrentSceneName())) { + return; + } var heroTransform = HeroController.instance.transform; - // For position updating, we use a stopwatch to check whether the latest update wasn't too soon ago. - // Because each update of the player position in a packet needs to acquire the packet lock, which without - // this rate-limit might happen every frame - if (!_lastPositionStopwatch.IsRunning) { - _lastPositionStopwatch.Start(); - } + // Accumulate unscaled real time so throttling pauses cleanly when the app is suspended or + // backgrounded, instead of bursting an update on resume like a wall-clock stopwatch would. + _timeSinceLastPosition += Time.unscaledDeltaTime; // Update rate of 60 Hz - const int updateRate = 1000 / 60; - if (_lastPositionStopwatch.ElapsedMilliseconds > updateRate) { + const float updateRate = 1f / 60f; + // Heartbeat (seconds) so a stationary player is re-broadcast for late-joining peers. + const float heartbeat = 0.5f; + if (_timeSinceLastPosition > updateRate) { var newPosition = heroTransform.position; - // If the position changed since last check - if (newPosition != _lastPosition) { - // Update the last position, since it changed + // Send if the position changed, or if the heartbeat interval has elapsed + if (newPosition != _lastPosition || _timeSinceLastPosition > heartbeat) { _lastPosition = newPosition; - // Restart the stopwatch - _lastPositionStopwatch.Restart(); + _timeSinceLastPosition = 0f; _netClient.UpdateManager.UpdatePlayerPosition(new Vector2(newPosition.x, newPosition.y)); } diff --git a/SSMP/Util/NetworkTickBehaviour.cs b/SSMP/Util/NetworkTickBehaviour.cs new file mode 100644 index 00000000..dcd3a2fc --- /dev/null +++ b/SSMP/Util/NetworkTickBehaviour.cs @@ -0,0 +1,18 @@ +using System; +using UnityEngine; + +namespace SSMP.Util; + +/// +/// MonoBehaviour that fires every Unity frame, independent of . +/// +internal class NetworkTickBehaviour : MonoBehaviour { + /// + /// Callback invoked once per frame. + /// + public Action? OnTick; + + private void Update() { + OnTick?.Invoke(); + } +}