1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 18:23:04 +08:00

Merge branch 'master' into velocity-based-ball-animation

This commit is contained in:
Dean Herbert 2023-10-25 14:01:19 +09:00 committed by GitHub
commit 23ea128f30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 704 additions and 126 deletions

View File

@ -8,6 +8,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osuTK; using osuTK;
@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Catch.Objects
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); 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; protected override HitWindows CreateHitWindows() => HitWindows.Empty;

View File

@ -17,6 +17,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning; using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -182,11 +183,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy(); public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy();
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5);
/// <summary> /// <summary>
/// Calculates the width of the area used for attempting catches in gameplay. /// Calculates the width of the area used for attempting catches in gameplay.
/// </summary> /// </summary>
@ -471,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
d.Expire(); d.Expire();
} }
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize) * 2);
private enum DroppedObjectAnimation private enum DroppedObjectAnimation
{ {
Drop, Drop,

View File

@ -108,7 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
RelativeSizeAxes = Axes.X RelativeSizeAxes = Axes.X
}, },
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both }, tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
}); });
maskedContents.AddRange(new[] maskedContents.AddRange(new[]

View File

@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.7115569159190587d, 206, "diffcalc-test")] [TestCase(6.710442985146793d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")] [TestCase(1.4386882251130073d, 45, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 2, "very-fast-slider")] [TestCase(0.42506480230838789d, 2, "very-fast-slider")]
[TestCase(0.14102693012101306d, 1, "nan-slider")] [TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, 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(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) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => 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(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) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -0,0 +1,135 @@
// 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 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));
}
}
}

View File

@ -10,6 +10,7 @@ using osu.Game.Beatmaps;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
const double time_slider_start = 1000; 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; float followCircleRadius = circleRadius * 1.2f;
performTest(new Beatmap<OsuHitObject> performTest(new Beatmap<OsuHitObject>

View File

@ -6,6 +6,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Audio; using osu.Game.Audio;
@ -26,6 +27,15 @@ namespace osu.Game.Rulesets.Osu.Tests
private TestDrawableSpinner drawableSpinner; 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(true)]
[TestCase(false)] [TestCase(false)]
public void TestVariousSpinners(bool autoplay) public void TestVariousSpinners(bool autoplay)
@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od }); spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
return drawableSpinner = new TestDrawableSpinner(spinner, true) return drawableSpinner = new TestDrawableSpinner(spinner, true, spinRate)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Depth = depthIndex++, Depth = depthIndex++,
@ -114,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize });
drawableSpinner = new TestDrawableSpinner(spinner, auto) drawableSpinner = new TestDrawableSpinner(spinner, auto, spinRate)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Depth = depthIndex++, Depth = depthIndex++,
@ -130,18 +140,20 @@ namespace osu.Game.Rulesets.Osu.Tests
private partial class TestDrawableSpinner : DrawableSpinner private partial class TestDrawableSpinner : DrawableSpinner
{ {
private readonly bool auto; private readonly bool auto;
private readonly BindableDouble spinRate;
public TestDrawableSpinner(Spinner s, bool auto) public TestDrawableSpinner(Spinner s, bool auto, BindableDouble spinRate)
: base(s) : base(s)
{ {
this.auto = auto; this.auto = auto;
this.spinRate = spinRate;
} }
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
if (auto) if (auto)
RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 2)); RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * spinRate.Value));
} }
} }
} }

View File

