1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 17:52:56 +08:00

Reimplement multiplayer syncing using new master/slave clocks

This commit is contained in:
smoogipoo 2021-04-15 19:12:52 +09:00
parent fe3ba2b80e
commit 1705d472b5
6 changed files with 104 additions and 207 deletions

View File

@ -1,15 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Utils;
@ -156,27 +153,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
checkPausedInstant(55, false);
}
[Test]
public void TestPlayerStartsCatchingUpOnlyAfterExceedingMaxOffset()
{
start(new[] { 55, 56 });
loadSpectateScreen();
sendFrames(55, 1000);
sendFrames(56, 1000);
Bindable<double> slowDownAdjustment;
AddStep("slow down player 2", () =>
{
slowDownAdjustment = new Bindable<double>(0.99);
getInstance(56).Beatmap.Track.AddAdjustment(AdjustableProperty.Frequency, slowDownAdjustment);
});
AddUntilStep("exceeded min offset but not catching up", () => getGameplayOffset(55, 56) > PlayerInstance.MAX_OFFSET && !getInstance(56).IsCatchingUp);
AddUntilStep("catching up or caught up", () => getInstance(56).IsCatchingUp || Math.Abs(getGameplayOffset(55, 56)) < PlayerInstance.SYNC_TARGET * 2);
}
[Test]
public void TestPlayersCatchUpAfterFallingBehind()
{
@ -184,7 +160,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
loadSpectateScreen();
// Send initial frames for both players. A few more for player 1.
sendFrames(55, 100);
sendFrames(55, 1000);
sendFrames(56, 10);
checkPausedInstant(55, false);
checkPausedInstant(56, false);
@ -194,11 +170,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddWaitStep("wait a few more frames", 10);
// Send more frames for player 2. It should unpause.
sendFrames(56, 100);
sendFrames(56, 1000);
checkPausedInstant(56, false);
// Player 2 should catch up to player 1 after unpausing.
AddUntilStep("player 2 not catching up", () => !getInstance(56).IsCatchingUp);
AddUntilStep("player 2 not catching up", () => !getInstance(56).GameplayClock.IsCatchingUp);
AddWaitStep("wait a bit", 10);
}

View File

@ -0,0 +1,78 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public class MultiplayerSlaveClock : IFrameBasedClock, IMultiplayerSlaveClock
{
/// <summary>
/// The catchup rate.
/// </summary>
public const double CATCHUP_RATE = 2;
private readonly IFrameBasedClock masterClock;
public MultiplayerSlaveClock(IFrameBasedClock masterClock)
{
this.masterClock = masterClock;
}
public double CurrentTime { get; private set; }
public bool IsRunning { get; private set; }
public void Reset() => CurrentTime = 0;
public void Start() => IsRunning = true;
public void Stop() => IsRunning = false;
public bool Seek(double position) => true;
public void ResetSpeedAdjustments()
{
}
public double Rate => IsCatchingUp ? CATCHUP_RATE : 1;
double IAdjustableClock.Rate
{
get => Rate;
set => throw new NotSupportedException();
}
double IClock.Rate => Rate;
public void ProcessFrame()
{
masterClock.ProcessFrame();
ElapsedFrameTime = 0;
FramesPerSecond = 0;
if (IsRunning)
{
double elapsedSource = masterClock.ElapsedFrameTime;
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;
ElapsedFrameTime = elapsed;
FramesPerSecond = masterClock.FramesPerSecond;
}
}
public double ElapsedFrameTime { get; private set; }
public double FramesPerSecond { get; private set; }
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
public IBindable<bool> WaitingOnFrames { get; } = new Bindable<bool>();
public bool IsCatchingUp { get; set; }
}
}

View File

