1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-15 10:22:56 +08:00

Merge pull request #10605 from peppy/spectator-replay-watcher

Add screen hierarchy for spectating another player
This commit is contained in:
Dan Balasescu 2020-11-02 15:29:59 +09:00 committed by GitHub
commit 2c51c24913
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1117 additions and 71 deletions

View File

@ -0,0 +1,296 @@
// 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 NUnit.Framework;
using osu.Game.Replays;
using osu.Game.Rulesets.Replays;
namespace osu.Game.Tests.NonVisual
{
[TestFixture]
public class StreamingFramedReplayInputHandlerTest
{
private Replay replay;
private TestInputHandler handler;
[SetUp]
public void SetUp()
{
handler = new TestInputHandler(replay = new Replay
{
HasReceivedAllFrames = false,
Frames = new List<ReplayFrame>
{
new TestReplayFrame(0),
new TestReplayFrame(1000),
new TestReplayFrame(2000),
new TestReplayFrame(3000, true),
new TestReplayFrame(4000, true),
new TestReplayFrame(5000, true),
new TestReplayFrame(7000, true),
new TestReplayFrame(8000),
}
});
}
[Test]
public void TestNormalPlayback()
{
Assert.IsNull(handler.CurrentFrame);
confirmCurrentFrame(null);
confirmNextFrame(0);
setTime(0, 0);
confirmCurrentFrame(0);
confirmNextFrame(1);
// if we hit the first frame perfectly, time should progress to it.
setTime(1000, 1000);
confirmCurrentFrame(1);
confirmNextFrame(2);
// in between non-important frames should progress based on input.
setTime(1200, 1200);
confirmCurrentFrame(1);
setTime(1400, 1400);
confirmCurrentFrame(1);
// progressing beyond the next frame should force time to that frame once.
setTime(2200, 2000);
confirmCurrentFrame(2);
// second attempt should progress to input time
setTime(2200, 2200);
confirmCurrentFrame(2);
// entering important section
setTime(3000, 3000);
confirmCurrentFrame(3);
// cannot progress within
setTime(3500, null);
confirmCurrentFrame(3);
setTime(4000, 4000);
confirmCurrentFrame(4);
// still cannot progress
setTime(4500, null);
confirmCurrentFrame(4);
setTime(5200, 5000);
confirmCurrentFrame(5);
// important section AllowedImportantTimeSpan allowance
setTime(5200, 5200);
confirmCurrentFrame(5);
setTime(7200, 7000);
confirmCurrentFrame(6);
setTime(7200, null);
confirmCurrentFrame(6);
// exited important section
setTime(8200, 8000);
confirmCurrentFrame(7);
confirmNextFrame(null);
setTime(8200, null);
confirmCurrentFrame(7);
confirmNextFrame(null);
setTime(8400, null);
confirmCurrentFrame(7);
confirmNextFrame(null);
}
[Test]
public void TestIntroTime()
{
setTime(-1000, -1000);
confirmCurrentFrame(null);
confirmNextFrame(0);
setTime(-500, -500);
confirmCurrentFrame(null);
confirmNextFrame(0);
setTime(0, 0);
confirmCurrentFrame(0);
confirmNextFrame(1);
}
[Test]
public void TestBasicRewind()
{
setTime(2800, 0);
setTime(2800, 1000);
setTime(2800, 2000);
setTime(2800, 2800);
confirmCurrentFrame(2);
confirmNextFrame(3);
// pivot without crossing a frame boundary
setTime(2700, 2700);
confirmCurrentFrame(2);
confirmNextFrame(1);
// cross current frame boundary; should not yet update frame
setTime(1980, 1980);
confirmCurrentFrame(2);
confirmNextFrame(1);
setTime(1200, 1200);
confirmCurrentFrame(2);
confirmNextFrame(1);
// ensure each frame plays out until start
setTime(-500, 1000);
confirmCurrentFrame(1);
confirmNextFrame(0);
setTime(-500, 0);
confirmCurrentFrame(0);
confirmNextFrame(null);
setTime(-500, -500);
confirmCurrentFrame(0);
confirmNextFrame(null);
}
[Test]
public void TestRewindInsideImportantSection()
{
fastForwardToPoint(3000);
setTime(4000, 4000);
confirmCurrentFrame(4);
confirmNextFrame(5);
setTime(3500, null);
confirmCurrentFrame(4);
confirmNextFrame(3);
setTime(3000, 3000);
confirmCurrentFrame(3);
confirmNextFrame(2);
setTime(3500, null);
confirmCurrentFrame(3);
confirmNextFrame(4);
setTime(4000, 4000);
confirmCurrentFrame(4);
confirmNextFrame(5);
setTime(4500, null);
confirmCurrentFrame(4);
confirmNextFrame(5);
setTime(4000, null);
confirmCurrentFrame(4);
confirmNextFrame(5);
setTime(3500, null);
confirmCurrentFrame(4);
confirmNextFrame(3);
setTime(3000, 3000);
confirmCurrentFrame(3);
confirmNextFrame(2);
}
[Test]
public void TestRewindOutOfImportantSection()
{
fastForwardToPoint(3500);
confirmCurrentFrame(3);
confirmNextFrame(4);
setTime(3200, null);
// next frame doesn't change even though direction reversed, because of important section.
confirmCurrentFrame(3);
confirmNextFrame(4);
setTime(3000, null);
confirmCurrentFrame(3);
confirmNextFrame(4);
setTime(2800, 2800);
confirmCurrentFrame(3);
confirmNextFrame(2);
}
private void fastForwardToPoint(double destination)
{
for (int i = 0; i < 1000; i++)
{
if (handler.SetFrameFromTime(destination) == null)
return;
}
throw new TimeoutException("Seek was never fulfilled");
}
private void setTime(double set, double? expect)
{
Assert.AreEqual(expect, handler.SetFrameFromTime(set));
}
private void confirmCurrentFrame(int? frame)
{
if (frame.HasValue)
{
Assert.IsNotNull(handler.CurrentFrame);
Assert.AreEqual(replay.Frames[frame.Value].Time, handler.CurrentFrame.Time);
}
else
{
Assert.IsNull(handler.CurrentFrame);
}
}
private void confirmNextFrame(int? frame)
{
if (frame.HasValue)
{
Assert.IsNotNull(handler.NextFrame);
Assert.AreEqual(replay.Frames[frame.Value].Time, handler.NextFrame.Time);
}
else
{
Assert.IsNull(handler.NextFrame);
}
}
private class TestReplayFrame : ReplayFrame
{
public readonly bool IsImportant;
public TestReplayFrame(double time, bool isImportant = false)
: base(time)
{
IsImportant = isImportant;
}
}
private class TestInputHandler : FramedReplayInputHandler<TestReplayFrame>
{
public TestInputHandler(Replay replay)
: base(replay)
{
FrameAccuratePlayback = true;
}
protected override double AllowedImportantTimeSpan => 1000;
protected override bool IsImportant(TestReplayFrame frame) => frame.IsImportant;
}
}
}

