mirror of
https://github.com/ppy/osu.git
synced 2025-01-12 16:02:55 +08:00
Merge branch 'master' into velocity-based-ball-animation
This commit is contained in:
commit
23ea128f30
@ -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;
|
||||
|
@ -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
|
||||
/// </summary>
|
||||
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>
|
||||
/// Calculates the width of the area used for attempting catches in gameplay.
|
||||
/// </summary>
|
||||
@ -471,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
|
||||
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
|
||||
{
|
||||
Drop,
|
||||
|
@ -108,7 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
RelativeSizeAxes = Axes.X
|
||||
},
|
||||
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
|
||||
slidingSample = new PausableSkinnableSound { Looping = true }
|
||||
slidingSample = new PausableSkinnableSound
|
||||
{
|
||||
Looping = true,
|
||||
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
|
||||
}
|
||||
});
|
||||
|
||||
maskedContents.AddRange(new[]
|
||||
|
@ -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());
|
||||
|
||||
|
135
osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs
Normal file
135
osu.Game.Rulesets.Osu.Tests/SpinnerSpinHistoryTest.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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<OsuHitObject>
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Ignore("An upcoming implementation will fix this case")]
|
||||
public void TestVibrateWithoutSpinningOffCentre()
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[Ignore("An upcoming implementation will fix this case")]
|
||||
public void TestVibrateWithoutSpinningOnCentre()
|
||||
{
|
||||
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).
|
||||
/// </summary>
|
||||
[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<ReplayFrame> 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<ReplayFrame> 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<DrawableSpinner>().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)
|
||||
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </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;
|
||||
public float TotalRotation => History.TotalRotation;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the spinning history of the spinner.<br />
|
||||
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
|
||||
/// </summary>
|
||||
public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
|
||||
|
||||
/// <summary>
|
||||
/// Time instant at which the spin was started (the first user input which caused an increase in spin).
|
||||
|
@ -107,7 +107,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
headContainer = new Container<DrawableSliderHead> { 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);
|
||||
|
@ -48,9 +48,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
/// <summary>
|
||||
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
|
||||
/// </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>
|
||||
/// 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a judgement result.
|
||||
/// </summary>
|
||||
|
146
osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs
Normal file
146
osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerSpinHistory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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<double> gainedBonus = null!;
|
||||
private IBindable<int> completedSpins = null!;
|
||||
private IBindable<double> 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();
|
||||
|
@ -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<double> gainedBonus = null!;
|
||||
private IBindable<int> completedSpins = null!;
|
||||
private IBindable<double> 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();
|
||||
|
@ -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)
|
||||
|
@ -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 readonly Bindable<bool> completed = new Bindable<bool>();
|
||||
@ -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();
|
||||
|
@ -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)))
|
||||
{
|
||||
|
@ -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; }
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Maps a difficulty value [0, 10] to a two-piece linear range of values.
|
||||
/// </summary>
|
||||
|
@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -159,6 +159,26 @@ namespace osu.Game.Rulesets.Objects.Drawables
|
||||
/// </summary>
|
||||
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>
|
||||
/// Creates a new <see cref="DrawableHitObject"/>.
|
||||
/// </summary>
|
||||
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<SkinnableSound>
|
||||
{
|
||||
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
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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<int>("Volume", new BindableInt(100)
|
||||
{
|
||||
MinValue = 0,
|
||||
MinValue = DrawableHitObject.MINIMUM_SAMPLE_VOLUME,
|
||||
MaxValue = 100,
|
||||
})
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -38,6 +38,7 @@ namespace osu.Game.Screens.Select
|
||||
public OptionalTextFilter Creator;
|
||||
public OptionalTextFilter Artist;
|
||||
public OptionalTextFilter Title;
|
||||
public OptionalTextFilter DifficultyName;
|
||||
|
||||
public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
|
||||
{
|
||||
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
/// <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)
|
||||
{
|
||||
onHoverAction?.Invoke();
|
||||
resetCarouselPosition?.Invoke();
|
||||
return base.OnHover(e);
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,12 @@ namespace osu.Game.Skinning
|
||||
/// </summary>
|
||||
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 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);
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -37,7 +37,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Realm" Version="11.5.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="SharpCompress" Version="0.34.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
|
Loading…
Reference in New Issue
Block a user