@ -2,14 +2,10 @@
// 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.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Utils;
using osu.Game.Online.Spectator;
using osu.Game.Screens.Play;
using osu.Game.Screens.Spectate;
@ -18,9 +14,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public class MultiplayerSpectator : SpectatorScreen
{
private const double min_duration_to_allow_playback = 50;
private const double maximum_start_delay = 15000;
// Isolates beatmap/ruleset to this screen.
public override bool DisallowExternalBeatmapRulesetChanges => true;
@ -30,10 +23,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
private SpectatorStreamingClient spectatorClient { get; set; }
private readonly PlayerInstance[] instances;
private GameplayClockContainer gameplayClockContainer;
private MasterGameplayClockContainer masterClockContainer;
private IMultiplayerSyncManager syncManager;
private PlayerGrid grid;
private MultiplayerSpectatorLeaderboard leaderboard;
private double? loadFinishTime;
public MultiplayerSpectator(int[] userIds)
: base(userIds.AsSpan().Slice(0, Math.Min(16, userIds.Length)).ToArray())
@ -46,7 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
Container leaderboardContainer;
InternalChild = gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
Child = new GridContainer
{
@ -70,6 +63,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
};
InternalChildren = new[]
{
(Drawable)(syncManager = new MultiplayerSyncManager(masterClockContainer)),
masterClockContainer
};
// Todo: This is not quite correct - it should be per-user to adjust for other mod combinations.
var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor();
@ -78,65 +77,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
LoadComponentAsync(leaderboard = new MultiplayerSpectatorLeaderboard(scoreProcessor, UserIds) { Expanded = { Value = true } }, leaderboardContainer.Add);
}
protected override void UpdateAfterChildren()
protected override void LoadComplete()
{
base.UpdateAfterChildren();
base.LoadComplete();
if (AllPlayersLoaded)
loadFinishTime ??= Time.Current;
updateGameplayPlayingState();
}
private bool canStartGameplay =>
// All players must be loaded, and...
AllPlayersLoaded
&& (
// All players have frames...
instances.All(i => i.Score.Replay.Frames.Count > 0)
// Or any player has frames and the maximum start delay has been exceeded.
|| (Time.Current - loadFinishTime > maximum_start_delay
&& instances.Any(i => i.Score.Replay.Frames.Count > 0))
);
private bool firstStartFrame = true;
private void updateGameplayPlayingState()
{
// Make sure all players are loaded and have frames before starting any.
if (!canStartGameplay)
{
foreach (var inst in instances)
inst?.PauseGameplay();
return;
}
if (firstStartFrame)
gameplayClockContainer.Restart();
// Not all instances may be in a valid gameplay state (see canStartGameplay). Only control the ones that are.
IEnumerable<PlayerInstance> validInstances = instances.Where(i => i.Score.Replay.Frames.Count > 0);
double targetGameplayTime = gameplayClockContainer.GameplayClock.CurrentTime;
var instanceTimes = string.Join(',', validInstances.Select(i => $" {i.User.Id}: {(int)i.GetCurrentGameplayTime()}"));
Logger.Log($"target: {(int)targetGameplayTime},{instanceTimes}");
foreach (var inst in validInstances)
{
Debug.Assert(inst != null);
double lastFrameTime = inst.Score.Replay.Frames.Select(f => f.Time).Last();
double currentTime = inst.GetCurrentGameplayTime();
bool canContinuePlayback = Precision.DefinitelyBigger(lastFrameTime, currentTime, min_duration_to_allow_playback);
if (!canContinuePlayback)
continue;
inst.ContinueGameplay(targetGameplayTime);
}
firstStartFrame = false;
masterClockContainer.Stop();
masterClockContainer.Restart();
}
protected override void OnUserStateChanged(int userId, SpectatorState spectatorState)
@ -151,15 +97,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
if (existingInstance != null)
{
grid.Remove(existingInstance);
syncManager.RemoveSlave(existingInstance.GameplayClock);
leaderboard.RemoveClock(existingInstance.User.Id);
}
LoadComponentAsync(instances[userIndex] = new PlayerInstance(gameplayState.Score, gameplayClockContainer.GameplayClock), d =>
LoadComponentAsync(instances[userIndex] = new PlayerInstance(gameplayState.Score, new MultiplayerSlaveClock(masterClockContainer.GameplayClock)), d =>
{
if (instances[userIndex] == d)
{
grid.Add(d);
leaderboard.AddClock(d.User.Id, d.Beatmap.Track);
syncManager.AddSlave(d.GameplayClock);
leaderboard.AddClock(d.User.Id, d.GameplayClock);
}
});
}

View File