View File

@ -0,0 +1,295 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneSpectator : ScreenTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
// used just to show beatmap card for the time being.
protected override bool UseOnlineAPI => true;
private Spectator spectatorScreen;
[Resolved]
private OsuGameBase game { get; set; }
private int nextFrame;
private BeatmapSetInfo importedBeatmap;
private int importedBeatmapId;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("reset sent frames", () => nextFrame = 0);
AddStep("import beatmap", () =>
{
importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result;
importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineBeatmapID ?? -1;
});
AddStep("add streaming client", () =>
{
Remove(testSpectatorStreamingClient);
Add(testSpectatorStreamingClient);
});
finish();
}
[Test]
public void TestFrameStarvationAndResume()
{
loadSpectatingScreen();
AddAssert("screen hasn't changed", () => Stack.CurrentScreen is Spectator);
start();
sendFrames();
waitForPlayer();
AddAssert("ensure frames arrived", () => replayHandler.HasFrames);
AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null);
checkPaused(true);
double? pausedTime = null;
AddStep("store time", () => pausedTime = currentFrameStableTime);
sendFrames();
AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null);
checkPaused(true);
AddAssert("time advanced", () => currentFrameStableTime > pausedTime);
}
[Test]
public void TestPlayStartsWithNoFrames()
{
loadSpectatingScreen();
start();
waitForPlayer();
checkPaused(true);
sendFrames(1000); // send enough frames to ensure play won't be paused
checkPaused(false);
}
[Test]
public void TestSpectatingDuringGameplay()
{
start();
loadSpectatingScreen();
AddStep("advance frame count", () => nextFrame = 300);
sendFrames();
waitForPlayer();
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime > 30000);
}
[Test]
public void TestHostRetriesWhileWatching()
{
loadSpectatingScreen();
start();
sendFrames();
waitForPlayer();
Player lastPlayer = null;
AddStep("store first player", () => lastPlayer = player);
start();
sendFrames();
waitForPlayer();
AddAssert("player is different", () => lastPlayer != player);
}
[Test]
public void TestHostFails()
{
loadSpectatingScreen();
start();
waitForPlayer();
checkPaused(true);
finish();
checkPaused(false);
// TODO: should replay until running out of frames then fail
}
[Test]
public void TestStopWatchingDuringPlay()
{
loadSpectatingScreen();
start();
sendFrames();
waitForPlayer();
AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit());
AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null);
}
[Test]
public void TestStopWatchingThenHostRetries()
{
loadSpectatingScreen();
start();
sendFrames();
waitForPlayer();
AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit());
AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null);
// host starts playing a new session
start();
waitForPlayer();
}
[Test]
public void TestWatchingBeatmapThatDoesntExistLocally()
{
loadSpectatingScreen();
start(-1234);
sendFrames();
AddAssert("screen didn't change", () => Stack.CurrentScreen is Spectator);
}
private OsuFramedReplayInputHandler replayHandler =>
(OsuFramedReplayInputHandler)Stack.ChildrenOfType<OsuInputManager>().First().ReplayInputHandler;
private Player player => Stack.CurrentScreen as Player;
private double currentFrameStableTime
=> player.ChildrenOfType<FrameStabilityContainer>().First().FrameStableClock.CurrentTime;
private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(beatmapId ?? importedBeatmapId));
private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(beatmapId ?? importedBeatmapId));
private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
private void sendFrames(int count = 10)
{
AddStep("send frames", () =>
{
testSpectatorStreamingClient.SendFrames(nextFrame, count);
nextFrame += count;
});
}
private void loadSpectatingScreen()
{
AddStep("load screen", () => LoadScreen(spectatorScreen = new Spectator(testSpectatorStreamingClient.StreamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
}
internal class TestSpectatorStreamingClient : SpectatorStreamingClient
{
public readonly User StreamingUser = new User { Id = 1234, Username = "Test user" };
private int beatmapId;
public void StartPlay(int beatmapId)
{
this.beatmapId = beatmapId;
sendState(beatmapId);
}
public void EndPlay(int beatmapId)
{
((ISpectatorClient)this).UserFinishedPlaying((int)StreamingUser.Id, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
sentState = false;
}
private bool sentState;
public void SendFrames(int index, int count)
{
var frames = new List<LegacyReplayFrame>();
for (int i = index; i < index + count; i++)
{
var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1;
frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
var bundle = new FrameDataBundle(frames);
((ISpectatorClient)this).UserSentFrames((int)StreamingUser.Id, bundle);
if (!sentState)
sendState(beatmapId);
}
public override void WatchUser(int userId)
{
if (sentState)
{
// usually the server would do this.
sendState(beatmapId);
}
base.WatchUser(userId);
}
private void sendState(int beatmapId)
{
sentState = true;
((ISpectatorClient)this).UserBeganPlaying((int)StreamingUser.Id, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
}
}
}
}

View File

@ -64,6 +64,16 @@ namespace osu.Game.Online.Spectator
/// </summary>
public event Action<int, FrameDataBundle> OnNewFrames;
/// <summary>
/// Called whenever a user starts a play session.
/// </summary>
public event Action<int, SpectatorState> OnUserBeganPlaying;
/// <summary>
/// Called whenever a user finishes a play session.
/// </summary>
public event Action<int, SpectatorState> OnUserFinishedPlaying;
[BackgroundDependencyLoader]
private void load()
{
@ -154,18 +164,24 @@ namespace osu.Game.Online.Spectator
if (!playingUsers.Contains(userId))
playingUsers.Add(userId);
OnUserBeganPlaying?.Invoke(userId, state);
return Task.CompletedTask;
}
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
{
playingUsers.Remove(userId);
OnUserFinishedPlaying?.Invoke(userId, state);
return Task.CompletedTask;
}
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
{
OnNewFrames?.Invoke(userId, data);
return Task.CompletedTask;
}
@ -211,7 +227,7 @@ namespace osu.Game.Online.Spectator
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
}
public void WatchUser(int userId)
public virtual void WatchUser(int userId)
{
if (watchingUsers.Contains(userId))
return;

View File

@ -8,6 +8,12 @@ namespace osu.Game.Replays
{
public class Replay
{
/// <summary>
/// Whether all frames for this replay have been received.
/// If false, gameplay would be paused to wait for further data, for instance.
/// </summary>
public bool HasReceivedAllFrames = true;
public List<ReplayFrame> Frames = new List<ReplayFrame>();
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
@ -39,42 +40,34 @@ namespace osu.Game.Rulesets.Replays
return null;
if (!currentFrameIndex.HasValue)
return (TFrame)Frames[0];
return currentDirection > 0 ? (TFrame)Frames[0] : null;
if (currentDirection > 0)
return currentFrameIndex == Frames.Count - 1 ? null : (TFrame)Frames[currentFrameIndex.Value + 1];
else
return currentFrameIndex == 0 ? null : (TFrame)Frames[nextFrameIndex];
int nextFrame = clampedNextFrameIndex;
if (nextFrame == currentFrameIndex.Value)
return null;
return (TFrame)Frames[clampedNextFrameIndex];
}
}
private int? currentFrameIndex;
private int nextFrameIndex => currentFrameIndex.HasValue ? Math.Clamp(currentFrameIndex.Value + (currentDirection > 0 ? 1 : -1), 0, Frames.Count - 1) : 0;
private int clampedNextFrameIndex =>
currentFrameIndex.HasValue ? Math.Clamp(currentFrameIndex.Value + currentDirection, 0, Frames.Count - 1) : 0;
protected FramedReplayInputHandler(Replay replay)
{
this.replay = replay;
}
private bool advanceFrame()
{
int newFrame = nextFrameIndex;
// ensure we aren't at an extent.
if (newFrame == currentFrameIndex) return false;
currentFrameIndex = newFrame;
return true;
}
private const double sixty_frame_time = 1000.0 / 60;
protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2;
protected double? CurrentTime { get; private set; }
private int currentDirection;
private int currentDirection = 1;
/// <summary>
/// When set, we will ensure frames executed by nested drawables are frame-accurate to replay data.
@ -82,7 +75,7 @@ namespace osu.Game.Rulesets.Replays
/// </summary>
public bool FrameAccuratePlayback;
protected bool HasFrames => Frames.Count > 0;
public bool HasFrames => Frames.Count > 0;
private bool inImportantSection
{
@ -111,6 +104,62 @@ namespace osu.Game.Rulesets.Replays
/// <param name="time">The time which we should use for finding the current frame.</param>
/// <returns>The usable time value. If null, we should not advance time as we do not have enough data.</returns>
public override double? SetFrameFromTime(double time)
{
updateDirection(time);
Debug.Assert(currentDirection != 0);
if (!HasFrames)
{
// in the case all frames are received, allow time to progress regardless.
if (replay.HasReceivedAllFrames)
return CurrentTime = time;
return null;
}
TFrame next = NextFrame;
// if we have a next frame, check if it is before or at the current time in playback, and advance time to it if so.
if (next != null)
{
int compare = time.CompareTo(next.Time);
if (compare == 0 || compare == currentDirection)
{
currentFrameIndex = clampedNextFrameIndex;
return CurrentTime = CurrentFrame.Time;
}
}
// at this point, the frame index can't be advanced.
// even so, we may be able to propose the clock progresses forward due to being at an extent of the replay,
// or moving towards the next valid frame (ie. interpolating in a non-important section).
// the exception is if currently in an important section, which is respected above all.
if (inImportantSection)
{
Debug.Assert(next != null || !replay.HasReceivedAllFrames);
return null;
}
// if a next frame does exist, allow interpolation.
if (next != null)
return CurrentTime = time;
// if all frames have been received, allow playing beyond extents.
if (replay.HasReceivedAllFrames)
return CurrentTime = time;
// if not all frames are received but we are before the first frame, allow playing.
if (time < Frames[0].Time)
return CurrentTime = time;
// in the case we have no next frames and haven't received enough frame data, block.
return null;
}
private void updateDirection(double time)
{
if (!CurrentTime.HasValue)
{
@ -121,27 +170,6 @@ namespace osu.Game.Rulesets.Replays
currentDirection = time.CompareTo(CurrentTime);
if (currentDirection == 0) currentDirection = 1;
}
if (HasFrames)
{
// check if the next frame is valid for the current playback direction.
// validity is if the next frame is equal or "earlier"
var compare = time.CompareTo(NextFrame?.Time);
if (compare == 0 || compare == currentDirection)
{
if (advanceFrame())
return CurrentTime = CurrentFrame.Time;
}
else
{
// if we didn't change frames, we need to ensure we are allowed to run frames in between, else return null.
if (inImportantSection)
return null;
}
}
return CurrentTime = time;
}
}
}

View File

@ -85,12 +85,12 @@ namespace osu.Game.Rulesets.UI
public override bool UpdateSubTree()
{
state = frameStableClock.IsPaused.Value ? PlaybackState.NotValid : PlaybackState.Valid;
int loops = MaxCatchUpFrames;
while (state != PlaybackState.NotValid && loops-- > 0)
do
{
// update clock is always trying to approach the aim time.
// it should be provided as the original value each loop.
updateClock();
if (state == PlaybackState.NotValid)
@ -98,21 +98,33 @@ namespace osu.Game.Rulesets.UI
base.UpdateSubTree();
UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat);
}
} while (state == PlaybackState.RequiresCatchUp && loops-- > 0);
return true;
}
private void updateClock()
{
if (frameStableClock.WaitingOnFrames.Value)
{
// if waiting on frames, run one update loop to determine if frames have arrived.
state = PlaybackState.Valid;
}
else if (frameStableClock.IsPaused.Value)
{
// time should not advance while paused, nor should anything run.
state = PlaybackState.NotValid;
return;
}
else
{
state = PlaybackState.Valid;
}
if (parentGameplayClock == null)
setClock(); // LoadComplete may not be run yet, but we still want the clock.
// each update start with considering things in valid state.
state = PlaybackState.Valid;
// our goal is to catch up to the time provided by the parent clock.
var proposedTime = parentGameplayClock.CurrentTime;
double proposedTime = parentGameplayClock.CurrentTime;
if (FrameStablePlayback)
// if we require frame stability, the proposed time will be adjusted to move at most one known
@ -127,21 +139,22 @@ namespace osu.Game.Rulesets.UI
state = PlaybackState.NotValid;
}
if (proposedTime != manualClock.CurrentTime)
direction = proposedTime > manualClock.CurrentTime ? 1 : -1;
if (state == PlaybackState.Valid)
direction = proposedTime >= manualClock.CurrentTime ? 1 : -1;
double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime);
frameStableClock.IsCatchingUp.Value = timeBehind > 200;
frameStableClock.WaitingOnFrames.Value = state == PlaybackState.NotValid;
manualClock.CurrentTime = proposedTime;
manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction;
manualClock.IsRunning = parentGameplayClock.IsRunning;
double timeBehind = Math.Abs(manualClock.CurrentTime - parentGameplayClock.CurrentTime);
// determine whether catch-up is required.
if (state == PlaybackState.Valid && timeBehind > 0)
state = PlaybackState.RequiresCatchUp;
frameStableClock.IsCatchingUp.Value = timeBehind > 200;
// The manual clock time has changed in the above code. The framed clock now needs to be updated
// to ensure that the its time is valid for our children before input is processed
framedClock.ProcessFrame();
@ -253,6 +266,8 @@ namespace osu.Game.Rulesets.UI
public readonly Bindable<bool> IsCatchingUp = new Bindable<bool>();
public readonly Bindable<bool> WaitingOnFrames = new Bindable<bool>();
public override IEnumerable<Bindable<double>> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<Bindable<double>>();
public FrameStabilityClock(FramedClock underlyingClock)
@ -261,6 +276,8 @@ namespace osu.Game.Rulesets.UI
}
IBindable<bool> IFrameStableClock.IsCatchingUp => IsCatchingUp;
IBindable<bool> IFrameStableClock.WaitingOnFrames => WaitingOnFrames;
}
}
}