@ -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. /// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary> /// </summary>
[Test] [Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestVibrateWithoutSpinningOffCentre() public void TestVibrateWithoutSpinningOffCentre()
{ {
List<ReplayFrame> frames = new List<ReplayFrame>(); List<ReplayFrame> frames = new List<ReplayFrame>();
@ -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. /// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary> /// </summary>
[Test] [Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestVibrateWithoutSpinningOnCentre() public void TestVibrateWithoutSpinningOnCentre()
{ {
List<ReplayFrame> frames = new List<ReplayFrame>(); List<ReplayFrame> frames = new List<ReplayFrame>();
@ -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). /// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW).
/// </summary> /// </summary>
[Test] [Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestSpinHalfBothDirections() public void TestSpinHalfBothDirections()
{ {
performTest(new SpinFramesGenerator(time_spinner_start) performTest(new SpinFramesGenerator(time_spinner_start)
@ -149,7 +146,6 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase(-180, 540, 1)] [TestCase(-180, 540, 1)]
[TestCase(180, -900, 2)] [TestCase(180, -900, 2)]
[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) public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks)
{ {
performTest(new SpinFramesGenerator(time_spinner_start) performTest(new SpinFramesGenerator(time_spinner_start)
@ -162,17 +158,27 @@ namespace osu.Game.Rulesets.Osu.Tests
} }
[Test] [Test]
[Ignore("An upcoming implementation will fix this case")]
public void TestRewind() 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<ReplayFrame> frames = new SpinFramesGenerator(time_spinner_start) List<ReplayFrame> frames =
.Spin(360, 500) // 2000ms -> 1 full CW spin new SpinFramesGenerator(time_spinner_start)
.Spin(-180, 500) // 2500ms -> 0.5 CCW spins // 1500ms start
.Spin(90, 500) // 3000ms -> 0.25 CW spins .Spin(360, 500)
.Spin(450, 500) // 3500ms -> 1 full CW spin // 2000ms -> 1 full CW spin
.Spin(180, 500) // 4000ms -> 0.5 CW spins .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(); .Build();
loadPlayer(frames); loadPlayer(frames);
@ -190,15 +196,35 @@ namespace osu.Game.Rulesets.Osu.Tests
DrawableSpinner drawableSpinner = null!; DrawableSpinner drawableSpinner = null!;
AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null); AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null);
assertTotalRotation(4000, 900); assertFinalRotationCorrect();
assertTotalRotation(3750, 810); assertTotalRotation(3750, 810);
assertTotalRotation(3500, 720); assertTotalRotation(3500, 720);
assertTotalRotation(3250, 530); assertTotalRotation(3250, 530);
assertTotalRotation(3000, 540); assertTotalRotation(3000, 450);
assertTotalRotation(2750, 540); assertTotalRotation(2750, 540);
assertTotalRotation(2500, 540); assertTotalRotation(2500, 540);
assertTotalRotation(2250, 360); assertTotalRotation(2250, 450);
assertTotalRotation(2000, 180); 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); assertTotalRotation(1500, 0);
void assertTotalRotation(double time, float expected) void assertTotalRotation(double time, float expected)
@ -211,8 +237,11 @@ namespace osu.Game.Rulesets.Osu.Tests
void addSeekStep(double time) void addSeekStep(double time)
{ {
AddStep($"seek to {time}", () => clock.Seek(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)); AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time));
} }
void assertFinalRotationCorrect() => assertTotalRotation(4000, 900);
} }
private void assertTicksHit(int count) private void assertTicksHit(int count)

View File

@ -4,6 +4,7 @@
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Judgements namespace osu.Game.Rulesets.Osu.Judgements
{ {
@ -15,28 +16,15 @@ namespace osu.Game.Rulesets.Osu.Judgements
public Spinner Spinner => (Spinner)HitObject; public Spinner Spinner => (Spinner)HitObject;
/// <summary> /// <summary>
/// The total rotation performed on the spinner disc, disregarding the spin direction, /// The total amount that the spinner was rotated.
/// adjusted for the track's playback rate.
/// </summary> /// </summary>
/// <remarks> public float TotalRotation => History.TotalRotation;
/// <para>
/// This value is always non-negative and is monotonically increasing with time /// <summary>
/// (i.e. will only increase if time is passing forward, but can decrease during rewind). /// Stores the spinning history of the spinner.<br />
/// </para> /// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// <para> /// </summary>
/// The rotation from each frame is multiplied by the clock's current playback rate. public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
/// 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.
/// </para>
/// </remarks>
/// <example>
/// 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.
/// </example>
public float TotalRotation;
/// <summary> /// <summary>
/// Time instant at which the spin was started (the first user input which caused an increase in spin). /// Time instant at which the spin was started (the first user input which caused an increase in spin).

View File

@ -107,7 +107,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both }, headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both },
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, }, OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
Ball, Ball,
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
}); });
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);

View File