@ -4,7 +4,6 @@
using osu.Framework.Bindables;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
@ -12,13 +11,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public class MultiplayerSpectatorPlayer : SpectatorPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
private readonly MultiplayerSlaveClock gameplayClock;
public new SubGameplayClockContainer GameplayClockContainer => (SubGameplayClockContainer)base.GameplayClockContainer;
private readonly GameplayClock gameplayClock;
public MultiplayerSpectatorPlayer(Score score, GameplayClock gameplayClock)
public MultiplayerSpectatorPlayer(Score score, MultiplayerSlaveClock gameplayClock)
: base(score)
{
this.gameplayClock = gameplayClock;

View File

@ -26,11 +26,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary>
public const double MAXIMUM_START_DELAY = 15000;
/// <summary>
/// The catchup rate.
/// </summary>
public const double CATCHUP_RATE = 2;
/// <summary>
/// The master clock which is used to control the timing of all slave clocks.
/// </summary>

View File

@ -4,7 +4,6 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
@ -14,21 +13,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
public class PlayerInstance : CompositeDrawable
{
/// <summary>
/// The rate at which a user catches up after becoming desynchronised.
/// </summary>
private const double catchup_rate = 2;
/// <summary>
/// The offset from the expected time at which to START synchronisation.
/// </summary>
public const double MAX_OFFSET = 50;
/// <summary>
/// The maximum offset from the expected time at which to STOP synchronisation.
/// </summary>
public const double SYNC_TARGET = 16;
public bool PlayerLoaded => stack?.CurrentScreen is Player;
public User User => Score.ScoreInfo.User;
@ -36,17 +20,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
public WorkingBeatmap Beatmap { get; private set; }
public readonly Score Score;
private readonly GameplayClock gameplayClock;
public bool IsCatchingUp { get; private set; }
public readonly MultiplayerSlaveClock GameplayClock;
private OsuScreenStack stack;
private MultiplayerSpectatorPlayer player;
public PlayerInstance(Score score, GameplayClock gameplayClock)
public PlayerInstance(Score score, MultiplayerSlaveClock gameplayClock)
{
Score = score;
this.gameplayClock = gameplayClock;
GameplayClock = gameplayClock;
RelativeSizeAxes = Axes.Both;
Masking = true;
@ -67,83 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
};
stack.Push(new MultiplayerSpectatorPlayerLoader(Score, () => player = new MultiplayerSpectatorPlayer(Score, gameplayClock)));
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
updateCatchup();
}
private double targetGameplayTime;
private void updateCatchup()
{
if (player?.IsLoaded != true)
return;
if (Score.Replay.Frames.Count == 0)
return;
if (player.GameplayClockContainer.IsPaused.Value)
return;
double currentTime = Beatmap.Track.CurrentTime;
double timeBehind = targetGameplayTime - currentTime;
double offsetForCatchup = IsCatchingUp ? SYNC_TARGET : MAX_OFFSET;
bool catchupRequired = timeBehind > offsetForCatchup;
// Skip catchup if no work needs to be done.
if (catchupRequired == IsCatchingUp)
return;
if (catchupRequired)
{
// player.GameplayClockContainer.AdjustableClock.Rate = catchup_rate;
Logger.Log($"{User.Id} catchup started (behind: {(int)timeBehind})");
}
else
{
// player.GameplayClockContainer.AdjustableClock.Rate = 1;
Logger.Log($"{User.Id} catchup finished (behind: {(int)timeBehind})");
}
IsCatchingUp = catchupRequired;
}
public double GetCurrentGameplayTime()
{
if (player?.IsLoaded != true)
return 0;
return player.GameplayClockContainer.GameplayClock.CurrentTime;
}
public bool IsPlaying()
{
if (player.IsLoaded != true)
return false;
return player.GameplayClockContainer.GameplayClock.IsRunning;
}
public void ContinueGameplay(double targetGameplayTime)
{
if (player?.IsLoaded != true)
return;
player.GameplayClockContainer.Start();
this.targetGameplayTime = targetGameplayTime;
}
public void PauseGameplay()
{
if (player?.IsLoaded != true)
return;
player.GameplayClockContainer.Stop();
stack.Push(new MultiplayerSpectatorPlayerLoader(Score, () => new MultiplayerSpectatorPlayer(Score, GameplayClock)));
}
// Player interferes with global input, so disable input for now.