View File

@ -9,5 +9,10 @@ namespace osu.Game.Rulesets.UI
public interface IFrameStableClock : IFrameBasedClock
{
IBindable<bool> IsCatchingUp { get; }
/// <summary>
/// Whether the frame stable clock is waiting on new frames to arrive to be able to progress time.
/// </summary>
IBindable<bool> WaitingOnFrames { get; }
}
}

View File

@ -37,6 +37,7 @@ namespace osu.Game.Screens.Play
private readonly DecoupleableInterpolatingFramedClock adjustableClock;
private readonly double gameplayStartTime;
private readonly bool startAtGameplayStart;
private readonly double firstHitObjectTime;
@ -62,10 +63,19 @@ namespace osu.Game.Screens.Play
private readonly FramedOffsetClock platformOffsetClock;
public GameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime)
/// <summary>
/// Creates a new <see cref="GameplayClockContainer"/>.
/// </summary>
/// <param name="beatmap">The beatmap being played.</param>
/// <param name="gameplayStartTime">The suggested time to start gameplay at.</param>
/// <param name="startAtGameplayStart">
/// Whether <paramref name="gameplayStartTime"/> should be used regardless of when storyboard events and hitobjects are supposed to start.
/// </param>
public GameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false)
{
this.beatmap = beatmap;
this.gameplayStartTime = gameplayStartTime;
this.startAtGameplayStart = startAtGameplayStart;
track = beatmap.Track;
firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime;
@ -103,16 +113,21 @@ namespace osu.Game.Screens.Play
userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true);
// sane default provided by ruleset.
double startTime = Math.Min(0, gameplayStartTime);
double startTime = gameplayStartTime;
// if a storyboard is present, it may dictate the appropriate start time by having events in negative time space.
// this is commonly used to display an intro before the audio track start.
startTime = Math.Min(startTime, beatmap.Storyboard.FirstEventTime);
if (!startAtGameplayStart)
{
startTime = Math.Min(0, startTime);
// some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available.
// this is not available as an option in the live editor but can still be applied via .osu editing.
if (beatmap.BeatmapInfo.AudioLeadIn > 0)
startTime = Math.Min(startTime, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn);
// if a storyboard is present, it may dictate the appropriate start time by having events in negative time space.
// this is commonly used to display an intro before the audio track start.
startTime = Math.Min(startTime, beatmap.Storyboard.FirstEventTime);
// some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available.
// this is not available as an option in the live editor but can still be applied via .osu editing.
if (beatmap.BeatmapInfo.AudioLeadIn > 0)
startTime = Math.Min(startTime, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn);
}
Seek(startTime);