@ -48,9 +48,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// <summary> /// <summary>
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes. /// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
/// </summary> /// </summary>
public IBindable<double> GainedBonus => gainedBonus; public double CurrentBonusScore => score_per_tick * Math.Clamp(completedFullSpins.Value - HitObject.SpinsRequiredForBonus, 0, HitObject.MaximumBonusSpins);
private readonly Bindable<double> gainedBonus = new BindableDouble(); /// <summary>
/// The maximum amount of bonus score which can be achieved from extra spins.
/// </summary>
public double MaximumBonusScore => score_per_tick * HitObject.MaximumBonusSpins;
public IBindable<int> CompletedFullSpins => completedFullSpins;
private readonly Bindable<int> completedFullSpins = new Bindable<int>();
/// <summary> /// <summary>
/// The number of spins per minute this spinner is spinning at, for display purposes. /// 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 spinningSample = new PausableSkinnableSound
{ {
Volume = { Value = 0 }, Volume = { Value = 0 },
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
Looping = true, Looping = true,
Frequency = { Value = spinning_sample_initial_frequency } 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 static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult;
private int completedFullSpins;
private void updateBonusScore() private void updateBonusScore()
{ {
if (ticks.Count == 0) if (ticks.Count == 0)
@ -295,27 +301,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
int spins = (int)(Result.TotalRotation / 360); int spins = (int)(Result.TotalRotation / 360);
if (spins < completedFullSpins) if (spins < completedFullSpins.Value)
{ {
// rewinding, silently handle // rewinding, silently handle
completedFullSpins = spins; completedFullSpins.Value = spins;
return; return;
} }
while (completedFullSpins != spins) while (completedFullSpins.Value != spins)
{ {
var tick = ticks.FirstOrDefault(t => !t.Result.HasResult); var tick = ticks.FirstOrDefault(t => !t.Result.HasResult);
// tick may be null if we've hit the spin limit. // tick may be null if we've hit the spin limit.
if (tick != null) tick?.TriggerResult(true);
{
tick.TriggerResult(true);
if (tick is DrawableSpinnerBonusTick) completedFullSpins.Value++;
gainedBonus.Value = score_per_tick * (spins - HitObject.SpinsRequiredForBonus);
}
completedFullSpins++;
} }
} }
} }

View File

@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre; 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;
}
/// <summary> /// <summary>
/// Apply a judgement result. /// Apply a judgement result.
/// </summary> /// </summary>

View File

@ -0,0 +1,146 @@
// 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 System.Diagnostics;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
/// <summary>
/// Stores the spinning history of a single spinner.<br />
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// </summary>
/// <remarks>
/// 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.<br />
/// </remarks>
/// <example>
/// 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.
/// </example>
public class SpinnerSpinHistory
{
/// <summary>
/// The sum of all complete spins and any current partial spin, in degrees.
/// </summary>
/// <remarks>
/// This is the final scoring value.
/// </remarks>
public float TotalRotation => 360 * completedSpins.Count + currentSpinMaxRotation;
private readonly Stack<CompletedSpin> completedSpins = new Stack<CompletedSpin>();
/// <summary>
/// The total accumulated (absolute) rotation.
/// </summary>
private float totalAccumulatedRotation;
private float totalAccumulatedRotationAtLastCompletion;
/// <summary>
/// For the current spin, represents the maximum absolute rotation (from 0..360) achieved by the user.
/// </summary>
/// <remarks>
/// This is used to report <see cref="TotalRotation"/> 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.
/// </remarks>
private float currentSpinMaxRotation;
/// <summary>
/// The current spin, from -360..360.
/// </summary>
private float currentSpinRotation => totalAccumulatedRotation - totalAccumulatedRotationAtLastCompletion;
private double lastReportTime = double.NegativeInfinity;
/// <summary>
/// Report a delta update based on user input.
/// </summary>
/// <param name="currentTime">The current time.</param>
/// <param name="delta">The delta of the angle moved through since the last report.</param>
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;
}
/// <summary>
/// Represents a single completed spin.
/// </summary>
private class CompletedSpin
{
/// <summary>
/// The time at which this spin completion occurred.
/// </summary>
public readonly double CompletionTime;
/// <summary>
/// The direction this spin completed in.
/// </summary>
public readonly int Direction;
public CompletedSpin(double completionTime, int direction)
{
Debug.Assert(direction == -1 || direction == 1);
CompletionTime = completionTime;
Direction = direction;
}
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.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. // 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); 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(); protected override HitWindows CreateHitWindows() => new OsuHitWindows();

View File

@ -35,14 +35,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
bonusCounter = new OsuSpriteText
{
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 24),
Y = -120,
},
new ArgonSpinnerDisc new ArgonSpinnerDisc
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -85,19 +77,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
}; };
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(_ =>
{ {
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); if (drawableSpinner.CurrentBonusScore <= 0)
bonusCounter.FadeOutFromOne(1500); 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.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
bonusCounter.FadeOutFromOne(1500);
}
}); });
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -24,6 +24,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private Container spmContainer = null!; private Container spmContainer = null!;
private OsuSpriteText spmCounter = null!; private OsuSpriteText spmCounter = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public DefaultSpinner() public DefaultSpinner()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -80,19 +83,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
}); });
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(bonus =>
{ {
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); 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.FadeOutFromOne(1500);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
}
}); });
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -101,15 +101,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
rotationTransferred = true; rotationTransferred = true;
} }
currentRotation += delta;
double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate; double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate;
delta = (float)(delta * Math.Abs(rate));
Debug.Assert(Math.Abs(delta) <= 180); Debug.Assert(Math.Abs(delta) <= 180);
// rate has to be applied each frame, because it's not guaranteed to be constant throughout playback currentRotation += delta;
// (see: ModTimeRamp) drawableSpinner.Result.History.ReportDelta(Time.Current, delta);
drawableSpinner.Result.TotalRotation += (float)(Math.Abs(delta) * rate);
} }
private void resetState(DrawableHitObject obj) private void resetState(DrawableHitObject obj)

