Merge pull request #12376 from ekrctb/refactor-framed-replay-input-hander

Rewrite framed replay input handler for robustness
This commit is contained in:
Dean Herbert 2021-04-15 22:26:35 +09:00 committed by GitHub
commit fd3d1fa8e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 463 deletions

View File

@ -20,27 +20,14 @@ namespace osu.Game.Tests.NonVisual
{ {
handler = new TestInputHandler(replay = new Replay handler = new TestInputHandler(replay = new Replay
{ {
Frames = new List<ReplayFrame> HasReceivedAllFrames = false
{
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] [Test]
public void TestNormalPlayback() public void TestNormalPlayback()
{ {
Assert.IsNull(handler.CurrentFrame); setReplayFrames();
confirmCurrentFrame(null);
confirmNextFrame(0);
setTime(0, 0); setTime(0, 0);
confirmCurrentFrame(0); confirmCurrentFrame(0);
@ -107,6 +94,8 @@ namespace osu.Game.Tests.NonVisual
[Test] [Test]
public void TestIntroTime() public void TestIntroTime()
{ {
setReplayFrames();
setTime(-1000, -1000); setTime(-1000, -1000);
confirmCurrentFrame(null); confirmCurrentFrame(null);
confirmNextFrame(0); confirmNextFrame(0);
@ -123,6 +112,8 @@ namespace osu.Game.Tests.NonVisual
[Test] [Test]
public void TestBasicRewind() public void TestBasicRewind()
{ {
setReplayFrames();
setTime(2800, 0); setTime(2800, 0);
setTime(2800, 1000); setTime(2800, 1000);
setTime(2800, 2000); setTime(2800, 2000);
@ -133,34 +124,35 @@ namespace osu.Game.Tests.NonVisual
// pivot without crossing a frame boundary // pivot without crossing a frame boundary
setTime(2700, 2700); setTime(2700, 2700);
confirmCurrentFrame(2); confirmCurrentFrame(2);
confirmNextFrame(1); confirmNextFrame(3);
// cross current frame boundary; should not yet update frame // cross current frame boundary
setTime(1980, 1980); setTime(1980, 2000);
confirmCurrentFrame(2); confirmCurrentFrame(2);
confirmNextFrame(1); confirmNextFrame(3);
setTime(1200, 1200); setTime(1200, 1200);
confirmCurrentFrame(2); confirmCurrentFrame(1);
confirmNextFrame(1); confirmNextFrame(2);
// ensure each frame plays out until start // ensure each frame plays out until start
setTime(-500, 1000); setTime(-500, 1000);
confirmCurrentFrame(1); confirmCurrentFrame(1);
confirmNextFrame(0); confirmNextFrame(2);
setTime(-500, 0); setTime(-500, 0);
confirmCurrentFrame(0); confirmCurrentFrame(0);
confirmNextFrame(null); confirmNextFrame(1);
setTime(-500, -500); setTime(-500, -500);
confirmCurrentFrame(0); confirmCurrentFrame(null);
confirmNextFrame(null); confirmNextFrame(0);
} }
[Test] [Test]
public void TestRewindInsideImportantSection() public void TestRewindInsideImportantSection()
{ {
setReplayFrames();
fastForwardToPoint(3000); fastForwardToPoint(3000);
setTime(4000, 4000); setTime(4000, 4000);
@ -168,12 +160,12 @@ namespace osu.Game.Tests.NonVisual
confirmNextFrame(5); confirmNextFrame(5);
setTime(3500, null); setTime(3500, null);
confirmCurrentFrame(4); confirmCurrentFrame(3);
confirmNextFrame(3); confirmNextFrame(4);
setTime(3000, 3000); setTime(3000, 3000);
confirmCurrentFrame(3); confirmCurrentFrame(3);
confirmNextFrame(2); confirmNextFrame(4);
setTime(3500, null); setTime(3500, null);
confirmCurrentFrame(3); confirmCurrentFrame(3);
@ -187,46 +179,127 @@ namespace osu.Game.Tests.NonVisual
confirmCurrentFrame(4); confirmCurrentFrame(4);
confirmNextFrame(5); confirmNextFrame(5);
setTime(4000, null); setTime(4000, 4000);
confirmCurrentFrame(4); confirmCurrentFrame(4);
confirmNextFrame(5); confirmNextFrame(5);
setTime(3500, null); setTime(3500, null);
confirmCurrentFrame(4); confirmCurrentFrame(3);
confirmNextFrame(3); confirmNextFrame(4);
setTime(3000, 3000); setTime(3000, 3000);
confirmCurrentFrame(3); confirmCurrentFrame(3);
confirmNextFrame(2); confirmNextFrame(4);
} }
[Test] [Test]
public void TestRewindOutOfImportantSection() public void TestRewindOutOfImportantSection()
{ {
setReplayFrames();
fastForwardToPoint(3500); fastForwardToPoint(3500);
confirmCurrentFrame(3); confirmCurrentFrame(3);
confirmNextFrame(4); confirmNextFrame(4);
setTime(3200, null); setTime(3200, null);
// next frame doesn't change even though direction reversed, because of important section.
confirmCurrentFrame(3); confirmCurrentFrame(3);
confirmNextFrame(4); confirmNextFrame(4);
setTime(3000, null); setTime(3000, 3000);
confirmCurrentFrame(3); confirmCurrentFrame(3);
confirmNextFrame(4); confirmNextFrame(4);
setTime(2800, 2800); setTime(2800, 2800);
confirmCurrentFrame(3); confirmCurrentFrame(2);
confirmNextFrame(2); confirmNextFrame(3);
}
[Test]
public void TestReplayStreaming()
{
// no frames are arrived yet
setTime(0, null);
setTime(1000, null);
Assert.IsTrue(handler.WaitingForFrame, "Should be waiting for the first frame");
replay.Frames.Add(new TestReplayFrame(0));
replay.Frames.Add(new TestReplayFrame(1000));
// should always play from beginning
setTime(1000, 0);
confirmCurrentFrame(0);
Assert.IsFalse(handler.WaitingForFrame, "Should not be waiting yet");
setTime(1000, 1000);
confirmCurrentFrame(1);
confirmNextFrame(null);
Assert.IsTrue(handler.WaitingForFrame, "Should be waiting");
// cannot seek beyond the last frame
setTime(1500, null);
confirmCurrentFrame(1);
setTime(-100, 0);
confirmCurrentFrame(0);
// can seek to the point before the first frame, however
setTime(-100, -100);
confirmCurrentFrame(null);
confirmNextFrame(0);
fastForwardToPoint(1000);
setTime(3000, null);
replay.Frames.Add(new TestReplayFrame(2000));
confirmCurrentFrame(1);
setTime(1000, 1000);
setTime(3000, 2000);
}
[Test]
public void TestMultipleFramesSameTime()
{
replay.Frames.Add(new TestReplayFrame(0));
replay.Frames.Add(new TestReplayFrame(0));
replay.Frames.Add(new TestReplayFrame(1000));
replay.Frames.Add(new TestReplayFrame(1000));
replay.Frames.Add(new TestReplayFrame(2000));
// forward direction is prioritized when multiple frames have the same time.
setTime(0, 0);
setTime(0, 0);
setTime(2000, 1000);
setTime(2000, 1000);
setTime(1000, 1000);
setTime(1000, 1000);
setTime(-100, 1000);
setTime(-100, 0);
setTime(-100, 0);
setTime(-100, -100);
}
private void setReplayFrames()
{
replay.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),
};
replay.HasReceivedAllFrames = true;
} }
private void fastForwardToPoint(double destination) private void fastForwardToPoint(double destination)
{ {
for (int i = 0; i < 1000; i++) for (int i = 0; i < 1000; i++)
{ {
if (handler.SetFrameFromTime(destination) == null) var time = handler.SetFrameFromTime(destination);
if (time == null || time == destination)
return; return;
} }
@ -235,33 +308,17 @@ namespace osu.Game.Tests.NonVisual
private void setTime(double set, double? expect) 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.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.CurrentFrame?.Time, "Unexpected current frame");
{
Assert.IsNotNull(handler.CurrentFrame);
Assert.AreEqual(replay.Frames[frame.Value].Time, handler.CurrentFrame.Time);
}
else
{
Assert.IsNull(handler.CurrentFrame);
}
} }
private void confirmNextFrame(int? frame) private void confirmNextFrame(int? frame)
{ {
if (frame.HasValue) Assert.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.NextFrame?.Time, "Unexpected next frame");
{
Assert.IsNotNull(handler.NextFrame);
Assert.AreEqual(replay.Frames[frame.Value].Time, handler.NextFrame.Time);
}
else
{
Assert.IsNull(handler.NextFrame);
}
} }
private class TestReplayFrame : ReplayFrame private class TestReplayFrame : ReplayFrame

View File

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

View File

@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Gameplay
waitForPlayer(); waitForPlayer();
AddAssert("ensure frames arrived", () => replayHandler.HasFrames); AddAssert("ensure frames arrived", () => replayHandler.HasFrames);
AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
checkPaused(true); checkPaused(true);
double? pausedTime = null; double? pausedTime = null;
@ -92,7 +92,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(); sendFrames();
AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame);
checkPaused(true); checkPaused(true);
AddAssert("time advanced", () => currentFrameStableTime > pausedTime); AddAssert("time advanced", () => currentFrameStableTime > pausedTime);

View File

@ -204,27 +204,27 @@ namespace osu.Game.Tests.Visual.Gameplay
return; return;
} }
if (replayHandler.NextFrame != null) if (!replayHandler.HasFrames)
{ return;
var lastFrame = replay.Frames.LastOrDefault();
// this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved). var lastFrame = replay.Frames.LastOrDefault();
// in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation.
if (lastFrame != null)
latency = Math.Max(latency, Time.Current - lastFrame.Time);
latencyDisplay.Text = $"latency: {latency:N1}"; // this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved).
// in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation.
if (lastFrame != null)
latency = Math.Max(latency, Time.Current - lastFrame.Time);
double proposedTime = Time.Current - latency + Time.Elapsed; latencyDisplay.Text = $"latency: {latency:N1}";
// this will either advance by one or zero frames. double proposedTime = Time.Current - latency + Time.Elapsed;
double? time = replayHandler.SetFrameFromTime(proposedTime);
if (time == null) // this will either advance by one or zero frames.
return; double? time = replayHandler.SetFrameFromTime(proposedTime);
manualClock.CurrentTime = time.Value; if (time == null)
} return;
manualClock.CurrentTime = time.Value;
} }
[TearDownSteps] [TearDownSteps]

View File

@ -32,8 +32,6 @@ namespace osu.Game.Input.Handlers
public override bool Initialize(GameHost host) => true; public override bool Initialize(GameHost host) => true;
public override bool IsActive => true;
public class ReplayState<T> : IInput public class ReplayState<T> : IInput
where T : struct where T : struct
{ {

View File

@ -3,7 +3,6 @@
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;
@ -17,80 +16,92 @@ namespace osu.Game.Rulesets.Replays
public abstract class FramedReplayInputHandler<TFrame> : ReplayInputHandler public abstract class FramedReplayInputHandler<TFrame> : ReplayInputHandler
where TFrame : ReplayFrame where TFrame : ReplayFrame
{ {
private readonly Replay replay; /// <summary>
/// Whether we have at least one replay frame.
/// </summary>
public bool HasFrames => Frames.Count != 0;
protected List<ReplayFrame> Frames => replay.Frames; /// <summary>
/// Whether we are waiting for new frames to be received.
/// </summary>
public bool WaitingForFrame => !replay.HasReceivedAllFrames && currentFrameIndex == Frames.Count - 1;
/// <summary>
/// The current frame of the replay.
/// The current time is always between the start and the end time of the current frame.
/// </summary>
/// <remarks>Returns null if the current time is strictly before the first frame.</remarks>
/// <exception cref="InvalidOperationException">The replay is empty.</exception>
public TFrame CurrentFrame public TFrame CurrentFrame
{ {
get get
{ {
if (!HasFrames || !currentFrameIndex.HasValue) if (!HasFrames)
return null; throw new InvalidOperationException($"Attempted to get {nameof(CurrentFrame)} of an empty replay");
return (TFrame)Frames[currentFrameIndex.Value]; return currentFrameIndex == -1 ? null : (TFrame)Frames[currentFrameIndex];
} }
} }
/// <summary>
/// The next frame of the replay.
/// The start time is always greater or equal to the start time of <see cref="CurrentFrame"/> regardless of the seeking direction.
/// </summary>
/// <remarks>Returns null if the current frame is the last frame.</remarks>
/// <exception cref="InvalidOperationException">The replay is empty.</exception>
public TFrame NextFrame public TFrame NextFrame
{ {
get get
{ {
if (!HasFrames) if (!HasFrames)
return null; throw new InvalidOperationException($"Attempted to get {nameof(NextFrame)} of an empty replay");
if (!currentFrameIndex.HasValue) return currentFrameIndex == Frames.Count - 1 ? null : (TFrame)Frames[currentFrameIndex + 1];
return currentDirection > 0 ? (TFrame)Frames[0] : null;
int nextFrame = clampedNextFrameIndex;
if (nextFrame == currentFrameIndex.Value)
return null;
return (TFrame)Frames[clampedNextFrameIndex];
} }
} }
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;
/// <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.
/// Disabling this can make replay playback smoother (useful for autoplay, currently). /// Disabling this can make replay playback smoother (useful for autoplay, currently).
/// </summary> /// </summary>
public bool FrameAccuratePlayback; 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<ReplayFrame> Frames => replay.Frames;
private readonly Replay replay;
private int currentFrameIndex;
private const double sixty_frame_time = 1000.0 / 60;
protected FramedReplayInputHandler(Replay replay)
{
// TODO: This replay frame ordering should be enforced on the Replay type.
// Currently, the ordering can be broken if the frames are added after this construction.
replay.Frames.Sort((x, y) => x.Time.CompareTo(y.Time));
this.replay = replay;
currentFrameIndex = -1;
CurrentTime = double.NegativeInfinity;
}
private bool inImportantSection private bool inImportantSection
{ {
get get
{ {
if (!HasFrames || !FrameAccuratePlayback) if (!HasFrames || !FrameAccuratePlayback || CurrentFrame == null)
return false; return false;
var frame = currentDirection > 0 ? CurrentFrame : NextFrame; 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
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
} }
} }
@ -105,71 +116,52 @@ namespace osu.Game.Rulesets.Replays
/// <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) 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) if (replay.HasReceivedAllFrames)
return CurrentTime = time; return CurrentTime = time;
return null; 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 the proposed time is after the current frame end time, we progress forwards to precisely the new frame's time (regardless of incoming time).
if (next != null) if (frameEnd <= time)
{ {
int compare = time.CompareTo(next.Time); time = frameEnd;
currentFrameIndex++;
if (compare == 0 || compare == currentDirection)
{
currentFrameIndex = clampedNextFrameIndex;
return CurrentTime = CurrentFrame.Time;
}
} }
// If the proposed time is before the current frame start time, and we are at the frame boundary, we progress backwards.
else if (time < frameStart && CurrentTime == frameStart)
currentFrameIndex--;
// at this point, the frame index can't be advanced. frameStart = getFrameTime(currentFrameIndex);
// even so, we may be able to propose the clock progresses forward due to being at an extent of the replay, frameEnd = getFrameTime(currentFrameIndex + 1);
// 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. // Pause until more frames are arrived.
if (inImportantSection) if (WaitingForFrame && frameStart < time)
{ {
Debug.Assert(next != null || !replay.HasReceivedAllFrames); CurrentTime = frameStart;
return null; return null;
} }
// if a next frame does exist, allow interpolation. CurrentTime = Math.Clamp(time, frameStart, frameEnd);
if (next != null)
return CurrentTime = time;
// if all frames have been received, allow playing beyond extents. // In an important section, a mid-frame time cannot be used and a null is returned instead.
if (replay.HasReceivedAllFrames) return inImportantSection && frameStart < time && time < frameEnd ? null : CurrentTime;
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) private double getFrameTime(int index)
{ {
if (!CurrentTime.HasValue) if (index < 0)
{ return double.NegativeInfinity;
currentDirection = 1; if (index >= Frames.Count)
} return double.PositiveInfinity;
else
{ return Frames[index].Time;
currentDirection = time.CompareTo(CurrentTime);
if (currentDirection == 0) currentDirection = 1;
}
} }
} }
} }

View File

@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Scoring
/// </summary> /// </summary>
public int JudgedHits { get; private set; } public int JudgedHits { get; private set; }
private JudgementResult lastAppliedResult;
private readonly BindableBool hasCompleted = new BindableBool(); private readonly BindableBool hasCompleted = new BindableBool();
/// <summary> /// <summary>
@ -53,12 +55,11 @@ namespace osu.Game.Rulesets.Scoring
public void ApplyResult(JudgementResult result) public void ApplyResult(JudgementResult result)
{ {
JudgedHits++; JudgedHits++;
lastAppliedResult = result;
ApplyResultInternal(result); ApplyResultInternal(result);
NewJudgement?.Invoke(result); NewJudgement?.Invoke(result);
updateHasCompleted();
} }
/// <summary> /// <summary>
@ -69,8 +70,6 @@ namespace osu.Game.Rulesets.Scoring
{ {
JudgedHits--; JudgedHits--;
updateHasCompleted();
RevertResultInternal(result); RevertResultInternal(result);
} }
@ -134,6 +133,10 @@ namespace osu.Game.Rulesets.Scoring
} }
} }
private void updateHasCompleted() => hasCompleted.Value = JudgedHits == MaxHits; protected override void Update()
{
base.Update();
hasCompleted.Value = JudgedHits == MaxHits && (JudgedHits == 0 || lastAppliedResult.TimeAbsolute < Clock.CurrentTime);
}
} }
} }

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -10,6 +11,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges;
using osu.Framework.Input.StateChanges.Events; using osu.Framework.Input.StateChanges.Events;
using osu.Framework.Input.States; using osu.Framework.Input.States;
using osu.Game.Configuration; using osu.Game.Configuration;
@ -100,6 +102,17 @@ namespace osu.Game.Rulesets.UI
#endregion #endregion
// to avoid allocation
private readonly List<IInput> emptyInputList = new List<IInput>();
protected override List<IInput> GetPendingInputs()
{
if (replayInputHandler?.IsActive == false)
return emptyInputList;
return base.GetPendingInputs();
}
#region Setting application (disables etc.) #region Setting application (disables etc.)
private Bindable<bool> mouseDisabled; private Bindable<bool> mouseDisabled;