diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 92a60663de..b4fc081a2a 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -37,11 +37,6 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestNormalPlayback() { - Assert.IsNull(handler.CurrentFrame); - - confirmCurrentFrame(null); - confirmNextFrame(0); - setTime(0, 0); confirmCurrentFrame(0); confirmNextFrame(1); @@ -97,22 +92,22 @@ namespace osu.Game.Tests.NonVisual // exited important section setTime(8200, 8000); confirmCurrentFrame(7); - confirmNextFrame(null); + confirmNextFrame(7); setTime(8200, 8200); confirmCurrentFrame(7); - confirmNextFrame(null); + confirmNextFrame(7); } [Test] public void TestIntroTime() { setTime(-1000, -1000); - confirmCurrentFrame(null); + confirmCurrentFrame(0); confirmNextFrame(0); setTime(-500, -500); - confirmCurrentFrame(null); + confirmCurrentFrame(0); confirmNextFrame(0); setTime(0, 0); @@ -133,29 +128,29 @@ namespace osu.Game.Tests.NonVisual // pivot without crossing a frame boundary setTime(2700, 2700); confirmCurrentFrame(2); - confirmNextFrame(1); + confirmNextFrame(3); - // cross current frame boundary; should not yet update frame - setTime(1980, 1980); + // cross current frame boundary + setTime(1980, 2000); confirmCurrentFrame(2); - confirmNextFrame(1); + confirmNextFrame(3); setTime(1200, 1200); - confirmCurrentFrame(2); - confirmNextFrame(1); + confirmCurrentFrame(1); + confirmNextFrame(2); // ensure each frame plays out until start setTime(-500, 1000); confirmCurrentFrame(1); - confirmNextFrame(0); + confirmNextFrame(2); setTime(-500, 0); confirmCurrentFrame(0); - confirmNextFrame(null); + confirmNextFrame(1); setTime(-500, -500); confirmCurrentFrame(0); - confirmNextFrame(null); + confirmNextFrame(0); } [Test] @@ -168,12 +163,12 @@ namespace osu.Game.Tests.NonVisual confirmNextFrame(5); setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); + confirmCurrentFrame(3); + confirmNextFrame(4); setTime(3000, 3000); confirmCurrentFrame(3); - confirmNextFrame(2); + confirmNextFrame(4); setTime(3500, null); confirmCurrentFrame(3); @@ -187,17 +182,17 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(4); confirmNextFrame(5); - setTime(4000, null); + setTime(4000, 4000); confirmCurrentFrame(4); confirmNextFrame(5); setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); + confirmCurrentFrame(3); + confirmNextFrame(4); setTime(3000, 3000); confirmCurrentFrame(3); - confirmNextFrame(2); + confirmNextFrame(4); } [Test] @@ -209,24 +204,24 @@ namespace osu.Game.Tests.NonVisual 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); + setTime(3000, 3000); confirmCurrentFrame(3); confirmNextFrame(4); setTime(2800, 2800); - confirmCurrentFrame(3); - confirmNextFrame(2); + confirmCurrentFrame(2); + confirmNextFrame(3); } private void fastForwardToPoint(double destination) { for (int i = 0; i < 1000; i++) { - if (handler.SetFrameFromTime(destination) == null) + var time = handler.SetFrameFromTime(destination); + if (time == null || time == destination) return; } @@ -235,33 +230,17 @@ namespace osu.Game.Tests.NonVisual private void setTime(double set, double? expect) { - Assert.AreEqual(expect, handler.SetFrameFromTime(set)); + Assert.AreEqual(expect, handler.SetFrameFromTime(set), "Unexpected return value"); } - private void confirmCurrentFrame(int? frame) + 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); - } + Assert.AreEqual(replay.Frames[frame].Time, handler.CurrentFrame.Time, "Unexpected current frame"); } - private void confirmNextFrame(int? frame) + 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); - } + Assert.AreEqual(replay.Frames[frame].Time, handler.NextFrame.Time, "Unexpected next frame"); } private class TestReplayFrame : ReplayFrame diff --git a/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs deleted file mode 100644 index 21ec29b10b..0000000000 --- a/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using 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 - { - 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 - { - public TestInputHandler(Replay replay) - : base(replay) - { - FrameAccuratePlayback = true; - } - - protected override double AllowedImportantTimeSpan => 1000; - - protected override bool IsImportant(TestReplayFrame frame) => frame.IsImportant; - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 9d85a9995d..9f1faa8e26 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Gameplay waitForPlayer(); AddAssert("ensure frames arrived", () => replayHandler.HasFrames); - AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + AddUntilStep("wait for frame starvation", () => replayHandler.WaitingNextFrame); checkPaused(true); double? pausedTime = null; @@ -92,7 +92,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(); - AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + AddUntilStep("wait for frame starvation", () => replayHandler.WaitingNextFrame); checkPaused(true); AddAssert("time advanced", () => currentFrameStableTime > pausedTime); diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 5eaccc766e..f527c0e105 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; -using System.Diagnostics; using JetBrains.Annotations; using osu.Game.Input.Handlers; using osu.Game.Replays; @@ -17,70 +18,80 @@ namespace osu.Game.Rulesets.Replays public abstract class FramedReplayInputHandler : ReplayInputHandler where TFrame : ReplayFrame { - public override bool IsActive => HasFrames; + /// + /// Whether we have at least one replay frame. + /// + public bool HasFrames => Frames.Count != 0; - private readonly Replay replay; - - protected List Frames => replay.Frames; + /// + /// Whether we are waiting for new frames to be received. + /// + public bool WaitingNextFrame => !replay.HasReceivedAllFrames && currentFrameIndex == Frames.Count - 1; + /// + /// The current frame of the replay. + /// The current time is always between the start and the end time of the current frame. + /// + /// The replay is empty. public TFrame CurrentFrame { get { if (!HasFrames) - throw new InvalidOperationException($"Cannot get {nameof(CurrentFrame)} of the empty replay"); + throw new InvalidOperationException($"Attempted to get {nameof(CurrentFrame)} of an empty replay"); - if (!currentFrameIndex.HasValue) - return null; - - return (TFrame)Frames[currentFrameIndex.Value]; + return (TFrame)Frames[Math.Max(0, currentFrameIndex)]; } } + /// + /// The next frame of the replay. + /// The start time is always greater or equals to the start time of regardless of the seeking direction. + /// If it is before the first frame of the replay or the after the last frame of the replay, and agree. + /// + /// The replay is empty. public TFrame NextFrame { get { if (!HasFrames) - throw new InvalidOperationException($"Cannot get {nameof(NextFrame)} of the empty replay"); + throw new InvalidOperationException($"Attempted to get {nameof(NextFrame)} of an empty replay"); - if (!currentFrameIndex.HasValue) - return currentDirection > 0 ? (TFrame)Frames[0] : null; - - int nextFrame = clampedNextFrameIndex; - - if (nextFrame == currentFrameIndex.Value) - return null; - - return (TFrame)Frames[clampedNextFrameIndex]; + return (TFrame)Frames[Math.Min(currentFrameIndex + 1, Frames.Count - 1)]; } } - private int? currentFrameIndex; - - private int clampedNextFrameIndex => - currentFrameIndex.HasValue ? Math.Clamp(currentFrameIndex.Value + currentDirection, 0, Frames.Count - 1) : 0; - - protected FramedReplayInputHandler(Replay replay) - { - this.replay = replay; - } - - 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 = 1; - /// /// When set, we will ensure frames executed by nested drawables are frame-accurate to replay data. /// Disabling this can make replay playback smoother (useful for autoplay, currently). /// public bool FrameAccuratePlayback; - public bool HasFrames => Frames.Count > 0; + // This input handler should be enabled only if there is at least one replay frame. + public override bool IsActive => HasFrames; + + // Can make it non-null but that is a breaking change. + protected double? CurrentTime { get; private set; } + + protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2; + + protected List Frames => replay.Frames; + + private readonly Replay replay; + + private int currentFrameIndex; + + private const double sixty_frame_time = 1000.0 / 60; + + protected FramedReplayInputHandler(Replay replay) + { + // This replay frame ordering should be enforced on the Replay type + replay.Frames.Sort((x, y) => x.Time.CompareTo(y.Time)); + + this.replay = replay; + currentFrameIndex = -1; + CurrentTime = double.NegativeInfinity; + } private bool inImportantSection { @@ -89,13 +100,8 @@ namespace osu.Game.Rulesets.Replays if (!HasFrames || !FrameAccuratePlayback) return false; - var frame = currentDirection > 0 ? CurrentFrame : NextFrame; - - if (frame == null) - return false; - - return IsImportant(frame) && // a button is in a pressed state - Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span + return IsImportant(CurrentFrame) && // a button is in a pressed state + Math.Abs(CurrentTime - NextFrame.Time ?? 0) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span } } @@ -110,71 +116,52 @@ namespace osu.Game.Rulesets.Replays /// The usable time value. If null, we should not advance time as we do not have enough data. 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. + // In the case all frames are received, allow time to progress regardless. if (replay.HasReceivedAllFrames) return CurrentTime = time; return null; } - TFrame next = NextFrame; + double frameStart = getFrameTime(currentFrameIndex); + double frameEnd = getFrameTime(currentFrameIndex + 1); - // 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) + // If the proposed time is after the current frame end time, we progress forwards. + // If the proposed time is before the current frame start time, and we are at the frame boundary, we progress backwards. + if (frameEnd <= time) { - int compare = time.CompareTo(next.Time); - - if (compare == 0 || compare == currentDirection) - { - currentFrameIndex = clampedNextFrameIndex; - return CurrentTime = CurrentFrame.Time; - } + time = frameEnd; + currentFrameIndex++; } + else if (time < frameStart && CurrentTime == frameStart) + currentFrameIndex--; - // 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). + frameStart = getFrameTime(currentFrameIndex); + frameEnd = getFrameTime(currentFrameIndex + 1); - // the exception is if currently in an important section, which is respected above all. - if (inImportantSection) + // Pause until more frames are arrived. + if (WaitingNextFrame && frameStart < time) { - Debug.Assert(next != null || !replay.HasReceivedAllFrames); + CurrentTime = frameStart; return null; } - // if a next frame does exist, allow interpolation. - if (next != null) - return CurrentTime = time; + CurrentTime = Math.Clamp(time, frameStart, frameEnd); - // 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; + // In an important section, a mid-frame time cannot be used and a null is returned instead. + return inImportantSection && frameStart < time && time < frameEnd ? null : CurrentTime; } - private void updateDirection(double time) + private double getFrameTime(int index) { - if (!CurrentTime.HasValue) - { - currentDirection = 1; - } - else - { - currentDirection = time.CompareTo(CurrentTime); - if (currentDirection == 0) currentDirection = 1; - } + if (index < 0) + return double.NegativeInfinity; + if (index >= Frames.Count) + return double.PositiveInfinity; + + return Frames[index].Time; } } }