View File

@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
}); });
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
private readonly Bindable<bool> completed = new Bindable<bool>(); private readonly Bindable<bool> completed = new Bindable<bool>();
@ -116,12 +116,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = DrawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = DrawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(bonus =>
{
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.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(800, Easing.Out); bonusCounter.FadeOutFromOne(800, Easing.Out);
bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out); bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
}
}); });
spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -13,6 +13,7 @@ using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring; using osu.Game.Scoring;
using osuTK; using osuTK;
@ -208,8 +209,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
if (score.HitEvents.Count == 0) if (score.HitEvents.Count == 0)
return; return;
// Todo: This should probably not be done like this. float radius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(playableBeatmap.Difficulty.CircleSize, true);
float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.Difficulty.CircleSize - 5) / 5) / 2;
foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)))
{ {

View File

@ -2,10 +2,12 @@
// 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.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Filter; using osu.Game.Rulesets.Filter;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Carousel;
using osu.Game.Screens.Select.Filter; using osu.Game.Screens.Select.Filter;
namespace osu.Game.Tests.NonVisual.Filtering namespace osu.Game.Tests.NonVisual.Filtering
@ -382,6 +384,57 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual("unrecognised=keyword", filterCriteria.SearchText.Trim()); 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 private class CustomFilterCriteria : IRulesetFilterCriteria
{ {
public string? CustomValue { get; set; } public string? CustomValue { get; set; }

View File

@ -64,7 +64,8 @@ namespace osu.Game.Tests.Visual.Online
new[] { "Plain", "This is plain comment" }, new[] { "Plain", "This is plain comment" },
new[] { "Pinned", "This is pinned comment" }, new[] { "Pinned", "This is pinned comment" },
new[] { "Link", "Please visit https://osu.ppy.sh" }, new[] { "Link", "Please visit https://osu.ppy.sh" },
new[] { "Big Image", "![](Backgrounds/bg1)" },
new[] { "Small Image", "![](Cursor/cursortrail)" },
new[] new[]
{ {
"Heading", @"# Heading 1 "Heading", @"# Heading 1

View File

@ -55,13 +55,20 @@ namespace osu.Game.Beatmaps
static double DifficultyRange(double difficulty, double min, double mid, double max) static double DifficultyRange(double difficulty, double min, double mid, double max)
{ {
if (difficulty > 5) if (difficulty > 5)
return mid + (max - mid) * (difficulty - 5) / 5; return mid + (max - mid) * DifficultyRange(difficulty);
if (difficulty < 5) if (difficulty < 5)
return mid - (mid - min) * (5 - difficulty) / 5; return mid + (mid - min) * DifficultyRange(difficulty);
return mid; return mid;
} }
/// <summary>
/// Maps a difficulty value [0, 10] to a linear range of [-1, 1].
/// </summary>
/// <param name="difficulty">The difficulty value to be mapped.</param>
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
static double DifficultyRange(double difficulty) => (difficulty - 5) / 5;
/// <summary> /// <summary>
/// Maps a difficulty value [0, 10] to a two-piece linear range of values. /// Maps a difficulty value [0, 10] to a two-piece linear range of values.
/// </summary> /// </summary>

View File

@ -2,8 +2,14 @@
// 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 Markdig.Syntax; 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.Containers.Markdown;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Graphics.Containers.Markdown; using osu.Game.Graphics.Containers.Markdown;
using osuTK;
namespace osu.Game.Overlays.Comments namespace osu.Game.Overlays.Comments
{ {
@ -16,6 +22,8 @@ namespace osu.Game.Overlays.Comments
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock); protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
private partial class CommentMarkdownHeading : OsuMarkdownHeading private partial class CommentMarkdownHeading : OsuMarkdownHeading
{ {
public CommentMarkdownHeading(HeadingBlock headingBlock) 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!;
}
}
}
}
} }
} }

View File

@ -159,6 +159,26 @@ namespace osu.Game.Rulesets.Objects.Drawables
/// </summary> /// </summary>
internal bool IsInitialized; internal bool IsInitialized;
/// <summary>
/// The minimum allowable volume for sample playback.
/// <see cref="Samples"/> quieter than that will be forcibly played at this volume instead.
/// </summary>
/// <remarks>
/// <para>
/// Drawable hitobjects adding their own custom samples, or other sample playback sources
/// (i.e. <see cref="GameplaySampleTriggerSource"/>) must enforce this themselves.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public const int MINIMUM_SAMPLE_VOLUME = 5;
/// <summary> /// <summary>
/// Creates a new <see cref="DrawableHitObject"/>. /// Creates a new <see cref="DrawableHitObject"/>.
/// </summary> /// </summary>
@ -181,7 +201,10 @@ namespace osu.Game.Rulesets.Objects.Drawables
comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount); comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount);
// Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal. // 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 = skinSource;
CurrentSkin.SourceChanged += skinSourceChanged; CurrentSkin.SourceChanged += skinSourceChanged;

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 osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
@ -38,5 +39,23 @@ namespace osu.Game.Rulesets.Objects.Legacy
return timingControlPoint.BeatLength * bpmMultiplier; return timingControlPoint.BeatLength * bpmMultiplier;
} }
/// <summary>
/// Calculates scale from a CS value, with an optional fudge that was historically applied to the osu! ruleset.
/// </summary>
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);
}
} }
} }

