diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1132396608..65ac05261a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2022.1.0-eap10", + "version": "2022.1.1", "commands": [ "jb" ] @@ -27,4 +27,4 @@ ] } } -} +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 514acef525..729f2f266d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: uses: actions/cache@v3 with: path: ${{ github.workspace }}/inspectcode - key: inspectcode-${{ hashFiles('.config/dotnet-tools.json') }}-${{ hashFiles('.github/workflows/ci.yml' ) }} + key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', '.editorconfig', '.globalconfig') }} - name: Dotnet code style run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index cb922c5a58..bc285dbe11 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 5ecd9cc675..718ada1905 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 33ad0ac4f7..6b9c3f4d63 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 5ecd9cc675..718ada1905 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/osu.Android.props b/osu.Android.props index 97d9dbc380..98dc28d915 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,10 +52,10 @@ - + - + diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index eb9045d9ce..405f0a8006 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -4,8 +4,6 @@ using System; using System.IO; using System.Runtime.Versioning; -using System.Threading; -using System.Threading.Tasks; using osu.Desktop.LegacyIpc; using osu.Framework; using osu.Framework.Development; @@ -63,8 +61,6 @@ namespace osu.Desktop using (DesktopGameHost host = Host.GetSuitableDesktopHost(gameName, new HostOptions { BindIPC = true })) { - host.ExceptionThrown += handleException; - if (!host.IsPrimaryInstance) { if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args @@ -131,23 +127,5 @@ namespace osu.Desktop // tools.SetProcessAppUserModelId(); }); } - - private static int allowableExceptions = DebugUtils.IsDebugBuild ? 0 : 1; - - /// - /// Allow a maximum of one unhandled exception, per second of execution. - /// - /// - private static bool handleException(Exception arg) - { - bool continueExecution = Interlocked.Decrement(ref allowableExceptions) >= 0; - - Logger.Log($"Unhandled exception has been {(continueExecution ? $"allowed with {allowableExceptions} more allowable exceptions" : "denied")} ."); - - // restore the stock of allowable exceptions after a short delay. - Task.Delay(1000).ContinueWith(_ => Interlocked.Increment(ref allowableExceptions)); - - return continueExecution; - } } } diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index a4f309c6ac..a4f9e2671b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,7 +24,7 @@ - + diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 434c0e0367..36ffd3b5b6 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -8,7 +8,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index fc6d900567..b957ade952 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index ddad2adfea..d3b4b378c0 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 4ce29ab5c7..2c0d3fd937 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index a6b8eb8651..ce468d399b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index f9c13a8169..b7bfe14402 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -136,6 +136,37 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestAddFileToAsyncImportedBeatmap() + { + RunTestWithRealm((realm, storage) => + { + BeatmapSetInfo? detachedSet = null; + + using (var importer = new BeatmapModelManager(realm, storage)) + using (new RealmRulesetStore(realm, storage)) + { + Task.Run(async () => + { + Live? beatmapSet; + + using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) + // ReSharper disable once AccessToDisposedClosure + beatmapSet = await importer.Import(reader); + + Assert.NotNull(beatmapSet); + Debug.Assert(beatmapSet != null); + + // Intentionally detach on async thread as to not trigger a refresh on the main thread. + beatmapSet.PerformRead(s => detachedSet = s.Detach()); + }).WaitSafely(); + + Debug.Assert(detachedSet != null); + importer.AddFile(detachedSet, new MemoryStream(), "test"); + } + }); + } + [Test] public void TestImportBeatmapThenCleanup() { diff --git a/osu.Game.Tests/Mods/ModSettingsTest.cs b/osu.Game.Tests/Mods/ModSettingsTest.cs new file mode 100644 index 0000000000..b9ea1f2567 --- /dev/null +++ b/osu.Game.Tests/Mods/ModSettingsTest.cs @@ -0,0 +1,36 @@ +// 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.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Tests.Mods +{ + public class ModSettingsTest + { + [Test] + public void TestModSettingsUnboundWhenCopied() + { + var original = new OsuModDoubleTime(); + var copy = (OsuModDoubleTime)original.DeepClone(); + + original.SpeedChange.Value = 2; + + Assert.That(original.SpeedChange.Value, Is.EqualTo(2.0)); + Assert.That(copy.SpeedChange.Value, Is.EqualTo(1.5)); + } + + [Test] + public void TestMultiModSettingsUnboundWhenCopied() + { + var original = new MultiMod(new OsuModDoubleTime()); + var copy = (MultiMod)original.DeepClone(); + + ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2; + + Assert.That(((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value, Is.EqualTo(2.0)); + Assert.That(((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value, Is.EqualTo(1.5)); + } + } +} diff --git a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs new file mode 100644 index 0000000000..3992d9abe6 --- /dev/null +++ b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs @@ -0,0 +1,80 @@ +// 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 osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Mods +{ + public class TestCustomisableModRuleset : Ruleset + { + public static RulesetInfo CreateTestRulesetInfo() => new TestCustomisableModRuleset().RulesetInfo; + + public override IEnumerable GetModsFor(ModType type) + { + if (type == ModType.Conversion) + { + return new Mod[] + { + new TestModCustomisable1(), + new TestModCustomisable2() + }; + } + + return Array.Empty(); + } + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); + + public override string Description { get; } = "test"; + public override string ShortName { get; } = "tst"; + + public class TestModCustomisable1 : TestModCustomisable + { + public override string Name => "Customisable Mod 1"; + + public override string Acronym => "CM1"; + } + + public class TestModCustomisable2 : TestModCustomisable + { + public override string Name => "Customisable Mod 2"; + + public override string Acronym => "CM2"; + + public override bool RequiresConfiguration => true; + } + + public abstract class TestModCustomisable : Mod, IApplicableMod + { + public override double ScoreMultiplier => 1.0; + + public override string Description => "This is a customisable test mod."; + + public override ModType Type => ModType.Conversion; + + [SettingSource("Sample float", "Change something for a mod")] + public BindableFloat SliderBindable { get; } = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 7 + }; + + [SettingSource("Sample bool", "Clicking this changes a setting")] + public BindableBool TickBindable { get; } = new BindableBool(); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index 4012a672ed..7c05abc2cd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; @@ -66,6 +67,13 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestPopoverHasFocus() + { + clickDifficultyPiece(0); + velocityPopoverHasFocus(); + } + [Test] public void TestSingleSelection() { @@ -133,6 +141,15 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Click(MouseButton.Left); }); + private void velocityPopoverHasFocus() => AddUntilStep("velocity popover textbox focused", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + var textbox = slider?.ChildrenOfType().Single(); + + return textbox?.HasFocus == true; + }); + private void velocityPopoverHasSingleValue(double velocity) => AddUntilStep($"velocity popover has {velocity}", () => { var popover = this.ChildrenOfType().SingleOrDefault(); @@ -151,6 +168,7 @@ namespace osu.Game.Tests.Visual.Editing private void dismissPopover() { + AddStep("unfocus textbox", () => InputManager.Key(Key.Escape)); AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs index dca30a6fc0..4501eea88e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs @@ -57,6 +57,13 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestPopoverHasFocus() + { + clickSamplePiece(0); + samplePopoverHasFocus(); + } + [Test] public void TestSingleSelection() { @@ -173,14 +180,23 @@ namespace osu.Game.Tests.Visual.Editing samplePopoverHasSingleBank("normal"); } - private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} difficulty piece", () => + private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => { - var difficultyPiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); + var samplePiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); - InputManager.MoveMouseTo(difficultyPiece); + InputManager.MoveMouseTo(samplePiece); InputManager.Click(MouseButton.Left); }); + private void samplePopoverHasFocus() => AddUntilStep("sample popover textbox focused", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + var textbox = slider?.ChildrenOfType().Single(); + + return textbox?.HasFocus == true; + }); + private void samplePopoverHasSingleVolume(int volume) => AddUntilStep($"sample popover has volume {volume}", () => { var popover = this.ChildrenOfType().SingleOrDefault(); @@ -215,8 +231,9 @@ namespace osu.Game.Tests.Visual.Editing private void dismissPopover() { + AddStep("unfocus textbox", () => InputManager.Key(Key.Escape)); AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); - AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); + AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); } private void setVolumeViaPopover(int volume) => AddStep($"set volume {volume} via popover", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index 8df32c500e..81763564fa 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -24,7 +24,7 @@ using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Play; -using osu.Game.Tests.Visual.UserInterface; +using osu.Game.Tests.Mods; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay { new Drawable[] { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { Recorder = recorder = new TestReplayRecorder(new Score { @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Visual.Gameplay }, new Drawable[] { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { ReplayInputHandler = new TestFramedReplayInputHandler(replay) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs deleted file mode 100644 index f8fab784cc..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Play.HUD; -using osu.Game.Screens.Play.PlayerSettings; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneReplaySettingsOverlay : OsuTestScene - { - public TestSceneReplaySettingsOverlay() - { - ExampleContainer container; - - Add(new PlayerSettingsOverlay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - State = { Value = Visibility.Visible } - }); - - Add(container = new ExampleContainer()); - - AddStep(@"Add button", () => container.Add(new TriangleButton - { - RelativeSizeAxes = Axes.X, - Text = @"Button", - })); - - AddStep(@"Add checkbox", () => container.Add(new PlayerCheckbox - { - LabelText = "Checkbox", - })); - - AddStep(@"Add textbox", () => container.Add(new FocusedTextBox - { - RelativeSizeAxes = Axes.X, - Height = 30, - PlaceholderText = "Textbox", - HoldFocus = false, - })); - } - - private class ExampleContainer : PlayerSettingsGroup - { - public ExampleContainer() - : base("example") - { - } - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 4ec46036f6..f8748922cf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -27,8 +27,8 @@ using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Tests.Mods; using osu.Game.Tests.Visual.Spectator; -using osu.Game.Tests.Visual.UserInterface; using osuTK; using osuTK.Graphics; @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Gameplay { new Drawable[] { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { Recorder = recorder = new TestReplayRecorder { @@ -107,7 +107,7 @@ namespace osu.Game.Tests.Visual.Gameplay }, new Drawable[] { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { Clock = new FramedClock(manualClock), ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index 26a0301d8a..f40c31b07f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -1,21 +1,105 @@ // 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 System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneFreeModSelectOverlay : MultiplayerTestScene { - [SetUp] - public new void Setup() => Schedule(() => + private FreeModSelectOverlay freeModSelectOverlay; + private readonly Bindable>> availableMods = new Bindable>>(); + + [BackgroundDependencyLoader] + private void load(OsuGameBase osuGameBase) { - Child = new FreeModSelectOverlay + availableMods.BindTo(osuGameBase.AvailableMods); + } + + [Test] + public void TestFreeModSelect() + { + createFreeModSelect(); + + AddUntilStep("all visible mods are playable", + () => this.ChildrenOfType() + .Where(panel => panel.IsPresent) + .All(panel => panel.Mod.HasImplementation && panel.Mod.UserPlayable)); + + AddToggleStep("toggle visibility", visible => + { + if (freeModSelectOverlay != null) + freeModSelectOverlay.State.Value = visible ? Visibility.Visible : Visibility.Hidden; + }); + } + + [Test] + public void TestCustomisationNotAvailable() + { + createFreeModSelect(); + + AddStep("select difficulty adjust", () => freeModSelectOverlay.SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddWaitStep("wait some", 3); + AddAssert("customisation area not expanded", () => this.ChildrenOfType().Single().Height == 0); + } + + [Test] + public void TestSelectDeselectAll() + { + createFreeModSelect(); + + AddStep("click select all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("all mods selected", assertAllAvailableModsSelected); + + AddStep("click deselect all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("all mods deselected", () => !freeModSelectOverlay.SelectedMods.Value.Any()); + } + + private void createFreeModSelect() + { + AddStep("create free mod select screen", () => Child = freeModSelectOverlay = new FreeModSelectOverlay { State = { Value = Visibility.Visible } - }; - }); + }); + AddUntilStep("all column content loaded", + () => freeModSelectOverlay.ChildrenOfType().Any() + && freeModSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); + } + + private bool assertAllAvailableModsSelected() + { + var allAvailableMods = availableMods.Value + .SelectMany(pair => pair.Value) + .Where(mod => mod.UserPlayable && mod.HasImplementation) + .ToList(); + + foreach (var availableMod in allAvailableMods) + { + if (freeModSelectOverlay.SelectedMods.Value.All(selectedMod => selectedMod.GetType() != availableMod.GetType())) + return false; + } + + return true; + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectScreen.cs deleted file mode 100644 index 4eb14542ba..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectScreen.cs +++ /dev/null @@ -1,105 +0,0 @@ -// 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 System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; -using osu.Framework.Testing; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.OnlinePlay; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneFreeModSelectScreen : MultiplayerTestScene - { - private FreeModSelectScreen freeModSelectScreen; - private readonly Bindable>> availableMods = new Bindable>>(); - - [BackgroundDependencyLoader] - private void load(OsuGameBase osuGameBase) - { - availableMods.BindTo(osuGameBase.AvailableMods); - } - - [Test] - public void TestFreeModSelect() - { - createFreeModSelect(); - - AddUntilStep("all visible mods are playable", - () => this.ChildrenOfType() - .Where(panel => panel.IsPresent) - .All(panel => panel.Mod.HasImplementation && panel.Mod.UserPlayable)); - - AddToggleStep("toggle visibility", visible => - { - if (freeModSelectScreen != null) - freeModSelectScreen.State.Value = visible ? Visibility.Visible : Visibility.Hidden; - }); - } - - [Test] - public void TestCustomisationNotAvailable() - { - createFreeModSelect(); - - AddStep("select difficulty adjust", () => freeModSelectScreen.SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); - AddWaitStep("wait some", 3); - AddAssert("customisation area not expanded", () => this.ChildrenOfType().Single().Height == 0); - } - - [Test] - public void TestSelectDeselectAll() - { - createFreeModSelect(); - - AddStep("click select all button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("all mods selected", assertAllAvailableModsSelected); - - AddStep("click deselect all button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Last()); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("all mods deselected", () => !freeModSelectScreen.SelectedMods.Value.Any()); - } - - private void createFreeModSelect() - { - AddStep("create free mod select screen", () => Child = freeModSelectScreen = new FreeModSelectScreen - { - State = { Value = Visibility.Visible } - }); - AddUntilStep("all column content loaded", - () => freeModSelectScreen.ChildrenOfType().Any() - && freeModSelectScreen.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); - } - - private bool assertAllAvailableModsSelected() - { - var allAvailableMods = availableMods.Value - .SelectMany(pair => pair.Value) - .Where(mod => mod.UserPlayable && mod.HasImplementation) - .ToList(); - - foreach (var availableMod in allAvailableMods) - { - if (freeModSelectScreen.SelectedMods.Value.All(selectedMod => selectedMod.GetType() != availableMod.GetType())) - return false; - } - - return true; - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 97cb08e3c1..8e45d99eae 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -627,7 +627,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 061fe5715b..eacd80925d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -132,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertHasFreeModButton(Type type, bool hasButton = true) { AddAssert($"{type.ReadableName()} {(hasButton ? "displayed" : "not displayed")} in freemod overlay", - () => this.ChildrenOfType() + () => this.ChildrenOfType() .Single() .ChildrenOfType() .Where(panel => !panel.Filtered.Value) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 6173580f0b..ca79fa9cb8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -172,7 +172,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); AddUntilStep("mod select contains only double time mod", - () => this.ChildrenOfType() + () => this.ChildrenOfType() .SingleOrDefault()? .ChildrenOfType() .SingleOrDefault(panel => !panel.Filtered.Value)?.Mod is OsuModDoubleTime); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index 0f8337deb6..e4871f611e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -23,7 +23,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; -using osu.Game.Utils; namespace osu.Game.Tests.Visual.Navigation { @@ -33,7 +32,6 @@ namespace osu.Game.Tests.Visual.Navigation private IReadOnlyList requiredGameDependencies => new[] { typeof(OsuGame), - typeof(SentryLogger), typeof(OsuLogo), typeof(IdleTracker), typeof(OnScreenDisplay), diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 9674ef7ae1..a3e0caedb9 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -568,7 +568,7 @@ namespace osu.Game.Tests.Visual.Navigation public class TestPlaySongSelect : PlaySongSelect { - public ModSelectScreen ModSelectOverlay => ModSelect; + public ModSelectOverlay ModSelectOverlay => ModSelect; public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index a1d51683e4..2a5fc050d3 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -103,7 +103,7 @@ namespace osu.Game.Tests.Visual.Ranking score.Accuracy = accuracy; score.Rank = rank; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen(score))); + loadResultsScreen(() => screen = createResultsScreen(score)); AddUntilStep("wait for loaded", () => screen.IsLoaded); AddAssert("retry overlay present", () => screen.RetryOverlay != null); } @@ -113,7 +113,7 @@ namespace osu.Game.Tests.Visual.Ranking { UnrankedSoloResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createUnrankedSoloResultsScreen())); + loadResultsScreen(() => screen = createUnrankedSoloResultsScreen()); AddUntilStep("wait for loaded", () => screen.IsLoaded); AddAssert("retry overlay present", () => screen.RetryOverlay != null); } @@ -123,7 +123,7 @@ namespace osu.Game.Tests.Visual.Ranking { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + loadResultsScreen(() => screen = createResultsScreen()); AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => @@ -162,7 +162,7 @@ namespace osu.Game.Tests.Visual.Ranking { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + loadResultsScreen(() => screen = createResultsScreen()); AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => @@ -201,7 +201,7 @@ namespace osu.Game.Tests.Visual.Ranking { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + loadResultsScreen(() => screen = createResultsScreen()); AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); ScorePanel expandedPanel = null; @@ -231,7 +231,7 @@ namespace osu.Game.Tests.Visual.Ranking var tcs = new TaskCompletionSource(); - AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), tcs.Task))); + loadResultsScreen(() => screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), tcs.Task)); AddUntilStep("wait for loaded", () => screen.IsLoaded); @@ -255,7 +255,7 @@ namespace osu.Game.Tests.Visual.Ranking { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + loadResultsScreen(() => screen = createResultsScreen()); AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddAssert("download button is disabled", () => !screen.ChildrenOfType().Last().Enabled.Value); @@ -276,7 +276,7 @@ namespace osu.Game.Tests.Visual.Ranking var ruleset = new RulesetWithNoPerformanceCalculator(); var score = TestResources.CreateTestScoreInfo(ruleset.RulesetInfo); - AddStep("load results", () => Child = new TestResultsContainer(createResultsScreen(score))); + loadResultsScreen(() => createResultsScreen(score)); AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddAssert("PP displayed as 0", () => @@ -287,6 +287,22 @@ namespace osu.Game.Tests.Visual.Ranking }); } + private void loadResultsScreen(Func createResults) + { + ResultsScreen results = null; + + AddStep("load results", () => Child = new TestResultsContainer(results = createResults())); + + // expanded panel should be centered the moment results screen is loaded + // but can potentially be scrolled away on certain specific load scenarios. + // see: https://github.com/ppy/osu/issues/18226 + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, results.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? TestResources.CreateTestScoreInfo()); private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo()); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 083e24be7b..aad7f6b301 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1008,7 +1008,7 @@ namespace osu.Game.Tests.Visual.SongSelect public WorkingBeatmap CurrentBeatmap => Beatmap.Value; public IWorkingBeatmap CurrentBeatmapDetailsBeatmap => BeatmapDetails.Beatmap; public new BeatmapCarousel Carousel => base.Carousel; - public new ModSelectScreen ModSelect => base.ModSelect; + public new ModSelectOverlay ModSelect => base.ModSelect; public new void PresentScore(ScoreInfo score) => base.PresentScore(score); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index 39298f56ba..48b5690243 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using Moq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; @@ -25,9 +27,9 @@ namespace osu.Game.Tests.Visual.UserInterface { private FirstRunSetupOverlay overlay; - private readonly Mock performer = new Mock(); + private readonly Mock performer = new Mock(); - private readonly Mock notificationOverlay = new Mock(); + private readonly Mock notificationOverlay = new Mock(); private Notification lastNotification; @@ -37,8 +39,8 @@ namespace osu.Game.Tests.Visual.UserInterface private void load() { Dependencies.Cache(LocalConfig = new OsuConfigManager(LocalStorage)); - Dependencies.CacheAs(performer.Object); - Dependencies.CacheAs(notificationOverlay.Object); + Dependencies.CacheAs(performer.Object); + Dependencies.CacheAs(notificationOverlay.Object); } [SetUpSteps] @@ -72,7 +74,6 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - [Ignore("Enable when first run setup is being displayed on first run.")] public void TestDoesntOpenOnSecondRun() { AddStep("set first run", () => LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, true)); @@ -196,5 +197,31 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("overlay shown", () => overlay.State.Value == Visibility.Visible); AddAssert("is resumed", () => overlay.CurrentScreen is ScreenBeatmaps); } + + // interface mocks break hot reload, mocking this stub implementation instead works around it. + // see: https://github.com/moq/moq4/issues/1252 + [UsedImplicitly] + public class TestNotificationOverlay : INotificationOverlay + { + public virtual void Post(Notification notification) + { + } + + public virtual void Hide() + { + } + + public virtual IBindable UnreadCount => null; + } + + // interface mocks break hot reload, mocking this stub implementation instead works around it. + // see: https://github.com/moq/moq4/issues/1252 + [UsedImplicitly] + public class TestPerformerFromScreenRunner : IPerformFromScreenRunner + { + public virtual void PerformFromScreen(Action action, IEnumerable validScreens = null) + { + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs deleted file mode 100644 index fdc21d80ff..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public class TestSceneModButton : OsuTestScene - { - public TestSceneModButton() - { - Children = new Drawable[] - { - new ModButton(new MultiMod(new TestMod1(), new TestMod2(), new TestMod3(), new TestMod4())) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - } - }; - } - - private class TestMod1 : TestMod - { - public override string Name => "Test mod 1"; - - public override string Acronym => "M1"; - } - - private class TestMod2 : TestMod - { - public override string Name => "Test mod 2"; - - public override string Acronym => "M2"; - - public override IconUsage? Icon => FontAwesome.Solid.Exclamation; - } - - private class TestMod3 : TestMod - { - public override string Name => "Test mod 3"; - - public override string Acronym => "M3"; - - public override IconUsage? Icon => FontAwesome.Solid.ArrowRight; - } - - private class TestMod4 : TestMod - { - public override string Name => "Test mod 4"; - - public override string Acronym => "M4"; - } - - private abstract class TestMod : Mod, IApplicableMod - { - public override double ScoreMultiplier => 1.0; - - public override string Description => "This is a test mod."; - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index b429619044..fc543d9db7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -10,45 +10,209 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Play.HUD; using osuTK; -using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - [Description("mod select and icon display")] - public class TestSceneModSelectOverlay : OsuTestScene + [TestFixture] + public class TestSceneModSelectOverlay : OsuManualInputManagerTestScene { - private RulesetStore rulesets; - private ModDisplay modDisplay; - private TestModSelectOverlay modSelect; + [Resolved] + private RulesetStore rulesetStore { get; set; } - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - this.rulesets = rulesets; - } - - [SetUp] - public void SetUp() => Schedule(() => - { - SelectedMods.Value = Array.Empty(); - createDisplay(() => new TestModSelectOverlay()); - }); + private UserModSelectOverlay modSelectOverlay; [SetUpSteps] public void SetUpSteps() { - AddStep("show", () => modSelect.Show()); + AddStep("clear contents", Clear); + AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); + AddStep("reset mods", () => SelectedMods.SetDefault()); + } + + private void createScreen() + { + AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods } + }); + waitForColumnLoad(); + } + + [Test] + public void TestStateChange() + { + createScreen(); + AddStep("toggle state", () => modSelectOverlay.ToggleVisibility()); + } + + [Test] + public void TestPreexistingSelection() + { + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); + createScreen(); + AddUntilStep("two panels active", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + AddAssert("mod multiplier correct", () => + { + double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); + return Precision.AlmostEquals(multiplier, modSelectOverlay.ChildrenOfType().Single().Current.Value); + }); + assertCustomisationToggleState(disabled: false, active: false); + AddAssert("setting items created", () => modSelectOverlay.ChildrenOfType().Any()); + } + + [Test] + public void TestExternalSelection() + { + createScreen(); + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); + AddUntilStep("two panels active", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + AddAssert("mod multiplier correct", () => + { + double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); + return Precision.AlmostEquals(multiplier, modSelectOverlay.ChildrenOfType().Single().Current.Value); + }); + assertCustomisationToggleState(disabled: false, active: false); + AddAssert("setting items created", () => modSelectOverlay.ChildrenOfType().Any()); + } + + [Test] + public void TestRulesetChange() + { + createScreen(); + changeRuleset(0); + changeRuleset(1); + changeRuleset(2); + changeRuleset(3); + } + + [Test] + public void TestIncompatibilityToggling() + { + createScreen(); + changeRuleset(0); + + AddStep("activate DT", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); + AddAssert("DT active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModDoubleTime)); + AddAssert("DT panel active", () => getPanelForMod(typeof(OsuModDoubleTime)).Active.Value); + + AddStep("activate NC", () => getPanelForMod(typeof(OsuModNightcore)).TriggerClick()); + AddAssert("only NC active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModNightcore)); + AddAssert("DT panel not active", () => !getPanelForMod(typeof(OsuModDoubleTime)).Active.Value); + AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); + + AddStep("activate HR", () => getPanelForMod(typeof(OsuModHardRock)).TriggerClick()); + AddAssert("NC+HR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) + && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModHardRock))); + AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); + AddAssert("HR panel active", () => getPanelForMod(typeof(OsuModHardRock)).Active.Value); + + AddStep("activate MR", () => getPanelForMod(typeof(OsuModMirror)).TriggerClick()); + AddAssert("NC+MR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) + && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModMirror))); + AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); + AddAssert("HR panel not active", () => !getPanelForMod(typeof(OsuModHardRock)).Active.Value); + AddAssert("MR panel active", () => getPanelForMod(typeof(OsuModMirror)).Active.Value); + } + + [Test] + public void TestDimmedState() + { + createScreen(); + changeRuleset(0); + + AddUntilStep("any column dimmed", () => this.ChildrenOfType().Any(column => !column.Active.Value)); + + ModColumn lastColumn = null; + + AddAssert("last column dimmed", () => !this.ChildrenOfType().Last().Active.Value); + AddStep("request scroll to last column", () => + { + var lastDimContainer = this.ChildrenOfType().Last(); + lastColumn = lastDimContainer.Column; + lastDimContainer.RequestScroll?.Invoke(lastDimContainer); + }); + AddUntilStep("column undimmed", () => lastColumn.Active.Value); + + AddStep("click panel", () => + { + InputManager.MoveMouseTo(lastColumn.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("panel selected", () => lastColumn.ChildrenOfType().First().Active.Value); + } + + [Test] + public void TestCustomisationToggleState() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("dismiss mod customisation via toggle", () => + { + InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("dismiss mod customisation via keyboard", () => InputManager.Key(Key.Escape)); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray()); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); + assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. + } + + [Test] + public void TestDismissCustomisationViaDimmedArea() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("move mouse to dimmed area", () => + { + InputManager.MoveMouseTo(new Vector2( + modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.X, + (modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.Y + modSelectOverlay.ScreenSpaceDrawQuad.BottomLeft.Y) / 2)); + }); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("move mouse to first mod panel", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().First())); + AddAssert("first mod panel is hovered", () => modSelectOverlay.ChildrenOfType().First().IsHovered); } /// @@ -58,10 +222,12 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestSettingsNotCrossPolluting() { Bindable> selectedMods2 = null; + ModSelectOverlay modSelectOverlay2 = null; + createScreen(); AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); - AddStep("set setting", () => modSelect.ChildrenOfType>().First().Current.Value = 8); + AddStep("set setting", () => modSelectOverlay.ChildrenOfType>().First().Current.Value = 8); AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); @@ -69,7 +235,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create second overlay", () => { - Add(modSelect = new TestModSelectOverlay().With(d => + Add(modSelectOverlay2 = new UserModSelectOverlay().With(d => { d.Origin = Anchor.TopCentre; d.Anchor = Anchor.TopCentre; @@ -77,7 +243,7 @@ namespace osu.Game.Tests.Visual.UserInterface })); }); - AddStep("show", () => modSelect.Show()); + AddStep("show", () => modSelectOverlay2.Show()); AddAssert("ensure first is unchanged", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); AddAssert("ensure second is default", () => selectedMods2.Value.OfType().Single().CircleSize.Value == null); @@ -88,83 +254,51 @@ namespace osu.Game.Tests.Visual.UserInterface { var osuModDoubleTime = new OsuModDoubleTime { SpeedChange = { Value = 1.2 } }; + createScreen(); changeRuleset(0); AddStep("set dt mod with custom rate", () => { SelectedMods.Value = new[] { osuModDoubleTime }; }); AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2); - AddStep("deselect", () => modSelect.DeselectAllButton.TriggerClick()); + AddStep("deselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0); - AddStep("reselect", () => modSelect.GetModButton(osuModDoubleTime).TriggerClick()); + AddStep("reselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true); } [Test] public void TestAnimationFlushOnClose() { + createScreen(); changeRuleset(0); AddStep("Select all fun mods", () => { - modSelect.ModSectionsContainer - .Single(c => c.ModType == ModType.DifficultyIncrease) - .SelectAll(); + modSelectOverlay.ChildrenOfType() + .Single(c => c.ModType == ModType.DifficultyIncrease) + .SelectAll(); }); - AddUntilStep("many mods selected", () => modDisplay.Current.Value.Count >= 5); + AddUntilStep("many mods selected", () => SelectedMods.Value.Count >= 5); AddStep("trigger deselect and close overlay", () => { - modSelect.ModSectionsContainer - .Single(c => c.ModType == ModType.DifficultyIncrease) - .DeselectAll(); + modSelectOverlay.ChildrenOfType() + .Single(c => c.ModType == ModType.DifficultyIncrease) + .DeselectAll(); - modSelect.Hide(); + modSelectOverlay.Hide(); }); - AddAssert("all mods deselected", () => modDisplay.Current.Value.Count == 0); - } - - [Test] - public void TestOsuMods() - { - changeRuleset(0); - - var osu = new OsuRuleset(); - - var easierMods = osu.GetModsFor(ModType.DifficultyReduction); - var harderMods = osu.GetModsFor(ModType.DifficultyIncrease); - - var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); - - var doubleTimeMod = harderMods.OfType().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime)); - - var easy = easierMods.FirstOrDefault(m => m is OsuModEasy); - var hardRock = harderMods.FirstOrDefault(m => m is OsuModHardRock); - - testSingleMod(noFailMod); - testMultiMod(doubleTimeMod); - testIncompatibleMods(easy, hardRock); - testDeselectAll(easierMods.Where(m => !(m is MultiMod))); - } - - [Test] - public void TestManiaMods() - { - changeRuleset(3); - - var mania = new ManiaRuleset(); - - testModsWithSameBaseType( - mania.CreateMod(), - mania.CreateMod()); + AddAssert("all mods deselected", () => SelectedMods.Value.Count == 0); } [Test] public void TestRulesetChanges() { + createScreen(); changeRuleset(0); var noFailMod = new OsuRuleset().GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); @@ -173,42 +307,42 @@ namespace osu.Game.Tests.Visual.UserInterface changeRuleset(0); - AddAssert("ensure mods still selected", () => modDisplay.Current.Value.SingleOrDefault(m => m is OsuModNoFail) != null); + AddAssert("ensure mods still selected", () => SelectedMods.Value.SingleOrDefault(m => m is OsuModNoFail) != null); changeRuleset(3); - AddAssert("ensure mods not selected", () => modDisplay.Current.Value.Count == 0); + AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); changeRuleset(0); - AddAssert("ensure mods not selected", () => modDisplay.Current.Value.Count == 0); + AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); } [Test] public void TestExternallySetCustomizedMod() { + createScreen(); changeRuleset(0); AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); AddAssert("ensure button is selected and customized accordingly", () => { - var button = modSelect.GetModButton(SelectedMods.Value.Single()); - return ((OsuModDoubleTime)button.SelectedMod).SpeedChange.Value == 1.01; + var button = getPanelForMod(SelectedMods.Value.Single().GetType()); + return ((OsuModDoubleTime)button.Mod).SpeedChange.Value == 1.01; }); } [Test] public void TestSettingsAreRetainedOnReload() { + createScreen(); changeRuleset(0); AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); - AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); - AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); - + createScreen(); AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); } @@ -218,236 +352,155 @@ namespace osu.Game.Tests.Visual.UserInterface Mod external = new OsuModDoubleTime(); Mod overlayButtonMod = null; + createScreen(); changeRuleset(0); AddStep("set mod externally", () => { SelectedMods.Value = new[] { external }; }); AddAssert("ensure button is selected", () => { - var button = modSelect.GetModButton(SelectedMods.Value.Single()); - overlayButtonMod = button.SelectedMod; - return overlayButtonMod.GetType() == external.GetType(); + var button = getPanelForMod(SelectedMods.Value.Single().GetType()); + overlayButtonMod = button.Mod; + return button.Active.Value; }); // Right now, when an external change occurs, the ModSelectOverlay will replace the global instance with its own AddAssert("mod instance doesn't match", () => external != overlayButtonMod); AddAssert("one mod present in global selected", () => SelectedMods.Value.Count == 1); - AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Contains(overlayButtonMod)); - AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Contains(external)); - } - - [Test] - public void TestNonStacked() - { - changeRuleset(0); - - AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay())); - - AddStep("show", () => modSelect.Show()); - - AddAssert("ensure all buttons are spread out", () => modSelect.ChildrenOfType().All(m => m.Mods.Length <= 1)); + AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Any(mod => ReferenceEquals(mod, overlayButtonMod))); + AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Any(mod => ReferenceEquals(mod, external))); } [Test] public void TestChangeIsValidChangesButtonVisibility() { + createScreen(); changeRuleset(0); - AddAssert("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); + AddAssert("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); - AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime)); - AddUntilStep("double time not visible", () => modSelect.ChildrenOfType().All(b => !b.Mods.Any(m => m is OsuModDoubleTime))); - AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + AddStep("make double time invalid", () => modSelectOverlay.IsValidMod = m => !(m is OsuModDoubleTime)); + AddUntilStep("double time not visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => panel.Filtered.Value)); + AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); - AddStep("make double time valid again", () => modSelect.IsValidMod = m => true); - AddUntilStep("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime))); - AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore))); + AddStep("make double time valid again", () => modSelectOverlay.IsValidMod = m => true); + AddUntilStep("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); + AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(b => b.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); } [Test] public void TestChangeIsValidPreservesSelection() { + createScreen(); changeRuleset(0); AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); - AddAssert("DT + HD selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + AddAssert("DT + HD selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - AddStep("make NF invalid", () => modSelect.IsValidMod = m => !(m is ModNoFail)); - AddAssert("DT + HD still selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); + AddStep("make NF invalid", () => modSelectOverlay.IsValidMod = m => !(m is ModNoFail)); + AddAssert("DT + HD still selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); } [Test] public void TestUnimplementedModIsUnselectable() { var testRuleset = new TestUnimplementedModOsuRuleset(); - changeTestRuleset(testRuleset.RulesetInfo); - var conversionMods = testRuleset.GetModsFor(ModType.Conversion); + createScreen(); - var unimplementedMod = conversionMods.FirstOrDefault(m => m is TestUnimplementedMod); + AddStep("set ruleset", () => Ruleset.Value = testRuleset.RulesetInfo); + waitForColumnLoad(); - testUnimplementedMod(unimplementedMod); + AddAssert("unimplemented mod panel is filtered", () => getPanelForMod(typeof(TestUnimplementedMod)).Filtered.Value); } - private void testSingleMod(Mod mod) + [Test] + public void TestDeselectAllViaButton() { - selectNext(mod); - checkSelected(mod); + createScreen(); + changeRuleset(0); - selectPrevious(mod); - checkNotSelected(mod); + AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); + AddAssert("DT + HD selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - selectNext(mod); - selectNext(mod); - checkNotSelected(mod); - - selectPrevious(mod); - selectPrevious(mod); - checkNotSelected(mod); - } - - private void testMultiMod(MultiMod multiMod) - { - foreach (var mod in multiMod.Mods) + AddStep("click deselect all button", () => { - selectNext(mod); - checkSelected(mod); - } - - for (int index = multiMod.Mods.Length - 1; index >= 0; index--) - selectPrevious(multiMod.Mods[index]); - - foreach (var mod in multiMod.Mods) - checkNotSelected(mod); - } - - private void testUnimplementedMod(Mod mod) - { - selectNext(mod); - checkNotSelected(mod); - } - - private void testIncompatibleMods(Mod modA, Mod modB) - { - selectNext(modA); - checkSelected(modA); - checkNotSelected(modB); - - selectNext(modB); - checkSelected(modB); - checkNotSelected(modA); - - selectPrevious(modB); - checkNotSelected(modA); - checkNotSelected(modB); - } - - private void testDeselectAll(IEnumerable mods) - { - foreach (var mod in mods) - selectNext(mod); - - AddAssert("check for any selection", () => modSelect.SelectedMods.Value.Any()); - AddStep("deselect all", () => modSelect.DeselectAllButton.Action.Invoke()); - AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any()); - } - - private void testModsWithSameBaseType(Mod modA, Mod modB) - { - selectNext(modA); - checkSelected(modA); - selectNext(modB); - checkSelected(modB); - - // Backwards - selectPrevious(modA); - checkSelected(modA); - } - - private void selectNext(Mod mod) => AddStep($"left click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(1)); - - private void selectPrevious(Mod mod) => AddStep($"right click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(-1)); - - private void checkSelected(Mod mod) - { - AddAssert($"check {mod.Name} is selected", () => - { - var button = modSelect.GetModButton(mod); - return modSelect.SelectedMods.Value.SingleOrDefault(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); }); + AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); } - private void changeRuleset(int? onlineId) + [Test] + public void TestCloseViaBackButton() { - AddStep($"change ruleset to {(onlineId?.ToString() ?? "none")}", () => { Ruleset.Value = rulesets.AvailableRulesets.FirstOrDefault(r => r.OnlineID == onlineId); }); - waitForLoad(); - } + createScreen(); + changeRuleset(0); - private void changeTestRuleset(RulesetInfo rulesetInfo) - { - AddStep($"change ruleset to {rulesetInfo.Name}", () => { Ruleset.Value = rulesetInfo; }); - waitForLoad(); - } + AddStep("select difficulty adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: true); + AddAssert("back button disabled", () => !this.ChildrenOfType().First().Enabled.Value); - private void waitForLoad() => - AddUntilStep("wait for icons to load", () => modSelect.AllLoaded); - - private void checkNotSelected(Mod mod) - { - AddAssert($"check {mod.Name} is not selected", () => + AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); + AddStep("click back button", () => { - var button = modSelect.GetModButton(mod); - return modSelect.SelectedMods.Value.All(m => m.GetType() != mod.GetType()) && button.SelectedMod?.GetType() != mod.GetType(); + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); }); + AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); } - private void createDisplay(Func createOverlayFunc) + [Test] + public void TestColumnHiding() { - Children = new Drawable[] + AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay { - modSelect = createOverlayFunc().With(d => - { - d.Origin = Anchor.BottomCentre; - d.Anchor = Anchor.BottomCentre; - d.SelectedMods.BindTarget = SelectedMods; - }), - modDisplay = new ModDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Position = new Vector2(-5, 25), - Current = { BindTarget = modSelect.SelectedMods } - } - }; + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods }, + IsValidMod = mod => mod.Type == ModType.DifficultyIncrease || mod.Type == ModType.Conversion + }); + waitForColumnLoad(); + changeRuleset(0); + + AddAssert("two columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 2); + + AddStep("unset filter", () => modSelectOverlay.IsValidMod = _ => true); + AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); + + AddStep("filter out everything", () => modSelectOverlay.IsValidMod = _ => false); + AddAssert("no columns visible", () => this.ChildrenOfType().All(col => !col.IsPresent)); + + AddStep("hide", () => modSelectOverlay.Hide()); + AddStep("set filter for 3 columns", () => modSelectOverlay.IsValidMod = mod => mod.Type == ModType.DifficultyReduction + || mod.Type == ModType.Automation + || mod.Type == ModType.Conversion); + + AddStep("show", () => modSelectOverlay.Show()); + AddUntilStep("3 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 3); } - private class TestModSelectOverlay : UserModSelectOverlay + private void waitForColumnLoad() => AddUntilStep("all column content loaded", + () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); + + private void changeRuleset(int id) { - public new Bindable> SelectedMods => base.SelectedMods; - - public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); - - public new FillFlowContainer ModSectionsContainer => - base.ModSectionsContainer; - - public ModButton GetModButton(Mod mod) - { - var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type); - return section.ButtonsContainer.OfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); - } - - public new TriangleButton DeselectAllButton => base.DeselectAllButton; - - public new Color4 LowMultiplierColour => base.LowMultiplierColour; - public new Color4 HighMultiplierColour => base.HighMultiplierColour; + AddStep($"set ruleset to {id}", () => Ruleset.Value = rulesetStore.GetRuleset(id)); + waitForColumnLoad(); } - private class TestNonStackedModSelectOverlay : TestModSelectOverlay + private void assertCustomisationToggleState(bool disabled, bool active) { - protected override bool Stacked => false; + ShearedToggleButton getToggle() => modSelectOverlay.ChildrenOfType().Single(); + + AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled); + AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active); } + private ModPanel getPanelForMod(Type modType) + => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); + private class TestUnimplementedMod : Mod { public override string Name => "Unimplemented mod"; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs deleted file mode 100644 index fa7758df59..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs +++ /dev/null @@ -1,525 +0,0 @@ -// 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 NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Mods; -using osu.Game.Overlays.Settings; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Mods; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public class TestSceneModSelectScreen : OsuManualInputManagerTestScene - { - [Resolved] - private RulesetStore rulesetStore { get; set; } - - private UserModSelectScreen modSelectScreen; - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("clear contents", Clear); - AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); - AddStep("reset mods", () => SelectedMods.SetDefault()); - } - - private void createScreen() - { - AddStep("create screen", () => Child = modSelectScreen = new UserModSelectScreen - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - SelectedMods = { BindTarget = SelectedMods } - }); - waitForColumnLoad(); - } - - [Test] - public void TestStateChange() - { - createScreen(); - AddStep("toggle state", () => modSelectScreen.ToggleVisibility()); - } - - [Test] - public void TestPreexistingSelection() - { - AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); - createScreen(); - AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - AddAssert("mod multiplier correct", () => - { - double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); - return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); - }); - assertCustomisationToggleState(disabled: false, active: false); - AddAssert("setting items created", () => modSelectScreen.ChildrenOfType().Any()); - } - - [Test] - public void TestExternalSelection() - { - createScreen(); - AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModAlternate(), new OsuModDaycore() }); - AddUntilStep("two panels active", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - AddAssert("mod multiplier correct", () => - { - double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); - return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); - }); - assertCustomisationToggleState(disabled: false, active: false); - AddAssert("setting items created", () => modSelectScreen.ChildrenOfType().Any()); - } - - [Test] - public void TestRulesetChange() - { - createScreen(); - changeRuleset(0); - changeRuleset(1); - changeRuleset(2); - changeRuleset(3); - } - - [Test] - public void TestIncompatibilityToggling() - { - createScreen(); - changeRuleset(0); - - AddStep("activate DT", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); - AddAssert("DT active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModDoubleTime)); - AddAssert("DT panel active", () => getPanelForMod(typeof(OsuModDoubleTime)).Active.Value); - - AddStep("activate NC", () => getPanelForMod(typeof(OsuModNightcore)).TriggerClick()); - AddAssert("only NC active", () => SelectedMods.Value.Single().GetType() == typeof(OsuModNightcore)); - AddAssert("DT panel not active", () => !getPanelForMod(typeof(OsuModDoubleTime)).Active.Value); - AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); - - AddStep("activate HR", () => getPanelForMod(typeof(OsuModHardRock)).TriggerClick()); - AddAssert("NC+HR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) - && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModHardRock))); - AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); - AddAssert("HR panel active", () => getPanelForMod(typeof(OsuModHardRock)).Active.Value); - - AddStep("activate MR", () => getPanelForMod(typeof(OsuModMirror)).TriggerClick()); - AddAssert("NC+MR active", () => SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModNightcore)) - && SelectedMods.Value.Any(mod => mod.GetType() == typeof(OsuModMirror))); - AddAssert("NC panel active", () => getPanelForMod(typeof(OsuModNightcore)).Active.Value); - AddAssert("HR panel not active", () => !getPanelForMod(typeof(OsuModHardRock)).Active.Value); - AddAssert("MR panel active", () => getPanelForMod(typeof(OsuModMirror)).Active.Value); - } - - [Test] - public void TestDimmedState() - { - createScreen(); - changeRuleset(0); - - AddUntilStep("any column dimmed", () => this.ChildrenOfType().Any(column => !column.Active.Value)); - - ModColumn lastColumn = null; - - AddAssert("last column dimmed", () => !this.ChildrenOfType().Last().Active.Value); - AddStep("request scroll to last column", () => - { - var lastDimContainer = this.ChildrenOfType().Last(); - lastColumn = lastDimContainer.Column; - lastDimContainer.RequestScroll?.Invoke(lastDimContainer); - }); - AddUntilStep("column undimmed", () => lastColumn.Active.Value); - - AddStep("click panel", () => - { - InputManager.MoveMouseTo(lastColumn.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("panel selected", () => lastColumn.ChildrenOfType().First().Active.Value); - } - - [Test] - public void TestCustomisationToggleState() - { - createScreen(); - assertCustomisationToggleState(disabled: true, active: false); - - AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - assertCustomisationToggleState(disabled: false, active: false); - - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); - assertCustomisationToggleState(disabled: false, active: true); - - AddStep("dismiss mod customisation via toggle", () => - { - InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType().Single()); - InputManager.Click(MouseButton.Left); - }); - assertCustomisationToggleState(disabled: false, active: false); - - AddStep("reset mods", () => SelectedMods.SetDefault()); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); - assertCustomisationToggleState(disabled: false, active: true); - - AddStep("dismiss mod customisation via keyboard", () => InputManager.Key(Key.Escape)); - assertCustomisationToggleState(disabled: false, active: false); - - AddStep("append another mod not requiring config", () => SelectedMods.Value = SelectedMods.Value.Append(new OsuModFlashlight()).ToArray()); - assertCustomisationToggleState(disabled: false, active: false); - - AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); - assertCustomisationToggleState(disabled: true, active: false); - - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); - assertCustomisationToggleState(disabled: false, active: true); - - AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); - assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. - } - - [Test] - public void TestDismissCustomisationViaDimmedArea() - { - createScreen(); - assertCustomisationToggleState(disabled: true, active: false); - - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); - assertCustomisationToggleState(disabled: false, active: true); - - AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); - AddStep("move mouse to dimmed area", () => - { - InputManager.MoveMouseTo(new Vector2( - modSelectScreen.ScreenSpaceDrawQuad.TopLeft.X, - (modSelectScreen.ScreenSpaceDrawQuad.TopLeft.Y + modSelectScreen.ScreenSpaceDrawQuad.BottomLeft.Y) / 2)); - }); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - assertCustomisationToggleState(disabled: false, active: false); - - AddStep("move mouse to first mod panel", () => InputManager.MoveMouseTo(modSelectScreen.ChildrenOfType().First())); - AddAssert("first mod panel is hovered", () => modSelectScreen.ChildrenOfType().First().IsHovered); - } - - /// - /// Ensure that two mod overlays are not cross polluting via central settings instances. - /// - [Test] - public void TestSettingsNotCrossPolluting() - { - Bindable> selectedMods2 = null; - ModSelectScreen modSelectScreen2 = null; - - createScreen(); - AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); - - AddStep("set setting", () => modSelectScreen.ChildrenOfType>().First().Current.Value = 8); - - AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); - - AddStep("create second bindable", () => selectedMods2 = new Bindable>(new Mod[] { new OsuModDifficultyAdjust() })); - - AddStep("create second overlay", () => - { - Add(modSelectScreen2 = new UserModSelectScreen().With(d => - { - d.Origin = Anchor.TopCentre; - d.Anchor = Anchor.TopCentre; - d.SelectedMods.BindTarget = selectedMods2; - })); - }); - - AddStep("show", () => modSelectScreen2.Show()); - - AddAssert("ensure first is unchanged", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); - AddAssert("ensure second is default", () => selectedMods2.Value.OfType().Single().CircleSize.Value == null); - } - - [Test] - public void TestSettingsResetOnDeselection() - { - var osuModDoubleTime = new OsuModDoubleTime { SpeedChange = { Value = 1.2 } }; - - createScreen(); - changeRuleset(0); - - AddStep("set dt mod with custom rate", () => { SelectedMods.Value = new[] { osuModDoubleTime }; }); - - AddAssert("selected mod matches", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.Value == 1.2); - - AddStep("deselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); - AddAssert("selected mods empty", () => SelectedMods.Value.Count == 0); - - AddStep("reselect", () => getPanelForMod(typeof(OsuModDoubleTime)).TriggerClick()); - AddAssert("selected mod has default value", () => (SelectedMods.Value.Single() as OsuModDoubleTime)?.SpeedChange.IsDefault == true); - } - - [Test] - public void TestAnimationFlushOnClose() - { - createScreen(); - changeRuleset(0); - - AddStep("Select all fun mods", () => - { - modSelectScreen.ChildrenOfType() - .Single(c => c.ModType == ModType.DifficultyIncrease) - .SelectAll(); - }); - - AddUntilStep("many mods selected", () => SelectedMods.Value.Count >= 5); - - AddStep("trigger deselect and close overlay", () => - { - modSelectScreen.ChildrenOfType() - .Single(c => c.ModType == ModType.DifficultyIncrease) - .DeselectAll(); - - modSelectScreen.Hide(); - }); - - AddAssert("all mods deselected", () => SelectedMods.Value.Count == 0); - } - - [Test] - public void TestRulesetChanges() - { - createScreen(); - changeRuleset(0); - - var noFailMod = new OsuRuleset().GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); - - AddStep("set mods externally", () => { SelectedMods.Value = new[] { noFailMod }; }); - - changeRuleset(0); - - AddAssert("ensure mods still selected", () => SelectedMods.Value.SingleOrDefault(m => m is OsuModNoFail) != null); - - changeRuleset(3); - - AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); - - changeRuleset(0); - - AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); - } - - [Test] - public void TestExternallySetCustomizedMod() - { - createScreen(); - changeRuleset(0); - - AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); - - AddAssert("ensure button is selected and customized accordingly", () => - { - var button = getPanelForMod(SelectedMods.Value.Single().GetType()); - return ((OsuModDoubleTime)button.Mod).SpeedChange.Value == 1.01; - }); - } - - [Test] - public void TestSettingsAreRetainedOnReload() - { - createScreen(); - changeRuleset(0); - - AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } }); - AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); - - createScreen(); - AddAssert("setting remains", () => (SelectedMods.Value.SingleOrDefault() as OsuModDoubleTime)?.SpeedChange.Value == 1.01); - } - - [Test] - public void TestExternallySetModIsReplacedByOverlayInstance() - { - Mod external = new OsuModDoubleTime(); - Mod overlayButtonMod = null; - - createScreen(); - changeRuleset(0); - - AddStep("set mod externally", () => { SelectedMods.Value = new[] { external }; }); - - AddAssert("ensure button is selected", () => - { - var button = getPanelForMod(SelectedMods.Value.Single().GetType()); - overlayButtonMod = button.Mod; - return button.Active.Value; - }); - - // Right now, when an external change occurs, the ModSelectOverlay will replace the global instance with its own - AddAssert("mod instance doesn't match", () => external != overlayButtonMod); - - AddAssert("one mod present in global selected", () => SelectedMods.Value.Count == 1); - AddAssert("globally selected matches button's mod instance", () => SelectedMods.Value.Any(mod => ReferenceEquals(mod, overlayButtonMod))); - AddAssert("globally selected doesn't contain original external change", () => !SelectedMods.Value.Any(mod => ReferenceEquals(mod, external))); - } - - [Test] - public void TestChangeIsValidChangesButtonVisibility() - { - createScreen(); - changeRuleset(0); - - AddAssert("double time visible", () => modSelectScreen.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); - - AddStep("make double time invalid", () => modSelectScreen.IsValidMod = m => !(m is OsuModDoubleTime)); - AddUntilStep("double time not visible", () => modSelectScreen.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => panel.Filtered.Value)); - AddAssert("nightcore still visible", () => modSelectScreen.ChildrenOfType().Where(panel => panel.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); - - AddStep("make double time valid again", () => modSelectScreen.IsValidMod = m => true); - AddUntilStep("double time visible", () => modSelectScreen.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); - AddAssert("nightcore still visible", () => modSelectScreen.ChildrenOfType().Where(b => b.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); - } - - [Test] - public void TestChangeIsValidPreservesSelection() - { - createScreen(); - changeRuleset(0); - - AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); - AddAssert("DT + HD selected", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - - AddStep("make NF invalid", () => modSelectScreen.IsValidMod = m => !(m is ModNoFail)); - AddAssert("DT + HD still selected", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - } - - [Test] - public void TestUnimplementedModIsUnselectable() - { - var testRuleset = new TestUnimplementedModOsuRuleset(); - - createScreen(); - - AddStep("set ruleset", () => Ruleset.Value = testRuleset.RulesetInfo); - waitForColumnLoad(); - - AddAssert("unimplemented mod panel is filtered", () => getPanelForMod(typeof(TestUnimplementedMod)).Filtered.Value); - } - - [Test] - public void TestDeselectAllViaButton() - { - createScreen(); - changeRuleset(0); - - AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); - AddAssert("DT + HD selected", () => modSelectScreen.ChildrenOfType().Count(panel => panel.Active.Value) == 2); - - AddStep("click deselect all button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().Last()); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); - } - - [Test] - public void TestCloseViaBackButton() - { - createScreen(); - changeRuleset(0); - - AddStep("select difficulty adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); - assertCustomisationToggleState(disabled: false, active: true); - AddAssert("back button disabled", () => !this.ChildrenOfType().First().Enabled.Value); - - AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); - AddStep("click back button", () => - { - InputManager.MoveMouseTo(this.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); - }); - AddAssert("mod select hidden", () => modSelectScreen.State.Value == Visibility.Hidden); - } - - [Test] - public void TestColumnHiding() - { - AddStep("create screen", () => Child = modSelectScreen = new UserModSelectScreen - { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - SelectedMods = { BindTarget = SelectedMods }, - IsValidMod = mod => mod.Type == ModType.DifficultyIncrease || mod.Type == ModType.Conversion - }); - waitForColumnLoad(); - changeRuleset(0); - - AddAssert("two columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 2); - - AddStep("unset filter", () => modSelectScreen.IsValidMod = _ => true); - AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); - - AddStep("filter out everything", () => modSelectScreen.IsValidMod = _ => false); - AddAssert("no columns visible", () => this.ChildrenOfType().All(col => !col.IsPresent)); - - AddStep("hide", () => modSelectScreen.Hide()); - AddStep("set filter for 3 columns", () => modSelectScreen.IsValidMod = mod => mod.Type == ModType.DifficultyReduction - || mod.Type == ModType.Automation - || mod.Type == ModType.Conversion); - - AddStep("show", () => modSelectScreen.Show()); - AddUntilStep("3 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 3); - } - - private void waitForColumnLoad() => AddUntilStep("all column content loaded", - () => modSelectScreen.ChildrenOfType().Any() && modSelectScreen.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); - - private void changeRuleset(int id) - { - AddStep($"set ruleset to {id}", () => Ruleset.Value = rulesetStore.GetRuleset(id)); - waitForColumnLoad(); - } - - private void assertCustomisationToggleState(bool disabled, bool active) - { - ShearedToggleButton getToggle() => modSelectScreen.ChildrenOfType().Single(); - - AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled); - AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active); - } - - private ModPanel getPanelForMod(Type modType) - => modSelectScreen.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); - - private class TestUnimplementedMod : Mod - { - public override string Name => "Unimplemented mod"; - public override string Acronym => "UM"; - public override string Description => "A mod that is not implemented."; - public override double ScoreMultiplier => 1; - public override ModType Type => ModType.Conversion; - } - - private class TestUnimplementedModOsuRuleset : OsuRuleset - { - public override string ShortName => "unimplemented"; - - public override IEnumerable GetModsFor(ModType type) - { - if (type == ModType.Conversion) return base.GetModsFor(type).Concat(new[] { new TestUnimplementedMod() }); - - return base.GetModsFor(type); - } - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs deleted file mode 100644 index 9a3083e8db..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ /dev/null @@ -1,238 +0,0 @@ -// 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 NUnit.Framework; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Utils; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.UI; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public class TestSceneModSettings : OsuManualInputManagerTestScene - { - private TestModSelectOverlay modSelect; - - private readonly Mod testCustomisableMod = new TestModCustomisable1(); - - private readonly Mod testCustomisableAutoOpenMod = new TestModCustomisable2(); - - [SetUp] - public void SetUp() => Schedule(() => - { - SelectedMods.Value = Array.Empty(); - Ruleset.Value = CreateTestRulesetInfo(); - }); - - [Test] - public void TestButtonShowsOnCustomisableMod() - { - createModSelect(); - openModSelect(); - - AddAssert("button disabled", () => !modSelect.CustomiseButton.Enabled.Value); - AddUntilStep("wait for button load", () => modSelect.ButtonsLoaded); - AddStep("select mod", () => modSelect.SelectMod(testCustomisableMod)); - AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); - AddStep("open Customisation", () => modSelect.CustomiseButton.TriggerClick()); - AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableMod)); - AddAssert("controls hidden", () => modSelect.ModSettingsContainer.State.Value == Visibility.Hidden); - } - - [Test] - public void TestButtonShowsOnModAlreadyAdded() - { - AddStep("set active mods", () => SelectedMods.Value = new List { testCustomisableMod }); - - createModSelect(); - - AddAssert("mods still active", () => SelectedMods.Value.Count == 1); - - openModSelect(); - AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); - } - - [Test] - public void TestCustomisationMenuVisibility() - { - createModSelect(); - openModSelect(); - - AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.State.Value == Visibility.Hidden); - AddStep("select mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); - AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.State.Value == Visibility.Visible); - AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); - AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.State.Value == Visibility.Hidden); - } - - [Test] - public void TestModSettingsUnboundWhenCopied() - { - OsuModDoubleTime original = null; - OsuModDoubleTime copy = null; - - AddStep("create mods", () => - { - original = new OsuModDoubleTime(); - copy = (OsuModDoubleTime)original.DeepClone(); - }); - - AddStep("change property", () => original.SpeedChange.Value = 2); - - AddAssert("original has new value", () => Precision.AlmostEquals(2.0, original.SpeedChange.Value)); - AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, copy.SpeedChange.Value)); - } - - [Test] - public void TestMultiModSettingsUnboundWhenCopied() - { - MultiMod original = null; - MultiMod copy = null; - - AddStep("create mods", () => - { - original = new MultiMod(new OsuModDoubleTime()); - copy = (MultiMod)original.DeepClone(); - }); - - AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2); - - AddAssert("original has new value", () => Precision.AlmostEquals(2.0, ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value)); - AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value)); - } - - [Test] - public void TestCustomisationMenuNoClickthrough() - { - createModSelect(); - openModSelect(); - - AddStep("change mod settings menu width to full screen", () => modSelect.SetModSettingsWidth(1.0f)); - AddStep("select cm2", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); - AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.State.Value == Visibility.Visible); - AddStep("hover over mod behind settings menu", () => InputManager.MoveMouseTo(modSelect.GetModButton(testCustomisableMod))); - AddAssert("Mod is not considered hovered over", () => !modSelect.GetModButton(testCustomisableMod).IsHovered); - AddStep("left click mod", () => InputManager.Click(MouseButton.Left)); - AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); - AddStep("right click mod", () => InputManager.Click(MouseButton.Right)); - AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); - } - - private void createModSelect() - { - AddStep("create mod select", () => - { - Child = modSelect = new TestModSelectOverlay - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - SelectedMods = { BindTarget = SelectedMods } - }; - }); - } - - private void openModSelect() - { - AddStep("open", () => modSelect.Show()); - AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); - } - - private class TestModSelectOverlay : UserModSelectOverlay - { - public new VisibilityContainer ModSettingsContainer => base.ModSettingsContainer; - public new TriangleButton CustomiseButton => base.CustomiseButton; - - public bool ButtonsLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); - - public ModButton GetModButton(Mod mod) - { - return ModSectionsContainer.ChildrenOfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); - } - - public void SelectMod(Mod mod) => - GetModButton(mod).SelectNext(1); - - public void SetModSettingsWidth(float newWidth) => - ModSettingsContainer.Parent.Width = newWidth; - } - - public static RulesetInfo CreateTestRulesetInfo() => new TestCustomisableModRuleset().RulesetInfo; - - public class TestCustomisableModRuleset : Ruleset - { - public override IEnumerable GetModsFor(ModType type) - { - if (type == ModType.Conversion) - { - return new Mod[] - { - new TestModCustomisable1(), - new TestModCustomisable2() - }; - } - - return Array.Empty(); - } - - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); - - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); - - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); - - public override string Description { get; } = "test"; - public override string ShortName { get; } = "tst"; - } - - private class TestModCustomisable1 : TestModCustomisable - { - public override string Name => "Customisable Mod 1"; - - public override string Acronym => "CM1"; - } - - private class TestModCustomisable2 : TestModCustomisable - { - public override string Name => "Customisable Mod 2"; - - public override string Acronym => "CM2"; - - public override bool RequiresConfiguration => true; - } - - private abstract class TestModCustomisable : Mod, IApplicableMod - { - public override double ScoreMultiplier => 1.0; - - public override string Description => "This is a customisable test mod."; - - public override ModType Type => ModType.Conversion; - - [SettingSource("Sample float", "Change something for a mod")] - public BindableFloat SliderBindable { get; } = new BindableFloat - { - MinValue = 0, - MaxValue = 10, - Default = 5, - Value = 7 - }; - - [SettingSource("Sample bool", "Clicking this changes a setting")] - public BindableBool TickBindable { get; } = new BindableBool(); - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs index 9ccfba7c74..f45c55d912 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs @@ -1,44 +1,40 @@ // 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.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneRoundedButton : OsuTestScene + public class TestSceneRoundedButton : ThemeComparisonTestScene { - [Test] - public void TestBasic() + private readonly BindableBool enabled = new BindableBool(true); + + protected override Drawable CreateContent() => new RoundedButton { - RoundedButton button = null; + Width = 400, + Text = "Test button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Enabled = { BindTarget = enabled }, + }; - AddStep("create button", () => Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.DarkGray - }, - button = new RoundedButton - { - Width = 400, - Text = "Test button", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Action = () => { } - } - } - }); + [Test] + public void TestDisabled() + { + AddToggleStep("toggle disabled", disabled => enabled.Value = !disabled); + } - AddToggleStep("toggle disabled", disabled => button.Action = disabled ? (Action)null : () => { }); + [Test] + public void TestBackgroundColour() + { + AddStep("set red scheme", () => CreateThemedContent(OverlayColourScheme.Red)); + AddAssert("first button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Highlight1); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs new file mode 100644 index 0000000000..8ef24e58a0 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs @@ -0,0 +1,62 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneSettingsToolboxGroup : OsuManualInputManagerTestScene + { + private SettingsToolboxGroup group; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = group = new SettingsToolboxGroup("example") + { + Children = new Drawable[] + { + new RoundedButton + { + RelativeSizeAxes = Axes.X, + Text = @"Button", + Enabled = { Value = true }, + }, + new OsuCheckbox + { + LabelText = @"Checkbox", + }, + new OutlinedTextBox + { + RelativeSizeAxes = Axes.X, + Height = 30, + PlaceholderText = @"Textbox", + } + }, + }; + }); + + [Test] + public void TestClickExpandButtonMultipleTimes() + { + AddAssert("group expanded by default", () => group.Expanded.Value); + AddStep("click expand button multiple times", () => + { + InputManager.MoveMouseTo(group.ChildrenOfType().Single()); + Scheduler.AddDelayed(() => InputManager.Click(MouseButton.Left), 100); + Scheduler.AddDelayed(() => InputManager.Click(MouseButton.Left), 200); + Scheduler.AddDelayed(() => InputManager.Click(MouseButton.Left), 300); + }); + AddAssert("group contracted", () => !group.Expanded.Value); + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 0bcf533653..a1eef4ce47 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index c7314a4969..6fd53d923b 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 64e77384a2..026a83cceb 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.Linq; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -164,6 +166,20 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.EditorHitAnimations, false); } + public IDictionary GetLoggableState() => + new Dictionary(ConfigStore.Where(kvp => !keyContainsPrivateInformation(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString())); + + private static bool keyContainsPrivateInformation(OsuSetting argKey) + { + switch (argKey) + { + case OsuSetting.Token: + return true; + } + + return false; + } + public OsuConfigManager(Storage storage) : base(storage) { diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index b0a70b51d0..937876a70e 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -344,6 +344,26 @@ namespace osu.Game.Database } } + /// + /// Write changes to realm. + /// + /// The work to run. + public T Write(Func action) + { + if (ThreadSafety.IsUpdateThread) + { + total_writes_update.Value++; + return Realm.Write(action); + } + else + { + total_writes_async.Value++; + + using (var realm = getRealmInstance()) + return realm.Write(action); + } + } + /// /// Write changes to realm. /// diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 29a797bd78..08514d94c3 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -33,9 +32,12 @@ namespace osu.Game.Graphics.UserInterface private Color4? backgroundColour; + /// + /// Sets a custom background colour to this button, replacing the provided default. + /// public Color4 BackgroundColour { - get => backgroundColour ?? Color4.White; + get => backgroundColour ?? defaultBackgroundColour; set { backgroundColour = value; @@ -43,6 +45,23 @@ namespace osu.Game.Graphics.UserInterface } } + private Color4 defaultBackgroundColour; + + /// + /// Sets a default background colour to this button. + /// + protected Color4 DefaultBackgroundColour + { + get => defaultBackgroundColour; + set + { + defaultBackgroundColour = value; + + if (backgroundColour == null) + Background.FadeColour(value); + } + } + protected override Container Content { get; } protected Box Hover; @@ -89,8 +108,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(OsuColour colours) { - if (backgroundColour == null) - BackgroundColour = colours.BlueDark; + DefaultBackgroundColour = colours.BlueDark; } protected override void LoadComplete() @@ -106,10 +124,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { if (Enabled.Value) - { - Debug.Assert(backgroundColour != null); - Background.FlashColour(backgroundColour.Value.Lighten(0.4f), 200); - } + Background.FlashColour(BackgroundColour.Lighten(0.4f), 200); return base.OnClick(e); } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index b1d4691938..4e391c8221 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -184,14 +184,12 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateBackgroundColour() { - if (!IsPreSelected && !IsSelected) - { - Background.FadeOut(600, Easing.OutQuint); - return; - } - - Background.FadeIn(100, Easing.OutQuint); Background.FadeColour(IsPreSelected ? BackgroundColourHover : BackgroundColourSelected, 100, Easing.OutQuint); + + if (IsPreSelected || IsSelected) + Background.FadeIn(100, Easing.OutQuint); + else + Background.FadeOut(600, Easing.OutQuint); } protected override void UpdateForegroundColour() diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index f535a32b39..ec56b6d784 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -29,8 +28,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) { - if (BackgroundColour == Color4.White) - BackgroundColour = overlayColourProvider?.Highlight1 ?? colours.Blue3; + DefaultBackgroundColour = overlayColourProvider?.Highlight1 ?? colours.Blue3; } protected override void LoadComplete() diff --git a/osu.Game/Localisation/ModSelectScreenStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs similarity index 94% rename from osu.Game/Localisation/ModSelectScreenStrings.cs rename to osu.Game/Localisation/ModSelectOverlayStrings.cs index 0c113fd381..e9af7147e3 100644 --- a/osu.Game/Localisation/ModSelectScreenStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -5,9 +5,9 @@ using osu.Framework.Localisation; namespace osu.Game.Localisation { - public static class ModSelectScreenStrings + public static class ModSelectOverlayStrings { - private const string prefix = @"osu.Game.Resources.Localisation.ModSelectScreen"; + private const string prefix = @"osu.Game.Resources.Localisation.ModSelectOverlay"; /// /// "Mod Select" @@ -26,4 +26,4 @@ namespace osu.Game.Localisation private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 54c4231b06..3d56d33689 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -14,6 +14,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -56,6 +57,8 @@ using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; using osuTK.Graphics; +using Sentry; +using Logger = osu.Framework.Logging.Logger; namespace osu.Game { @@ -258,7 +261,7 @@ namespace osu.Game { dependencies.CacheAs(this); - dependencies.Cache(SentryLogger); + SentryLogger.AttachUser(API.LocalUser); dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); @@ -1197,6 +1200,15 @@ namespace osu.Game private void screenChanged(IScreen current, IScreen newScreen) { + SentrySdk.ConfigureScope(scope => + { + scope.Contexts[@"screen stack"] = new + { + Current = newScreen?.GetType().ReadableName(), + Previous = current?.GetType().ReadableName(), + }; + }); + switch (newScreen) { case IntroScreen intro: diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 324fcada89..2e4758a134 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -85,6 +86,8 @@ namespace osu.Game public bool IsDeployedBuild => AssemblyVersion.Major > 0; + internal const string BUILD_SUFFIX = "lazer"; + public virtual string Version { get @@ -93,7 +96,7 @@ namespace osu.Game return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); var version = AssemblyVersion; - return $@"{version.Major}.{version.Minor}.{version.Build}-lazer"; + return $@"{version.Major}.{version.Minor}.{version.Build}-{BUILD_SUFFIX}"; } } @@ -180,9 +183,21 @@ namespace osu.Game /// protected DatabaseContextFactory EFContextFactory { get; private set; } + /// + /// Number of unhandled exceptions to allow before aborting execution. + /// + /// + /// When an unhandled exception is encountered, an internal count will be decremented. + /// If the count hits zero, the game will crash. + /// Each second, the count is incremented until reaching the value specified. + /// + protected virtual int UnhandledExceptionsBeforeCrash => DebugUtils.IsDebugBuild ? 0 : 1; + public OsuGameBase() { Name = @"osu!"; + + allowableExceptions = UnhandledExceptionsBeforeCrash; } [BackgroundDependencyLoader] @@ -408,6 +423,8 @@ namespace osu.Game LocalConfig ??= UseDevelopmentServer ? new DevelopmentOsuConfigManager(Storage) : new OsuConfigManager(Storage); + + host.ExceptionThrown += onExceptionThrown; } /// @@ -505,6 +522,23 @@ namespace osu.Game AvailableMods.Value = dict; } + private int allowableExceptions; + + /// + /// Allows a maximum of one unhandled exception, per second of execution. + /// + private bool onExceptionThrown(Exception _) + { + bool continueExecution = Interlocked.Decrement(ref allowableExceptions) >= 0; + + Logger.Log($"Unhandled exception has been {(continueExecution ? $"allowed with {allowableExceptions} more allowable exceptions" : "denied")} ."); + + // restore the stock of allowable exceptions after a short delay. + Task.Delay(1000).ContinueWith(_ => Interlocked.Increment(ref allowableExceptions)); + + return continueExecution; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -514,6 +548,9 @@ namespace osu.Game LocalConfig?.Dispose(); realm?.Dispose(); + + if (Host != null) + Host.ExceptionThrown -= onExceptionThrown; } } } diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs index 1f18d181cb..d1ea91e51a 100644 --- a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs +++ b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs @@ -19,13 +19,16 @@ namespace osu.Game.Overlays.FirstRunSetup protected FillFlowContainer Content { get; private set; } + protected const float CONTENT_FONT_SIZE = 16; + + protected const float HEADER_FONT_SIZE = 24; + [Resolved] protected OverlayColourProvider OverlayColourProvider { get; private set; } [BackgroundDependencyLoader] private void load() { - const float header_size = 40; const float spacing = 20; InternalChildren = new Drawable[] @@ -33,23 +36,29 @@ namespace osu.Game.Overlays.FirstRunSetup new OsuScrollContainer(Direction.Vertical) { RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - Children = new Drawable[] + Masking = false, + Child = new Container { - new OsuSpriteText + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 30 }, + Children = new Drawable[] { - Text = this.GetLocalisableDescription(), - Font = OsuFont.Default.With(size: header_size), - Colour = OverlayColourProvider.Light1, + new OsuSpriteText + { + Text = this.GetLocalisableDescription(), + Font = OsuFont.TorusAlternate.With(size: HEADER_FONT_SIZE), + Colour = OverlayColourProvider.Light1, + }, + Content = new FillFlowContainer + { + Y = HEADER_FONT_SIZE + spacing, + Spacing = new Vector2(spacing), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } }, - Content = new FillFlowContainer - { - Y = header_size + spacing, - Spacing = new Vector2(spacing), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - } }, } }; @@ -59,7 +68,7 @@ namespace osu.Game.Overlays.FirstRunSetup { base.OnEntering(e); this - .FadeInFromZero(500) + .FadeInFromZero(100) .MoveToX(offset) .MoveToX(0, 500, Easing.OutQuint); } @@ -68,7 +77,7 @@ namespace osu.Game.Overlays.FirstRunSetup { base.OnResuming(e); this - .FadeInFromZero(500) + .FadeInFromZero(100) .MoveToX(0, 500, Easing.OutQuint); } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 190a0badab..66acdca8c7 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -46,11 +46,11 @@ namespace osu.Game.Overlays.FirstRunSetup [BackgroundDependencyLoader(permitNulls: true)] private void load(LegacyImportManager? legacyImportManager) { - Vector2 buttonSize = new Vector2(500, 60); + Vector2 buttonSize = new Vector2(400, 50); Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunSetupBeatmapScreenStrings.Description, @@ -63,7 +63,7 @@ namespace osu.Game.Overlays.FirstRunSetup Height = 30, Children = new Drawable[] { - currentlyLoadedBeatmaps = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 24, weight: FontWeight.SemiBold)) + currentlyLoadedBeatmaps = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: HEADER_FONT_SIZE, weight: FontWeight.SemiBold)) { Colour = OverlayColourProvider.Content2, TextAnchor = Anchor.Centre, @@ -73,7 +73,7 @@ namespace osu.Game.Overlays.FirstRunSetup }, } }, - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunSetupBeatmapScreenStrings.TutorialDescription, @@ -89,7 +89,7 @@ namespace osu.Game.Overlays.FirstRunSetup Text = FirstRunSetupBeatmapScreenStrings.TutorialButton, Action = downloadTutorial }, - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunSetupBeatmapScreenStrings.BundledDescription, @@ -105,7 +105,7 @@ namespace osu.Game.Overlays.FirstRunSetup Text = FirstRunSetupBeatmapScreenStrings.BundledButton, Action = downloadBundled }, - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = "If you have an existing osu! install, you can also choose to import your existing beatmap collection.", @@ -131,7 +131,7 @@ namespace osu.Game.Overlays.FirstRunSetup })); } }, - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunSetupBeatmapScreenStrings.ObtainMoreBeatmaps, diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs index dc3d40ad95..1a88e6a842 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs @@ -9,7 +9,7 @@ using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; @@ -22,11 +22,11 @@ namespace osu.Game.Overlays.FirstRunSetup private SearchContainer searchContainer; [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 24)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Text = FirstRunSetupOverlayStrings.BehaviourDescription, RelativeSizeAxes = Axes.X, @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.FirstRunSetup { new[] { - new TriangleButton + new RoundedButton { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, @@ -59,10 +59,11 @@ namespace osu.Game.Overlays.FirstRunSetup Action = applyStandard, }, Empty(), - new DangerousTriangleButton + new RoundedButton { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + BackgroundColour = colours.Pink3, Text = FirstRunSetupOverlayStrings.ClassicDefaults, RelativeSizeAxes = Axes.X, Action = applyClassic diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index 152d67ab27..8452691bb5 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -35,9 +35,11 @@ namespace osu.Game.Overlays.FirstRunSetup [BackgroundDependencyLoader] private void load(OsuConfigManager config) { + const float screen_width = 640; + Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 24)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Text = FirstRunSetupOverlayStrings.UIScaleDescription, RelativeSizeAxes = Axes.X, @@ -54,7 +56,7 @@ namespace osu.Game.Overlays.FirstRunSetup Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.None, - Size = new Vector2(960, 960 / 16f * 9 / 2), + Size = new Vector2(screen_width, screen_width / 16f * 9 / 2), Children = new Drawable[] { new GridContainer @@ -123,6 +125,7 @@ namespace osu.Game.Overlays.FirstRunSetup private class SampleScreenContainer : CompositeDrawable { + private readonly OsuScreen screen; // Minimal isolation from main game. [Cached] @@ -142,6 +145,12 @@ namespace osu.Game.Overlays.FirstRunSetup public override bool PropagatePositionalInputSubTree => false; public override bool PropagateNonPositionalInputSubTree => false; + public SampleScreenContainer(OsuScreen screen) + { + this.screen = screen; + RelativeSizeAxes = Axes.Both; + } + [BackgroundDependencyLoader] private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { @@ -149,13 +158,8 @@ namespace osu.Game.Overlays.FirstRunSetup Beatmap.Value.LoadTrack(); Ruleset.Value = rulesets.AvailableRulesets.First(); - } - public SampleScreenContainer(Screen screen) - { OsuScreenStack stack; - RelativeSizeAxes = Axes.Both; - OsuLogo logo; Padding = new MarginPadding(5); @@ -189,7 +193,8 @@ namespace osu.Game.Overlays.FirstRunSetup }, }; - stack.Push(screen); + // intentionally load synchronously so it is included in the initial load of the first run screen. + stack.PushSynchronously(screen); } } } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index 10e15a7555..420d630857 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.FirstRunSetup { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Text = FirstRunSetupOverlayStrings.WelcomeDescription, RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index 607bef76dd..cebb2f5e3b 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Screens; +using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -62,12 +63,15 @@ namespace osu.Game.Overlays typeof(ScreenBehaviour), }; - private Container stackContainer = null!; + private Container screenContent = null!; private Bindable? overlayActivationMode; private Container content = null!; + private LoadingSpinner loading = null!; + private ScheduledDelegate? loadingShowDelegate; + public FirstRunSetupOverlay() : base(OverlayColourScheme.Purple) { @@ -86,36 +90,48 @@ namespace osu.Game.Overlays Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + Padding = new MarginPadding { Bottom = 20, }, + Child = new GridContainer { - Horizontal = 70 * 1.2f, - Bottom = 20, - }, - Child = new InputBlockingContainer - { - Masking = true, - CornerRadius = 14, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + ColumnDimensions = new[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background6, - }, - stackContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Vertical = 20, - Horizontal = 70, - }, - } + new Dimension(), + new Dimension(minSize: 640, maxSize: 800), + new Dimension(), }, - }, + Content = new[] + { + new[] + { + Empty(), + new InputBlockingContainer + { + Masking = true, + CornerRadius = 14, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6, + }, + loading = new LoadingSpinner(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 20 }, + Child = screenContent = new Container { RelativeSizeAxes = Axes.Both, }, + }, + }, + }, + Empty(), + }, + } + } }, }); @@ -171,8 +187,7 @@ namespace osu.Game.Overlays config.BindWith(OsuSetting.ShowFirstRunSetup, showFirstRunSetup); - // TODO: uncomment when happy with the whole flow. - // if (showFirstRunSetup.Value) Show(); + if (showFirstRunSetup.Value) Show(); } public override bool OnPressed(KeyBindingPressEvent e) @@ -269,7 +284,7 @@ namespace osu.Game.Overlays { Debug.Assert(currentStepIndex == null); - stackContainer.Child = stack = new ScreenStack + screenContent.Child = stack = new ScreenStack { RelativeSizeAxes = Axes.Both, }; @@ -300,12 +315,20 @@ namespace osu.Game.Overlays if (currentStepIndex < steps.Length) { - stack.Push((Screen)Activator.CreateInstance(steps[currentStepIndex.Value])); + var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value]); + + loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); + nextScreen.OnLoadComplete += _ => + { + loadingShowDelegate?.Cancel(); + loading.Hide(); + }; + + stack.Push(nextScreen); } else { - // TODO: uncomment when happy with the whole flow. - // showFirstRunSetup.Value = false; + showFirstRunSetup.Value = false; currentStepIndex = null; Hide(); } diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModButton.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModButton.cs deleted file mode 100644 index 6e2cb40596..0000000000 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModButton.cs +++ /dev/null @@ -1,66 +0,0 @@ -// 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 System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Game.Rulesets.Mods; -using osu.Game.Utils; -using osuTK; - -namespace osu.Game.Overlays.Mods -{ - public class IncompatibilityDisplayingModButton : ModButton - { - private readonly CompositeDrawable incompatibleIcon; - - [Resolved] - private Bindable> selectedMods { get; set; } - - public IncompatibilityDisplayingModButton(Mod mod) - : base(mod) - { - ButtonContent.Add(incompatibleIcon = new IncompatibleIcon - { - Anchor = Anchor.BottomRight, - Origin = Anchor.Centre, - Position = new Vector2(-13), - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - selectedMods.BindValueChanged(_ => Scheduler.AddOnce(updateCompatibility), true); - } - - protected override void DisplayMod(Mod mod) - { - base.DisplayMod(mod); - - Scheduler.AddOnce(updateCompatibility); - } - - private void updateCompatibility() - { - var m = SelectedMod ?? Mods.First(); - - bool isIncompatible = false; - - if (selectedMods.Value.Count > 0 && !selectedMods.Value.Contains(m)) - isIncompatible = !ModUtils.CheckCompatibleSet(selectedMods.Value.Append(m)); - - if (isIncompatible) - incompatibleIcon.Show(); - else - incompatibleIcon.Hide(); - } - - public override ITooltip GetCustomTooltip() => new IncompatibilityDisplayingTooltip(); - } -} diff --git a/osu.Game/Overlays/Mods/IncompatibleIcon.cs b/osu.Game/Overlays/Mods/IncompatibleIcon.cs deleted file mode 100644 index df134fe4a4..0000000000 --- a/osu.Game/Overlays/Mods/IncompatibleIcon.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Mods -{ - public class IncompatibleIcon : VisibilityContainer, IHasTooltip - { - private Circle circle; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Size = new Vector2(20); - - State.Value = Visibility.Hidden; - Alpha = 0; - - InternalChildren = new Drawable[] - { - circle = new Circle - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray4, - }, - new SpriteIcon - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(0.6f), - Icon = FontAwesome.Solid.Slash, - Colour = Color4.White, - Shadow = true, - } - }; - } - - protected override void PopIn() - { - this.FadeIn(200, Easing.OutQuint); - circle.FlashColour(Color4.Red, 500, Easing.OutQuint); - this.ScaleTo(1.8f).Then().ScaleTo(1, 500, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(200, Easing.OutQuint); - this.ScaleTo(0.8f, 200, Easing.In); - } - - public LocalisableString TooltipText => "Incompatible with current selected mods"; - } -} diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs deleted file mode 100644 index 979e2c8da3..0000000000 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osuTK.Graphics; -using osuTK.Input; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; -using System; -using System.Linq; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Overlays.Mods -{ - /// - /// Represents a clickable button which can cycle through one of more mods. - /// - public class ModButton : ModButtonEmpty, IHasCustomTooltip - { - private ModIcon foregroundIcon; - private ModIcon backgroundIcon; - private readonly SpriteText text; - private readonly Container iconsContainer; - - /// - /// Fired when the selection changes. - /// - public Action SelectionChanged; - - public LocalisableString TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; - - private const Easing mod_switch_easing = Easing.InOutSine; - private const double mod_switch_duration = 120; - - // A selected index of -1 means not selected. - private int selectedIndex = -1; - - /// - /// Change the selected mod index of this button. - /// - /// The new index. - /// Whether any settings applied to the mod should be reset on selection. - /// Whether the selection changed. - private bool changeSelectedIndex(int newIndex, bool resetSettings = true) - { - if (newIndex == selectedIndex) return false; - - int direction = newIndex < selectedIndex ? -1 : 1; - - bool beforeSelected = Selected; - - Mod previousSelection = SelectedMod ?? Mods[0]; - - if (newIndex >= Mods.Length) - newIndex = -1; - else if (newIndex < -1) - newIndex = Mods.Length - 1; - - if (newIndex >= 0 && !Mods[newIndex].HasImplementation) - return false; - - selectedIndex = newIndex; - - Mod newSelection = SelectedMod ?? Mods[0]; - - if (resetSettings) - newSelection.ResetSettingsToDefaults(); - - Schedule(() => - { - if (beforeSelected != Selected) - { - iconsContainer.RotateTo(Selected ? 5f : 0f, 300, Easing.OutElastic); - iconsContainer.ScaleTo(Selected ? 1.1f : 1f, 300, Easing.OutElastic); - } - - if (previousSelection != newSelection) - { - const float rotate_angle = 16; - - foregroundIcon.RotateTo(rotate_angle * direction, mod_switch_duration, mod_switch_easing); - backgroundIcon.RotateTo(-rotate_angle * direction, mod_switch_duration, mod_switch_easing); - - backgroundIcon.Mod = newSelection; - - using (BeginDelayedSequence(mod_switch_duration)) - { - foregroundIcon - .RotateTo(-rotate_angle * direction) - .RotateTo(0f, mod_switch_duration, mod_switch_easing); - - backgroundIcon - .RotateTo(rotate_angle * direction) - .RotateTo(0f, mod_switch_duration, mod_switch_easing); - - Schedule(() => DisplayMod(newSelection)); - } - } - - foregroundIcon.Selected.Value = Selected; - }); - - SelectionChanged?.Invoke(SelectedMod); - - return true; - } - - public bool Selected => selectedIndex != -1; - - private Color4 selectedColour; - - public Color4 SelectedColour - { - get => selectedColour; - set - { - if (value == selectedColour) return; - - selectedColour = value; - if (Selected) foregroundIcon.Colour = value; - } - } - - private Mod mod; - - protected readonly Container ButtonContent; - - public Mod Mod - { - get => mod; - set - { - mod = value; - - if (mod == null) - { - Mods = Array.Empty(); - Alpha = 0; - } - else - { - Mods = (mod as MultiMod)?.Mods ?? new[] { mod }; - Alpha = 1; - } - - createIcons(); - - if (Mods.Length > 0) - { - DisplayMod(Mods[0]); - } - } - } - - public Mod[] Mods { get; private set; } - - public virtual Mod SelectedMod => Mods.ElementAtOrDefault(selectedIndex); - - protected override bool OnMouseDown(MouseDownEvent e) - { - ButtonContent.ScaleTo(0.9f, 800, Easing.Out); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - ButtonContent.ScaleTo(1, 500, Easing.OutElastic); - - // only trigger the event if we are inside the area of the button - if (Contains(e.ScreenSpaceMousePosition)) - { - switch (e.Button) - { - case MouseButton.Right: - SelectNext(-1); - break; - } - } - } - - protected override bool OnClick(ClickEvent e) - { - SelectNext(1); - - return true; - } - - /// - /// Select the next available mod in a specified direction. - /// - /// 1 for forwards, -1 for backwards. - public void SelectNext(int direction) - { - int start = selectedIndex + direction; - // wrap around if we are at an extremity. - if (start >= Mods.Length) - start = -1; - else if (start < -1) - start = Mods.Length - 1; - - for (int i = start; i < Mods.Length && i >= 0; i += direction) - { - if (SelectAt(i)) - return; - } - - Deselect(); - } - - /// - /// Select the mod at the provided index. - /// - /// The index to select. - /// Whether any settings applied to the mod should be reset on selection. - /// Whether the selection changed. - public bool SelectAt(int index, bool resetSettings = true) - { - if (!Mods[index].HasImplementation) return false; - - changeSelectedIndex(index, resetSettings); - return true; - } - - public void Deselect() => changeSelectedIndex(-1); - - protected virtual void DisplayMod(Mod mod) - { - if (backgroundIcon != null) - backgroundIcon.Mod = foregroundIcon.Mod; - foregroundIcon.Mod = mod; - text.Text = mod.Name; - Colour = mod.HasImplementation ? Color4.White : Color4.Gray; - } - - private void createIcons() - { - iconsContainer.Clear(); - - if (Mods.Length > 1) - { - iconsContainer.AddRange(new[] - { - backgroundIcon = new ModIcon(Mods[1], false) - { - Origin = Anchor.BottomRight, - Anchor = Anchor.BottomRight, - Position = new Vector2(1.5f), - }, - foregroundIcon = new ModIcon(Mods[0], false) - { - Origin = Anchor.BottomRight, - Anchor = Anchor.BottomRight, - Position = new Vector2(-1.5f), - }, - }); - } - else - { - iconsContainer.Add(foregroundIcon = new ModIcon(Mod, false) - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - }); - } - } - - public ModButton(Mod mod) - { - Children = new Drawable[] - { - new Container - { - Size = new Vector2(77f, 80f), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Children = new Drawable[] - { - ButtonContent = new Container - { - Children = new Drawable[] - { - iconsContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - }, - }, - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - } - } - }, - text = new OsuSpriteText - { - Y = 75, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Font = OsuFont.GetFont(size: 18) - }, - new HoverSounds() - }; - Mod = mod; - } - - public virtual ITooltip GetCustomTooltip() => new ModButtonTooltip(); - - public Mod TooltipContent => SelectedMod ?? Mods.FirstOrDefault(); - } -} diff --git a/osu.Game/Overlays/Mods/ModButtonEmpty.cs b/osu.Game/Overlays/Mods/ModButtonEmpty.cs deleted file mode 100644 index 03afe5adba..0000000000 --- a/osu.Game/Overlays/Mods/ModButtonEmpty.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osu.Framework.Graphics.Containers; - -namespace osu.Game.Overlays.Mods -{ - /// - /// A mod button used exclusively for providing an empty space the size of a mod button. - /// - public class ModButtonEmpty : Container - { - public ModButtonEmpty() - { - Size = new Vector2(100f); - AlwaysPresent = true; - } - } -} diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 3a2fda0bb0..b32ebb4a5c 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -384,7 +384,7 @@ namespace osu.Game.Overlays.Mods /// /// /// This method exists to be able to receive mod instances that come from potentially-external sources and to copy the changes across to this column's state. - /// uses this to substitute any external mod references in + /// uses this to substitute any external mod references in /// to references that are owned by this column. /// internal void SetSelection(IReadOnlyList mods) diff --git a/osu.Game/Overlays/Mods/ModControlSection.cs b/osu.Game/Overlays/Mods/ModControlSection.cs deleted file mode 100644 index 10b3bc7c2b..0000000000 --- a/osu.Game/Overlays/Mods/ModControlSection.cs +++ /dev/null @@ -1,54 +0,0 @@ -// 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 osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using osuTK; - -namespace osu.Game.Overlays.Mods -{ - public class ModControlSection : CompositeDrawable - { - protected FillFlowContainer FlowContent; - - public readonly Mod Mod; - - public ModControlSection(Mod mod, IEnumerable modControls) - { - Mod = mod; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - FlowContent = new FillFlowContainer - { - Margin = new MarginPadding { Top = 30 }, - Spacing = new Vector2(0, 5), - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - ChildrenEnumerable = modControls - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AddRangeInternal(new Drawable[] - { - new OsuSpriteText - { - Text = Mod.Name, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Colour = colours.Yellow, - }, - FlowContent - }); - } - } -} diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs deleted file mode 100644 index a70191a864..0000000000 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osuTK.Input; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using System; -using System.Linq; -using System.Collections.Generic; -using System.Threading; -using Humanizer; -using osu.Framework.Input.Events; -using osu.Game.Graphics; - -namespace osu.Game.Overlays.Mods -{ - public class ModSection : CompositeDrawable - { - private readonly Drawable header; - - public FillFlowContainer ButtonsContainer { get; } - - protected IReadOnlyList Buttons { get; private set; } = Array.Empty(); - - public Action Action; - - public Key[] ToggleKeys; - - public readonly ModType ModType; - - public IEnumerable SelectedMods => Buttons.Select(b => b.SelectedMod).Where(m => m != null); - - private CancellationTokenSource modsLoadCts; - - protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0; - - /// - /// True when all mod icons have completed loading. - /// - public bool ModIconsLoaded { get; private set; } = true; - - public IEnumerable Mods - { - set - { - var modContainers = value.Select(m => - { - if (m == null) - return new ModButtonEmpty(); - - return CreateModButton(m).With(b => - { - b.SelectionChanged = mod => - { - ModButtonStateChanged(mod); - Action?.Invoke(mod); - }; - }); - }).ToArray(); - - modsLoadCts?.Cancel(); - - if (modContainers.Length == 0) - { - ModIconsLoaded = true; - header.Hide(); - Hide(); - return; - } - - ModIconsLoaded = false; - - LoadComponentsAsync(modContainers, c => - { - ModIconsLoaded = true; - ButtonsContainer.ChildrenEnumerable = c; - }, (modsLoadCts = new CancellationTokenSource()).Token); - - Buttons = modContainers.OfType().ToArray(); - - header.FadeIn(200); - this.FadeIn(200); - } - } - - protected virtual void ModButtonStateChanged(Mod mod) - { - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.ControlPressed) return false; - - if (ToggleKeys != null) - { - int index = Array.IndexOf(ToggleKeys, e.Key); - if (index > -1 && index < Buttons.Count) - Buttons[index].SelectNext(e.ShiftPressed ? -1 : 1); - } - - return base.OnKeyDown(e); - } - - private const double initial_multiple_selection_delay = 120; - - private double selectionDelay = initial_multiple_selection_delay; - private double lastSelection; - - private readonly Queue pendingSelectionOperations = new Queue(); - - protected override void Update() - { - base.Update(); - - if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay) - { - if (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) - { - dequeuedAction(); - - // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). - selectionDelay = Math.Max(30, selectionDelay * 0.8f); - lastSelection = Time.Current; - } - else - { - // reset the selection delay after all animations have been completed. - // this will cause the next action to be immediately performed. - selectionDelay = initial_multiple_selection_delay; - } - } - } - - /// - /// Selects all mods. - /// - public void SelectAll() - { - pendingSelectionOperations.Clear(); - - foreach (var button in Buttons.Where(b => !b.Selected)) - pendingSelectionOperations.Enqueue(() => button.SelectAt(0)); - } - - /// - /// Deselects all mods. - /// - public void DeselectAll() - { - pendingSelectionOperations.Clear(); - DeselectTypes(Buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); - } - - /// - /// Deselect one or more mods in this section. - /// - /// The types of s which should be deselected. - /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow. - /// If this deselection is triggered by a user selection, this should contain the newly selected type. This type will never be deselected, even if it matches one provided in . - public void DeselectTypes(IEnumerable modTypes, bool immediate = false, Mod newSelection = null) - { - foreach (var button in Buttons) - { - if (button.SelectedMod == null) continue; - - if (button.SelectedMod == newSelection) - continue; - - foreach (var type in modTypes) - { - if (type.IsInstanceOfType(button.SelectedMod)) - { - if (immediate) - button.Deselect(); - else - pendingSelectionOperations.Enqueue(button.Deselect); - } - } - } - } - - /// - /// Updates all buttons with the given list of selected mods. - /// - /// The new list of selected mods to select. - public void UpdateSelectedButtons(IReadOnlyList newSelectedMods) - { - foreach (var button in Buttons) - updateButtonSelection(button, newSelectedMods); - } - - private void updateButtonSelection(ModButton button, IReadOnlyList newSelectedMods) - { - foreach (var mod in newSelectedMods) - { - int index = Array.FindIndex(button.Mods, m1 => mod.GetType() == m1.GetType()); - if (index < 0) - continue; - - var buttonMod = button.Mods[index]; - - // as this is likely coming from an external change, ensure the settings of the mod are in sync. - buttonMod.CopyFrom(mod); - - button.SelectAt(index, false); - return; - } - - button.Deselect(); - } - - public ModSection(ModType type) - { - ModType = type; - - AutoSizeAxes = Axes.Y; - RelativeSizeAxes = Axes.X; - - Origin = Anchor.TopCentre; - Anchor = Anchor.TopCentre; - - InternalChildren = new[] - { - header = CreateHeader(type.Humanize(LetterCasing.Title)), - ButtonsContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Spacing = new Vector2(50f, 0f), - Margin = new MarginPadding - { - Top = 20, - }, - AlwaysPresent = true - }, - }; - } - - protected virtual Drawable CreateHeader(string text) => new OsuSpriteText - { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = text - }; - - protected virtual ModButton CreateModButton(Mod mod) => new ModButton(mod); - - /// - /// Run any delayed selections (due to animation) immediately to leave mods in a good (final) state. - /// - public void FlushPendingSelections() - { - while (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) - dequeuedAction(); - } - } -} diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index cf57322594..b3c3eee15a 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -1,532 +1,646 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Lists; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; using osu.Game.Rulesets.Mods; -using osu.Game.Screens; -using osu.Game.Utils; using osuTK; -using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract class ModSelectOverlay : WaveOverlayContainer + public abstract class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler { - public const float HEIGHT = 510; + protected const int BUTTON_WIDTH = 200; - protected readonly TriangleButton DeselectAllButton; - protected readonly TriangleButton CustomiseButton; - protected readonly TriangleButton CloseButton; + [Cached] + public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); - protected readonly FillFlowContainer FooterContainer; - - protected override bool BlockNonPositionalInput => false; - - protected override bool DimMainContent => false; - - /// - /// Whether s underneath the same instance should appear as stacked buttons. - /// - protected virtual bool Stacked => true; - - /// - /// Whether configurable s can be configured by the local user. - /// - protected virtual bool AllowConfiguration => true; - - [NotNull] private Func isValidMod = m => true; /// - /// A function that checks whether a given mod is selectable. + /// A function determining whether each mod in the column should be displayed. + /// A return value of means that the mod is not filtered and therefore its corresponding panel should be displayed. + /// A return value of means that the mod is filtered out and therefore its corresponding panel should be hidden. /// - [NotNull] public Func IsValidMod { get => isValidMod; set { isValidMod = value ?? throw new ArgumentNullException(nameof(value)); - updateAvailableMods(); + + if (IsLoaded) + updateAvailableMods(); } } - protected readonly FillFlowContainer ModSectionsContainer; + /// + /// Whether the total score multiplier calculated from the current selected set of mods should be shown. + /// + protected virtual bool ShowTotalMultiplier => true; - protected readonly ModSettingsContainer ModSettingsContainer; + protected virtual ModColumn CreateModColumn(ModType modType, Key[]? toggleKeys = null) => new ModColumn(modType, false, toggleKeys); - [Cached] - public readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); + protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; - private Bindable>> availableMods; + protected virtual IEnumerable CreateFooterButtons() => createDefaultFooterButtons(); - protected Color4 LowMultiplierColour; - protected Color4 HighMultiplierColour; + private readonly BindableBool customisationVisible = new BindableBool(); - private const float content_width = 0.8f; - private const float footer_button_spacing = 20; + private ModSettingsArea modSettingsArea = null!; + private ColumnScrollContainer columnScroll = null!; + private ColumnFlowContainer columnFlow = null!; + private FillFlowContainer footerButtonFlow = null!; + private ShearedButton backButton = null!; - private Sample sampleOn, sampleOff; + private DifficultyMultiplierDisplay? multiplierDisplay; - protected ModSelectOverlay() + private ShearedToggleButton? customisationButton; + + protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) + : base(colourScheme) { - Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2"); - Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2"); - Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); - Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e"); + } - RelativeSizeAxes = Axes.X; - Height = HEIGHT; + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Header.Title = ModSelectOverlayStrings.ModSelectTitle; + Header.Description = ModSelectOverlayStrings.ModSelectDescription; - Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING }; + AddRange(new Drawable[] + { + new ClickToReturnContainer + { + RelativeSizeAxes = Axes.Both, + HandleMouse = { BindTarget = customisationVisible }, + OnClicked = () => customisationVisible.Value = false + }, + modSettingsArea = new ModSettingsArea + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 0 + } + }); - Children = new Drawable[] + MainAreaContent.AddRange(new Drawable[] { new Container { + Padding = new MarginPadding + { + Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING, + Bottom = PADDING + }, RelativeSizeAxes = Axes.Both, - Masking = true, + RelativePositionAxes = Axes.Both, Children = new Drawable[] { - new Box + columnScroll = new ColumnScrollContainer { RelativeSizeAxes = Axes.Both, - Colour = new Color4(36, 50, 68, 255) - }, - new Triangles - { - TriangleScale = 5, - RelativeSizeAxes = Axes.Both, - ColourLight = new Color4(53, 66, 82, 255), - ColourDark = new Color4(41, 54, 70, 255), - }, - }, - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RowDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, 90), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - new Container + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ColumnFlowContainer { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Children = new Drawable[] + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Shear = new Vector2(SHEAR, 0), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 70 }, + Padding = new MarginPadding { Bottom = 10 }, + Children = new[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(10).Opacity(100), - }, - new FillFlowContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Width = content_width, - Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = @"Gameplay Mods", - Font = OsuFont.GetFont(size: 22, weight: FontWeight.Bold), - Shadow = true, - Margin = new MarginPadding - { - Bottom = 4, - }, - }, - new OsuTextFlowContainer(text => - { - text.Font = text.Font.With(size: 18); - text.Shadow = true; - }) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play.\nOthers are just for fun.", - }, - }, - }, - }, - }, - }, - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - // Body - new OsuScrollContainer - { - ScrollbarVisible = false, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Vertical = 10, - Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING - }, - Children = new Drawable[] - { - ModSectionsContainer = new FillFlowContainer - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0f, 10f), - Width = content_width, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - Children = new[] - { - CreateModSection(ModType.DifficultyReduction).With(s => - { - s.ToggleKeys = new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }; - s.Action = modButtonPressed; - }), - CreateModSection(ModType.DifficultyIncrease).With(s => - { - s.ToggleKeys = new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }; - s.Action = modButtonPressed; - }), - CreateModSection(ModType.Automation).With(s => - { - s.ToggleKeys = new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }; - s.Action = modButtonPressed; - }), - CreateModSection(ModType.Conversion).With(s => - { - s.Action = modButtonPressed; - }), - CreateModSection(ModType.Fun).With(s => - { - s.Action = modButtonPressed; - }), - } - }, - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Padding = new MarginPadding(30), - Width = 0.3f, - Children = new Drawable[] - { - ModSettingsContainer = new ModSettingsContainer - { - Alpha = 0, - SelectedMods = { BindTarget = SelectedMods }, - }, - } - }, + createModColumnContent(ModType.DifficultyReduction, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }), + createModColumnContent(ModType.DifficultyIncrease, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }), + createModColumnContent(ModType.Automation, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }), + createModColumnContent(ModType.Conversion), + createModColumnContent(ModType.Fun) } - }, - }, - new Drawable[] - { - new Container - { - Name = "Footer content", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = new Color4(172, 20, 116, 255), - Alpha = 0.5f, - }, - FooterContainer = new FillFlowContainer - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - RelativePositionAxes = Axes.X, - Width = content_width, - Spacing = new Vector2(footer_button_spacing, footer_button_spacing / 2), - Padding = new MarginPadding - { - Vertical = 15, - Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING - }, - Children = new[] - { - DeselectAllButton = new TriangleButton - { - Width = 180, - Text = "Deselect All", - Action = deselectAll, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - }, - CustomiseButton = new TriangleButton - { - Width = 180, - Text = "Customisation", - Action = () => ModSettingsContainer.ToggleVisibility(), - Enabled = { Value = false }, - Alpha = AllowConfiguration ? 1 : 0, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - }, - CloseButton = new TriangleButton - { - Width = 180, - Text = CommonStrings.ButtonsClose, - Action = Hide, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - }, - } - } - }, } - }, + } + } + } + }); + + if (ShowTotalMultiplier) + { + MainAreaContent.Add(new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.X, + Height = DifficultyMultiplierDisplay.HEIGHT, + Margin = new MarginPadding { Horizontal = 100 }, + Child = multiplierDisplay = new DifficultyMultiplierDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre }, + }); + } + + FooterContent.Child = footerButtonFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Padding = new MarginPadding + { + Vertical = PADDING, + Horizontal = 70 }, + Spacing = new Vector2(10), + ChildrenEnumerable = CreateFooterButtons().Prepend(backButton = new ShearedButton(BUTTON_WIDTH) + { + Text = CommonStrings.Back, + Action = Hide, + DarkerColour = colours.Pink2, + LighterColour = colours.Pink1 + }) }; - - ((IBindable)CustomiseButton.Enabled).BindTo(ModSettingsContainer.HasSettingsForSelection); - } - - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuGameBase osu) - { - availableMods = osu.AvailableMods.GetBoundCopy(); - - sampleOn = audio.Samples.Get(@"UI/check-on"); - sampleOff = audio.Samples.Get(@"UI/check-off"); - } - - private void deselectAll() - { - foreach (var section in ModSectionsContainer.Children) - section.DeselectAll(); - - refreshSelectedMods(); } protected override void LoadComplete() { base.LoadComplete(); - availableMods.BindValueChanged(_ => updateAvailableMods(), true); + State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true); - // intentionally bound after the above line to avoid a potential update feedback cycle. - // i haven't actually observed this happening but as updateAvailableMods() changes the selection it is plausible. - SelectedMods.BindValueChanged(_ => updateSelectedButtons()); + // This is an optimisation to prevent refreshing the available settings controls when it can be + // reasonably assumed that the settings panel is never to be displayed (e.g. FreeModSelectOverlay). + if (customisationButton != null) + ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); + + SelectedMods.BindValueChanged(val => + { + updateMultiplier(); + updateCustomisation(val); + updateSelectionFromBindable(); + }, true); + + foreach (var column in columnFlow.Columns) + { + column.SelectionChangedByUser += updateBindableFromSelection; + } + + customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); + + updateAvailableMods(); + + // Start scrolled slightly to the right to give the user a sense that + // there is more horizontal content available. + ScheduleAfterChildren(() => + { + columnScroll.ScrollTo(200, false); + columnScroll.ScrollToStart(); + }); + } + + /// + /// Select all visible mods in all columns. + /// + protected void SelectAll() + { + foreach (var column in columnFlow.Columns) + column.SelectAll(); + } + + /// + /// Deselect all visible mods in all columns. + /// + protected void DeselectAll() + { + foreach (var column in columnFlow.Columns) + column.DeselectAll(); + } + + private ColumnDimContainer createModColumnContent(ModType modType, Key[]? toggleKeys = null) + { + var column = CreateModColumn(modType, toggleKeys).With(column => + { + column.Filter = IsValidMod; + // spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden. + column.Margin = new MarginPadding { Right = 10 }; + }); + + return new ColumnDimContainer(column) + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140), + }; + } + + private ShearedButton[] createDefaultFooterButtons() + => new[] + { + customisationButton = new ShearedToggleButton(BUTTON_WIDTH) + { + Text = ModSelectOverlayStrings.ModCustomisation, + Active = { BindTarget = customisationVisible } + }, + new ShearedButton(BUTTON_WIDTH) + { + Text = CommonStrings.DeselectAll, + Action = DeselectAll + } + }; + + private void updateMultiplier() + { + if (multiplierDisplay == null) + return; + + double multiplier = 1.0; + + foreach (var mod in SelectedMods.Value) + multiplier *= mod.ScoreMultiplier; + + multiplierDisplay.Current.Value = multiplier; + } + + private void updateAvailableMods() + { + foreach (var column in columnFlow.Columns) + column.Filter = m => m.HasImplementation && isValidMod.Invoke(m); + } + + private void updateCustomisation(ValueChangedEvent> valueChangedEvent) + { + if (customisationButton == null) + return; + + bool anyCustomisableMod = false; + bool anyModWithRequiredCustomisationAdded = false; + + foreach (var mod in SelectedMods.Value) + { + anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); + anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration; + } + + if (anyCustomisableMod) + { + customisationVisible.Disabled = false; + + if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) + customisationVisible.Value = true; + } + else + { + if (customisationVisible.Value) + customisationVisible.Value = false; + + customisationVisible.Disabled = true; + } + } + + private void updateCustomisationVisualState() + { + const double transition_duration = 300; + + MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); + + foreach (var button in footerButtonFlow) + { + if (button != customisationButton) + button.Enabled.Value = !customisationVisible.Value; + } + + float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; + + modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); + TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); + } + + private void updateSelectionFromBindable() + { + // `SelectedMods` may contain mod references that come from external sources. + // to ensure isolation, first pull in the potentially-external change into the mod columns... + foreach (var column in columnFlow.Columns) + column.SetSelection(SelectedMods.Value); + + // and then, when done, replace the potentially-external mod references in `SelectedMods` with ones we own. + updateBindableFromSelection(); + } + + private void updateBindableFromSelection() + { + var candidateSelection = columnFlow.Columns.SelectMany(column => column.SelectedMods).ToArray(); + + // the following guard intends to check cases where we've already replaced potentially-external mod references with our own and avoid endless recursion. + // TODO: replace custom comparer with System.Collections.Generic.ReferenceEqualityComparer when fully on .NET 6 + if (candidateSelection.SequenceEqual(SelectedMods.Value, new FuncEqualityComparer(ReferenceEquals))) + return; + + SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection); + } + + #region Transition handling + + private const float distance = 700; + + protected override void PopIn() + { + const double fade_in_duration = 400; + + base.PopIn(); + + multiplierDisplay? + .Delay(fade_in_duration * 0.65f) + .FadeIn(fade_in_duration / 2, Easing.OutQuint) + .ScaleTo(1, fade_in_duration, Easing.OutElastic); + + int nonFilteredColumnCount = 0; + + for (int i = 0; i < columnFlow.Count; i++) + { + var column = columnFlow[i].Column; + + double delay = column.AllFiltered.Value ? 0 : nonFilteredColumnCount * 30; + double duration = column.AllFiltered.Value ? 0 : fade_in_duration; + float startingYPosition = 0; + if (!column.AllFiltered.Value) + startingYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; + + column.TopLevelContent + .MoveToY(startingYPosition) + .Delay(delay) + .MoveToY(0, duration, Easing.OutQuint) + .FadeIn(duration, Easing.OutQuint); + + if (!column.AllFiltered.Value) + nonFilteredColumnCount += 1; + } } protected override void PopOut() { + const double fade_out_duration = 500; + base.PopOut(); - foreach (var section in ModSectionsContainer) + multiplierDisplay? + .FadeOut(fade_out_duration / 2, Easing.OutQuint) + .ScaleTo(0.75f, fade_out_duration, Easing.OutQuint); + + int nonFilteredColumnCount = 0; + + for (int i = 0; i < columnFlow.Count; i++) { - section.FlushPendingSelections(); + var column = columnFlow[i].Column; + + double duration = column.AllFiltered.Value ? 0 : fade_out_duration; + float newYPosition = 0; + if (!column.AllFiltered.Value) + newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; + + column.FlushPendingSelections(); + column.TopLevelContent + .MoveToY(newYPosition, duration, Easing.OutQuint) + .FadeOut(duration, Easing.OutQuint); + + if (!column.AllFiltered.Value) + nonFilteredColumnCount += 1; } - - FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - FooterContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - - foreach (var section in ModSectionsContainer.Children) - { - section.ButtonsContainer.TransformSpacingTo(new Vector2(100f, 0f), WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - section.ButtonsContainer.MoveToX(100f, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - section.ButtonsContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine); - } - } - - protected override void PopIn() - { - base.PopIn(); - - FooterContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); - FooterContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - - foreach (var section in ModSectionsContainer.Children) - { - section.ButtonsContainer.TransformSpacingTo(new Vector2(50f, 0f), WaveContainer.APPEAR_DURATION, Easing.OutQuint); - section.ButtonsContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); - section.ButtonsContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - } - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - // don't absorb control as ToolbarRulesetSelector uses control + number to navigate - if (e.ControlPressed) return false; - - switch (e.Key) - { - case Key.Number1: - DeselectAllButton.TriggerClick(); - return true; - - case Key.Number2: - CloseButton.TriggerClick(); - return true; - } - - return base.OnKeyDown(e); - } - - public override bool OnPressed(KeyBindingPressEvent e) => false; // handled by back button - - private void updateAvailableMods() - { - if (availableMods?.Value == null) - return; - - foreach (var section in ModSectionsContainer.Children) - { - IEnumerable modEnumeration = availableMods.Value[section.ModType]; - - if (!Stacked) - modEnumeration = ModUtils.FlattenMods(modEnumeration); - - section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.DeepClone()); - } - - updateSelectedButtons(); - OnAvailableModsChanged(); - } - - /// - /// Returns a valid form of a given if possible, or null otherwise. - /// - /// - /// This is a recursive process during which any invalid mods are culled while preserving structures where possible. - /// - /// The to check. - /// A valid form of if exists, or null otherwise. - [CanBeNull] - private Mod getValidModOrNull([NotNull] Mod mod) - { - if (!(mod is MultiMod multi)) - return IsValidMod(mod) ? mod : null; - - var validSubset = multi.Mods.Select(getValidModOrNull).Where(m => m != null).ToArray(); - - if (validSubset.Length == 0) - return null; - - return validSubset.Length == 1 ? validSubset[0] : new MultiMod(validSubset); - } - - private void updateSelectedButtons() - { - // Enumeration below may update the bindable list. - var selectedMods = SelectedMods.Value.ToList(); - - foreach (var section in ModSectionsContainer.Children) - section.UpdateSelectedButtons(selectedMods); - } - - private void modButtonPressed(Mod selectedMod) - { - if (selectedMod != null) - { - if (State.Value == Visibility.Visible) - Scheduler.AddOnce(playSelectedSound); - - OnModSelected(selectedMod); - - if (selectedMod.RequiresConfiguration && AllowConfiguration) - ModSettingsContainer.Show(); - } - else - { - if (State.Value == Visibility.Visible) - Scheduler.AddOnce(playDeselectedSound); - } - - refreshSelectedMods(); - } - - private void playSelectedSound() => sampleOn?.Play(); - private void playDeselectedSound() => sampleOff?.Play(); - - /// - /// Invoked after has changed. - /// - protected virtual void OnAvailableModsChanged() - { - } - - /// - /// Invoked when a new has been selected. - /// - /// The that has been selected. - protected virtual void OnModSelected(Mod mod) - { - } - - private void refreshSelectedMods() => SelectedMods.Value = ModSectionsContainer.Children.SelectMany(s => s.SelectedMods).ToArray(); - - /// - /// Creates a that groups s with the same . - /// - /// The of s in the section. - /// The . - protected virtual ModSection CreateModSection(ModType type) => new ModSection(type); - - #region Disposal - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - availableMods?.UnbindAll(); - SelectedMods?.UnbindAll(); } #endregion + + #region Input handling + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + // Pressing the back binding should only go back one step at a time. + hideOverlay(false); + return true; + + // This is handled locally here because this overlay is being registered at the game level + // and therefore takes away keyboard focus from the screen stack. + case GlobalAction.ToggleModSelection: + case GlobalAction.Select: + { + // Pressing toggle or select should completely hide the overlay in one shot. + hideOverlay(true); + return true; + } + } + + return base.OnPressed(e); + + void hideOverlay(bool immediate) + { + if (customisationVisible.Value) + { + Debug.Assert(customisationButton != null); + customisationButton.TriggerClick(); + + if (!immediate) + return; + } + + backButton.TriggerClick(); + } + } + + #endregion + + #region Sample playback control + + private readonly Bindable samplePlaybackDisabled = new BindableBool(true); + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; + + #endregion + + /// + /// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility. + /// + internal class ColumnScrollContainer : OsuScrollContainer + { + public ColumnScrollContainer() + : base(Direction.Horizontal) + { + } + + protected override void Update() + { + base.Update(); + + // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. + // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. + float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); + float rightVisibleBound = leftVisibleBound + DrawWidth; + + // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. + // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. + float leftMovementBound = Math.Min(Current, Target); + float rightMovementBound = Math.Max(Current, Target) + DrawWidth; + + foreach (var column in Child) + { + // DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear, + // so we have to manually compensate. + var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent); + var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * SHEAR, 0), ScrollContent); + + bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound) + && Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X); + bool isBeingScrolledToward = Precision.AlmostBigger(topLeft.X, leftMovementBound) + && Precision.DefinitelyBigger(rightMovementBound, bottomRight.X); + + column.Active.Value = isCurrentlyVisible || isBeingScrolledToward; + } + } + } + + /// + /// Manages layout of mod columns. + /// + internal class ColumnFlowContainer : FillFlowContainer + { + public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); + + public override void Add(ColumnDimContainer dimContainer) + { + base.Add(dimContainer); + + Debug.Assert(dimContainer != null); + dimContainer.Column.Shear = Vector2.Zero; + } + } + + /// + /// Encapsulates a column and provides dim and input blocking based on an externally managed "active" state. + /// + internal class ColumnDimContainer : Container + { + public ModColumn Column { get; } + + /// + /// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen. + /// + public readonly Bindable Active = new BindableBool(); + + /// + /// Invoked when the column is clicked while not active, requesting a scroll to be performed to bring it on-screen. + /// + public Action? RequestScroll { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ColumnDimContainer(ModColumn column) + { + Child = Column = column; + column.Active.BindTo(Active); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Active.BindValueChanged(_ => updateState()); + Column.AllFiltered.BindValueChanged(_ => updateState(), true); + FinishTransforms(); + } + + protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || Column.SelectionAnimationRunning; + + private void updateState() + { + Colour4 targetColour; + + Column.Alpha = Column.AllFiltered.Value ? 0 : 1; + + if (Column.Active.Value) + targetColour = Colour4.White; + else + targetColour = IsHovered ? colours.GrayC : colours.Gray8; + + this.FadeColour(targetColour, 800, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + if (!Active.Value) + RequestScroll?.Invoke(this); + + return true; + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + updateState(); + return Active.Value; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + } + + /// + /// A container which blocks and handles input, managing the "return from customisation" state change. + /// + private class ClickToReturnContainer : Container + { + public BindableBool HandleMouse { get; } = new BindableBool(); + + public Action? OnClicked { get; set; } + + public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; + + protected override bool Handle(UIEvent e) + { + if (!HandleMouse.Value) + return base.Handle(e); + + switch (e) + { + case ClickEvent _: + OnClicked?.Invoke(); + return true; + + case MouseEvent _: + return true; + } + + return base.Handle(e); + } + } } } diff --git a/osu.Game/Overlays/Mods/ModSelectScreen.cs b/osu.Game/Overlays/Mods/ModSelectScreen.cs deleted file mode 100644 index 8b19e38954..0000000000 --- a/osu.Game/Overlays/Mods/ModSelectScreen.cs +++ /dev/null @@ -1,664 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; -using osu.Framework.Layout; -using osu.Framework.Lists; -using osu.Framework.Utils; -using osu.Game.Audio; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; -using osu.Game.Localisation; -using osu.Game.Rulesets.Mods; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods -{ - public abstract class ModSelectScreen : ShearedOverlayContainer, ISamplePlaybackDisabler - { - protected const int BUTTON_WIDTH = 200; - - [Cached] - public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); - - private Func isValidMod = m => true; - - /// - /// A function determining whether each mod in the column should be displayed. - /// A return value of means that the mod is not filtered and therefore its corresponding panel should be displayed. - /// A return value of means that the mod is filtered out and therefore its corresponding panel should be hidden. - /// - public Func IsValidMod - { - get => isValidMod; - set - { - isValidMod = value ?? throw new ArgumentNullException(nameof(value)); - - if (IsLoaded) - updateAvailableMods(); - } - } - - /// - /// Whether the total score multiplier calculated from the current selected set of mods should be shown. - /// - protected virtual bool ShowTotalMultiplier => true; - - protected virtual ModColumn CreateModColumn(ModType modType, Key[]? toggleKeys = null) => new ModColumn(modType, false, toggleKeys); - - protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; - - protected virtual IEnumerable CreateFooterButtons() => createDefaultFooterButtons(); - - private readonly BindableBool customisationVisible = new BindableBool(); - - private ModSettingsArea modSettingsArea = null!; - private ColumnScrollContainer columnScroll = null!; - private ColumnFlowContainer columnFlow = null!; - private FillFlowContainer footerButtonFlow = null!; - private ShearedButton backButton = null!; - - private DifficultyMultiplierDisplay? multiplierDisplay; - - private ShearedToggleButton? customisationButton; - - protected ModSelectScreen(OverlayColourScheme colourScheme = OverlayColourScheme.Green) - : base(colourScheme) - { - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Header.Title = ModSelectScreenStrings.ModSelectTitle; - Header.Description = ModSelectScreenStrings.ModSelectDescription; - - AddRange(new Drawable[] - { - new ClickToReturnContainer - { - RelativeSizeAxes = Axes.Both, - HandleMouse = { BindTarget = customisationVisible }, - OnClicked = () => customisationVisible.Value = false - }, - modSettingsArea = new ModSettingsArea - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Height = 0 - } - }); - - MainAreaContent.AddRange(new Drawable[] - { - new Container - { - Padding = new MarginPadding - { - Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING, - Bottom = PADDING - }, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Children = new Drawable[] - { - columnScroll = new ColumnScrollContainer - { - RelativeSizeAxes = Axes.Both, - Masking = false, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = columnFlow = new ColumnFlowContainer - { - Direction = FillDirection.Horizontal, - Shear = new Vector2(SHEAR, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 70 }, - Children = new[] - { - createModColumnContent(ModType.DifficultyReduction, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }), - createModColumnContent(ModType.DifficultyIncrease, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }), - createModColumnContent(ModType.Automation, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }), - createModColumnContent(ModType.Conversion), - createModColumnContent(ModType.Fun) - } - } - } - } - } - }); - - if (ShowTotalMultiplier) - { - MainAreaContent.Add(new Container - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.X, - Height = DifficultyMultiplierDisplay.HEIGHT, - Margin = new MarginPadding { Horizontal = 100 }, - Child = multiplierDisplay = new DifficultyMultiplierDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }, - }); - } - - FooterContent.Child = footerButtonFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Padding = new MarginPadding - { - Vertical = PADDING, - Horizontal = 70 - }, - Spacing = new Vector2(10), - ChildrenEnumerable = CreateFooterButtons().Prepend(backButton = new ShearedButton(BUTTON_WIDTH) - { - Text = CommonStrings.Back, - Action = Hide, - DarkerColour = colours.Pink2, - LighterColour = colours.Pink1 - }) - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true); - - ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); - - SelectedMods.BindValueChanged(val => - { - updateMultiplier(); - updateCustomisation(val); - updateSelectionFromBindable(); - }, true); - - foreach (var column in columnFlow.Columns) - { - column.SelectionChangedByUser += updateBindableFromSelection; - } - - customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); - - updateAvailableMods(); - - // Start scrolled slightly to the right to give the user a sense that - // there is more horizontal content available. - ScheduleAfterChildren(() => - { - columnScroll.ScrollTo(200, false); - columnScroll.ScrollToStart(); - }); - } - - /// - /// Select all visible mods in all columns. - /// - protected void SelectAll() - { - foreach (var column in columnFlow.Columns) - column.SelectAll(); - } - - /// - /// Deselect all visible mods in all columns. - /// - protected void DeselectAll() - { - foreach (var column in columnFlow.Columns) - column.DeselectAll(); - } - - private ColumnDimContainer createModColumnContent(ModType modType, Key[]? toggleKeys = null) - { - var column = CreateModColumn(modType, toggleKeys).With(column => - { - column.Filter = IsValidMod; - // spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden. - column.Margin = new MarginPadding { Right = 10 }; - }); - - return new ColumnDimContainer(column) - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140), - }; - } - - private ShearedButton[] createDefaultFooterButtons() - => new[] - { - customisationButton = new ShearedToggleButton(BUTTON_WIDTH) - { - Text = ModSelectScreenStrings.ModCustomisation, - Active = { BindTarget = customisationVisible } - }, - new ShearedButton(BUTTON_WIDTH) - { - Text = CommonStrings.DeselectAll, - Action = DeselectAll - } - }; - - private void updateMultiplier() - { - if (multiplierDisplay == null) - return; - - double multiplier = 1.0; - - foreach (var mod in SelectedMods.Value) - multiplier *= mod.ScoreMultiplier; - - multiplierDisplay.Current.Value = multiplier; - } - - private void updateAvailableMods() - { - foreach (var column in columnFlow.Columns) - column.Filter = m => m.HasImplementation && isValidMod.Invoke(m); - } - - private void updateCustomisation(ValueChangedEvent> valueChangedEvent) - { - if (customisationButton == null) - return; - - bool anyCustomisableMod = false; - bool anyModWithRequiredCustomisationAdded = false; - - foreach (var mod in SelectedMods.Value) - { - anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); - anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration; - } - - if (anyCustomisableMod) - { - customisationVisible.Disabled = false; - - if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) - customisationVisible.Value = true; - } - else - { - if (customisationVisible.Value) - customisationVisible.Value = false; - - customisationVisible.Disabled = true; - } - } - - private void updateCustomisationVisualState() - { - const double transition_duration = 300; - - MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); - - foreach (var button in footerButtonFlow) - { - if (button != customisationButton) - button.Enabled.Value = !customisationVisible.Value; - } - - float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; - - modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); - TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); - } - - private void updateSelectionFromBindable() - { - // `SelectedMods` may contain mod references that come from external sources. - // to ensure isolation, first pull in the potentially-external change into the mod columns... - foreach (var column in columnFlow.Columns) - column.SetSelection(SelectedMods.Value); - - // and then, when done, replace the potentially-external mod references in `SelectedMods` with ones we own. - updateBindableFromSelection(); - } - - private void updateBindableFromSelection() - { - var candidateSelection = columnFlow.Columns.SelectMany(column => column.SelectedMods).ToArray(); - - // the following guard intends to check cases where we've already replaced potentially-external mod references with our own and avoid endless recursion. - // TODO: replace custom comparer with System.Collections.Generic.ReferenceEqualityComparer when fully on .NET 6 - if (candidateSelection.SequenceEqual(SelectedMods.Value, new FuncEqualityComparer(ReferenceEquals))) - return; - - SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection); - } - - #region Transition handling - - private const float distance = 700; - - protected override void PopIn() - { - const double fade_in_duration = 400; - - base.PopIn(); - - multiplierDisplay? - .Delay(fade_in_duration * 0.65f) - .FadeIn(fade_in_duration / 2, Easing.OutQuint) - .ScaleTo(1, fade_in_duration, Easing.OutElastic); - - int nonFilteredColumnCount = 0; - - for (int i = 0; i < columnFlow.Count; i++) - { - var column = columnFlow[i].Column; - - double delay = column.AllFiltered.Value ? 0 : nonFilteredColumnCount * 30; - double duration = column.AllFiltered.Value ? 0 : fade_in_duration; - float startingYPosition = 0; - if (!column.AllFiltered.Value) - startingYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; - - column.TopLevelContent - .MoveToY(startingYPosition) - .Delay(delay) - .MoveToY(0, duration, Easing.OutQuint) - .FadeIn(duration, Easing.OutQuint); - - if (!column.AllFiltered.Value) - nonFilteredColumnCount += 1; - } - } - - protected override void PopOut() - { - const double fade_out_duration = 500; - - base.PopOut(); - - multiplierDisplay? - .FadeOut(fade_out_duration / 2, Easing.OutQuint) - .ScaleTo(0.75f, fade_out_duration, Easing.OutQuint); - - int nonFilteredColumnCount = 0; - - for (int i = 0; i < columnFlow.Count; i++) - { - var column = columnFlow[i].Column; - - double duration = column.AllFiltered.Value ? 0 : fade_out_duration; - float newYPosition = 0; - if (!column.AllFiltered.Value) - newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; - - column.FlushPendingSelections(); - column.TopLevelContent - .MoveToY(newYPosition, duration, Easing.OutQuint) - .FadeOut(duration, Easing.OutQuint); - - if (!column.AllFiltered.Value) - nonFilteredColumnCount += 1; - } - } - - #endregion - - #region Input handling - - public override bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat) - return false; - - switch (e.Action) - { - case GlobalAction.Back: - // Pressing the back binding should only go back one step at a time. - hideOverlay(false); - return true; - - // This is handled locally here because this overlay is being registered at the game level - // and therefore takes away keyboard focus from the screen stack. - case GlobalAction.ToggleModSelection: - case GlobalAction.Select: - { - // Pressing toggle or select should completely hide the overlay in one shot. - hideOverlay(true); - return true; - } - } - - return base.OnPressed(e); - - void hideOverlay(bool immediate) - { - if (customisationVisible.Value) - { - Debug.Assert(customisationButton != null); - customisationButton.TriggerClick(); - - if (!immediate) - return; - } - - backButton.TriggerClick(); - } - } - - #endregion - - #region Sample playback control - - private readonly Bindable samplePlaybackDisabled = new BindableBool(true); - IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; - - #endregion - - /// - /// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility. - /// - internal class ColumnScrollContainer : OsuScrollContainer - { - public ColumnScrollContainer() - : base(Direction.Horizontal) - { - } - - protected override void Update() - { - base.Update(); - - // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. - // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. - float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); - float rightVisibleBound = leftVisibleBound + DrawWidth; - - // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. - // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. - float leftMovementBound = Math.Min(Current, Target); - float rightMovementBound = Math.Max(Current, Target) + DrawWidth; - - foreach (var column in Child) - { - // DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear, - // so we have to manually compensate. - var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent); - var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * SHEAR, 0), ScrollContent); - - bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound) - && Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X); - bool isBeingScrolledToward = Precision.AlmostBigger(topLeft.X, leftMovementBound) - && Precision.DefinitelyBigger(rightMovementBound, bottomRight.X); - - column.Active.Value = isCurrentlyVisible || isBeingScrolledToward; - } - } - } - - /// - /// Manages padding and layout of mod columns. - /// - internal class ColumnFlowContainer : FillFlowContainer - { - public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); - - private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); - - public ColumnFlowContainer() - { - AddLayout(drawSizeLayout); - } - - public override void Add(ColumnDimContainer dimContainer) - { - base.Add(dimContainer); - - Debug.Assert(dimContainer != null); - dimContainer.Column.Shear = Vector2.Zero; - } - - protected override void Update() - { - base.Update(); - - if (!drawSizeLayout.IsValid) - { - Padding = new MarginPadding - { - Left = DrawHeight * SHEAR, - Bottom = 10 - }; - - drawSizeLayout.Validate(); - } - } - } - - /// - /// Encapsulates a column and provides dim and input blocking based on an externally managed "active" state. - /// - internal class ColumnDimContainer : Container - { - public ModColumn Column { get; } - - /// - /// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen. - /// - public readonly Bindable Active = new BindableBool(); - - /// - /// Invoked when the column is clicked while not active, requesting a scroll to be performed to bring it on-screen. - /// - public Action? RequestScroll { get; set; } - - [Resolved] - private OsuColour colours { get; set; } = null!; - - public ColumnDimContainer(ModColumn column) - { - Child = Column = column; - column.Active.BindTo(Active); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - Active.BindValueChanged(_ => updateState()); - Column.AllFiltered.BindValueChanged(_ => updateState(), true); - FinishTransforms(); - } - - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || Column.SelectionAnimationRunning; - - private void updateState() - { - Colour4 targetColour; - - Column.Alpha = Column.AllFiltered.Value ? 0 : 1; - - if (Column.Active.Value) - targetColour = Colour4.White; - else - targetColour = IsHovered ? colours.GrayC : colours.Gray8; - - this.FadeColour(targetColour, 800, Easing.OutQuint); - } - - protected override bool OnClick(ClickEvent e) - { - if (!Active.Value) - RequestScroll?.Invoke(this); - - return true; - } - - protected override bool OnHover(HoverEvent e) - { - base.OnHover(e); - updateState(); - return Active.Value; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - updateState(); - } - } - - /// - /// A container which blocks and handles input, managing the "return from customisation" state change. - /// - private class ClickToReturnContainer : Container - { - public BindableBool HandleMouse { get; } = new BindableBool(); - - public Action? OnClicked { get; set; } - - public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; - - protected override bool Handle(UIEvent e) - { - if (!HandleMouse.Value) - return base.Handle(e); - - switch (e) - { - case ClickEvent _: - OnClicked?.Invoke(); - return true; - - case MouseEvent _: - return true; - } - - return base.Handle(e); - } - } - } -} diff --git a/osu.Game/Overlays/Mods/ModSettingsContainer.cs b/osu.Game/Overlays/Mods/ModSettingsContainer.cs deleted file mode 100644 index 64d65cab3b..0000000000 --- a/osu.Game/Overlays/Mods/ModSettingsContainer.cs +++ /dev/null @@ -1,111 +0,0 @@ -// 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.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Configuration; -using osu.Game.Graphics.Containers; -using osu.Game.Rulesets.Mods; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Mods -{ - public class ModSettingsContainer : VisibilityContainer - { - public readonly IBindable> SelectedMods = new Bindable>(Array.Empty()); - - public IBindable HasSettingsForSelection => hasSettingsForSelection; - - private readonly Bindable hasSettingsForSelection = new Bindable(); - - private readonly FillFlowContainer modSettingsContent; - - private readonly Container content; - - private const double transition_duration = 400; - - public ModSettingsContainer() - { - RelativeSizeAxes = Axes.Both; - - Child = content = new Container - { - Masking = true, - CornerRadius = 10, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - X = 1, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = new Color4(0, 0, 0, 192) - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = modSettingsContent = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0f, 10f), - Padding = new MarginPadding(20), - } - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedMods.BindValueChanged(modsChanged, true); - } - - private void modsChanged(ValueChangedEvent> mods) - { - modSettingsContent.Clear(); - - foreach (var mod in mods.NewValue) - { - var settings = mod.CreateSettingsControls().ToList(); - if (settings.Count > 0) - modSettingsContent.Add(new ModControlSection(mod, settings)); - } - - bool hasSettings = modSettingsContent.Count > 0; - - if (!hasSettings) - Hide(); - - hasSettingsForSelection.Value = hasSettings; - } - - protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnHover(HoverEvent e) => true; - - protected override void PopIn() - { - this.FadeIn(transition_duration, Easing.OutQuint); - content.MoveToX(0, transition_duration, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(transition_duration, Easing.OutQuint); - content.MoveToX(1, transition_duration, Easing.OutQuint); - } - } -} diff --git a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs index 161f89c2eb..8ff5e28c8f 100644 --- a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs @@ -1,30 +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 System.Linq; +using JetBrains.Annotations; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK.Input; namespace osu.Game.Overlays.Mods { public class UserModSelectOverlay : ModSelectOverlay { - protected override void OnModSelected(Mod mod) + public UserModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) + : base(colourScheme) { - base.OnModSelected(mod); - - foreach (var section in ModSectionsContainer.Children) - section.DeselectTypes(mod.IncompatibleMods, true, mod); } - protected override ModSection CreateModSection(ModType type) => new UserModSection(type); + protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys); - private class UserModSection : ModSection + protected override IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) { - public UserModSection(ModType type) - : base(type) + var addedMods = newSelection.Except(oldSelection); + var removedMods = oldSelection.Except(newSelection); + + IEnumerable modsAfterRemoval = newSelection.Except(removedMods).ToList(); + + // the preference is that all new mods should override potential incompatible old mods. + // in general that's a bit difficult to compute if more than one mod is added at a time, + // so be conservative and just remove all mods that aren't compatible with any one added mod. + foreach (var addedMod in addedMods) + { + if (!ModUtils.CheckCompatibleSet(modsAfterRemoval.Append(addedMod), out var invalidMods)) + modsAfterRemoval = modsAfterRemoval.Except(invalidMods); + + modsAfterRemoval = modsAfterRemoval.Append(addedMod).ToList(); + } + + return modsAfterRemoval.ToList(); + } + + private class UserModColumn : ModColumn + { + public UserModColumn(ModType modType, bool allowBulkSelection, [CanBeNull] Key[] toggleKeys = null) + : base(modType, allowBulkSelection, toggleKeys) { } - protected override ModButton CreateModButton(Mod mod) => new IncompatibilityDisplayingModButton(mod); + protected override ModPanel CreateModPanel(Mod mod) => new IncompatibilityDisplayingModPanel(mod); } } } diff --git a/osu.Game/Overlays/Mods/UserModSelectScreen.cs b/osu.Game/Overlays/Mods/UserModSelectScreen.cs deleted file mode 100644 index a018797cba..0000000000 --- a/osu.Game/Overlays/Mods/UserModSelectScreen.cs +++ /dev/null @@ -1,53 +0,0 @@ -// 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 System.Linq; -using JetBrains.Annotations; -using osu.Game.Rulesets.Mods; -using osu.Game.Utils; -using osuTK.Input; - -namespace osu.Game.Overlays.Mods -{ - public class UserModSelectScreen : ModSelectScreen - { - public UserModSelectScreen(OverlayColourScheme colourScheme = OverlayColourScheme.Green) - : base(colourScheme) - { - } - - protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys); - - protected override IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) - { - var addedMods = newSelection.Except(oldSelection); - var removedMods = oldSelection.Except(newSelection); - - IEnumerable modsAfterRemoval = newSelection.Except(removedMods).ToList(); - - // the preference is that all new mods should override potential incompatible old mods. - // in general that's a bit difficult to compute if more than one mod is added at a time, - // so be conservative and just remove all mods that aren't compatible with any one added mod. - foreach (var addedMod in addedMods) - { - if (!ModUtils.CheckCompatibleSet(modsAfterRemoval.Append(addedMod), out var invalidMods)) - modsAfterRemoval = modsAfterRemoval.Except(invalidMods); - - modsAfterRemoval = modsAfterRemoval.Append(addedMod).ToList(); - } - - return modsAfterRemoval.ToList(); - } - - private class UserModColumn : ModColumn - { - public UserModColumn(ModType modType, bool allowBulkSelection, [CanBeNull] Key[] toggleKeys = null) - : base(modType, allowBulkSelection, toggleKeys) - { - } - - protected override ModPanel CreateModPanel(Mod mod) => new IncompatibilityDisplayingModPanel(mod); - } - } -} diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 808d4fc422..077762c0d0 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -156,6 +156,10 @@ namespace osu.Game.Overlays private void updateExpandedState(ValueChangedEvent expanded) { + // clearing transforms is necessary to avoid a previous height transform + // potentially continuing to get processed while content has changed to autosize. + content.ClearTransforms(); + if (expanded.NewValue) content.AutoSizeAxes = Axes.Y; else diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index b230bab0c2..eaaa663fe7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -122,6 +122,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline beatmap.EndChange(); }); } + + protected override void LoadComplete() + { + base.LoadComplete(); + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(sliderVelocitySlider)); + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index fc0952d4f0..ab21a83c43 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -126,6 +126,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); } + protected override void LoadComplete() + { + base.LoadComplete(); + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(volume)); + } + private static string? getCommonBank(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleBank).Distinct().Count() == 1 ? relevantControlPoints.First().SampleBank : null; private static int? getCommonVolume(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleVolume).Distinct().Count() == 1 ? (int?)relevantControlPoints.First().SampleVolume : null; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 51b8792d87..7e66c57917 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -270,12 +270,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override bool OnMouseDown(MouseDownEvent e) { if (base.OnMouseDown(e)) - { beginUserDrag(); - return true; - } - return false; + return true; } protected override void OnMouseUp(MouseUpEvent e) diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 0cf2cf6c54..16a04982f5 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays.Settings; @@ -107,6 +108,14 @@ namespace osu.Game.Screens.Edit.Timing Current.BindValueChanged(_ => updateState(), true); } + public override bool AcceptsFocus => true; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + GetContainingInputManager().ChangeFocus(textBox); + } + private void updateState() { if (Current.Value is T nonNullValue) diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index d5abaaab4e..6e1c9b7a59 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -2,156 +2,51 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using osu.Game.Overlays; +using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; +using osuTK.Input; +using osu.Game.Localisation; namespace osu.Game.Screens.OnlinePlay { - /// - /// A used for free-mod selection in online play. - /// public class FreeModSelectOverlay : ModSelectOverlay { - protected override bool Stacked => false; - - protected override bool AllowConfiguration => false; + protected override bool ShowTotalMultiplier => false; public new Func IsValidMod { get => base.IsValidMod; - set => base.IsValidMod = m => m.HasImplementation && m.UserPlayable && value(m); + set => base.IsValidMod = m => m.UserPlayable && value.Invoke(m); } public FreeModSelectOverlay() + : base(OverlayColourScheme.Plum) { - IsValidMod = m => true; - - DeselectAllButton.Alpha = 0; - - Drawable selectAllButton; - Drawable deselectAllButton; - - FooterContainer.AddRange(new[] - { - selectAllButton = new TriangleButton - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Width = 180, - Text = "Select All", - Action = selectAll, - }, - // Unlike the base mod select overlay, this button deselects mods instantaneously. - deselectAllButton = new TriangleButton - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Width = 180, - Text = "Deselect All", - Action = deselectAll, - }, - }); - - FooterContainer.SetLayoutPosition(selectAllButton, -2); - FooterContainer.SetLayoutPosition(deselectAllButton, -1); + IsValidMod = _ => true; } - private void selectAll() + protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new ModColumn(modType, true, toggleKeys); + + protected override IEnumerable CreateFooterButtons() => new[] { - foreach (var section in ModSectionsContainer.Children) - section.SelectAll(); - } - - private void deselectAll() - { - foreach (var section in ModSectionsContainer.Children) - section.DeselectAll(); - } - - protected override void OnAvailableModsChanged() - { - base.OnAvailableModsChanged(); - - foreach (var section in ModSectionsContainer.Children) - ((FreeModSection)section).UpdateCheckboxState(); - } - - protected override ModSection CreateModSection(ModType type) => new FreeModSection(type); - - private class FreeModSection : ModSection - { - private HeaderCheckbox checkbox; - - public FreeModSection(ModType type) - : base(type) + new ShearedButton(BUTTON_WIDTH) { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = CommonStrings.SelectAll, + Action = SelectAll + }, + new ShearedButton(BUTTON_WIDTH) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = CommonStrings.DeselectAll, + Action = DeselectAll } - - protected override Drawable CreateHeader(string text) => new Container - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Child = checkbox = new HeaderCheckbox - { - LabelText = text, - Changed = onCheckboxChanged - } - }; - - private void onCheckboxChanged(bool value) - { - if (value) - SelectAll(); - else - DeselectAll(); - } - - protected override void ModButtonStateChanged(Mod mod) - { - base.ModButtonStateChanged(mod); - UpdateCheckboxState(); - } - - public void UpdateCheckboxState() - { - if (!SelectionAnimationRunning) - { - var validButtons = Buttons.Where(b => b.Mod.HasImplementation); - checkbox.Current.Value = validButtons.All(b => b.Selected); - } - } - } - - private class HeaderCheckbox : OsuCheckbox - { - public Action Changed; - - protected override bool PlaySoundsOnUserChange => false; - - public HeaderCheckbox() - : base(false) - - { - } - - protected override void ApplyLabelParameters(SpriteText text) - { - base.ApplyLabelParameters(text); - - text.Font = OsuFont.GetFont(weight: FontWeight.Bold); - } - - protected override void OnUserChange(bool value) - { - base.OnUserChange(value); - Changed?.Invoke(value); - } - } + }; } } diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs deleted file mode 100644 index e92de5e083..0000000000 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs +++ /dev/null @@ -1,52 +0,0 @@ -// 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 osu.Game.Overlays; -using System.Collections.Generic; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; -using osuTK.Input; -using osu.Game.Localisation; - -namespace osu.Game.Screens.OnlinePlay -{ - public class FreeModSelectScreen : ModSelectScreen - { - protected override bool ShowTotalMultiplier => false; - - public new Func IsValidMod - { - get => base.IsValidMod; - set => base.IsValidMod = m => m.UserPlayable && value.Invoke(m); - } - - public FreeModSelectScreen() - : base(OverlayColourScheme.Plum) - { - IsValidMod = _ => true; - } - - protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new ModColumn(modType, true, toggleKeys); - - protected override IEnumerable CreateFooterButtons() => new[] - { - new ShearedButton(BUTTON_WIDTH) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Text = CommonStrings.SelectAll, - Action = SelectAll - }, - new ShearedButton(BUTTON_WIDTH) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Text = CommonStrings.DeselectAll, - Action = DeselectAll - } - }; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index ec4e329f7a..13b2c37ded 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Match public readonly Room Room; private readonly bool allowEdit; - private ModSelectScreen userModsSelectOverlay; + private ModSelectOverlay userModsSelectOverlay; [CanBeNull] private IDisposable userModsSelectOverlayRegistration; @@ -231,7 +231,7 @@ namespace osu.Game.Screens.OnlinePlay.Match } }; - LoadComponent(userModsSelectOverlay = new UserModSelectScreen(OverlayColourScheme.Plum) + LoadComponent(userModsSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Plum) { SelectedMods = { BindTarget = UserMods }, IsValidMod = _ => false diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 02ff040a94..5dab845999 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -43,6 +43,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private readonly MultiplayerRoomUser[] users; + private readonly Bindable leaderboardExpanded = new BindableBool(); + private LoadingLayer loadingDisplay; private FillFlowContainer leaderboardFlow; @@ -76,13 +78,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Spacing = new Vector2(5) }); + HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); + LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); + // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(GameplayState.Ruleset.RulesetInfo, ScoreProcessor, users), l => { if (!LoadedBeatmapSuccessfully) return; - ((IBindable)leaderboard.Expanded).BindTo(HUDOverlay.ShowHud); + leaderboard.Expanded.BindTo(leaderboardExpanded); leaderboardFlow.Insert(0, l); @@ -99,7 +104,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer LoadComponentAsync(new GameplayChatDisplay(Room) { - Expanded = { BindTarget = HUDOverlay.ShowHud }, + Expanded = { BindTarget = leaderboardExpanded }, }, chat => leaderboardFlow.Insert(2, chat)); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); @@ -152,6 +157,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } + private void updateLeaderboardExpandedState() => + leaderboardExpanded.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; + private void failAndBail(string message = null) { if (!string.IsNullOrEmpty(message)) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index c4503773ad..fb18a33d66 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -53,7 +53,7 @@ namespace osu.Game.Screens.OnlinePlay private IReadOnlyList initialMods; private bool itemSelected; - private readonly FreeModSelectScreen freeModSelectOverlay; + private readonly FreeModSelectOverlay freeModSelectOverlay; private IDisposable freeModSelectOverlayRegistration; protected OnlinePlaySongSelect(Room room) @@ -62,7 +62,7 @@ namespace osu.Game.Screens.OnlinePlay Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; - freeModSelectOverlay = new FreeModSelectScreen + freeModSelectOverlay = new FreeModSelectOverlay { SelectedMods = { BindTarget = FreeMods }, IsValidMod = IsValidFreeMod, @@ -160,7 +160,7 @@ namespace osu.Game.Screens.OnlinePlay return base.OnExiting(e); } - protected override ModSelectScreen CreateModSelectOverlay() => new UserModSelectScreen(OverlayColourScheme.Plum) + protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { IsValidMod = IsValidMod }; diff --git a/osu.Game/Screens/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index ebbcbd7650..18b16ba865 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -29,6 +29,13 @@ namespace osu.Game.Screens ScreenExited += ScreenChanged; } + public void PushSynchronously(OsuScreen screen) + { + LoadComponent(screen); + + Push(screen); + } + private void screenPushed(IScreen prev, IScreen next) { if (LoadState < LoadState.Ready) diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index abfed1acd0..f6087e0958 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -68,7 +68,9 @@ namespace osu.Game.Screens.Play internal readonly IBindable IsPlaying = new Bindable(); - private bool holdingForHUD; + public IBindable HoldingForHUD => holdingForHUD; + + private readonly BindableBool holdingForHUD = new BindableBool(); private readonly SkinnableTargetContainer mainComponents; @@ -144,7 +146,8 @@ namespace osu.Game.Screens.Play hideTargets.ForEach(d => d.Hide()); } - public override void Hide() => throw new InvalidOperationException($"{nameof(HUDOverlay)} should not be hidden as it will remove the ability of a user to quit. Use {nameof(ShowHud)} instead."); + public override void Hide() => + throw new InvalidOperationException($"{nameof(HUDOverlay)} should not be hidden as it will remove the ability of a user to quit. Use {nameof(ShowHud)} instead."); protected override void LoadComplete() { @@ -152,6 +155,7 @@ namespace osu.Game.Screens.Play ShowHud.BindValueChanged(visible => hideTargets.ForEach(d => d.FadeTo(visible.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING))); + holdingForHUD.BindValueChanged(_ => updateVisibility()); IsPlaying.BindValueChanged(_ => updateVisibility()); configVisibilityMode.BindValueChanged(_ => updateVisibility(), true); @@ -204,7 +208,7 @@ namespace osu.Game.Screens.Play if (ShowHud.Disabled) return; - if (holdingForHUD) + if (holdingForHUD.Value) { ShowHud.Value = true; return; @@ -287,8 +291,7 @@ namespace osu.Game.Screens.Play switch (e.Action) { case GlobalAction.HoldForHUD: - holdingForHUD = true; - updateVisibility(); + holdingForHUD.Value = true; return true; case GlobalAction.ToggleInGameInterface: @@ -318,8 +321,7 @@ namespace osu.Game.Screens.Play switch (e.Action) { case GlobalAction.HoldForHUD: - holdingForHUD = false; - updateVisibility(); + holdingForHUD.Value = false; break; } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index eb5e996972..8870239485 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -102,7 +102,7 @@ namespace osu.Game.Screens.Select [Resolved(CanBeNull = true)] private LegacyImportManager legacyImportManager { get; set; } - protected ModSelectScreen ModSelect { get; private set; } + protected ModSelectOverlay ModSelect { get; private set; } protected Sample SampleConfirm { get; private set; } @@ -333,7 +333,7 @@ namespace osu.Game.Screens.Select (new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectScreen CreateModSelectOverlay() => new UserModSelectScreen(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { diff --git a/osu.Game/Stores/RealmArchiveModelManager.cs b/osu.Game/Stores/RealmArchiveModelManager.cs index 57e51b79aa..cc8229b436 100644 --- a/osu.Game/Stores/RealmArchiveModelManager.cs +++ b/osu.Game/Stores/RealmArchiveModelManager.cs @@ -45,11 +45,16 @@ namespace osu.Game.Stores // This method should be removed as soon as all the surrounding pieces support non-detached operations. if (!item.IsManaged) { - var managed = Realm.Realm.Find(item.ID); - managed.Realm.Write(() => operation(managed)); + // Importantly, begin the realm write *before* re-fetching, else the update realm may not be in a consistent state + // (ie. if an async import finished very recently). + Realm.Realm.Write(realm => + { + var managed = realm.Find(item.ID); + operation(managed); - item.Files.Clear(); - item.Files.AddRange(managed.Files.Detach()); + item.Files.Clear(); + item.Files.AddRange(managed.Files.Detach()); + }); } else operation(item); @@ -165,7 +170,9 @@ namespace osu.Game.Stores public bool Delete(TModel item) { - return Realm.Run(realm => + // Importantly, begin the realm write *before* re-fetching, else the update realm may not be in a consistent state + // (ie. if an async import finished very recently). + return Realm.Write(realm => { if (!item.IsManaged) item = realm.Find(item.ID); @@ -173,14 +180,16 @@ namespace osu.Game.Stores if (item?.DeletePending != false) return false; - realm.Write(r => item.DeletePending = true); + item.DeletePending = true; return true; }); } public void Undelete(TModel item) { - Realm.Run(realm => + // Importantly, begin the realm write *before* re-fetching, else the update realm may not be in a consistent state + // (ie. if an async import finished very recently). + Realm.Write(realm => { if (!item.IsManaged) item = realm.Find(item.ID); @@ -188,7 +197,7 @@ namespace osu.Game.Stores if (item?.DeletePending != true) return; - realm.Write(r => item.DeletePending = false); + item.DeletePending = false; }); } diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index d9c8199f75..3edb27ca9b 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -1,11 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Net; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Framework.Statistics; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Skinning; using Sentry; +using Sentry.Protocol; namespace osu.Game.Utils { @@ -14,26 +29,48 @@ namespace osu.Game.Utils /// public class SentryLogger : IDisposable { - private SentryClient sentry; - private Scope sentryScope; - private Exception lastException; + private IBindable? localUser; + + private readonly IDisposable? sentrySession; + + private readonly OsuGame game; public SentryLogger(OsuGame game) { - if (!game.IsDeployedBuild) return; - - var options = new SentryOptions + this.game = game; + sentrySession = SentrySdk.Init(options => { - Dsn = "https://ad9f78529cef40ac874afb95a9aca04e@sentry.ppy.sh/2", - Release = game.Version - }; + // Not setting the dsn will completely disable sentry. + if (game.IsDeployedBuild) + options.Dsn = "https://ad9f78529cef40ac874afb95a9aca04e@sentry.ppy.sh/2"; - sentry = new SentryClient(options); - sentryScope = new Scope(options); + options.AutoSessionTracking = true; + options.IsEnvironmentUser = false; + // The reported release needs to match release tags on github in order for sentry + // to automatically associate and track against releases. + options.Release = game.Version.Replace($@"-{OsuGameBase.BUILD_SUFFIX}", string.Empty); + }); Logger.NewEntry += processLogEntry; } + ~SentryLogger() => Dispose(false); + + public void AttachUser(IBindable user) + { + Debug.Assert(localUser == null); + + localUser = user.GetBoundCopy(); + localUser.BindValueChanged(u => + { + SentrySdk.ConfigureScope(scope => scope.User = new User + { + Username = u.NewValue.Username, + Id = u.NewValue.Id.ToString(), + }); + }, true); + } + private void processLogEntry(LogEntry entry) { if (entry.Level < LogLevel.Verbose) return; @@ -44,14 +81,116 @@ namespace osu.Game.Utils { if (!shouldSubmitException(exception)) return; - // since we let unhandled exceptions go ignored at times, we want to ensure they don't get submitted on subsequent reports. - if (lastException != null && lastException.Message == exception.Message && exception.StackTrace.StartsWith(lastException.StackTrace, StringComparison.Ordinal)) return; + // framework does some weird exception redirection which means sentry does not see unhandled exceptions using its automatic methods. + // but all unhandled exceptions still arrive via this pathway. we just need to mark them as unhandled for tagging purposes. + // easiest solution is to check the message matches what the framework logs this as. + // see https://github.com/ppy/osu-framework/blob/f932f8df053f0011d755c95ad9a2ed61b94d136b/osu.Framework/Platform/GameHost.cs#L336 + bool wasUnhandled = entry.Message == @"An unhandled error has occurred."; + bool wasUnobserved = entry.Message == @"An unobserved error has occurred."; - lastException = exception; - sentry.CaptureEvent(new SentryEvent(exception) { Message = entry.Message }, sentryScope); + if (wasUnobserved) + { + // see https://github.com/getsentry/sentry-dotnet/blob/c6a660b1affc894441c63df2695a995701671744/src/Sentry/Integrations/TaskUnobservedTaskExceptionIntegration.cs#L39 + exception.Data[Mechanism.MechanismKey] = @"UnobservedTaskException"; + } + + if (wasUnhandled) + { + // see https://github.com/getsentry/sentry-dotnet/blob/main/src/Sentry/Integrations/AppDomainUnhandledExceptionIntegration.cs#L38-L39 + exception.Data[Mechanism.MechanismKey] = @"AppDomain.UnhandledException"; + } + + exception.Data[Mechanism.HandledKey] = !wasUnhandled; + + SentrySdk.CaptureEvent(new SentryEvent(exception) + { + Message = entry.Message, + Level = getSentryLevel(entry.Level), + }, scope => + { + var beatmap = game.Dependencies.Get>().Value.BeatmapInfo; + + scope.Contexts[@"config"] = new + { + Game = game.Dependencies.Get().GetLoggableState() + // TODO: add framework config here. needs some consideration on how to expose. + }; + + game.Dependencies.Get().Run(realm => + { + scope.Contexts[@"realm"] = new + { + Counts = new + { + BeatmapSets = realm.All().Count(), + Beatmaps = realm.All().Count(), + Files = realm.All().Count(), + Skins = realm.All().Count(), + } + }; + }); + + scope.Contexts[@"global statistics"] = GlobalStatistics.GetStatistics() + .GroupBy(s => s.Group) + .ToDictionary(g => g.Key, items => items.ToDictionary(i => i.Name, g => g.DisplayValue)); + + scope.Contexts[@"beatmap"] = new + { + Name = beatmap.ToString(), + beatmap.OnlineID, + }; + + scope.Contexts[@"clocks"] = new + { + Audio = game.Dependencies.Get().CurrentTrack.CurrentTime, + Game = game.Clock.CurrentTime, + }; + }); } else - sentryScope.AddBreadcrumb(DateTimeOffset.Now, entry.Message, entry.Target.ToString(), "navigation"); + SentrySdk.AddBreadcrumb(entry.Message, entry.Target.ToString(), "navigation", level: getBreadcrumbLevel(entry.Level)); + } + + private BreadcrumbLevel getBreadcrumbLevel(LogLevel entryLevel) + { + switch (entryLevel) + { + case LogLevel.Debug: + return BreadcrumbLevel.Debug; + + case LogLevel.Verbose: + return BreadcrumbLevel.Info; + + case LogLevel.Important: + return BreadcrumbLevel.Warning; + + case LogLevel.Error: + return BreadcrumbLevel.Error; + + default: + throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null); + } + } + + private SentryLevel getSentryLevel(LogLevel entryLevel) + { + switch (entryLevel) + { + case LogLevel.Debug: + return SentryLevel.Debug; + + case LogLevel.Verbose: + return SentryLevel.Info; + + case LogLevel.Important: + return SentryLevel.Warning; + + case LogLevel.Error: + return SentryLevel.Error; + + default: + throw new ArgumentOutOfRangeException(nameof(entryLevel), entryLevel, null); + } } private bool shouldSubmitException(Exception exception) @@ -93,8 +232,7 @@ namespace osu.Game.Utils protected virtual void Dispose(bool isDisposing) { Logger.NewEntry -= processLogEntry; - sentry = null; - sentryScope = null; + sentrySession?.Dispose(); } #endregion diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 2f32c843c0..772a78c8fe 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -23,9 +23,9 @@ - - - + + + @@ -34,12 +34,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - - + + + diff --git a/osu.iOS.props b/osu.iOS.props index b483267696..af8f9b617c 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,7 +61,7 @@ - + @@ -84,11 +84,11 @@ - - - + + + - - + +