diff --git a/osu.Game.Benchmarks/BenchmarkScoreMultiplierCalculator.cs b/osu.Game.Benchmarks/BenchmarkScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..22e8b4103865 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkScoreMultiplierCalculator.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using NUnit.Framework; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkScoreMultiplierCalculator : BenchmarkTest + { + private ScoreMultiplierCalculator calculator = null!; + + [Params(1, 10, 100)] + public int Times { get; set; } + + public record ModTestCase(string Description, IEnumerable Mods) + { + public override string ToString() => Description; + } + + public static IEnumerable ValuesForMods => + [ + new ModTestCase("no mods", []), + new ModTestCase("single mod", [new OsuModHardRock()]), + new ModTestCase("single mod 2", [new OsuModEasy()]), + new ModTestCase("multiple mods", [new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime()]), + new ModTestCase("mods with adjusted settings", [ + new OsuModDoubleTime { SpeedChange = { Value = 2 } }, + new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }, + new OsuModHardRock() + ]), + ]; + + [ParamsSource(nameof(ValuesForMods))] + public ModTestCase Mods { get; set; } = null!; + + public override void SetUp() + { + base.SetUp(); + calculator = new OsuRuleset().CreateScoreMultiplierCalculator(); + } + + [Benchmark] + public double ViaModScoreMultiplier() => viaModScoreMultiplier(Times, Mods); + + [Test] + public void ViaModScoreMultiplier([Values(100)] int times, [ValueSource(nameof(ValuesForMods))] ModTestCase mods) + => viaModScoreMultiplier(times, mods); + + private double viaModScoreMultiplier(int times, ModTestCase mods) + { + double scoreMultiplier = 1; + + for (int i = 0; i < times; ++i) + { + scoreMultiplier = 1; + + foreach (var mod in mods.Mods) + scoreMultiplier *= mod.ScoreMultiplier; + } + + return scoreMultiplier; + } + + [Benchmark] + public double ViaCalculator() + => viaCalculator(Times, Mods); + + [Test] + public void ViaCalculator([Values(100)] int times, [ValueSource(nameof(ValuesForMods))] ModTestCase mods) + => viaCalculator(times, mods); + + private double viaCalculator(int times, ModTestCase mods) + { + double scoreMultiplier = 1; + + for (int i = 0; i < times; ++i) + scoreMultiplier = calculator.CalculateFor(mods.Mods); + + return scoreMultiplier; + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/CatchScoreMultiplierTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchScoreMultiplierTest.cs new file mode 100644 index 000000000000..a0f8a948c51e --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchScoreMultiplierTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Rulesets; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class CatchScoreMultiplierTest : RulesetScoreMultiplierTest + { + public CatchScoreMultiplierTest() + : base(new CatchRuleset()) + { + } + + [Test] + public void TestFlashlightOnNonDefaultSettings() + => TestModCombination([new CatchModFlashlight { ComboBasedSize = { Value = false } }]); + + [Test] + public void TestHalfTimeSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new CatchModHalfTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDaycoreSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new CatchModDaycore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDoubleTimeSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new CatchModDoubleTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestNightcoreSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new CatchModNightcore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestMultiplicativeCombination() + => TestModCombination([new CatchModHidden(), new CatchModHardRock()]); + } +} diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 154df0771b88..7c8418800b27 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -168,6 +168,8 @@ public override IEnumerable GetModsFor(ModType type) } } + public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new CatchScoreMultiplierCalculator(); + public override string Description => "osu!catch"; public override string ShortName => SHORT_NAME; diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreMultiplierCalculator.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..55ba80a4a0a0 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreMultiplierCalculator.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Catch.Scoring +{ + public class CatchScoreMultiplierCalculator : ScoreMultiplierCalculator + { + static CatchScoreMultiplierCalculator() + { + #region Difficulty Reduction + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + Single(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value)); + Single(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value)); + + #endregion + + #region Difficulty Increase + + Single(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.12 : 1); + // Sudden Death + // Perfect + Single(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value)); + Single(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value)); + Single(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1); + Single(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1); + // Accuracy Challenge + + #endregion + + #region Conversion + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.96); + // Mirror + + #endregion + + #region Automation + + // Autoplay + // Cinema + Single(hasMultiplier: 0.1); + + #endregion + + #region Fun + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + // Floating Fruits + // Muted + // No Scope + // Moving Fast + Single(hasMultiplier: 0.8); + + #endregion + + #region System + + // Score V2 + + #endregion + } + + private static double rateAdjustMultiplier(double speedChange) + { + // Round to the nearest multiple of 0.1. + double value = (int)(speedChange * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + if (speedChange >= 1) + return 1 + value / 5; + else + return 0.6 + value; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaScoreMultiplierTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaScoreMultiplierTest.cs new file mode 100644 index 000000000000..730210270e18 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaScoreMultiplierTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Rulesets; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class ManiaScoreMultiplierTest : RulesetScoreMultiplierTest + { + public ManiaScoreMultiplierTest() + : base(new ManiaRuleset()) + { + } + + [Test] + public void TestHalfTimeSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new ManiaModHalfTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDaycoreSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new ManiaModDaycore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDoubleTimeSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new ManiaModDoubleTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestNightcoreSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new ManiaModNightcore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestMultiplicativeCombination() + => TestModCombination([new ManiaModEasy(), new ManiaModKey4()]); + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 8c38367b95af..e3d859265a7f 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -307,6 +307,8 @@ public override IEnumerable GetModsFor(ModType type) } } + public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new ManiaScoreMultiplierCalculator(); + public override string Description => "osu!mania"; public override string ShortName => SHORT_NAME; diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreMultiplierCalculator.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..22c77db6a3d7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreMultiplierCalculator.cs @@ -0,0 +1,99 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Scoring +{ + public class ManiaScoreMultiplierCalculator : ScoreMultiplierCalculator + { + static ManiaScoreMultiplierCalculator() + { + #region Difficulty Reduction + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + Single(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value)); + Single(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value)); + Single(hasMultiplier: 0.9); + + #endregion + + #region Difficulty Increase + + // Hard Rock + // Sudden Death + // Perfect + // Double Time + // Nightcore + // Fade In + // Hidden + // Cover + // Flashlight + // Accuracy Challenge + + #endregion + + #region Conversion + + // Random + // Dual Stages + // Mirror + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.96); + // Invert + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + Single(hasMultiplier: 0.9); + + #endregion + + #region Automation + + // Autoplay + // Cinema + + #endregion + + #region Fun + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + // Muted + Single(hasMultiplier: 0.5); + + #endregion + + #region System + + // Score V2 + + #endregion + } + + private static double rateAdjustMultiplier(double speedChange) + { + // Round to the nearest multiple of 0.1. + double value = (int)(speedChange * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + if (speedChange >= 1) + return 1 + value / 5; + else + return 0.6 + value; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs index 685405101fff..8b65b90b0bca 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs @@ -8,6 +8,9 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; @@ -129,5 +132,74 @@ public void TestVelocityUndo() AddAssert("slider has correct velocity", () => slider!.Velocity, () => Is.EqualTo(velocityBefore)); AddAssert("slider has correct duration", () => slider!.Duration, () => Is.EqualTo(durationBefore)); } + + [Test] + public void TestVelocityToolbox() + { + ExpandableSlider velocitySlider = null!; + ExpandableButton useLastSliderButton = null!; + + AddStep("enter editor", () => Game.ScreenStack.Push(new EditorLoader())); + AddUntilStep("wait for editor load", () => editor?.ReadyForUse == true); + AddStep("retrieve controls", () => + { + var toolbox = this.ChildrenOfType().Single(); + velocitySlider = toolbox.ChildrenOfType>().Single(); + useLastSliderButton = toolbox.ChildrenOfType().Single(); + }); + + AddAssert("velocity slider at 1x", () => velocitySlider.Current.Value, () => Is.EqualTo(1)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False); + + AddStep("seek to 5000", () => editorClock.Seek(5000)); + AddStep("set 2x velocity", () => velocitySlider.Current.Value = 2); + placeSlider(); + AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType().Last().SliderVelocityMultiplier, () => Is.EqualTo(2)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True); + + AddStep("seek to 6000", () => editorClock.Seek(6000)); + placeSlider(); + AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType().Last().SliderVelocityMultiplier, () => Is.EqualTo(2)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True); + + AddStep("seek to 9000", () => editorClock.Seek(9000)); + AddStep("set 3x velocity", () => velocitySlider.Current.Value = 3); + placeSlider(); + AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType().Last().SliderVelocityMultiplier, () => Is.EqualTo(3)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button enabled", () => useLastSliderButton.Enabled.Value, () => Is.True); + + AddStep("seek to 10000", () => editorClock.Seek(10000)); + AddStep("set 1x velocity", () => velocitySlider.Current.Value = 1); + AddStep("use last slider velocity instead", () => useLastSliderButton.TriggerClick()); + placeSlider(); + AddAssert("placed slider has 3x velocity", () => editorBeatmap.HitObjects.OfType().Last().SliderVelocityMultiplier, () => Is.EqualTo(3)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False); + + AddStep("seek back to 7000", () => editorClock.Seek(7000)); + placeSlider(); + AddAssert("placed slider has 2x velocity", () => editorBeatmap.HitObjects.OfType().ElementAt(2).SliderVelocityMultiplier, () => Is.EqualTo(2)); + AddStep("expand right toolbox", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("wait for expand", () => useLastSliderButton.Expanded.Value, () => Is.True); + AddAssert("use last slider button disabled", () => useLastSliderButton.Enabled.Value, () => Is.False); + + void placeSlider() + { + AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left", () => InputManager.MoveMouseTo(editor.ChildrenOfType().First().ScreenSpaceDrawQuad.TopLeft + new Vector2(50))); + AddStep("start placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right", () => InputManager.MoveMouseTo(editor.ChildrenOfType().First().ScreenSpaceDrawQuad.BottomRight - new Vector2(50))); + AddStep("end placement", () => InputManager.Click(MouseButton.Right)); + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuScoreMultiplierTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuScoreMultiplierTest.cs new file mode 100644 index 000000000000..77e09f017f17 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuScoreMultiplierTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Rulesets; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class OsuScoreMultiplierTest : RulesetScoreMultiplierTest + { + public OsuScoreMultiplierTest() + : base(new OsuRuleset()) + { + } + + [Test] + public void TestFlashlightOnNonDefaultSettings() + => TestModCombination([new OsuModFlashlight { ComboBasedSize = { Value = false } }]); + + [Test] + public void TestHiddenOnNonDefaultSettings() + => TestModCombination([new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }]); + + [Test] + public void TestHalfTimeSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new OsuModHalfTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDaycoreSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new OsuModDaycore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDoubleTimeSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new OsuModDoubleTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestNightcoreSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new OsuModNightcore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestMultiplicativeCombination() + => TestModCombination([new OsuModHidden(), new OsuModHardRock()]); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index fd815ccc83da..52f3962cbe71 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -53,6 +53,9 @@ public partial class SliderPlacementBlueprint : HitObjectPlacementBlueprint [Resolved] private EditorClock? editorClock { get; set; } + [Resolved] + private OsuSliderVelocityToolboxGroup? sliderVelocityToolbox { get; set; } + private Bindable limitedDistanceSnap { get; set; } = null!; private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; @@ -110,9 +113,6 @@ protected override void LoadComplete() } } - [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; - public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, double fallbackTime) { var result = composer?.TrySnapToNearbyObjects(screenSpacePosition, fallbackTime); @@ -128,11 +128,7 @@ public override SnapResult UpdateTimeAndPosition(Vector2 screenSpacePosition, do case SliderPlacementState.Initial: BeginPlacement(); - double? nearestSliderVelocity = (editorBeatmap - .HitObjects - .LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime) as Slider)?.SliderVelocityMultiplier; - - HitObject.SliderVelocityMultiplier = nearestSliderVelocity ?? 1; + HitObject.SliderVelocityMultiplier = sliderVelocityToolbox?.SliderVelocity.Value ?? 1; HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); // Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation. diff --git a/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs index a88ae73b178b..e7aa9f163fa7 100644 --- a/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/FreehandSliderToolboxGroup.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Edit public partial class FreehandSliderToolboxGroup : EditorToolboxGroup { public FreehandSliderToolboxGroup() - : base("slider") + : base("freehand") { } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 6939cb63676a..41d3466cbc7a 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -75,6 +75,9 @@ protected override IEnumerable CreateTernaryButtons() [Cached(typeof(IDistanceSnapProvider))] public readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider(); + [Cached] + private readonly OsuSliderVelocityToolboxGroup sliderVelocityToolboxGroup = new OsuSliderVelocityToolboxGroup(); + [Cached] protected readonly OsuGridToolboxGroup OsuGridToolboxGroup = new OsuGridToolboxGroup(); @@ -111,6 +114,7 @@ private void load() RightToolbox.AddRange(new Drawable[] { + sliderVelocityToolboxGroup, OsuGridToolboxGroup, new TransformToolboxGroup { diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSliderVelocityToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/OsuSliderVelocityToolboxGroup.cs new file mode 100644 index 000000000000..c8e5bc13d531 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuSliderVelocityToolboxGroup.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class OsuSliderVelocityToolboxGroup : EditorToolboxGroup + { + /// + /// Whether the last slider's velocity should be used (if available). + /// + private bool useLastSliderVelocity; + + /// + /// The slider velocity to be used for new object placements. + /// + public IBindable SliderVelocity => sliderVelocity; + + private readonly BindableDouble sliderVelocity = new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10, + }; + + private ExpandableSlider slider = null!; + private ExpandableButton useLastSliderButton = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + private bool syncingBindables; + private double lastClockPosition = double.NegativeInfinity; + private readonly Cached sliderVelocitySourceObject = new Cached(); + + public OsuSliderVelocityToolboxGroup() + : base("velocity") + { + } + + [BackgroundDependencyLoader] + private void load() + { + Spacing = new Vector2(5); + Children = new Drawable[] + { + slider = new ExpandableSlider + { + ExpandedLabelText = "Slider velocity", + Current = new BindableDouble(1) + { + Precision = 0.01, + MinValue = 0.1, + MaxValue = 10, + }, + KeyboardStep = 0.1f, + }, + useLastSliderButton = new ExpandableButton + { + RelativeSizeAxes = Axes.X, + Action = () => + { + useLastSliderVelocity = true; + sliderVelocitySourceObject.Invalidate(); + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // set unconditionally to true initially. + // if there is no object available to get the slider velocity from, the code in `Update()` will handle that. + useLastSliderVelocity = true; + + sliderVelocity.BindValueChanged(_ => updateSliderFromVelocity(), true); + slider.Current.BindValueChanged(_ => + { + updateVelocityFromSlider(); + updateContractedText(); + }); + updateContractedText(); + useLastSliderButton.Expanded.BindValueChanged(_ => sliderVelocitySourceObject.Invalidate()); + + editorBeatmap.HitObjectAdded += invalidateSliderVelocitySourceObject; + editorBeatmap.HitObjectUpdated += invalidateSliderVelocitySourceObject; + editorBeatmap.HitObjectRemoved += invalidateSliderVelocitySourceObject; + } + + private void updateContractedText() + { + slider.ContractedLabelText = LocalisableString.Interpolate($@"SV: {slider.Current.Value.ToLocalisableString("N2")}x"); + } + + /// + /// Updates the displayed value of this toolbox's slider from a change to + /// (which is the source-of-truth used for new object placements). + /// This is only relevant when is true, + /// in which case this code is responsible for propagating the velocity from to the slider. + /// + private void updateSliderFromVelocity() + { + if (syncingBindables) + return; + + if (!useLastSliderVelocity) + return; + + syncingBindables = true; + slider.Current.Value = sliderVelocity.Value; + syncingBindables = false; + } + + /// + /// Updates the value of from a change to the slider's state. + /// This change is assumed to be user-provoked, and therefore is switched unconditionally off + /// as the presumed intent is to override the velocity from . + /// + private void updateVelocityFromSlider() + { + if (syncingBindables) + return; + + syncingBindables = true; + useLastSliderVelocity = false; + sliderVelocity.Value = slider.Current.Value; + syncingBindables = false; + sliderVelocitySourceObject.Invalidate(); + } + + private void invalidateSliderVelocitySourceObject(HitObject _) => sliderVelocitySourceObject.Invalidate(); + + protected override void Update() + { + base.Update(); + + if (editorClock.CurrentTime != lastClockPosition) + { + sliderVelocitySourceObject.Invalidate(); + lastClockPosition = editorClock.CurrentTime; + } + + // Three possible causes of invalidation: + // - The user seeked the clock, which means a different velocity source object needs to be used. + // - Some change to the beatmap was made, which means the previously-used velocity source object may no longer be the most relevant one. + // - The user is interacting with the toolbox in a way that requires a visual state update + // (hovered to expand it, clicked the button to use last slider's velocity, or dragged the manual velocity slider). + // This is a procedural one, because `sliderVelocitySourceObject` will have been pointing at the correct object already, + // but to decrease unnecessary work being done every frame, the invalidation is explicitly re-triggered to update the toolbox state. + if (!sliderVelocitySourceObject.IsValid) + { + var lastSlider = getLastSlider(); + sliderVelocitySourceObject.Value = lastSlider; + + if (lastSlider == null) + { + useLastSliderButton.Enabled.Value = false; + useLastSliderButton.ExpandedLabelText = "No sliders to get velocity from"; + useLastSliderButton.ContractedLabelText = default; + } + else + { + useLastSliderButton.Enabled.Value = useLastSliderButton.Expanded.Value && !useLastSliderVelocity; + useLastSliderButton.ExpandedLabelText = useLastSliderVelocity + ? "Using last slider's velocity" + : LocalisableString.Interpolate($@"Use last slider's velocity ({lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x)"); + useLastSliderButton.ContractedLabelText = $@"current {lastSlider.SliderVelocityMultiplier.ToLocalisableString("N2")}x"; + if (useLastSliderVelocity) + sliderVelocity.Value = lastSlider.SliderVelocityMultiplier; + } + } + } + + private Slider? getLastSlider() + { + return editorBeatmap + .HitObjects + .OfType() + .LastOrDefault(h => h.StartTime <= editorClock.CurrentTime); + } + + protected override void Dispose(bool isDisposing) + { + if (editorBeatmap.IsNotNull()) + { + editorBeatmap.HitObjectAdded -= invalidateSliderVelocitySourceObject; + editorBeatmap.HitObjectUpdated -= invalidateSliderVelocitySourceObject; + editorBeatmap.HitObjectRemoved -= invalidateSliderVelocitySourceObject; + } + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 4daf423b9d2b..a404c1bab517 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -233,6 +233,8 @@ public override IEnumerable GetModsFor(ModType type) } } + public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new OsuScoreMultiplierCalculator(); + public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetOsu }; public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(RulesetInfo, beatmap); diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreMultiplierCalculator.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..76b6a6718086 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreMultiplierCalculator.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Scoring +{ + public class OsuScoreMultiplierCalculator : ScoreMultiplierCalculator + { + static OsuScoreMultiplierCalculator() + { + #region Difficulty Reduction + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + Single(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value)); + Single(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value)); + + #endregion + + #region Difficulty Increase + + Single(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.06 : 1); + // Sudden Death + // Perfect + Single(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value)); + Single(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value)); + Single(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1); + // Traceable + Single(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1); + Single(hasMultiplier: blinds => blinds.UsesDefaultConfiguration ? 1.12 : 1); + // Strict Tracking + // Accuracy Challenge + + #endregion + + #region Conversion + + Single(hasMultiplier: 0.1); + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.96); + // Random + // Mirror + // Alternate + // Single Tap + + #endregion + + #region Automation + + // Autoplay + // Cinema + Single(hasMultiplier: 0.1); + Single(hasMultiplier: 0.1); + Single(hasMultiplier: 0.9); + + #endregion + + #region Fun + + // Transform + // Wiggle + // Spin In + // Grow + // Deflate + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + // Barrel Roll + // Approach Different + // Muted + // No Scope + Single(hasMultiplier: 0.5); + // Repel + Single(hasMultiplier: 0.5); + // Freeze Frame + // Bubbles + Single(hasMultiplier: 0.8); + // Depth + // Bloom + + #endregion + } + + private static double rateAdjustMultiplier(double speedChange) + { + // Round to the nearest multiple of 0.1. + double value = (int)(speedChange * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + if (speedChange >= 1) + return 1 + value / 5; + else + return 0.6 + value; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoScoreMultiplierTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoScoreMultiplierTest.cs new file mode 100644 index 000000000000..53f8c043ac92 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoScoreMultiplierTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Tests.Rulesets; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TaikoScoreMultiplierTest : RulesetScoreMultiplierTest + { + public TaikoScoreMultiplierTest() + : base(new TaikoRuleset()) + { + } + + [Test] + public void TestFlashlightOnNonDefaultSettings() + => TestModCombination([new TaikoModFlashlight { ComboBasedSize = { Value = false } }]); + + [Test] + public void TestHalfTimeSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new TaikoModHalfTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDaycoreSpeeds([Values(0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.99)] double speedChange) + => TestModCombination([new TaikoModDaycore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestDoubleTimeSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new TaikoModDoubleTime { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestNightcoreSpeeds([Values(1.01, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5, 1.55, 1.6, 1.65, 1.7, 1.75, 1.8, 1.85, 1.9, 1.95, 2)] double speedChange) + => TestModCombination([new TaikoModNightcore { SpeedChange = { Value = speedChange } }]); + + [Test] + public void TestMultiplicativeCombination() + => TestModCombination([new TaikoModHidden(), new TaikoModHardRock()]); + } +} diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreMultiplierCalculator.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..df5abc98326c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreMultiplierCalculator.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Mods; + +namespace osu.Game.Rulesets.Taiko.Scoring +{ + public class TaikoScoreMultiplierCalculator : ScoreMultiplierCalculator + { + static TaikoScoreMultiplierCalculator() + { + #region Difficulty Reduction + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + Single(hasMultiplier: halfTime => rateAdjustMultiplier(halfTime.SpeedChange.Value)); + Single(hasMultiplier: daycore => rateAdjustMultiplier(daycore.SpeedChange.Value)); + Single(hasMultiplier: 0.6); + + #endregion + + #region Difficulty Increase + + Single(hasMultiplier: hardRock => hardRock.UsesDefaultConfiguration ? 1.06 : 1); + // Sudden Death + // Perfect + Single(hasMultiplier: doubleTime => rateAdjustMultiplier(doubleTime.SpeedChange.Value)); + Single(hasMultiplier: nightcore => rateAdjustMultiplier(nightcore.SpeedChange.Value)); + Single(hasMultiplier: hidden => hidden.UsesDefaultConfiguration ? 1.06 : 1); + Single(hasMultiplier: flashlight => flashlight.UsesDefaultConfiguration ? 1.12 : 1); + // Accuracy Challenge + + #endregion + + #region Conversion + + // Random + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.96); + // Swap + // Single Tap + Single(hasMultiplier: 0.9); + + #endregion + + #region Automation + + // Autoplay + // Cinema + Single(hasMultiplier: 0.1); + + #endregion + + #region Fun + + Single(hasMultiplier: 0.5); + Single(hasMultiplier: 0.5); + // Muted + Single(hasMultiplier: 0.5); + + #endregion + + #region System + + // Score V2 + + #endregion + } + + private static double rateAdjustMultiplier(double speedChange) + { + // Round to the nearest multiple of 0.1. + double value = (int)(speedChange * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + if (speedChange >= 1) + return 1 + value / 5; + else + return 0.6 + value; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 1b33781889c4..6e8887d2e62f 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -188,6 +188,8 @@ public override IEnumerable GetModsFor(ModType type) } } + public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new TaikoScoreMultiplierCalculator(); + public override string Description => "osu!taiko"; public override string ShortName => SHORT_NAME; diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreMultiplierCalculatorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreMultiplierCalculatorTest.cs new file mode 100644 index 000000000000..80644a08c974 --- /dev/null +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreMultiplierCalculatorTest.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Tests.Rulesets.Scoring +{ + public class ScoreMultiplierCalculatorTest + { + [Test] + public void TestFlatMultiplier() + { + var calculator = new TestScoreMultiplierCalculator(); + + double multiplier = calculator.CalculateFor([new OsuModEasy()]); + + Assert.That(multiplier, Is.EqualTo(0.15)); + } + + [Test] + public void TestSettingDependentMultiplier() + { + var calculator = new TestScoreMultiplierCalculator(); + + double multiplier = calculator.CalculateFor([new OsuModDaycore { SpeedChange = { Value = 0.6 } }]); + + Assert.That(multiplier, Is.EqualTo(0.4)); + } + + [Test] + public void TestContextDependentMultiplier() + { + var calculator = new TestScoreMultiplierCalculator(); + + double multiplier; + + Assert.Multiple(() => + { + calculator.HardRockPenalty = false; + multiplier = calculator.CalculateFor([new OsuModHardRock()]); + Assert.That(multiplier, Is.EqualTo(1.4)); + + calculator.HardRockPenalty = true; + multiplier = calculator.CalculateFor([new OsuModHardRock()]); + Assert.That(multiplier, Is.EqualTo(1.2)); + }); + } + + [Test] + public void TestCombinationMultiplier() + { + var calculator = new TestScoreMultiplierCalculator(); + + double multiplier = calculator.CalculateFor([new OsuModEasy(), new OsuModDaycore()]); + + Assert.That(multiplier, Is.EqualTo(0.003)); + } + + [Test] + public void TestCombinationAndFlatMultipliers() + { + var calculator = new TestScoreMultiplierCalculator(); + + double multiplier = calculator.CalculateFor([new OsuModDaycore(), new OsuModHardRock(), new OsuModEasy()]); + + Assert.That(multiplier, Is.EqualTo(0.003 * 1.4)); + } + + private class TestScoreMultiplierCalculator : ScoreMultiplierCalculator + { + static TestScoreMultiplierCalculator() + { + Single(hasMultiplier: 0.15); + Single(hasMultiplier: daycore => (1 + daycore.SpeedChange.Value) / 4); + Single(hasMultiplier: (_, ctx) => ctx.HardRockPenalty ? 1.2 : 1.4); + Combination(hasMultiplier: (_, _) => 0.003); + } + + public bool HardRockPenalty { get; set; } + } + } +} diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 6da24e3571f4..a3b5a82b06f0 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -155,6 +155,8 @@ public static IEnumerable GetGlobalActionsFor(GlobalActionCategory new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark), new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark), new KeyBinding(new[] { InputKey.Control, InputKey.L }, GlobalAction.EditorDiscardUnsavedChanges), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.U }, GlobalAction.EditorSubmitBeatmap), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.O }, GlobalAction.EditorEditExternally), }; private static IEnumerable editorTestPlayKeyBindings => new[] @@ -528,6 +530,12 @@ public enum GlobalAction [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.NextSkin))] NextSkin, + + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.SubmitBeatmap))] + EditorSubmitBeatmap, + + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.EditExternally))] + EditorEditExternally } public enum GlobalActionCategory diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index bfca375af1ad..94b7b6e90f3f 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -210,6 +210,11 @@ public virtual LegacyMods ConvertToLegacyMods(Mod[] mods) public ModTouchDevice? GetTouchDeviceMod() => CreateMod(); + /// + /// Creates a relevant to this ruleset. + /// + public virtual ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new ScoreMultiplierCalculator(); + /// /// Create a transformer which adds lookups specific to a ruleset to skin sources. /// diff --git a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs new file mode 100644 index 000000000000..772f9d178b74 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// Calculates the multiplier to be applied to score with a given combination of mods. + /// + public class ScoreMultiplierCalculator + { + private static readonly List<(Type[] mods, Func multiplier)> combination_multipliers = []; + private static readonly Dictionary> single_multipliers_with_context = []; + private static readonly Dictionary> single_multipliers = []; + + /// + /// Defines a flat, setting-independent score multiplier for the given . + /// + public static void Single(double hasMultiplier) + where TMod : Mod + { + single_multipliers[typeof(TMod)] = _ => hasMultiplier; + } + + /// + /// Defines a setting-dependent score multiplier for the given . + /// + public static void Single(Func hasMultiplier) + where TMod : Mod + { + single_multipliers[typeof(TMod)] = mod => hasMultiplier.Invoke((TMod)mod); + } + + /// + /// Defines a setting-dependent score multiplier for the given . + /// The multiplier calculation is given additional context to calculate the multiplier via the type instance. + /// + public static void Single(Func hasMultiplier) + where TMod : Mod + where TContext : ScoreMultiplierCalculator + { + single_multipliers_with_context[typeof(TMod)] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context); + } + + /// + /// Defines a score multiplier specific to when both and mods are present. + /// + public static void Combination(Func hasMultiplier) + where T1 : Mod + where T2 : Mod + { + combination_multipliers.Add(([typeof(T1), typeof(T2)], mods => hasMultiplier((T1)mods[0], (T2)mods[1]))); + } + + /// + /// Calculates the multiplier to be applied to score with the given . + /// + public double CalculateFor(IEnumerable mods) + { + var allModsByType = mods.ToDictionary(m => m.GetType()); + + if (allModsByType.Count == 0) + return 1; + + var remainingModTypes = allModsByType.Keys.ToHashSet(); + + double result = 1; + + if (allModsByType.Count > 1) + { + foreach (var (combination, multiplier) in combination_multipliers) + { + if (remainingModTypes.IsSupersetOf(combination)) + { + var instances = combination.Select(t => allModsByType[t]).ToArray(); + result *= multiplier(instances); + remainingModTypes.ExceptWith(combination); + } + } + } + + foreach (var modType in remainingModTypes) + { + if (single_multipliers.TryGetValue(modType, out var multiplier)) + result *= multiplier(allModsByType[modType]); + else if (single_multipliers_with_context.TryGetValue(modType, out var multiplierWithContext)) + result *= multiplierWithContext(allModsByType[modType], this); + } + + return result; + } + } +} diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 072528cedb32..23f5dc82910c 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -832,6 +832,14 @@ public bool OnPressed(KeyBindingPressEvent e) case GlobalAction.EditorDiscardUnsavedChanges: DiscardUnsavedChanges(); return true; + + case GlobalAction.EditorEditExternally: + editExternally(); + return true; + + case GlobalAction.EditorSubmitBeatmap: + submitBeatmap(); + return true; } return false; @@ -1282,7 +1290,10 @@ private IEnumerable createFileMenuItems() if (RuntimeInfo.IsDesktop) { - var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally); + var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally) + { + Hotkey = new Hotkey(GlobalAction.EditorEditExternally) + }; saveRelatedMenuItems.Add(externalEdit); yield return externalEdit; } @@ -1293,7 +1304,10 @@ private IEnumerable createFileMenuItems() if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable) { - var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap); + var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap) + { + Hotkey = new Hotkey(GlobalAction.EditorSubmitBeatmap) + }; saveRelatedMenuItems.Add(upload); yield return upload; } @@ -1304,7 +1318,8 @@ private IEnumerable createFileMenuItems() yield return new EditorMenuItem(EditorStrings.OpenInfoPage, MenuItemType.Standard, () => (Game as OsuGame)?.OpenUrlExternally(editorBeatmap.BeatmapInfo.GetOnlineURL(api, editorBeatmap.BeatmapInfo.Ruleset))); yield return new EditorMenuItem(EditorStrings.OpenDiscussionPage, MenuItemType.Standard, - () => (Game as OsuGame)?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}")); + () => (Game as OsuGame)?.OpenUrlExternally( + $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}")); } yield return new OsuMenuItemSpacer(); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs index c67c5b5d7c24..ccfce6cab579 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/ScreenQueue.cs @@ -440,8 +440,13 @@ private void onMatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) => S loadRecentMatches(status.RecentMatches.OfType().ToArray()).FireAndForget(); }); + private int historyInsertOrder; + private async Task loadRecentMatches(RankedPlayRoomState[] matches) { + // matches initial API response. + const int max_panels = 50; + await userLookupCache.GetUsersAsync(matches.SelectMany(m => m.Users.Keys).ToArray()).ConfigureAwait(false); Scheduler.Add(() => @@ -464,6 +469,9 @@ private async Task loadRecentMatches(RankedPlayRoomState[] matches) resultPanelContainer.LayoutDuration = 400; resultPanelContainer.LayoutEasing = Easing.OutQuint; } + + while (resultPanelContainer.Count > max_panels) + resultPanelContainer.Children.First().RemoveAndDisposeImmediately(); }); } diff --git a/osu.Game/Tests/Rulesets/RulesetScoreMultiplierTest.cs b/osu.Game/Tests/Rulesets/RulesetScoreMultiplierTest.cs new file mode 100644 index 000000000000..fab314cdfffc --- /dev/null +++ b/osu.Game/Tests/Rulesets/RulesetScoreMultiplierTest.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Rulesets +{ + [TestFixture] + public abstract class RulesetScoreMultiplierTest + { + public Ruleset Ruleset { get; } + + protected RulesetScoreMultiplierTest(Ruleset ruleset) + { + Ruleset = ruleset; + } + + [Test] + public void TestDefaultMultiplierIsOne() + { + var calculator = Ruleset.CreateScoreMultiplierCalculator(); + Assert.That(calculator.CalculateFor([]), Is.EqualTo(1)); + } + + [Test] + public void TestMultipliersMatchForIndividualMods() + { + var mods = Ruleset.CreateAllMods(); + var calculator = Ruleset.CreateScoreMultiplierCalculator(); + + Assert.Multiple(() => + { + foreach (var mod in mods) + Assert.That(calculator.CalculateFor(mod.Yield()), Is.EqualTo(mod.ScoreMultiplier), message: $"Score multiplier not matching for mod {mod.Name}"); + }); + } + + protected void TestModCombination(IEnumerable mods) + { + var calculator = Ruleset.CreateScoreMultiplierCalculator(); + + double expected = 1; + foreach (var mod in mods) + expected *= mod.ScoreMultiplier; + + Assert.That(calculator.CalculateFor(mods), Is.EqualTo(expected)); + } + } +}