View File

@ -199,7 +199,7 @@ namespace osu.Game.Screens.Play
if (!ScoreProcessor.Mode.Disabled)
config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode);
InternalChild = GameplayClockContainer = new GameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime);
InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime);
AddInternal(gameplayBeatmap = new GameplayBeatmap(playableBeatmap));
AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer));
@ -238,6 +238,14 @@ namespace osu.Game.Screens.Play
skipOverlay.Hide();
}
DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting =>
{
if (waiting.NewValue)
GameplayClockContainer.Stop();
else
GameplayClockContainer.Start();
});
DrawableRuleset.IsPaused.BindValueChanged(paused =>
{
updateGameplayState();
@ -280,6 +288,8 @@ namespace osu.Game.Screens.Play
IsBreakTime.BindValueChanged(onBreakTimeChanged, true);
}
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new GameplayClockContainer(beatmap, gameplayStart);
private Drawable createUnderlayComponents() =>
DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both };

View File

@ -8,7 +8,7 @@ namespace osu.Game.Screens.Play
{
public class ReplayPlayer : Player
{
private readonly Score score;
protected readonly Score Score;
// Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108)
protected override bool CheckModsAllowFailure() => false;
@ -16,12 +16,12 @@ namespace osu.Game.Screens.Play
public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true)
: base(allowPause, showResults)
{
this.score = score;
Score = score;
}
protected override void PrepareReplay()
{
DrawableRuleset?.SetReplayScore(score);
DrawableRuleset?.SetReplayScore(Score);
}
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
@ -31,9 +31,9 @@ namespace osu.Game.Screens.Play
var baseScore = base.CreateScore();
// Since the replay score doesn't contain statistics, we'll pass them through here.
score.ScoreInfo.HitEvents = baseScore.HitEvents;
Score.ScoreInfo.HitEvents = baseScore.HitEvents;
return score.ScoreInfo;
return Score.ScoreInfo;
}
}
}

