mirror of
https://github.com/osukey/osukey.git
synced 2025-05-09 23:57:18 +09: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>
|
/// </summary>
|
||||||
public event Action<int, FrameDataBundle> OnNewFrames;
|
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]
|
[BackgroundDependencyLoader]
|
||||||
private void load()
|
private void load()
|
||||||
{
|
{
|
||||||
@ -154,18 +164,24 @@ namespace osu.Game.Online.Spectator
|
|||||||
if (!playingUsers.Contains(userId))
|
if (!playingUsers.Contains(userId))
|
||||||
playingUsers.Add(userId);
|
playingUsers.Add(userId);
|
||||||
|
|
||||||
|
OnUserBeganPlaying?.Invoke(userId, state);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
|
Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state)
|
||||||
{
|
{
|
||||||
playingUsers.Remove(userId);
|
playingUsers.Remove(userId);
|
||||||
|
|
||||||
|
OnUserFinishedPlaying?.Invoke(userId, state);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
|
Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data)
|
||||||
{
|
{
|
||||||
OnNewFrames?.Invoke(userId, data);
|
OnNewFrames?.Invoke(userId, data);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,7 +227,7 @@ namespace osu.Game.Online.Spectator
|
|||||||
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
|
connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WatchUser(int userId)
|
public virtual void WatchUser(int userId)
|
||||||
{
|
{
|
||||||
if (watchingUsers.Contains(userId))
|
if (watchingUsers.Contains(userId))
|
||||||
return;
|
return;
|
||||||
|
@ -8,6 +8,12 @@ namespace osu.Game.Replays
|
|||||||
{
|
{
|
||||||
public class Replay
|
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>();
|
public List<ReplayFrame> Frames = new List<ReplayFrame>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using osu.Game.Input.Handlers;
|
using osu.Game.Input.Handlers;
|
||||||
using osu.Game.Replays;
|
using osu.Game.Replays;
|
||||||
@ -39,42 +40,34 @@ namespace osu.Game.Rulesets.Replays
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (!currentFrameIndex.HasValue)
|
if (!currentFrameIndex.HasValue)
|
||||||
return (TFrame)Frames[0];
|
return currentDirection > 0 ? (TFrame)Frames[0] : null;
|
||||||
|
|
||||||
if (currentDirection > 0)
|
int nextFrame = clampedNextFrameIndex;
|
||||||
return currentFrameIndex == Frames.Count - 1 ? null : (TFrame)Frames[currentFrameIndex.Value + 1];
|
|
||||||
else
|
if (nextFrame == currentFrameIndex.Value)
|
||||||
return currentFrameIndex == 0 ? null : (TFrame)Frames[nextFrameIndex];
|
return null;
|
||||||
|
|
||||||
|
return (TFrame)Frames[clampedNextFrameIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int? currentFrameIndex;
|
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)
|
protected FramedReplayInputHandler(Replay replay)
|
||||||
{
|
{
|
||||||
this.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;
|
private const double sixty_frame_time = 1000.0 / 60;
|
||||||
|
|
||||||
protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2;
|
protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2;
|
||||||
|
|
||||||
protected double? CurrentTime { get; private set; }
|
protected double? CurrentTime { get; private set; }
|
||||||
|
|
||||||
private int currentDirection;
|
private int currentDirection = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When set, we will ensure frames executed by nested drawables are frame-accurate to replay data.
|
/// 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>
|
/// </summary>
|
||||||
public bool FrameAccuratePlayback;
|
public bool FrameAccuratePlayback;
|
||||||
|
|
||||||
protected bool HasFrames => Frames.Count > 0;
|
public bool HasFrames => Frames.Count > 0;
|
||||||
|
|
||||||
private bool inImportantSection
|
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>
|
/// <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>
|
/// <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)
|
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)
|
if (!CurrentTime.HasValue)
|
||||||
{
|
{
|
||||||
@ -121,27 +170,6 @@ namespace osu.Game.Rulesets.Replays
|
|||||||
currentDirection = time.CompareTo(CurrentTime);
|
currentDirection = time.CompareTo(CurrentTime);
|
||||||
if (currentDirection == 0) currentDirection = 1;
|
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()
|
public override bool UpdateSubTree()
|
||||||
{
|
{
|
||||||
state = frameStableClock.IsPaused.Value ? PlaybackState.NotValid : PlaybackState.Valid;
|
|
||||||
|
|
||||||
int loops = MaxCatchUpFrames;
|
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();
|
updateClock();
|
||||||
|
|
||||||
if (state == PlaybackState.NotValid)
|
if (state == PlaybackState.NotValid)
|
||||||
@ -98,21 +98,33 @@ namespace osu.Game.Rulesets.UI
|
|||||||
|
|
||||||
base.UpdateSubTree();
|
base.UpdateSubTree();
|
||||||
UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat);
|
UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat);
|
||||||
}
|
} while (state == PlaybackState.RequiresCatchUp && loops-- > 0);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateClock()
|
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)
|
if (parentGameplayClock == null)
|
||||||
setClock(); // LoadComplete may not be run yet, but we still want the clock.
|
setClock(); // LoadComplete may not be run yet, but we still want the clock.
|
||||||
|
|
||||||
// each update start with considering things in valid state.
|
double proposedTime = parentGameplayClock.CurrentTime;
|
||||||
state = PlaybackState.Valid;
|
|
||||||
|
|
||||||
// our goal is to catch up to the time provided by the parent clock.
|
|
||||||
var proposedTime = parentGameplayClock.CurrentTime;
|
|
||||||
|
|
||||||
if (FrameStablePlayback)
|
if (FrameStablePlayback)
|
||||||
// if we require frame stability, the proposed time will be adjusted to move at most one known
|
// 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;
|
state = PlaybackState.NotValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposedTime != manualClock.CurrentTime)
|
if (state == PlaybackState.Valid)
|
||||||
direction = proposedTime > manualClock.CurrentTime ? 1 : -1;
|
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.CurrentTime = proposedTime;
|
||||||
manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction;
|
manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction;
|
||||||
manualClock.IsRunning = parentGameplayClock.IsRunning;
|
manualClock.IsRunning = parentGameplayClock.IsRunning;
|
||||||
|
|
||||||
double timeBehind = Math.Abs(manualClock.CurrentTime - parentGameplayClock.CurrentTime);
|
|
||||||
|
|
||||||
// determine whether catch-up is required.
|
// determine whether catch-up is required.
|
||||||
if (state == PlaybackState.Valid && timeBehind > 0)
|
if (state == PlaybackState.Valid && timeBehind > 0)
|
||||||
state = PlaybackState.RequiresCatchUp;
|
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
|
// 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
|
// to ensure that the its time is valid for our children before input is processed
|
||||||
framedClock.ProcessFrame();
|
framedClock.ProcessFrame();
|
||||||
@ -253,6 +266,8 @@ namespace osu.Game.Rulesets.UI
|
|||||||
|
|
||||||
public readonly Bindable<bool> IsCatchingUp = new Bindable<bool>();
|
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 override IEnumerable<Bindable<double>> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<Bindable<double>>();
|
||||||
|
|
||||||
public FrameStabilityClock(FramedClock underlyingClock)
|
public FrameStabilityClock(FramedClock underlyingClock)
|
||||||
@ -261,6 +276,8 @@ namespace osu.Game.Rulesets.UI
|
|||||||
}
|
}
|
||||||
|
|
||||||
IBindable<bool> IFrameStableClock.IsCatchingUp => IsCatchingUp;
|
IBindable<bool> IFrameStableClock.IsCatchingUp => IsCatchingUp;
|
||||||
|
|
||||||
|
IBindable<bool> IFrameStableClock.WaitingOnFrames => WaitingOnFrames;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,5 +9,10 @@ namespace osu.Game.Rulesets.UI
|
|||||||
public interface IFrameStableClock : IFrameBasedClock
|
public interface IFrameStableClock : IFrameBasedClock
|
||||||
{
|
{
|
||||||
IBindable<bool> IsCatchingUp { get; }
|
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 DecoupleableInterpolatingFramedClock adjustableClock;
|
||||||
|
|
||||||
private readonly double gameplayStartTime;
|
private readonly double gameplayStartTime;
|
||||||
|
private readonly bool startAtGameplayStart;
|
||||||
|
|
||||||
private readonly double firstHitObjectTime;
|
private readonly double firstHitObjectTime;
|
||||||
|
|
||||||
@ -62,10 +63,19 @@ namespace osu.Game.Screens.Play
|
|||||||
|
|
||||||
private readonly FramedOffsetClock platformOffsetClock;
|
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.beatmap = beatmap;
|
||||||
this.gameplayStartTime = gameplayStartTime;
|
this.gameplayStartTime = gameplayStartTime;
|
||||||
|
this.startAtGameplayStart = startAtGameplayStart;
|
||||||
track = beatmap.Track;
|
track = beatmap.Track;
|
||||||
|
|
||||||
firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime;
|
firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime;
|
||||||
@ -103,16 +113,21 @@ namespace osu.Game.Screens.Play
|
|||||||
userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true);
|
userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true);
|
||||||
|
|
||||||
// sane default provided by ruleset.
|
// 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.
|
if (!startAtGameplayStart)
|
||||||
// this is commonly used to display an intro before the audio track start.
|
{
|
||||||
startTime = Math.Min(startTime, beatmap.Storyboard.FirstEventTime);
|
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.
|
// if a storyboard is present, it may dictate the appropriate start time by having events in negative time space.
|
||||||
// this is not available as an option in the live editor but can still be applied via .osu editing.
|
// this is commonly used to display an intro before the audio track start.
|
||||||
if (beatmap.BeatmapInfo.AudioLeadIn > 0)
|
startTime = Math.Min(startTime, beatmap.Storyboard.FirstEventTime);
|
||||||
startTime = Math.Min(startTime, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn);
|
|
||||||
|
// 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);
|
Seek(startTime);
|
||||||
|
|
||||||
|
@ -199,7 +199,7 @@ namespace osu.Game.Screens.Play
|
|||||||
if (!ScoreProcessor.Mode.Disabled)
|
if (!ScoreProcessor.Mode.Disabled)
|
||||||
config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode);
|
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(gameplayBeatmap = new GameplayBeatmap(playableBeatmap));
|
||||||
AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer));
|
AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer));
|
||||||
@ -238,6 +238,14 @@ namespace osu.Game.Screens.Play
|
|||||||
skipOverlay.Hide();
|
skipOverlay.Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting =>
|
||||||
|
{
|
||||||
|
if (waiting.NewValue)
|
||||||
|
GameplayClockContainer.Stop();
|
||||||
|
else
|
||||||
|
GameplayClockContainer.Start();
|
||||||
|
});
|
||||||
|
|
||||||
DrawableRuleset.IsPaused.BindValueChanged(paused =>
|
DrawableRuleset.IsPaused.BindValueChanged(paused =>
|
||||||
{
|
{
|
||||||
updateGameplayState();
|
updateGameplayState();
|
||||||
@ -280,6 +288,8 @@ namespace osu.Game.Screens.Play
|
|||||||
IsBreakTime.BindValueChanged(onBreakTimeChanged, true);
|
IsBreakTime.BindValueChanged(onBreakTimeChanged, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new GameplayClockContainer(beatmap, gameplayStart);
|
||||||
|
|
||||||
private Drawable createUnderlayComponents() =>
|
private Drawable createUnderlayComponents() =>
|
||||||
DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both };
|
DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both };
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ namespace osu.Game.Screens.Play
|
|||||||
{
|
{
|
||||||
public class ReplayPlayer : Player
|
public class ReplayPlayer : Player
|
||||||
{
|
{
|
||||||
private readonly Score score;
|
protected readonly Score Score;
|
||||||
|
|
||||||
// Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108)
|
// Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108)
|
||||||
protected override bool CheckModsAllowFailure() => false;
|
protected override bool CheckModsAllowFailure() => false;
|
||||||
@ -16,12 +16,12 @@ namespace osu.Game.Screens.Play
|
|||||||
public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true)
|
public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true)
|
||||||
: base(allowPause, showResults)
|
: base(allowPause, showResults)
|
||||||
{
|
{
|
||||||
this.score = score;
|
Score = score;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void PrepareReplay()
|
protected override void PrepareReplay()
|
||||||
{
|
{
|
||||||
DrawableRuleset?.SetReplayScore(score);
|
DrawableRuleset?.SetReplayScore(Score);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
|
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
|
||||||
@ -31,9 +31,9 @@ namespace osu.Game.Screens.Play
|
|||||||
var baseScore = base.CreateScore();
|
var baseScore = base.CreateScore();
|
||||||
|
|
||||||
// Since the replay score doesn't contain statistics, we'll pass them through here.
|
// 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…
x
Reference in New Issue
Block a user