// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; 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; public event Action ReadyToStart; /// /// The master clock which is used to control the timing of all player clocks clocks. /// public IAdjustableClock MasterClock { get; } public IBindable MasterState => masterState; /// /// The player clocks. /// private readonly List playerClocks = new List(); private readonly Bindable masterState = new Bindable(); private bool hasStarted; private double? firstStartAttemptTime; public CatchUpSyncManager(IAdjustableClock master) { MasterClock = master; } public void AddPlayerClock(ISpectatorPlayerClock clock) { Debug.Assert(!playerClocks.Contains(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; } updatePlayerCatchup(); updateMasterState(); } /// /// 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 performStart(); if (readyCount > 0) { firstStartAttemptTime ??= Time.Current; if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY) return performStart(); } bool performStart() { ReadyToStart?.Invoke(); return hasStarted = true; } return false; } /// /// Updates the catchup states of all player clocks clocks. /// private void updatePlayerCatchup() { 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 state of the master clock. /// private void updateMasterState() { bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; } } }