View File

@ -7,6 +7,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -45,7 +46,10 @@ namespace osu.Game.Rulesets.UI
Child = hitSounds = new Container<SkinnableSound> Child = hitSounds = new Container<SkinnableSound>
{ {
Name = "concurrent sample pool", 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
})
} }
}; };
} }

View File

@ -16,6 +16,7 @@ using osu.Game.Audio;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Timing;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -101,7 +102,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
}, },
volume = new IndeterminateSliderWithTextBoxInput<int>("Volume", new BindableInt(100) volume = new IndeterminateSliderWithTextBoxInput<int>("Volume", new BindableInt(100)
{ {
MinValue = 0, MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME,
MaxValue = 100, MaxValue = 100,
}) })
} }

View File

@ -1197,6 +1197,8 @@ namespace osu.Game.Screens.Select
{ {
private bool rightMouseScrollBlocked; private bool rightMouseScrollBlocked;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public CarouselScrollContainer() public CarouselScrollContainer()
{ {
// size is determined by the carousel itself, due to not all content necessarily being loaded. // size is determined by the carousel itself, due to not all content necessarily being loaded.

View File

@ -59,7 +59,7 @@ namespace osu.Game.Screens.Select.Carousel
criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode);
match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) || match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) ||
criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode);
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName);
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
if (!match) return false; if (!match) return false;

View File

