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:
commit
2c51c24913
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
295
osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
Normal file
295
osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
266
osu.Game/Screens/Play/Spectator.cs
Normal file
266
osu.Game/Screens/Play/Spectator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
60
osu.Game/Screens/Play/SpectatorPlayer.cs
Normal file
60
osu.Game/Screens/Play/SpectatorPlayer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
32
osu.Game/Screens/Play/SpectatorPlayerLoader.cs
Normal file
32
osu.Game/Screens/Play/SpectatorPlayerLoader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user