diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
index f4bd515995..b9fef6bf8c 100644
--- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
@@ -8,6 +8,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osuTK;
@@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Catch.Objects
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450);
- Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2;
+ Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize);
}
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs
index c5c9556ed7..0c2c157d10 100644
--- a/osu.Game.Rulesets.Catch/UI/Catcher.cs
+++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs
@@ -17,6 +17,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
@@ -182,11 +183,6 @@ namespace osu.Game.Rulesets.Catch.UI
///
public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy();
- ///
- /// Calculates the scale of the catcher based off the provided beatmap difficulty.
- ///
- private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5);
-
///
/// Calculates the width of the area used for attempting catches in gameplay.
///
@@ -471,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
d.Expire();
}
+ ///
+ /// Calculates the scale of the catcher based off the provided beatmap difficulty.
+ ///
+ private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize) * 2);
+
private enum DroppedObjectAnimation
{
Drop,
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
index 86920927dc..3490d50871 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
@@ -108,7 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
RelativeSizeAxes = Axes.X
},
tailContainer = new Container { RelativeSizeAxes = Axes.Both },
- slidingSample = new PausableSkinnableSound { Looping = true }
+ slidingSample = new PausableSkinnableSound
+ {
+ Looping = true,
+ MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
+ }
});
maskedContents.AddRange(new[]
diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
index f1c1c4734d..7b7deb9c67 100644
--- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
- [TestCase(6.7115569159190587d, 206, "diffcalc-test")]
- [TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
+ [TestCase(6.710442985146793d, 206, "diffcalc-test")]
+ [TestCase(1.4386882251130073d, 45, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 2, "very-fast-slider")]
[TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
- [TestCase(8.9757300665532966d, 206, "diffcalc-test")]
+ [TestCase(8.9742952703071666d, 206, "diffcalc-test")]
[TestCase(0.55071082800473514d, 2, "very-fast-slider")]
- [TestCase(1.7437232654020756d, 45, "zero-length-sliders")]
+ [TestCase(1.743180218215227d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
- [TestCase(6.7115569159190587d, 239, "diffcalc-test")]
+ [TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
- [TestCase(1.4391311903612753d, 54, "zero-length-sliders")]
+ [TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
diff --git a/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs b/osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs
new file mode 100644
index 0000000000..cd54873d36
--- /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/TestSceneSliderFollowCircleInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs
index 5adc50859f..d4bb789a12 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs
@@ -10,6 +10,7 @@ using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
@@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
const double time_slider_start = 1000;
- float circleRadius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (circleSize - 5) / 5) / 2;
+ float circleRadius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(circleSize, true);
float followCircleRadius = circleRadius * 1.2f;
performTest(new Beatmap
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs
index b1d9c453d6..ea57a6a1b5 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Audio;
@@ -26,6 +27,15 @@ namespace osu.Game.Rulesets.Osu.Tests
private TestDrawableSpinner drawableSpinner;
+ private readonly BindableDouble spinRate = new BindableDouble();
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ AddSliderStep("Spin rate", 0.5, 5, 1, val => spinRate.Value = val);
+ }
+
[TestCase(true)]
[TestCase(false)]
public void TestVariousSpinners(bool autoplay)
@@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
- return drawableSpinner = new TestDrawableSpinner(spinner, true)
+ return drawableSpinner = new TestDrawableSpinner(spinner, true, spinRate)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
@@ -114,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize });
- drawableSpinner = new TestDrawableSpinner(spinner, auto)
+ drawableSpinner = new TestDrawableSpinner(spinner, auto, spinRate)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
@@ -130,18 +140,20 @@ namespace osu.Game.Rulesets.Osu.Tests
private partial class TestDrawableSpinner : DrawableSpinner
{
private readonly bool auto;
+ private readonly BindableDouble spinRate;
- public TestDrawableSpinner(Spinner s, bool auto)
+ public TestDrawableSpinner(Spinner s, bool auto, BindableDouble spinRate)
: base(s)
{
this.auto = auto;
+ this.spinRate = spinRate;
}
protected override void Update()
{
base.Update();
if (auto)
- RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 2));
+ RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * spinRate.Value));
}
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs
index c4bf0d4e2e..5a473409a4 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,18 +158,28 @@ 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 });
+ 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 @@ namespace osu.Game.Rulesets.Osu.Tests
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, 540);
+ assertTotalRotation(3000, 450);
assertTotalRotation(2750, 540);
assertTotalRotation(2500, 540);
- assertTotalRotation(2250, 360);
- assertTotalRotation(2000, 180);
+ 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);
+ assertFinalRotationCorrect();
+ assertTotalRotation(3000, 450);
+ assertFinalRotationCorrect();
+ assertTotalRotation(2750, 540);
+ assertFinalRotationCorrect();
+ assertTotalRotation(2500, 540);
+ assertFinalRotationCorrect();
+ assertTotalRotation(2250, 450);
+ assertFinalRotationCorrect();
+ assertTotalRotation(2000, 360);
+ assertFinalRotationCorrect();
assertTotalRotation(1500, 0);
void assertTotalRotation(double time, float expected)
@@ -211,8 +237,11 @@ namespace osu.Game.Rulesets.Osu.Tests
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 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/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
index 09973ce5fd..b682d14879 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
@@ -107,7 +107,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
headContainer = new Container { RelativeSizeAxes = Axes.Both },
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
Ball,
- slidingSample = new PausableSkinnableSound { Looping = true }
+ slidingSample = new PausableSkinnableSound
+ {
+ Looping = true,
+ MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
+ }
});
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
index 9fa180cf93..c092b4dd4b 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
@@ -48,9 +48,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
///
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
///
- public IBindable GainedBonus => gainedBonus;
+ public double CurrentBonusScore => score_per_tick * Math.Clamp(completedFullSpins.Value - HitObject.SpinsRequiredForBonus, 0, HitObject.MaximumBonusSpins);
- private readonly Bindable gainedBonus = new BindableDouble();
+ ///
+ /// The maximum amount of bonus score which can be achieved from extra spins.
+ ///
+ public double MaximumBonusScore => score_per_tick * HitObject.MaximumBonusSpins;
+
+ public IBindable CompletedFullSpins => completedFullSpins;
+
+ private readonly Bindable completedFullSpins = new Bindable();
///
/// The number of spins per minute this spinner is spinning at, for display purposes.
@@ -99,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
spinningSample = new PausableSkinnableSound
{
Volume = { Value = 0 },
+ MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
Looping = true,
Frequency = { Value = spinning_sample_initial_frequency }
}
@@ -286,8 +294,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult;
- private int completedFullSpins;
-
private void updateBonusScore()
{
if (ticks.Count == 0)
@@ -295,27 +301,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
int spins = (int)(Result.TotalRotation / 360);
- if (spins < completedFullSpins)
+ if (spins < completedFullSpins.Value)
{
// rewinding, silently handle
- completedFullSpins = spins;
+ completedFullSpins.Value = spins;
return;
}
- while (completedFullSpins != spins)
+ while (completedFullSpins.Value != spins)
{
var tick = ticks.FirstOrDefault(t => !t.Result.HasResult);
// tick may be null if we've hit the spin limit.
- if (tick != null)
- {
- tick.TriggerResult(true);
+ tick?.TriggerResult(true);
- if (tick is DrawableSpinnerBonusTick)
- gainedBonus.Value = score_per_tick * (spins - HitObject.SpinsRequiredForBonus);
- }
-
- completedFullSpins++;
+ completedFullSpins.Value++;
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs
index 34253e3d4f..a5785dd1f6 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs
@@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre;
}
+ protected override void OnApply()
+ {
+ base.OnApply();
+
+ // the tick can be theoretically judged at any point in the spinner's duration,
+ // so it must be alive throughout the spinner's entire lifetime.
+ // this mostly matters for correct sample playback.
+ LifetimeStart = DrawableSpinner.HitObject.StartTime;
+ }
+
///
/// Apply a judgement result.
///
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..1c6c5b5d02
--- /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/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
index 0bdbfaa760..d74d28c748 100644
--- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
@@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
@@ -155,7 +156,7 @@ namespace osu.Game.Rulesets.Osu.Objects
// This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in.
TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN);
- Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2;
+ Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize, true);
}
protected override HitWindows CreateHitWindows() => new OsuHitWindows();
diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs
index d5a9cf46c5..ee9f228137 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs
@@ -35,14 +35,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
InternalChildren = new Drawable[]
{
- bonusCounter = new OsuSpriteText
- {
- Alpha = 0,
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Font = OsuFont.Default.With(size: 24),
- Y = -120,
- },
new ArgonSpinnerDisc
{
RelativeSizeAxes = Axes.Both,
@@ -85,19 +77,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
};
}
- private IBindable gainedBonus = null!;
+ private IBindable completedSpins = null!;
private IBindable spinsPerMinute = null!;
protected override void LoadComplete()
{
base.LoadComplete();
- gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy();
- gainedBonus.BindValueChanged(bonus =>
+ completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
+ completedSpins.BindValueChanged(_ =>
{
- bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
- bonusCounter.FadeOutFromOne(1500);
- bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
+ if (drawableSpinner.CurrentBonusScore <= 0)
+ return;
+
+ if (drawableSpinner.CurrentBonusScore == drawableSpinner.MaximumBonusScore)
+ {
+ bonusCounter.Text = "MAX";
+ bonusCounter.ScaleTo(1.5f).Then().ScaleTo(2.8f, 1000, Easing.OutQuint);
+
+ bonusCounter.FlashColour(Colour4.FromHex("FC618F"), 400);
+ bonusCounter.FadeOutFromOne(500);
+ }
+ else
+ {
+ bonusCounter.Text = drawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
+ bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
+ bonusCounter.FadeOutFromOne(1500);
+ }
});
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs
index 071fbe6add..4a76a1aec4 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs
@@ -24,6 +24,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private Container spmContainer = null!;
private OsuSpriteText spmCounter = null!;
+ [Resolved]
+ private OsuColour colours { get; set; } = null!;
+
public DefaultSpinner()
{
RelativeSizeAxes = Axes.Both;
@@ -80,19 +83,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
});
}
- private IBindable gainedBonus = null!;
+ private IBindable completedSpins = null!;
private IBindable spinsPerMinute = null!;
protected override void LoadComplete()
{
base.LoadComplete();
- gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy();
- gainedBonus.BindValueChanged(bonus =>
+ completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
+ completedSpins.BindValueChanged(bonus =>
{
- bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
- bonusCounter.FadeOutFromOne(1500);
- bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
+ if (drawableSpinner.CurrentBonusScore <= 0)
+ return;
+
+ if (drawableSpinner.CurrentBonusScore == drawableSpinner.MaximumBonusScore)
+ {
+ bonusCounter.Text = "MAX";
+ bonusCounter.ScaleTo(1.5f).Then().ScaleTo(2.8f, 1000, Easing.OutQuint);
+
+ bonusCounter.FlashColour(colours.YellowLight, 400);
+ bonusCounter.FadeOutFromOne(500);
+ }
+ else
+ {
+ bonusCounter.Text = drawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
+ bonusCounter.FadeOutFromOne(1500);
+ bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
+ }
});
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs
index c3c6629818..374f3f461b 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)
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
index d8f837ae5e..28acb4a996 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs
@@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
});
}
- private IBindable gainedBonus = null!;
+ private IBindable completedSpins = null!;
private IBindable spinsPerMinute = null!;
private readonly Bindable completed = new Bindable();
@@ -116,12 +116,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
base.LoadComplete();
- gainedBonus = DrawableSpinner.GainedBonus.GetBoundCopy();
- gainedBonus.BindValueChanged(bonus =>
+ completedSpins = DrawableSpinner.CompletedFullSpins.GetBoundCopy();
+ completedSpins.BindValueChanged(bonus =>
{
- bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
- bonusCounter.FadeOutFromOne(800, Easing.Out);
- bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
+ if (DrawableSpinner.CurrentBonusScore <= 0)
+ return;
+
+ bonusCounter.Text = DrawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
+
+ if (DrawableSpinner.CurrentBonusScore == DrawableSpinner.MaximumBonusScore)
+ {
+ bonusCounter.ScaleTo(1.4f).Then().ScaleTo(1.8f, 1000, Easing.Out);
+ bonusCounter.FadeOutFromOne(500, Easing.Out);
+ }
+ else
+ {
+ bonusCounter.FadeOutFromOne(800, Easing.Out);
+ bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
+ }
});
spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy();
diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
index 6564a086fe..83bab7dc01 100644
--- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
+++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
@@ -13,6 +13,7 @@ using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
+using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring;
using osuTK;
@@ -208,8 +209,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
if (score.HitEvents.Count == 0)
return;
- // Todo: This should probably not be done like this.
- float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.Difficulty.CircleSize - 5) / 5) / 2;
+ float radius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(playableBeatmap.Difficulty.CircleSize, true);
foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)))
{
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
index ce95e921b9..739a72df08 100644
--- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -2,10 +2,12 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Filter;
using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Carousel;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Tests.NonVisual.Filtering
@@ -382,6 +384,57 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("unrecognised=keyword", filterCriteria.SearchText.Trim());
}
+ [TestCase("[1]", new[] { 0 })]
+ [TestCase("[1", new[] { 0 })]
+ [TestCase("My[Favourite", new[] { 2 })]
+ [TestCase("My[Favourite]", new[] { 2 })]
+ [TestCase("My[Favourite]Song", new[] { 2 })]
+ [TestCase("Favourite]", new[] { 2 })]
+ [TestCase("[Diff", new[] { 0, 1, 3, 4, 6 })]
+ [TestCase("[Diff]", new[] { 0, 1, 3, 4, 6 })]
+ [TestCase("[Favourite]", new[] { 3 })]
+ [TestCase("Title1 [Diff]", new[] { 0, 1 })]
+ [TestCase("Title1[Diff]", new int[] { })]
+ [TestCase("[diff ]with]", new[] { 4 })]
+ [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })]
+ [TestCase("[Diff in title]", new int[] { })]
+ [TestCase("[Diff in diff]", new[] { 6 })]
+ [TestCase("diff=Diff", new[] { 0, 1, 3, 4, 6 })]
+ [TestCase("diff=Diff1", new[] { 0 })]
+ [TestCase("diff=\"Diff\"", new[] { 3, 4, 6 })]
+ [TestCase("diff=!\"Diff\"", new int[] { })]
+ public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes)
+ {
+ var carouselBeatmaps = (((string title, string difficultyName)[])new[]
+ {
+ ("Title1", "Diff1"),
+ ("Title1", "Diff2"),
+ ("My[Favourite]Song", "Expert"),
+ ("Title", "My Favourite Diff"),
+ ("Another One", "diff ]with [[ brackets]]]"),
+ ("Diff in title", "a"),
+ ("a", "Diff in diff"),
+ }).Select(info => new CarouselBeatmap(new BeatmapInfo
+ {
+ Metadata = new BeatmapMetadata
+ {
+ Title = info.title
+ },
+ DifficultyName = info.difficultyName
+ })).ToList();
+
+ var criteria = new FilterCriteria();
+
+ FilterQueryParser.ApplyQueries(criteria, query);
+ carouselBeatmaps.ForEach(b => b.Filter(criteria));
+
+ int[] visibleBeatmaps = carouselBeatmaps
+ .Where(b => !b.Filtered.Value)
+ .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();
+
+ Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
+ }
+
private class CustomFilterCriteria : IRulesetFilterCriteria
{
public string? CustomValue { get; set; }
diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs
index ac80463d3a..97e1cae11c 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs
@@ -64,7 +64,8 @@ namespace osu.Game.Tests.Visual.Online
new[] { "Plain", "This is plain comment" },
new[] { "Pinned", "This is pinned comment" },
new[] { "Link", "Please visit https://osu.ppy.sh" },
-
+ new[] { "Big Image", "![](Backgrounds/bg1)" },
+ new[] { "Small Image", "![](Cursor/cursortrail)" },
new[]
{
"Heading", @"# Heading 1
diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs
index 78234a9dd9..e7a3d87d0a 100644
--- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs
+++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs
@@ -55,13 +55,20 @@ namespace osu.Game.Beatmaps
static double DifficultyRange(double difficulty, double min, double mid, double max)
{
if (difficulty > 5)
- return mid + (max - mid) * (difficulty - 5) / 5;
+ return mid + (max - mid) * DifficultyRange(difficulty);
if (difficulty < 5)
- return mid - (mid - min) * (5 - difficulty) / 5;
+ return mid + (mid - min) * DifficultyRange(difficulty);
return mid;
}
+ ///
+ /// Maps a difficulty value [0, 10] to a linear range of [-1, 1].
+ ///
+ /// The difficulty value to be mapped.
+ /// Value to which the difficulty value maps in the specified range.
+ static double DifficultyRange(double difficulty) => (difficulty - 5) / 5;
+
///
/// Maps a difficulty value [0, 10] to a two-piece linear range of values.
///
diff --git a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
index 278cef9112..e48a52c787 100644
--- a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
+++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
@@ -2,8 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using Markdig.Syntax;
+using Markdig.Syntax.Inlines;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.Textures;
using osu.Game.Graphics.Containers.Markdown;
+using osuTK;
namespace osu.Game.Overlays.Comments
{
@@ -16,6 +22,8 @@ namespace osu.Game.Overlays.Comments
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
+ public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
+
private partial class CommentMarkdownHeading : OsuMarkdownHeading
{
public CommentMarkdownHeading(HeadingBlock headingBlock)
@@ -40,5 +48,64 @@ namespace osu.Game.Overlays.Comments
}
}
}
+
+ private partial class CommentMarkdownTextFlowContainer : MarkdownTextFlowContainer
+ {
+ protected override void AddImage(LinkInline linkInline) => AddDrawable(new CommentMarkdownImage(linkInline.Url));
+
+ private partial class CommentMarkdownImage : MarkdownImage
+ {
+ public CommentMarkdownImage(string url)
+ : base(url)
+ {
+ }
+
+ private DelayedLoadWrapper wrapper = null!;
+
+ protected override Drawable CreateContent(string url) => wrapper = new DelayedLoadWrapper(CreateImageContainer(url));
+
+ protected override ImageContainer CreateImageContainer(string url)
+ {
+ var container = new CommentImageContainer(url);
+ container.OnLoadComplete += d =>
+ {
+ // The size of DelayedLoadWrapper depends on AutoSizeAxes of it's content.
+ // But since it's set to None, we need to specify the size here manually.
+ wrapper.Size = container.Size;
+ d.FadeInFromZero(300, Easing.OutQuint);
+ };
+ return container;
+ }
+
+ private partial class CommentImageContainer : ImageContainer
+ {
+ // https://github.com/ppy/osu-web/blob/3bd0f406dc78d60b356d955cd4201f8c3e1cca09/resources/css/bem/osu-md.less#L36
+ // Web version defines max height in em units (6 em), which assuming default font size as 14 results in 84 px,
+ // which also seems to match observations upon inspecting the web element.
+ private const float max_height = 84f;
+
+ public CommentImageContainer(string url)
+ : base(url)
+ {
+ AutoSizeAxes = Axes.None;
+ }
+
+ protected override Sprite CreateImageSprite() => new Sprite
+ {
+ RelativeSizeAxes = Axes.Both
+ };
+
+ protected override Texture GetImageTexture(TextureStore textures, string url)
+ {
+ Texture t = base.GetImageTexture(textures, url);
+
+ if (t != null)
+ Size = t.Height > max_height ? new Vector2(max_height / t.Height * t.Width, max_height) : t.Size;
+
+ return t!;
+ }
+ }
+ }
+ }
}
}
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index 3bb0e3dfb8..ce6475d3ce 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -159,6 +159,26 @@ namespace osu.Game.Rulesets.Objects.Drawables
///
internal bool IsInitialized;
+ ///
+ /// The minimum allowable volume for sample playback.
+ /// quieter than that will be forcibly played at this volume instead.
+ ///
+ ///
+ ///
+ /// Drawable hitobjects adding their own custom samples, or other sample playback sources
+ /// (i.e. ) must enforce this themselves.
+ ///
+ ///
+ /// This sample volume floor is present in stable, although it is set at 8% rather than 5%.
+ /// See: https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1070,
+ /// https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1404-L1405.
+ /// The reason why it is 5% here is that the 8% cap was enforced in a silent manner
+ /// (i.e. the minimum selectable volume in the editor was 5%, but it would be played at 8% anyways),
+ /// which is confusing and arbitrary, so we're just doing 5% here at the cost of sacrificing strict parity.
+ ///
+ ///
+ public const int MINIMUM_SAMPLE_VOLUME = 5;
+
///
/// Creates a new .
///
@@ -181,7 +201,10 @@ namespace osu.Game.Rulesets.Objects.Drawables
comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount);
// Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal.
- base.AddInternal(Samples = new PausableSkinnableSound());
+ base.AddInternal(Samples = new PausableSkinnableSound
+ {
+ MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME
+ });
CurrentSkin = skinSource;
CurrentSkin.SourceChanged += skinSourceChanged;
diff --git a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs
index 6cff4b12c4..53cf835248 100644
--- a/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs
+++ b/osu.Game/Rulesets/Objects/Legacy/LegacyRulesetExtensions.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects.Types;
@@ -38,5 +39,23 @@ namespace osu.Game.Rulesets.Objects.Legacy
return timingControlPoint.BeatLength * bpmMultiplier;
}
+
+ ///
+ /// Calculates scale from a CS value, with an optional fudge that was historically applied to the osu! ruleset.
+ ///
+ public static float CalculateScaleFromCircleSize(float circleSize, bool applyFudge = false)
+ {
+ // The following comment is copied verbatim from osu-stable:
+ //
+ // Builds of osu! up to 2013-05-04 had the gamefield being rounded down, which caused incorrect radius calculations
+ // in widescreen cases. This ratio adjusts to allow for old replays to work post-fix, which in turn increases the lenience
+ // for all plays, but by an amount so small it should only be effective in replays.
+ //
+ // To match expectations of gameplay we need to apply this multiplier to circle scale. It's weird but is what it is.
+ // It works out to under 1 game pixel and is generally not meaningful to gameplay, but is to replay playback accuracy.
+ const float broken_gamefield_rounding_allowance = 1.00041f;
+
+ return (float)(1.0f - 0.7f * IBeatmapDifficultyInfo.DifficultyRange(circleSize)) / 2 * (applyFudge ? broken_gamefield_rounding_allowance : 1);
+ }
}
}
diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
index fed262868d..b61e8d9674 100644
--- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
+++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs
@@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
@@ -45,7 +46,10 @@ namespace osu.Game.Rulesets.UI
Child = hitSounds = new Container
{
Name = "concurrent sample pool",
- ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound())
+ ChildrenEnumerable = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new PausableSkinnableSound
+ {
+ MinimumSampleVolume = DrawableHitObject.MINIMUM_SAMPLE_VOLUME
+ })
}
};
}
diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
index b02cfb505e..28841fc9e5 100644
--- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs
@@ -16,6 +16,7 @@ using osu.Game.Audio;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit.Timing;
using osuTK;
using osuTK.Graphics;
@@ -101,7 +102,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
},
volume = new IndeterminateSliderWithTextBoxInput("Volume", new BindableInt(100)
{
- MinValue = 0,
+ MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME,
MaxValue = 100,
})
}
diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs
index 16ae54b413..eb47a7201a 100644
--- a/osu.Game/Screens/Select/BeatmapCarousel.cs
+++ b/osu.Game/Screens/Select/BeatmapCarousel.cs
@@ -1197,6 +1197,8 @@ namespace osu.Game.Screens.Select
{
private bool rightMouseScrollBlocked;
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
+
public CarouselScrollContainer()
{
// size is determined by the carousel itself, due to not all content necessarily being loaded.
diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
index 6917bd1da2..1d40862df7 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
@@ -59,7 +59,7 @@ namespace osu.Game.Screens.Select.Carousel
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) ||
criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode);
-
+ match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName);
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
if (!match) return false;
diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs
index a2ae114126..812a16c484 100644
--- a/osu.Game/Screens/Select/FilterCriteria.cs
+++ b/osu.Game/Screens/Select/FilterCriteria.cs
@@ -38,6 +38,7 @@ namespace osu.Game.Screens.Select
public OptionalTextFilter Creator;
public OptionalTextFilter Artist;
public OptionalTextFilter Title;
+ public OptionalTextFilter DifficultyName;
public OptionalRange UserStarDifficulty = new OptionalRange
{
@@ -68,8 +69,23 @@ namespace osu.Game.Screens.Select
string remainingText = value;
+ // Match either an open difficulty tag to the end of string,
+ // or match a closed one with a whitespace after it.
+ //
+ // To keep things simple, the closing ']' may be included in the match group,
+ // and is trimmed post-match.
+ foreach (Match quotedSegment in Regex.Matches(value, "(^|\\s)\\[(.*)(\\]\\s|$)"))
+ {
+ DifficultyName = new OptionalTextFilter
+ {
+ SearchTerm = quotedSegment.Groups[2].Value.Trim(']')
+ };
+
+ remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
+ }
+
// First handle quoted segments to ensure we keep inline spaces in exact matches.
- foreach (Match quotedSegment in Regex.Matches(searchText, "(\"[^\"]+\"[!]?)"))
+ foreach (Match quotedSegment in Regex.Matches(value, "(\"[^\"]+\"[!]?)"))
{
terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value });
remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
index 1238173b41..0d8905347b 100644
--- a/osu.Game/Screens/Select/FilterQueryParser.cs
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -76,6 +76,9 @@ namespace osu.Game.Screens.Select
case "title":
return TryUpdateCriteriaText(ref criteria.Title, op, value);
+ case "diff":
+ return TryUpdateCriteriaText(ref criteria.DifficultyName, op, value);
+
default:
return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false;
}
diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs
index bac84b8134..d5ec94ad71 100644
--- a/osu.Game/Screens/Select/SongSelect.cs
+++ b/osu.Game/Screens/Select/SongSelect.cs
@@ -171,11 +171,6 @@ namespace osu.Game.Screens.Select
AddRangeInternal(new Drawable[]
{
- new ResetScrollContainer(() => Carousel.ScrollToSelected())
- {
- RelativeSizeAxes = Axes.Y,
- Width = 250,
- },
new VerticalMaskingContainer
{
Children = new Drawable[]
@@ -243,6 +238,10 @@ namespace osu.Game.Screens.Select
Padding = new MarginPadding { Top = left_area_padding },
Children = new Drawable[]
{
+ new LeftSideInteractionContainer(() => Carousel.ScrollToSelected())
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
beatmapInfoWedge = new BeatmapInfoWedge
{
Height = WEDGE_HEIGHT,
@@ -1017,18 +1016,25 @@ namespace osu.Game.Screens.Select
}
}
- private partial class ResetScrollContainer : Container
+ ///
+ /// Handles mouse interactions required when moving away from the carousel.
+ ///
+ private partial class LeftSideInteractionContainer : Container
{
- private readonly Action? onHoverAction;
+ private readonly Action? resetCarouselPosition;
- public ResetScrollContainer(Action onHoverAction)
+ public LeftSideInteractionContainer(Action resetCarouselPosition)
{
- this.onHoverAction = onHoverAction;
+ this.resetCarouselPosition = resetCarouselPosition;
}
+ protected override bool OnScroll(ScrollEvent e) => true;
+
+ protected override bool OnMouseDown(MouseDownEvent e) => true;
+
protected override bool OnHover(HoverEvent e)
{
- onHoverAction?.Invoke();
+ resetCarouselPosition?.Invoke();
return base.OnHover(e);
}
}
diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs
index 59b3799e0a..f866a4f8ec 100644
--- a/osu.Game/Skinning/SkinnableSound.cs
+++ b/osu.Game/Skinning/SkinnableSound.cs
@@ -20,6 +20,12 @@ namespace osu.Game.Skinning
///
public partial class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent
{
+ ///
+ /// The minimum allowable volume for .
+ /// that specify a lower will be forcibly pulled up to this volume.
+ ///
+ public int MinimumSampleVolume { get; set; }
+
public override bool RemoveWhenNotAlive => false;
public override bool RemoveCompletedTransforms => false;
@@ -156,7 +162,7 @@ namespace osu.Game.Skinning
{
var sample = samplePool?.GetPooledSample(s) ?? new PoolableSkinnableSample(s);
sample.Looping = Looping;
- sample.Volume.Value = s.Volume / 100.0;
+ sample.Volume.Value = Math.Max(s.Volume, MinimumSampleVolume) / 100.0;
samplesContainer.Add(sample);
}
diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs
index 79df951396..2b4c64dca8 100644
--- a/osu.Game/Tests/Visual/OsuTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuTestScene.cs
@@ -432,6 +432,11 @@ namespace osu.Game.Tests.Visual
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)
{
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index c289ae89ee..848e17af1a 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -37,7 +37,7 @@
-
+