@ -38,6 +38,7 @@ namespace osu.Game.Screens.Select
public OptionalTextFilter Creator; public OptionalTextFilter Creator;
public OptionalTextFilter Artist; public OptionalTextFilter Artist;
public OptionalTextFilter Title; public OptionalTextFilter Title;
public OptionalTextFilter DifficultyName;
public OptionalRange<double> UserStarDifficulty = new OptionalRange<double> public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
{ {
@ -68,8 +69,23 @@ namespace osu.Game.Screens.Select
string remainingText = value; 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. // 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 }); terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value });
remainingText = remainingText.Replace(quotedSegment.Value, string.Empty); remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);

View File

@ -76,6 +76,9 @@ namespace osu.Game.Screens.Select
case "title": case "title":
return TryUpdateCriteriaText(ref criteria.Title, op, value); return TryUpdateCriteriaText(ref criteria.Title, op, value);
case "diff":
return TryUpdateCriteriaText(ref criteria.DifficultyName, op, value);
default: default:
return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false;
} }

View File

@ -171,11 +171,6 @@ namespace osu.Game.Screens.Select
AddRangeInternal(new Drawable[] AddRangeInternal(new Drawable[]
{ {
new ResetScrollContainer(() => Carousel.ScrollToSelected())
{
RelativeSizeAxes = Axes.Y,
Width = 250,
},
new VerticalMaskingContainer new VerticalMaskingContainer
{ {
Children = new Drawable[] Children = new Drawable[]
@ -243,6 +238,10 @@ namespace osu.Game.Screens.Select
Padding = new MarginPadding { Top = left_area_padding }, Padding = new MarginPadding { Top = left_area_padding },
Children = new Drawable[] Children = new Drawable[]
{ {
new LeftSideInteractionContainer(() => Carousel.ScrollToSelected())
{
RelativeSizeAxes = Axes.Both,
},
beatmapInfoWedge = new BeatmapInfoWedge beatmapInfoWedge = new BeatmapInfoWedge
{ {
Height = WEDGE_HEIGHT, Height = WEDGE_HEIGHT,
@ -1017,18 +1016,25 @@ namespace osu.Game.Screens.Select
} }
} }
private partial class ResetScrollContainer : Container /// <summary>
/// Handles mouse interactions required when moving away from the carousel.
/// </summary>
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) protected override bool OnHover(HoverEvent e)
{ {
onHoverAction?.Invoke(); resetCarouselPosition?.Invoke();
return base.OnHover(e); return base.OnHover(e);
} }
} }

View File

@ -20,6 +20,12 @@ namespace osu.Game.Skinning
/// </summary> /// </summary>
public partial class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent public partial class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent
{ {
/// <summary>
/// The minimum allowable volume for <see cref="Samples"/>.
/// <see cref="Samples"/> that specify a lower <see cref="ISampleInfo.Volume"/> will be forcibly pulled up to this volume.
/// </summary>
public int MinimumSampleVolume { get; set; }
public override bool RemoveWhenNotAlive => false; public override bool RemoveWhenNotAlive => false;
public override bool RemoveCompletedTransforms => false; public override bool RemoveCompletedTransforms => false;
@ -156,7 +162,7 @@ namespace osu.Game.Skinning
{ {
var sample = samplePool?.GetPooledSample(s) ?? new PoolableSkinnableSample(s); var sample = samplePool?.GetPooledSample(s) ?? new PoolableSkinnableSample(s);
sample.Looping = Looping; sample.Looping = Looping;
sample.Volume.Value = s.Volume / 100.0; sample.Volume.Value = Math.Max(s.Volume, MinimumSampleVolume) / 100.0;
samplesContainer.Add(sample); samplesContainer.Add(sample);
} }

View File

@ -432,6 +432,11 @@ namespace osu.Game.Tests.Visual
private bool running; 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") public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual")
: base(name) : base(name)
{ {

View File

@ -37,7 +37,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Realm" Version="11.5.0" /> <PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.1012.0" /> <PackageReference Include="ppy.osu.Framework" Version="2023.1012.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.1014.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2023.1023.0" />
<PackageReference Include="Sentry" Version="3.40.0" /> <PackageReference Include="Sentry" Version="3.40.0" />
<PackageReference Include="SharpCompress" Version="0.34.1" /> <PackageReference Include="SharpCompress" Version="0.34.1" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />