diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index bd26a99e51..ba7c6e9d33 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -59,11 +59,6 @@ namespace osu.Game.Rulesets.Osu.Edit { LayerBelowRuleset.AddRange(new Drawable[] { - new PlayfieldBorder - { - RelativeSizeAxes = Axes.Both, - PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } - }, distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index ae1b691767..c9d44fdab7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; using osuTK.Input; @@ -70,6 +71,11 @@ namespace osu.Game.Tests.Visual.Editing Child = editorBeatmapContainer = new EditorBeatmapContainer(Beatmap.Value) { Child = hitObjectComposer = new OsuHitObjectComposer(new OsuRuleset()) + { + // force the composer to fully overlap the playfield area by setting a 4:3 aspect ratio. + FillMode = FillMode.Fit, + FillAspectRatio = 4 / 3f + } }; }); } @@ -87,6 +93,65 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType().First().CurrentTool is HitCircleCompositionTool); } + [Test] + public void TestPlacementFailsWhenClickingButton() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + + AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick()); + + AddStep("move mouse to overlapping toggle button", () => + { + var playfield = hitObjectComposer.Playfield.ScreenSpaceDrawQuad; + var button = hitObjectComposer + .ChildrenOfType().First() + .ChildrenOfType().First(b => playfield.Contains(b.ScreenSpaceDrawQuad.Centre)); + + InputManager.MoveMouseTo(button); + }); + + AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); + + AddStep("attempt place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestPlacementWithinToolboxScrollArea() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + + AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick()); + + AddStep("move mouse to scroll area", () => + { + // Specifically wanting to test the area of overlap between the left expanding toolbox container + // and the playfield/composer. + var scrollArea = hitObjectComposer.ChildrenOfType().First().ScreenSpaceDrawQuad; + var playfield = hitObjectComposer.Playfield.ScreenSpaceDrawQuad; + InputManager.MoveMouseTo(new Vector2(scrollArea.TopLeft.X + 1, playfield.Centre.Y)); + }); + + AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); + + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); + } + [Test] public void TestDistanceSpacingHotkeys() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs index ec6e962c6a..bdb423a43c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectScreen.cs @@ -2,17 +2,21 @@ // 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.Input; @@ -64,6 +68,7 @@ namespace osu.Game.Tests.Visual.UserInterface return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); }); assertCustomisationToggleState(disabled: false, active: false); + AddAssert("setting items created", () => modSelectScreen.ChildrenOfType().Any()); } [Test] @@ -78,6 +83,7 @@ namespace osu.Game.Tests.Visual.UserInterface return Precision.AlmostEquals(multiplier, modSelectScreen.ChildrenOfType().Single().Current.Value); }); assertCustomisationToggleState(disabled: false, active: false); + AddAssert("setting items created", () => modSelectScreen.ChildrenOfType().Any()); } [Test] @@ -98,17 +104,25 @@ namespace osu.Game.Tests.Visual.UserInterface 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] @@ -169,6 +183,206 @@ namespace osu.Game.Tests.Visual.UserInterface assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. } + /// + /// 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); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectScreen.ChildrenOfType().Any() && modSelectScreen.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); @@ -188,5 +402,26 @@ namespace osu.Game.Tests.Visual.UserInterface 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/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 9cb8f2dfcc..13a3f006fb 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -82,6 +82,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), + new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing), + new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), }; public IEnumerable InGameKeyBindings => new[] @@ -305,6 +307,12 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorFlipVertically))] EditorFlipVertically, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorIncreaseDistanceSpacing))] + EditorIncreaseDistanceSpacing, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorDecreaseDistanceSpacing))] + EditorDecreaseDistanceSpacing, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.SelectPreviousGroup))] SelectPreviousGroup, diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 129706261b..cfe0fd55ce 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -249,6 +249,16 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorFlipVertically => new TranslatableString(getKey(@"editor_flip_vertically"), @"Flip selection vertically"); + /// + /// "Increase distance spacing" + /// + public static LocalisableString EditorIncreaseDistanceSpacing => new TranslatableString(getKey(@"editor_increase_distance_spacing"), @"Increase distance spacing"); + + /// + /// "Decrease distance spacing" + /// + public static LocalisableString EditorDecreaseDistanceSpacing => new TranslatableString(getKey(@"editor_decrease_distance_spacing"), @"Decrease distance spacing"); + /// /// "Toggle skin editor" /// diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 018922c074..a792c0a81e 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -1,6 +1,8 @@ // 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.Linq; @@ -10,6 +12,7 @@ using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -25,8 +28,6 @@ using osuTK; using osuTK.Graphics; using osuTK.Input; -#nullable enable - namespace osu.Game.Overlays.Mods { public class ModColumn : CompositeDrawable @@ -52,9 +53,22 @@ namespace osu.Game.Overlays.Mods } } - public Bindable> SelectedMods = new Bindable>(Array.Empty()); public Bindable Active = new BindableBool(true); + /// + /// List of mods marked as selected in this column. + /// + /// + /// Note that the mod instances returned by this property are owned solely by this column + /// (as in, they are locally-managed clones, to ensure proper isolation from any other external instances). + /// + public IReadOnlyList SelectedMods { get; private set; } = Array.Empty(); + + /// + /// Invoked when a mod panel has been selected interactively by the user. + /// + public event Action? SelectionChangedByUser; + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; protected virtual ModPanel CreateModPanel(Mod mod) => new ModPanel(mod); @@ -63,6 +77,15 @@ namespace osu.Game.Overlays.Mods private readonly Bindable>> availableMods = new Bindable>>(); + /// + /// All mods that are available for the current ruleset in this particular column. + /// + /// + /// Note that the mod instances in this list are owned solely by this column + /// (as in, they are locally-managed clones, to ensure proper isolation from any other external instances). + /// + private IReadOnlyList localAvailableMods = Array.Empty(); + private readonly TextFlowContainer headerText; private readonly Box headerBackground; private readonly Container contentContainer; @@ -226,6 +249,9 @@ namespace osu.Game.Overlays.Mods private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours) { availableMods.BindTo(game.AvailableMods); + // this `BindValueChanged` callback is intentionally here, to ensure that local available mods are constructed as early as possible. + // this is needed to make sure no external changes to mods are dropped while mod panels are asynchronously loading. + availableMods.BindValueChanged(_ => updateLocalAvailableMods(), true); headerBackground.Colour = accentColour = colours.ForModType(ModType); @@ -239,31 +265,26 @@ namespace osu.Game.Overlays.Mods contentBackground.Colour = colourProvider.Background4; } - protected override void LoadComplete() + private void updateLocalAvailableMods() { - base.LoadComplete(); - availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods)); - SelectedMods.BindValueChanged(_ => - { - // if a load is in progress, don't try to update the selection - the load flow will do so. - if (latestLoadTask == null) - updateActiveState(); - }); - updateMods(); + var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty()) + .Select(m => m.DeepClone()) + .ToList(); + + if (newMods.SequenceEqual(localAvailableMods)) + return; + + localAvailableMods = newMods; + Scheduler.AddOnce(loadPanels); } private CancellationTokenSource? cancellationTokenSource; - private void updateMods() + private void loadPanels() { - var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty()).ToList(); - - if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod))) - return; - cancellationTokenSource?.Cancel(); - var panels = newMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0))); + var panels = localAvailableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0))); Task? loadTask; @@ -277,13 +298,7 @@ namespace osu.Game.Overlays.Mods foreach (var panel in panelFlow) { - panel.Active.BindValueChanged(_ => - { - updateToggleAllState(); - SelectedMods.Value = panel.Active.Value - ? SelectedMods.Value.Append(panel.Mod).ToArray() - : SelectedMods.Value.Except(new[] { panel.Mod }).ToArray(); - }); + panel.Active.BindValueChanged(_ => panelStateChanged(panel)); } }, (cancellationTokenSource = new CancellationTokenSource()).Token); loadTask.ContinueWith(_ => @@ -296,7 +311,62 @@ namespace osu.Game.Overlays.Mods private void updateActiveState() { foreach (var panel in panelFlow) - panel.Active.Value = SelectedMods.Value.Contains(panel.Mod, EqualityComparer.Default); + panel.Active.Value = SelectedMods.Contains(panel.Mod); + } + + /// + /// This flag helps to determine the source of changes to . + /// If the value is false, then are changing due to a user selection on the UI. + /// If the value is true, then are changing due to an external call. + /// + private bool externalSelectionUpdateInProgress; + + private void panelStateChanged(ModPanel panel) + { + updateToggleAllState(); + + var newSelectedMods = panel.Active.Value + ? SelectedMods.Append(panel.Mod) + : SelectedMods.Except(panel.Mod.Yield()); + + SelectedMods = newSelectedMods.ToArray(); + if (!externalSelectionUpdateInProgress) + SelectionChangedByUser?.Invoke(); + } + + /// + /// Adjusts the set of selected mods in this column to match the passed in . + /// + /// + /// 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 + /// to references that are owned by this column. + /// + internal void SetSelection(IReadOnlyList mods) + { + externalSelectionUpdateInProgress = true; + + var newSelection = new List(); + + foreach (var mod in localAvailableMods) + { + var matchingSelectedMod = mods.SingleOrDefault(selected => selected.GetType() == mod.GetType()); + + if (matchingSelectedMod != null) + { + mod.CopyFrom(matchingSelectedMod); + newSelection.Add(mod); + } + else + { + mod.ResetSettingsToDefaults(); + } + } + + SelectedMods = newSelection; + updateActiveState(); + + externalSelectionUpdateInProgress = false; } #region Bulk select / deselect @@ -364,6 +434,15 @@ namespace osu.Game.Overlays.Mods pendingSelectionOperations.Enqueue(() => button.Active.Value = false); } + /// + /// 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(); + } + private class ToggleAllCheckbox : OsuCheckbox { private Color4 accentColour; diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index 5bf8cddd0c..a70191a864 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -250,9 +250,9 @@ namespace osu.Game.Overlays.Mods protected virtual ModButton CreateModButton(Mod mod) => new ModButton(mod); /// - /// Play out all remaining animations immediately to leave mods in a good (final) state. + /// Run any delayed selections (due to animation) immediately to leave mods in a good (final) state. /// - public void FlushAnimation() + 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 9ce79c25f7..cf57322594 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -369,7 +369,7 @@ namespace osu.Game.Overlays.Mods foreach (var section in ModSectionsContainer) { - section.FlushAnimation(); + section.FlushPendingSelections(); } FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine); diff --git a/osu.Game/Overlays/Mods/ModSelectScreen.cs b/osu.Game/Overlays/Mods/ModSelectScreen.cs index ffd6e9a52c..8060bca65f 100644 --- a/osu.Game/Overlays/Mods/ModSelectScreen.cs +++ b/osu.Game/Overlays/Mods/ModSelectScreen.cs @@ -179,7 +179,7 @@ namespace osu.Game.Overlays.Mods foreach (var column in columnFlow.Columns) { - column.SelectedMods.BindValueChanged(updateBindableFromSelection); + column.SelectionChangedByUser += updateBindableFromSelection; } customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); @@ -203,7 +203,7 @@ namespace osu.Game.Overlays.Mods private void updateAvailableMods() { foreach (var column in columnFlow.Columns) - column.Filter = isValidMod; + column.Filter = m => m.HasImplementation && isValidMod.Invoke(m); } private void updateCustomisation(ValueChangedEvent> valueChangedEvent) @@ -250,33 +250,26 @@ namespace osu.Game.Overlays.Mods private void updateSelectionFromBindable() { - // note that selectionBindableSyncInProgress is purposefully not checked here. - // this is because in the case of mod selection in solo gameplay, a user selection of a mod can actually lead to deselection of other incompatible mods. - // to synchronise state correctly, updateBindableFromSelection() computes the final mods (including incompatibility rules) and updates SelectedMods, - // and this method then runs unconditionally again to make sure the new visual selection accurately reflects the final set of selected mods. - // selectionBindableSyncInProgress ensures that mutual infinite recursion does not happen after that unconditional call. + // `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.SelectedMods.Value = SelectedMods.Value.Where(mod => mod.Type == column.ModType).ToArray(); + column.SetSelection(SelectedMods.Value); + + // and then, when done, replace the potentially-external mod references in `SelectedMods` with ones we own. + updateBindableFromSelection(); } - private bool selectionBindableSyncInProgress; - - private void updateBindableFromSelection(ValueChangedEvent> modSelectionChange) + private void updateBindableFromSelection() { - if (selectionBindableSyncInProgress) + var candidateSelection = columnFlow.Columns.SelectMany(column => column.SelectedMods).ToArray(); + + if (candidateSelection.SequenceEqual(SelectedMods.Value)) return; - selectionBindableSyncInProgress = true; - - SelectedMods.Value = ComputeNewModsFromSelection( - modSelectionChange.NewValue.Except(modSelectionChange.OldValue), - modSelectionChange.OldValue.Except(modSelectionChange.NewValue)); - - selectionBindableSyncInProgress = false; + SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection); } - protected virtual IReadOnlyList ComputeNewModsFromSelection(IEnumerable addedMods, IEnumerable removedMods) - => columnFlow.Columns.SelectMany(column => column.SelectedMods.Value).ToArray(); + protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; protected override void PopIn() { @@ -313,10 +306,12 @@ namespace osu.Game.Overlays.Mods { const float distance = 700; - columnFlow[i].Column - .TopLevelContent - .MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint) - .FadeOut(fade_out_duration, Easing.OutQuint); + var column = columnFlow[i].Column; + + column.FlushPendingSelections(); + column.TopLevelContent + .MoveToY(i % 2 == 0 ? -distance : distance, fade_out_duration, Easing.OutQuint) + .FadeOut(fade_out_duration, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs index be72c1e3e3..9c5f3b7f11 100644 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ b/osu.Game/Overlays/Mods/ModSettingsArea.cs @@ -1,6 +1,7 @@ // 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.Allocation; @@ -21,7 +22,7 @@ namespace osu.Game.Overlays.Mods { public class ModSettingsArea : CompositeDrawable { - public Bindable> SelectedMods { get; } = new Bindable>(); + public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); public const float HEIGHT = 250; @@ -77,7 +78,7 @@ namespace osu.Game.Overlays.Mods protected override void LoadComplete() { base.LoadComplete(); - SelectedMods.BindValueChanged(_ => updateMods()); + SelectedMods.BindValueChanged(_ => updateMods(), true); } private void updateMods() diff --git a/osu.Game/Overlays/Mods/UserModSelectScreen.cs b/osu.Game/Overlays/Mods/UserModSelectScreen.cs index ed0a07521b..ca33d35605 100644 --- a/osu.Game/Overlays/Mods/UserModSelectScreen.cs +++ b/osu.Game/Overlays/Mods/UserModSelectScreen.cs @@ -14,9 +14,12 @@ namespace osu.Game.Overlays.Mods { protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys); - protected override IReadOnlyList ComputeNewModsFromSelection(IEnumerable addedMods, IEnumerable removedMods) + protected override IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) { - IEnumerable modsAfterRemoval = SelectedMods.Value.Except(removedMods).ToList(); + 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, diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index 0505f9ab0e..680fc9aaa8 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -3,13 +3,19 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osu.Game.Overlays.OSD; using osu.Game.Overlays.Settings.Sections; using osu.Game.Rulesets.Objects; -using osuTK; namespace osu.Game.Rulesets.Edit { @@ -18,7 +24,7 @@ namespace osu.Game.Rulesets.Edit /// /// The base type of supported objects. [Cached(typeof(IDistanceSnapProvider))] - public abstract class DistancedHitObjectComposer : HitObjectComposer, IDistanceSnapProvider + public abstract class DistancedHitObjectComposer : HitObjectComposer, IDistanceSnapProvider, IScrollBindingHandler where TObject : HitObject { protected Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) @@ -33,7 +39,9 @@ namespace osu.Game.Rulesets.Edit protected ExpandingToolboxContainer RightSideToolboxContainer { get; private set; } private ExpandableSlider> distanceSpacingSlider; - private bool distanceSpacingScrollActive; + + [Resolved(canBeNull: true)] + private OnScreenDisplay onScreenDisplay { get; set; } protected DistancedHitObjectComposer(Ruleset ruleset) : base(ruleset) @@ -43,8 +51,9 @@ namespace osu.Game.Rulesets.Edit [BackgroundDependencyLoader] private void load() { - AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer + AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) { + Padding = new MarginPadding { Right = 10 }, Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -66,45 +75,58 @@ namespace osu.Game.Rulesets.Edit if (!DistanceSpacingMultiplier.Disabled) { DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing; - DistanceSpacingMultiplier.BindValueChanged(v => + DistanceSpacingMultiplier.BindValueChanged(multiplier => { - distanceSpacingSlider.ContractedLabelText = $"D. S. ({v.NewValue:0.##x})"; - distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({v.NewValue:0.##x})"; - EditorBeatmap.BeatmapInfo.DistanceSpacing = v.NewValue; + distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; + distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; + + if (multiplier.NewValue != multiplier.OldValue) + onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); + + EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; }, true); } } - protected override bool OnKeyDown(KeyDownEvent e) + public bool OnPressed(KeyBindingPressEvent e) { - if (!DistanceSpacingMultiplier.Disabled && e.ControlPressed && e.AltPressed && !e.Repeat) + switch (e.Action) { - RightSideToolboxContainer.Expanded.Value = true; - distanceSpacingScrollActive = true; - return true; + case GlobalAction.EditorIncreaseDistanceSpacing: + case GlobalAction.EditorDecreaseDistanceSpacing: + return adjustDistanceSpacing(e.Action, 0.1f); } - return base.OnKeyDown(e); + return false; } - protected override void OnKeyUp(KeyUpEvent e) + public void OnReleased(KeyBindingReleaseEvent e) { - if (!DistanceSpacingMultiplier.Disabled && distanceSpacingScrollActive && (!e.AltPressed || !e.ControlPressed)) - { - RightSideToolboxContainer.Expanded.Value = false; - distanceSpacingScrollActive = false; - } } - protected override bool OnScroll(ScrollEvent e) + public bool OnScroll(KeyBindingScrollEvent e) { - if (distanceSpacingScrollActive) + switch (e.Action) { - DistanceSpacingMultiplier.Value += e.ScrollDelta.Y * (e.IsPrecise ? 0.01f : 0.1f); - return true; + case GlobalAction.EditorIncreaseDistanceSpacing: + case GlobalAction.EditorDecreaseDistanceSpacing: + return adjustDistanceSpacing(e.Action, e.ScrollAmount * (e.IsPrecise ? 0.01f : 0.1f)); } - return base.OnScroll(e); + return false; + } + + private bool adjustDistanceSpacing(GlobalAction action, float amount) + { + if (DistanceSpacingMultiplier.Disabled) + return false; + + if (action == GlobalAction.EditorIncreaseDistanceSpacing) + DistanceSpacingMultiplier.Value += amount; + else if (action == GlobalAction.EditorDecreaseDistanceSpacing) + DistanceSpacingMultiplier.Value -= amount; + + return true; } public virtual float GetBeatSnapDistanceAt(HitObject referenceObject) @@ -145,18 +167,25 @@ namespace osu.Game.Rulesets.Edit return DurationToDistance(referenceObject, snappedEndTime - startTime); } - protected class ExpandingToolboxContainer : ExpandingContainer + private class DistanceSpacingToast : Toast { - protected override double HoverExpansionDelay => 250; + private readonly ValueChangedEvent change; - public ExpandingToolboxContainer() - : base(130, 250) + public DistanceSpacingToast(LocalisableString value, ValueChangedEvent change) + : base(getAction(change).GetLocalisableDescription(), value, string.Empty) { - RelativeSizeAxes = Axes.Y; - Padding = new MarginPadding { Left = 10 }; - - FillFlow.Spacing = new Vector2(10); + this.change = change; } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + ShortcutText.Text = config.LookupKeyBindings(getAction(change)).ToUpper(); + } + + private static GlobalAction getAction(ValueChangedEvent change) => change.NewValue - change.OldValue > 0 + ? GlobalAction.EditorIncreaseDistanceSpacing + : GlobalAction.EditorDecreaseDistanceSpacing; } } } diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs new file mode 100644 index 0000000000..e807dbd482 --- /dev/null +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + public class ExpandingToolboxContainer : ExpandingContainer + { + protected override double HoverExpansionDelay => 250; + + public ExpandingToolboxContainer(float contractedWidth, float expandedWidth) + : base(contractedWidth, expandedWidth) + { + RelativeSizeAxes = Axes.Y; + + FillFlow.Spacing = new Vector2(10); + } + + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && anyToolboxHovered(screenSpacePos); + + private bool anyToolboxHovered(Vector2 screenSpacePos) => FillFlow.Children.Any(d => d.ScreenSpaceDrawQuad.Contains(screenSpacePos)); + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + protected override bool OnClick(ClickEvent e) => true; + } +} diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index a235a5bc60..1c388fe68f 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -13,7 +13,6 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -115,8 +114,9 @@ namespace osu.Game.Rulesets.Edit .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, - new LeftToolboxFlow + new ExpandingToolboxContainer(80, 200) { + Padding = new MarginPadding { Left = 10 }, Children = new Drawable[] { new EditorToolboxGroup("toolbox (1-9)") @@ -382,18 +382,6 @@ namespace osu.Game.Rulesets.Edit } #endregion - - private class LeftToolboxFlow : ExpandingButtonContainer - { - public LeftToolboxFlow() - : base(80, 200) - { - RelativeSizeAxes = Axes.Y; - Padding = new MarginPadding { Right = 10 }; - - FillFlow.Spacing = new Vector2(10); - } - } } /// diff --git a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs deleted file mode 100644 index 98e026c49a..0000000000 --- a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs +++ /dev/null @@ -1,34 +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.Containers; -using osu.Game.Graphics.Containers; - -namespace osu.Game.Rulesets.Edit -{ - public class ScrollingToolboxGroup : EditorToolboxGroup - { - protected readonly OsuScrollContainer Scroll; - - protected readonly FillFlowContainer FillFlow; - - protected override Container Content { get; } - - public ScrollingToolboxGroup(string title, float scrollAreaHeight) - : base(title) - { - base.Content.Add(Scroll = new OsuScrollContainer - { - RelativeSizeAxes = Axes.X, - Height = scrollAreaHeight, - Child = Content = FillFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - }, - }); - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs index 95b4b2fe53..f0d26c7b6a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Layout; +using osu.Framework.Utils; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components @@ -72,33 +75,47 @@ namespace osu.Game.Screens.Edit.Compose.Components int index = 0; float currentPosition = startPosition; - while ((endPosition - currentPosition) * Math.Sign(step) > 0) + // Make lines the same width independent of display resolution. + float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width; + + List generatedLines = new List(); + + while (Precision.AlmostBigger((endPosition - currentPosition) * Math.Sign(step), 0)) { var gridLine = new Box { Colour = Colour4.White, - Alpha = index == 0 ? 0.3f : 0.1f, - EdgeSmoothness = new Vector2(0.2f) + Alpha = 0.1f, }; if (direction == Direction.Horizontal) { + gridLine.Origin = Anchor.CentreLeft; gridLine.RelativeSizeAxes = Axes.X; - gridLine.Height = 1; + gridLine.Height = lineWidth; gridLine.Y = currentPosition; } else { + gridLine.Origin = Anchor.TopCentre; gridLine.RelativeSizeAxes = Axes.Y; - gridLine.Width = 1; + gridLine.Width = lineWidth; gridLine.X = currentPosition; } - AddInternal(gridLine); + generatedLines.Add(gridLine); index += 1; currentPosition = startPosition + index * step; } + + if (generatedLines.Count == 0) + return; + + generatedLines.First().Alpha = 0.3f; + generatedLines.Last().Alpha = 0.3f; + + AddRangeInternal(generatedLines); } public Vector2 GetSnappedPosition(Vector2 original) diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs index c85a4fc38b..5a7a60b479 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectScreen.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay public new Func IsValidMod { get => base.IsValidMod; - set => base.IsValidMod = m => m.HasImplementation && m.UserPlayable && value.Invoke(m); + set => base.IsValidMod = m => m.UserPlayable && value.Invoke(m); } public FreeModSelectScreen()