diff --git a/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs b/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs new file mode 100644 index 000000000000..cd54873d3628 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class SpinnerSpinHistoryTest + { + private SpinnerSpinHistory history = null!; + + [SetUp] + public void Setup() + { + history = new SpinnerSpinHistory(); + } + + [TestCase(0, 0)] + [TestCase(10, 10)] + [TestCase(180, 180)] + [TestCase(350, 350)] + [TestCase(360, 360)] + [TestCase(370, 370)] + [TestCase(540, 540)] + [TestCase(720, 720)] + // --- + [TestCase(-0, 0)] + [TestCase(-10, 10)] + [TestCase(-180, 180)] + [TestCase(-350, 350)] + [TestCase(-360, 360)] + [TestCase(-370, 370)] + [TestCase(-540, 540)] + [TestCase(-720, 720)] + public void TestSpinOneDirection(float spin, float expectedRotation) + { + history.ReportDelta(500, spin); + Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation)); + } + + [TestCase(0, 0, 0, 0)] + // --- + [TestCase(10, -10, 0, 10)] + [TestCase(-10, 10, 0, 10)] + // --- + [TestCase(10, -20, 0, 10)] + [TestCase(-10, 20, 0, 10)] + // --- + [TestCase(20, -10, 0, 20)] + [TestCase(-20, 10, 0, 20)] + // --- + [TestCase(10, -360, 0, 350)] + [TestCase(-10, 360, 0, 350)] + // --- + [TestCase(360, -10, 0, 370)] + [TestCase(360, 10, 0, 370)] + [TestCase(-360, 10, 0, 370)] + [TestCase(-360, -10, 0, 370)] + // --- + [TestCase(10, 10, 10, 30)] + [TestCase(10, 10, -10, 20)] + [TestCase(10, -10, 10, 10)] + [TestCase(-10, -10, -10, 30)] + [TestCase(-10, -10, 10, 20)] + [TestCase(-10, 10, 10, 10)] + // --- + [TestCase(10, -20, -350, 360)] + [TestCase(10, -20, 350, 340)] + [TestCase(-10, 20, 350, 360)] + [TestCase(-10, 20, -350, 340)] + public void TestSpinMultipleDirections(float spin1, float spin2, float spin3, float expectedRotation) + { + history.ReportDelta(500, spin1); + history.ReportDelta(1000, spin2); + history.ReportDelta(1500, spin3); + Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation)); + } + + // One spin + [TestCase(370, -50, 320)] + [TestCase(-370, 50, 320)] + // Two spins + [TestCase(740, -420, 320)] + [TestCase(-740, 420, 320)] + public void TestRemoveAndCrossFullSpin(float deltaToAdd, float deltaToRemove, float expectedRotation) + { + history.ReportDelta(1000, deltaToAdd); + history.ReportDelta(500, deltaToRemove); + Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation)); + } + + // One spin + partial + [TestCase(400, -30, -50, 320)] + [TestCase(-400, 30, 50, 320)] + // Two spins + partial + [TestCase(800, -430, -50, 320)] + [TestCase(-800, 430, 50, 320)] + public void TestRemoveAndCrossFullAndPartialSpins(float deltaToAdd1, float deltaToAdd2, float deltaToRemove, float expectedRotation) + { + history.ReportDelta(1000, deltaToAdd1); + history.ReportDelta(1500, deltaToAdd2); + history.ReportDelta(500, deltaToRemove); + Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation)); + } + + [Test] + public void TestRewindMultipleFullSpins() + { + history.ReportDelta(500, 360); + history.ReportDelta(1000, 720); + + Assert.That(history.TotalRotation, Is.EqualTo(1080)); + + history.ReportDelta(250, -900); + + Assert.That(history.TotalRotation, Is.EqualTo(180)); + } + + [Test] + public void TestRewindOverDirectionChange() + { + history.ReportDelta(1000, 40); // max is now CW 40 degrees + Assert.That(history.TotalRotation, Is.EqualTo(40)); + history.ReportDelta(1100, -90); // max is now CCW 50 degrees + Assert.That(history.TotalRotation, Is.EqualTo(50)); + history.ReportDelta(1200, 110); // max is now CW 60 degrees + Assert.That(history.TotalRotation, Is.EqualTo(60)); + + history.ReportDelta(1000, -20); + Assert.That(history.TotalRotation, Is.EqualTo(40)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs index c4bf0d4e2ebf..5a473409a478 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs @@ -53,7 +53,6 @@ public void Setup() => Schedule(() => /// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms. /// [Test] - [Ignore("An upcoming implementation will fix this case")] public void TestVibrateWithoutSpinningOffCentre() { List frames = new List(); @@ -81,7 +80,6 @@ public void TestVibrateWithoutSpinningOffCentre() /// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms. /// [Test] - [Ignore("An upcoming implementation will fix this case")] public void TestVibrateWithoutSpinningOnCentre() { List frames = new List(); @@ -130,7 +128,6 @@ public void TestSpinSingleDirection(float amount, int expectedTicks) /// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW). /// [Test] - [Ignore("An upcoming implementation will fix this case")] public void TestSpinHalfBothDirections() { performTest(new SpinFramesGenerator(time_spinner_start) @@ -149,7 +146,6 @@ public void TestSpinHalfBothDirections() [TestCase(-180, 540, 1)] [TestCase(180, -900, 2)] [TestCase(-180, 900, 2)] - [Ignore("An upcoming implementation will fix this case")] public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks) { performTest(new SpinFramesGenerator(time_spinner_start) @@ -162,18 +158,28 @@ public void TestSpinOneDirectionThenChangeDirection(float direction1, float dire } [Test] - [Ignore("An upcoming implementation will fix this case")] public void TestRewind() { - AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 }); + AddStep("set manual clock", () => manualClock = new ManualClock + { + // Avoids interpolation trying to run ahead during testing. + Rate = 0 + }); - List frames = new SpinFramesGenerator(time_spinner_start) - .Spin(360, 500) // 2000ms -> 1 full CW spin - .Spin(-180, 500) // 2500ms -> 0.5 CCW spins - .Spin(90, 500) // 3000ms -> 0.25 CW spins - .Spin(450, 500) // 3500ms -> 1 full CW spin - .Spin(180, 500) // 4000ms -> 0.5 CW spins - .Build(); + List frames = + new SpinFramesGenerator(time_spinner_start) + // 1500ms start + .Spin(360, 500) + // 2000ms -> 1 full CW spin + .Spin(-180, 500) + // 2500ms -> 1 full CW spin + 0.5 CCW spins + .Spin(90, 500) + // 3000ms -> 1 full CW spin + 0.25 CCW spins + .Spin(450, 500) + // 3500ms -> 2 full CW spins + .Spin(180, 500) + // 4000ms -> 2 full CW spins + 0.5 CW spins + .Build(); loadPlayer(frames); @@ -190,15 +196,35 @@ public void TestRewind() DrawableSpinner drawableSpinner = null!; AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType().Single()) != null); - assertTotalRotation(4000, 900); + assertFinalRotationCorrect(); + assertTotalRotation(3750, 810); + assertTotalRotation(3500, 720); + assertTotalRotation(3250, 530); + assertTotalRotation(3000, 450); + assertTotalRotation(2750, 540); + assertTotalRotation(2500, 540); + assertTotalRotation(2250, 450); + assertTotalRotation(2000, 360); + assertTotalRotation(1500, 0); + + // same thing but always returning to final time to check. + assertFinalRotationCorrect(); assertTotalRotation(3750, 810); + assertFinalRotationCorrect(); assertTotalRotation(3500, 720); + assertFinalRotationCorrect(); assertTotalRotation(3250, 530); - assertTotalRotation(3000, 540); + assertFinalRotationCorrect(); + assertTotalRotation(3000, 450); + assertFinalRotationCorrect(); assertTotalRotation(2750, 540); + assertFinalRotationCorrect(); assertTotalRotation(2500, 540); - assertTotalRotation(2250, 360); - assertTotalRotation(2000, 180); + assertFinalRotationCorrect(); + assertTotalRotation(2250, 450); + assertFinalRotationCorrect(); + assertTotalRotation(2000, 360); + assertFinalRotationCorrect(); assertTotalRotation(1500, 0); void assertTotalRotation(double time, float expected) @@ -211,8 +237,11 @@ void assertTotalRotation(double time, float expected) void addSeekStep(double time) { AddStep($"seek to {time}", () => clock.Seek(time)); + // Lenience is required due to interpolation running slightly ahead on a stalled clock. AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time)); } + + void assertFinalRotationCorrect() => assertTotalRotation(4000, 900); } private void assertTicksHit(int count) diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index c5e15d63eaeb..e8fc1d99bc28 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -4,6 +4,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Judgements { @@ -15,28 +16,15 @@ public class OsuSpinnerJudgementResult : OsuJudgementResult public Spinner Spinner => (Spinner)HitObject; /// - /// The total rotation performed on the spinner disc, disregarding the spin direction, - /// adjusted for the track's playback rate. + /// The total amount that the spinner was rotated. /// - /// - /// - /// This value is always non-negative and is monotonically increasing with time - /// (i.e. will only increase if time is passing forward, but can decrease during rewind). - /// - /// - /// The rotation from each frame is multiplied by the clock's current playback rate. - /// The reason this is done is to ensure that spinners give the same score and require the same number of spins - /// regardless of whether speed-modifying mods are applied. - /// - /// - /// - /// Assuming no speed-modifying mods are active, - /// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise, - /// this property will return the value of 720 (as opposed to 0). - /// If Double Time is active instead (with a speed multiplier of 1.5x), - /// in the same scenario the property will return 720 * 1.5 = 1080. - /// - public float TotalRotation; + public float TotalRotation => History.TotalRotation; + + /// + /// Stores the spinning history of the spinner.
+ /// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner. + ///
+ public readonly SpinnerSpinHistory History = new SpinnerSpinHistory(); /// /// Time instant at which the spin was started (the first user input which caused an increase in spin). diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs new file mode 100644 index 000000000000..1c6c5b5d0280 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs @@ -0,0 +1,146 @@ +// 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 System.Diagnostics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + /// + /// Stores the spinning history of a single spinner.
+ /// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner. + ///
+ /// + /// A single, full rotation of the spinner is defined as a 360-degree rotation of the spinner, starting from 0, going in a single direction.
+ ///
+ /// + /// If the player spins 90-degrees clockwise, then changes direction, they need to spin 90-degrees counter-clockwise to return to 0 + /// and then continue rotating the spinner for another 360-degrees in the same direction. + /// + public class SpinnerSpinHistory + { + /// + /// The sum of all complete spins and any current partial spin, in degrees. + /// + /// + /// This is the final scoring value. + /// + public float TotalRotation => 360 * completedSpins.Count + currentSpinMaxRotation; + + private readonly Stack completedSpins = new Stack(); + + /// + /// The total accumulated (absolute) rotation. + /// + private float totalAccumulatedRotation; + + private float totalAccumulatedRotationAtLastCompletion; + + /// + /// For the current spin, represents the maximum absolute rotation (from 0..360) achieved by the user. + /// + /// + /// This is used to report in the case a user spins backwards. + /// Basically it allows us to not reduce the total rotation in such a case. + /// + /// This also stops spinner "cheese" where a user may rapidly change directions and cause an increase + /// in rotations. + /// + private float currentSpinMaxRotation; + + /// + /// The current spin, from -360..360. + /// + private float currentSpinRotation => totalAccumulatedRotation - totalAccumulatedRotationAtLastCompletion; + + private double lastReportTime = double.NegativeInfinity; + + /// + /// Report a delta update based on user input. + /// + /// The current time. + /// The delta of the angle moved through since the last report. + public void ReportDelta(double currentTime, float delta) + { + if (delta == 0) + return; + + // Importantly, outside of tests the max delta entering here is 180 degrees. + // If it wasn't for tests, we could add this line: + // + // Debug.Assert(Math.Abs(delta) < 180); + // + // For this to be 101% correct, we need to add the ability for important frames to be + // created based on gameplay intrinsics (ie. there should be one frame for any spinner delta 90 < n < 180 degrees). + // + // But this can come later. + + totalAccumulatedRotation += delta; + + if (currentTime >= lastReportTime) + { + currentSpinMaxRotation = Math.Max(currentSpinMaxRotation, Math.Abs(currentSpinRotation)); + + // Handle the case where the user has completed another spin. + // Note that this does could be an `if` rather than `while` if the above assertion held true. + // It is a `while` loop to handle tests which throw larger values at this method. + while (currentSpinMaxRotation >= 360) + { + int direction = Math.Sign(currentSpinRotation); + + completedSpins.Push(new CompletedSpin(currentTime, direction)); + + // Incrementing the last completion point will cause `currentSpinRotation` to + // hold the remaining spin that needs to be considered. + totalAccumulatedRotationAtLastCompletion += direction * 360; + + // Reset the current max as we are entering a new spin. + // Importantly, carry over the remainder (which is now stored in `currentSpinRotation`). + currentSpinMaxRotation = Math.Abs(currentSpinRotation); + } + } + else + { + // When rewinding, the main thing we care about is getting `totalAbsoluteRotationsAtLastCompletion` + // to the correct value. We can used the stored history for this. + while (completedSpins.TryPeek(out var segment) && segment.CompletionTime > currentTime) + { + completedSpins.Pop(); + totalAccumulatedRotationAtLastCompletion -= segment.Direction * 360; + } + + // This is a best effort. We may not have enough data to match this 1:1, but that's okay. + // We know that the player is somewhere in a spin. + // In the worst case, this will be lower than expected, and recover in forward playback. + currentSpinMaxRotation = Math.Abs(currentSpinRotation); + } + + lastReportTime = currentTime; + } + + /// + /// Represents a single completed spin. + /// + private class CompletedSpin + { + /// + /// The time at which this spin completion occurred. + /// + public readonly double CompletionTime; + + /// + /// The direction this spin completed in. + /// + public readonly int Direction; + + public CompletedSpin(double completionTime, int direction) + { + Debug.Assert(direction == -1 || direction == 1); + + CompletionTime = completionTime; + Direction = direction; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 69c2bf3dd0e6..4bdcc4b38182 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -101,15 +101,13 @@ public void AddRotation(float delta) rotationTransferred = true; } - currentRotation += delta; - double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate; + delta = (float)(delta * Math.Abs(rate)); Debug.Assert(Math.Abs(delta) <= 180); - // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback - // (see: ModTimeRamp) - drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate); + currentRotation += delta; + drawableSpinner.Result.History.ReportDelta(Time.Current, delta); } private void resetState(DrawableHitObject obj) diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 096917cf4f34..4860d3954ef0 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -432,6 +432,11 @@ public class TrackVirtualManual : Track private bool running; + public override double Rate => base.Rate + // This is mainly to allow some tests to override the rate to zero + // and avoid interpolation. + * referenceClock.Rate; + public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual") : base(name) {