View File

@ -0,0 +1,266 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Spectator;
using osu.Game.Overlays.BeatmapListing.Panels;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.Play
{
public class Spectator : OsuScreen
{
private readonly User targetUser;
[Resolved]
private Bindable<WorkingBeatmap> beatmap { get; set; }
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; }
private Ruleset rulesetInstance;
[Resolved]
private Bindable<IReadOnlyList<Mod>> mods { get; set; }
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private SpectatorStreamingClient spectatorStreaming { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private Replay replay;
private Container beatmapPanelContainer;
private SpectatorState state;
private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated;
/// <summary>
/// Becomes true if a new state is waiting to be loaded (while this screen was not active).
/// </summary>
private bool newStatePending;
public Spectator([NotNull] User targetUser)
{
this.targetUser = targetUser ?? throw new ArgumentNullException(nameof(targetUser));
}
[BackgroundDependencyLoader]
private void load()
{
InternalChildren = new Drawable[]
{
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Spacing = new Vector2(15),
Children = new Drawable[]
{
new OsuSpriteText
{
Text = "Currently spectating",
Font = OsuFont.Default.With(size: 30),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new UserGridPanel(targetUser)
{
Width = 290,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new OsuSpriteText
{
Text = "playing",
Font = OsuFont.Default.With(size: 30),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
beatmapPanelContainer = new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
spectatorStreaming.OnUserFinishedPlaying += userFinishedPlaying;
spectatorStreaming.OnNewFrames += userSentFrames;
spectatorStreaming.WatchUser((int)targetUser.Id);
managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
managerUpdated.BindValueChanged(beatmapUpdated);
}
private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> beatmap)
{
if (beatmap.NewValue.TryGetTarget(out var beatmapSet) && beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID))
attemptStart();
}
private void userSentFrames(int userId, FrameDataBundle data)
{
if (userId != targetUser.Id)
return;
// this should never happen as the server sends the user's state on watching,
// but is here as a safety measure.
if (replay == null)
return;
foreach (var frame in data.Frames)
{
IConvertibleReplayFrame convertibleFrame = rulesetInstance.CreateConvertibleReplayFrame();
convertibleFrame.FromLegacy(frame, beatmap.Value.Beatmap);
var convertedFrame = (ReplayFrame)convertibleFrame;
convertedFrame.Time = frame.Time;
replay.Frames.Add(convertedFrame);
}
}
private void userBeganPlaying(int userId, SpectatorState state)
{
if (userId != targetUser.Id)
return;
this.state = state;
if (this.IsCurrentScreen())
Schedule(attemptStart);
else
newStatePending = true;
}
public override void OnResuming(IScreen last)
{
base.OnResuming(last);
if (newStatePending)
{
attemptStart();
newStatePending = false;
}
}
private void userFinishedPlaying(int userId, SpectatorState state)
{
if (userId != targetUser.Id)
return;
if (replay == null) return;
replay.HasReceivedAllFrames = true;
replay = null;
}
private void attemptStart()
{
var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == state.RulesetID)?.CreateInstance();
// ruleset not available
if (resolvedRuleset == null)
return;
if (state.BeatmapID == null)
return;
var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == state.BeatmapID);
if (resolvedBeatmap == null)
{
showBeatmapPanel(state.BeatmapID.Value);
return;
}
replay ??= new Replay { HasReceivedAllFrames = false };
var scoreInfo = new ScoreInfo
{
Beatmap = resolvedBeatmap,
User = targetUser,
Mods = state.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
Ruleset = resolvedRuleset.RulesetInfo,
};
ruleset.Value = resolvedRuleset.RulesetInfo;
rulesetInstance = resolvedRuleset;
beatmap.Value = beatmaps.GetWorkingBeatmap(resolvedBeatmap);
this.Push(new SpectatorPlayerLoader(new Score
{
ScoreInfo = scoreInfo,
Replay = replay,
}));
}
private void showBeatmapPanel(int beatmapId)
{
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
req.Success += res => Schedule(() =>
{
beatmapPanelContainer.Child = new GridBeatmapPanel(res.ToBeatmapSet(rulesets));
});
api.Queue(req);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (spectatorStreaming != null)
{
spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
spectatorStreaming.OnUserFinishedPlaying -= userFinishedPlaying;
spectatorStreaming.OnNewFrames -= userSentFrames;
spectatorStreaming.StopWatchingUser((int)targetUser.Id);
}
managerUpdated?.UnbindAll();
}
}
}

View File

@ -0,0 +1,60 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Online.Spectator;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
public class SpectatorPlayer : ReplayPlayer
{
[Resolved]
private SpectatorStreamingClient spectatorStreaming { get; set; }
public SpectatorPlayer(Score score)
: base(score)
{
}
[BackgroundDependencyLoader]
private void load()
{
spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
{
// if we already have frames, start gameplay at the point in time they exist, should they be too far into the beatmap.
double? firstFrameTime = Score.Replay.Frames.FirstOrDefault()?.Time;
if (firstFrameTime == null || firstFrameTime <= gameplayStart + 5000)
return base.CreateGameplayClockContainer(beatmap, gameplayStart);
return new GameplayClockContainer(beatmap, firstFrameTime.Value, true);
}
public override bool OnExiting(IScreen next)
{
spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
return base.OnExiting(next);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (spectatorStreaming != null)
spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
}
private void userBeganPlaying(int userId, SpectatorState state)
{
if (userId == Score.ScoreInfo.UserID)
Schedule(this.Exit);
}
}
}

View File

@ -0,0 +1,32 @@
// 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.Screens;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
public class SpectatorPlayerLoader : PlayerLoader
{
public readonly ScoreInfo Score;
public SpectatorPlayerLoader(Score score)
: base(() => new SpectatorPlayer(score))
{
if (score.Replay == null)
throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score));
Score = score.ScoreInfo;
}
public override void OnEntering(IScreen last)
{
// these will be reverted thanks to PlayerLoader's lease.
Mods.Value = Score.Mods;
Ruleset.Value = Score.Ruleset;
base.OnEntering(last);
}
}
}