diff --git a/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs b/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs new file mode 100644 index 0000000000..9fee5382ec --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs @@ -0,0 +1,132 @@ +// 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, -180); + + Assert.That(history.TotalRotation, Is.EqualTo(180)); + } + + [Test] + public void TestRewindIntoSegmentThatHasNotCrossedZero() + { + history.ReportDelta(1000, -180); + history.ReportDelta(1500, 90); + history.ReportDelta(2000, 450); + history.ReportDelta(1750, -45); + + Assert.That(history.TotalRotation, Is.EqualTo(180)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs index c4bf0d4e2e..67c7dbdb66 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs @@ -53,7 +53,6 @@ namespace osu.Game.Rulesets.Osu.Tests /// 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 @@ namespace osu.Game.Rulesets.Osu.Tests /// 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 @@ namespace osu.Game.Rulesets.Osu.Tests /// 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 @@ namespace osu.Game.Rulesets.Osu.Tests [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,7 +158,6 @@ namespace osu.Game.Rulesets.Osu.Tests } [Test] - [Ignore("An upcoming implementation will fix this case")] public void TestRewind() { AddStep("set manual clock", () => manualClock = new ManualClock { Rate = 1 }); diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index c5e15d63ea..e8fc1d99bc 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 @@ namespace osu.Game.Rulesets.Osu.Judgements 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 0000000000..0464ed422f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs @@ -0,0 +1,138 @@ +// 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 * segments.Count + currentMaxRotation; + + /// + /// The list of all segments where either: + /// + /// The spinning direction was changed. + /// A full spin of 360 degrees was performed in either direction. + /// + /// + private readonly Stack segments = new Stack(); + + /// + /// The total accumulated rotation. + /// + private float currentAbsoluteRotation; + + private float lastCompletionAbsoluteRotation; + + /// + /// For the current spin, represents the maximum rotation (from 0..360) achieved by the user. + /// + private float currentMaxRotation; + + /// + /// The current spin, from -360..360. + /// + private float currentRotation => currentAbsoluteRotation - lastCompletionAbsoluteRotation; + + 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) + { + // TODO: Debug.Assert(Math.Abs(delta) < 180); + // This will require important frame guarantees. + + currentAbsoluteRotation += delta; + + if (currentTime >= lastReportTime) + addDelta(currentTime, delta); + else + rewindDelta(currentTime, delta); + + lastReportTime = currentTime; + } + + private void addDelta(double currentTime, float delta) + { + if (delta == 0) + return; + + currentMaxRotation = Math.Max(currentMaxRotation, Math.Abs(currentRotation)); + + while (currentMaxRotation >= 360) + { + int direction = Math.Sign(currentRotation); + + segments.Push(new SpinSegment(currentTime, direction)); + + lastCompletionAbsoluteRotation += direction * 360; + currentMaxRotation = Math.Abs(currentRotation); + } + } + + private void rewindDelta(double currentTime, float delta) + { + while (segments.TryPeek(out var segment) && segment.StartTime > currentTime) + { + segments.Pop(); + lastCompletionAbsoluteRotation -= segment.Direction * 360; + currentMaxRotation = Math.Abs(currentRotation); + } + + currentMaxRotation = Math.Abs(currentRotation); + } + + /// + /// Represents a single segment of history. + /// + /// + /// Each time the player changes direction, a new segment is recorded. + /// A segment stores the current absolute angle of rotation. Generally this would be either -360 or 360 for a completed spin, or + /// a number representing the last incomplete spin. + /// + private class SpinSegment + { + /// + /// The start time of this segment, when the direction change occurred. + /// + public readonly double StartTime; + + /// + /// The direction this segment started in. + /// + public readonly int Direction; + + public SpinSegment(double startTime, int direction) + { + Debug.Assert(direction == -1 || direction == 1); + + StartTime = startTime; + Direction = direction; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 69c2bf3dd0..4bdcc4b381 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -101,15 +101,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default 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)