// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Timing; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { /// /// A which synchronises de-synced player clocks through catchup. /// public class CatchUpSyncManager : Component, ISyncManager { /// /// The offset from the master clock to which player clocks should remain within to be considered in-sync. /// public const double SYNC_TARGET = 16; /// /// The offset from the master clock at which player clocks begin resynchronising. /// public const double MAX_SYNC_OFFSET = 50; /// /// The maximum delay to start gameplay, if any (but not all) player clocks are ready. /// public const double MAXIMUM_START_DELAY = 15000; /// /// The master clock which is used to control the timing of all player clocks clocks. /// public IAdjustableClock MasterClock { get; } /// /// The player clocks. /// private readonly List playerClocks = new List(); private bool hasStarted; private double? firstStartAttemptTime; public CatchUpSyncManager(IAdjustableClock master) { MasterClock = master; } public void AddPlayerClock(ISpectatorPlayerClock clock) => playerClocks.Add(clock); public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock); protected override void Update() { base.Update(); if (!attemptStart()) { // Ensure all player clocks are stopped until the start succeeds. foreach (var clock in playerClocks) clock.Stop(); return; } updateCatchup(); updateMasterClock(); } /// /// Attempts to start playback. Waits for all player clocks to have available frames for up to milliseconds. /// /// Whether playback was started and syncing should occur. private bool attemptStart() { if (hasStarted) return true; if (playerClocks.Count == 0) return false; int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); if (readyCount == playerClocks.Count) return hasStarted = true; if (readyCount > 0) { firstStartAttemptTime ??= Time.Current; if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY) return hasStarted = true; } return false; } /// /// Updates the catchup states of all player clocks clocks. /// private void updateCatchup() { for (int i = 0; i < playerClocks.Count; i++) { var clock = playerClocks[i]; // How far this player's clock is out of sync, compared to the master clock. // A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up). double timeDelta = MasterClock.CurrentTime - clock.CurrentTime; // Check that the player clock isn't too far ahead. // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. if (timeDelta < -SYNC_TARGET) { clock.Stop(); continue; } // Make sure the player clock is running if it can. if (!clock.WaitingOnFrames.Value) clock.Start(); if (clock.IsCatchingUp) { // Stop the player clock from catching up if it's within the sync target. if (timeDelta <= SYNC_TARGET) clock.IsCatchingUp = false; } else { // Make the player clock start catching up if it's exceeded the maximum allowable sync offset. if (timeDelta > MAX_SYNC_OFFSET) clock.IsCatchingUp = true; } } } /// /// Updates the master clock's running state. /// private void updateMasterClock() { bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); if (MasterClock.IsRunning != anyInSync) { if (anyInSync) MasterClock.Start(); else MasterClock.Stop(); } } } }