diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs index e47ae860c6..331509e10f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.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.Linq; using NUnit.Framework; @@ -12,11 +14,9 @@ using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Catch; -using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Taiko; +using osu.Game.Utils; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -41,20 +41,16 @@ namespace osu.Game.Tests.Visual.UserInterface Child = new ModColumn(modType, false) { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(modType) } }); - - AddStep("change ruleset to osu!", () => Ruleset.Value = new OsuRuleset().RulesetInfo); - AddStep("change ruleset to taiko", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); - AddStep("change ruleset to catch", () => Ruleset.Value = new CatchRuleset().RulesetInfo); - AddStep("change ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); } [Test] public void TestMultiSelection() { - ModColumn column = null; + ModColumn column = null!; AddStep("create content", () => Child = new Container { RelativeSizeAxes = Axes.Both, @@ -62,7 +58,8 @@ namespace osu.Game.Tests.Visual.UserInterface Child = column = new ModColumn(ModType.DifficultyIncrease, true) { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.DifficultyIncrease) } }); @@ -91,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestFiltering() { - TestModColumn column = null; + TestModColumn column = null!; AddStep("create content", () => Child = new Container { @@ -100,30 +97,31 @@ namespace osu.Game.Tests.Visual.UserInterface Child = column = new TestModColumn(ModType.Fun, true) { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.Fun) } }); - AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase)); + AddStep("set filter", () => setFilter(mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase))); AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); clickToggle(); AddUntilStep("wait for animation", () => !column.SelectionAnimationRunning); AddAssert("only visible items selected", () => column.ChildrenOfType().Where(panel => panel.Active.Value).All(panel => !panel.Filtered.Value)); - AddStep("unset filter", () => column.Filter = null); + AddStep("unset filter", () => setFilter(null)); AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); AddAssert("checkbox not selected", () => !column.ChildrenOfType().Single().Current.Value); - AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase)); + AddStep("set filter", () => setFilter(mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase))); AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); AddAssert("checkbox selected", () => column.ChildrenOfType().Single().Current.Value); - AddStep("filter out everything", () => column.Filter = _ => false); + AddStep("filter out everything", () => setFilter(_ => false)); AddUntilStep("no panels visible", () => column.ChildrenOfType().All(panel => panel.Filtered.Value)); AddUntilStep("checkbox hidden", () => !column.ChildrenOfType().Single().IsPresent); - AddStep("inset filter", () => column.Filter = null); + AddStep("inset filter", () => setFilter(null)); AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); AddUntilStep("checkbox visible", () => column.ChildrenOfType().Single().IsPresent); @@ -138,7 +136,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestKeyboardSelection() { - ModColumn column = null; + ModColumn column = null!; AddStep("create content", () => Child = new Container { RelativeSizeAxes = Axes.Both, @@ -146,7 +144,8 @@ namespace osu.Game.Tests.Visual.UserInterface Child = column = new ModColumn(ModType.DifficultyReduction, true, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }) { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.DifficultyReduction) } }); @@ -158,7 +157,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("press W again", () => InputManager.Key(Key.W)); AddAssert("NF panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); - AddStep("set filter to NF", () => column.Filter = mod => mod.Acronym == "NF"); + AddStep("set filter to NF", () => setFilter(mod => mod.Acronym == "NF")); AddStep("press W", () => InputManager.Key(Key.W)); AddAssert("NF panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); @@ -166,12 +165,18 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("press W again", () => InputManager.Key(Key.W)); AddAssert("NF panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); - AddStep("filter out everything", () => column.Filter = _ => false); + AddStep("filter out everything", () => setFilter(_ => false)); AddStep("press W", () => InputManager.Key(Key.W)); AddAssert("NF panel not selected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); - AddStep("clear filter", () => column.Filter = null); + AddStep("clear filter", () => setFilter(null)); + } + + private void setFilter(Func? filter) + { + foreach (var modState in this.ChildrenOfType().Single().AvailableMods) + modState.Filtered.Value = filter?.Invoke(modState.Mod) == false; } private class TestModColumn : ModColumn @@ -183,5 +188,13 @@ namespace osu.Game.Tests.Visual.UserInterface { } } + + private static ModState[] getExampleModsFor(ModType modType) + { + return new OsuRuleset().GetModsFor(modType) + .SelectMany(ModUtils.FlattenMod) + .Select(mod => new ModState(mod)) + .ToArray(); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index fc543d9db7..0b037a10cd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -18,6 +18,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Mods; using osuTK; using osuTK.Input; @@ -481,6 +482,21 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("3 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 3); } + [Test] + public void TestColumnHidingOnRulesetChange() + { + createScreen(); + + changeRuleset(0); + AddAssert("5 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 5); + + AddStep("change to ruleset without all mod types", () => Ruleset.Value = TestCustomisableModRuleset.CreateTestRulesetInfo()); + AddUntilStep("1 column visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 1); + + changeRuleset(0); + AddAssert("5 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 5); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs index aeb983d352..34c4458a21 100644 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs @@ -19,6 +19,11 @@ namespace osu.Game.Overlays.Mods [Resolved] private Bindable> selectedMods { get; set; } + public IncompatibilityDisplayingModPanel(ModState modState) + : base(modState) + { + } + public IncompatibilityDisplayingModPanel(Mod mod) : base(mod) { diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index b32ebb4a5c..9bb3f8bd9e 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -13,7 +13,6 @@ 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,7 +24,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; -using osu.Game.Utils; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -38,20 +36,30 @@ namespace osu.Game.Overlays.Mods public readonly ModType ModType; - private Func? filter; + private IReadOnlyList availableMods = Array.Empty(); /// - /// 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. + /// Sets the list of mods to show in this column. /// - public Func? Filter + public IReadOnlyList AvailableMods { - get => filter; + get => availableMods; set { - filter = value; + Debug.Assert(value.All(mod => mod.Mod.Type == ModType)); + + availableMods = value; + + foreach (var mod in availableMods) + { + mod.Active.BindValueChanged(_ => updateState()); + mod.Filtered.BindValueChanged(_ => updateState()); + } + updateState(); + + if (IsLoaded) + asyncLoadPanels(); } } @@ -60,44 +68,12 @@ namespace osu.Game.Overlays.Mods /// public Bindable Active = new BindableBool(true); - private readonly Bindable allFiltered = new BindableBool(); - - /// - /// True if all of the panels in this column have been filtered out by the current . - /// - public IBindable AllFiltered => allFiltered; - - /// - /// 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); + protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod); private readonly Key[]? toggleKeys; - 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; @@ -257,12 +233,8 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader] - private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { - availableMods.BindTo(game.AvailableMods); - updateLocalAvailableMods(asyncLoadContent: false); - availableMods.BindValueChanged(_ => updateLocalAvailableMods(asyncLoadContent: true)); - headerBackground.Colour = accentColour = colours.ForModType(ModType); if (toggleAllCheckbox != null) @@ -280,6 +252,7 @@ namespace osu.Game.Overlays.Mods base.LoadComplete(); toggleAllCheckbox?.Current.BindValueChanged(_ => updateToggleAllText(), true); + asyncLoadPanels(); } private void updateToggleAllText() @@ -288,34 +261,21 @@ namespace osu.Game.Overlays.Mods toggleAllCheckbox.LabelText = toggleAllCheckbox.Current.Value ? CommonStrings.DeselectAll : CommonStrings.SelectAll; } - private void updateLocalAvailableMods(bool asyncLoadContent) - { - var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(ModType) ?? Array.Empty()) - .Select(m => m.DeepClone()) - .ToList(); - - if (newMods.SequenceEqual(localAvailableMods)) - return; - - localAvailableMods = newMods; - - if (asyncLoadContent) - asyncLoadPanels(); - else - onPanelsLoaded(createPanels()); - } - private CancellationTokenSource? cancellationTokenSource; private void asyncLoadPanels() { cancellationTokenSource?.Cancel(); - var panels = createPanels(); + var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0))); Task? loadTask; - latestLoadTask = loadTask = LoadComponentsAsync(panels, onPanelsLoaded, (cancellationTokenSource = new CancellationTokenSource()).Token); + latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded => + { + panelFlow.ChildrenEnumerable = loaded; + updateState(); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); loadTask.ContinueWith(_ => { if (loadTask == latestLoadTask) @@ -323,97 +283,17 @@ namespace osu.Game.Overlays.Mods }); } - private IEnumerable createPanels() - { - var panels = localAvailableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0))); - return panels; - } - - private void onPanelsLoaded(IEnumerable loaded) - { - panelFlow.ChildrenEnumerable = loaded; - - updateState(); - - foreach (var panel in panelFlow) - { - panel.Active.BindValueChanged(_ => panelStateChanged(panel)); - } - } - private void updateState() { - foreach (var panel in panelFlow) - { - panel.Active.Value = SelectedMods.Contains(panel.Mod); - panel.ApplyFilter(Filter); - } - - allFiltered.Value = panelFlow.All(panel => panel.Filtered.Value); + Alpha = availableMods.All(mod => mod.Filtered.Value) ? 0 : 1; if (toggleAllCheckbox != null && !SelectionAnimationRunning) { - toggleAllCheckbox.Alpha = panelFlow.Any(panel => !panel.Filtered.Value) ? 1 : 0; - toggleAllCheckbox.Current.Value = panelFlow.Where(panel => !panel.Filtered.Value).All(panel => panel.Active.Value); + toggleAllCheckbox.Alpha = availableMods.Any(panel => !panel.Filtered.Value) ? 1 : 0; + toggleAllCheckbox.Current.Value = availableMods.Where(panel => !panel.Filtered.Value).All(panel => panel.Active.Value); } } - /// - /// 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) - { - if (externalSelectionUpdateInProgress) - return; - - var newSelectedMods = panel.Active.Value - ? SelectedMods.Append(panel.Mod) - : SelectedMods.Except(panel.Mod.Yield()); - - SelectedMods = newSelectedMods.ToArray(); - updateState(); - 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; - updateState(); - - externalSelectionUpdateInProgress = false; - } - #region Bulk select / deselect private const double initial_multiple_selection_delay = 120; @@ -455,7 +335,7 @@ namespace osu.Game.Overlays.Mods { pendingSelectionOperations.Clear(); - foreach (var button in panelFlow.Where(b => !b.Active.Value && !b.Filtered.Value)) + foreach (var button in availableMods.Where(b => !b.Active.Value && !b.Filtered.Value)) pendingSelectionOperations.Enqueue(() => button.Active.Value = true); } @@ -466,7 +346,7 @@ namespace osu.Game.Overlays.Mods { pendingSelectionOperations.Clear(); - foreach (var button in panelFlow.Where(b => b.Active.Value && !b.Filtered.Value)) + foreach (var button in availableMods.Where(b => b.Active.Value && !b.Filtered.Value)) pendingSelectionOperations.Enqueue(() => button.Active.Value = false); } @@ -553,10 +433,10 @@ namespace osu.Game.Overlays.Mods int index = Array.IndexOf(toggleKeys, e.Key); if (index < 0) return false; - var panel = panelFlow.ElementAtOrDefault(index); - if (panel == null || panel.Filtered.Value) return false; + var modState = availableMods.ElementAtOrDefault(index); + if (modState == null || modState.Filtered.Value) return false; - panel.Active.Toggle(); + modState.Active.Toggle(); return true; } diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index 4c4951307d..7010342bd8 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -28,9 +28,11 @@ namespace osu.Game.Overlays.Mods { public class ModPanel : OsuClickableContainer { - public Mod Mod { get; } - public BindableBool Active { get; } = new BindableBool(); - public BindableBool Filtered { get; } = new BindableBool(); + public Mod Mod => modState.Mod; + public BindableBool Active => modState.Active; + public BindableBool Filtered => modState.Filtered; + + private readonly ModState modState; protected readonly Box Background; protected readonly Container SwitchContainer; @@ -55,9 +57,9 @@ namespace osu.Game.Overlays.Mods private Sample? sampleOff; private Sample? sampleOn; - public ModPanel(Mod mod) + public ModPanel(ModState modState) { - Mod = mod; + this.modState = modState; RelativeSizeAxes = Axes.X; Height = 42; @@ -79,7 +81,7 @@ namespace osu.Game.Overlays.Mods SwitchContainer = new Container { RelativeSizeAxes = Axes.Y, - Child = new ModSwitchSmall(mod) + Child = new ModSwitchSmall(Mod) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -115,7 +117,7 @@ namespace osu.Game.Overlays.Mods { new OsuSpriteText { - Text = mod.Name, + Text = Mod.Name, Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), Margin = new MarginPadding @@ -125,7 +127,7 @@ namespace osu.Game.Overlays.Mods }, new OsuSpriteText { - Text = mod.Description, + Text = Mod.Description, Font = OsuFont.Default.With(size: 12), RelativeSizeAxes = Axes.X, Truncate = true, @@ -141,6 +143,11 @@ namespace osu.Game.Overlays.Mods Action = Active.Toggle; } + public ModPanel(Mod mod) + : this(new ModState(mod)) + { + } + [BackgroundDependencyLoader(true)] private void load(AudioManager audio, OsuColour colours, ISamplePlaybackDisabler? samplePlaybackDisabler) { diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index b3c3eee15a..d068839ab0 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; -using osu.Framework.Lists; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; @@ -22,6 +21,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; using osuTK; using osuTK.Input; @@ -47,9 +47,7 @@ namespace osu.Game.Overlays.Mods set { isValidMod = value ?? throw new ArgumentNullException(nameof(value)); - - if (IsLoaded) - updateAvailableMods(); + filterMods(); } } @@ -64,6 +62,10 @@ namespace osu.Game.Overlays.Mods protected virtual IEnumerable CreateFooterButtons() => createDefaultFooterButtons(); + private readonly Bindable>> availableMods = new Bindable>>(); + private readonly Dictionary> localAvailableMods = new Dictionary>(); + private IEnumerable allLocalAvailableMods => localAvailableMods.SelectMany(pair => pair.Value); + private readonly BindableBool customisationVisible = new BindableBool(); private ModSettingsArea modSettingsArea = null!; @@ -82,7 +84,7 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuGameBase game, OsuColour colours) { Header.Title = ModSelectOverlayStrings.ModSelectTitle; Header.Description = ModSelectOverlayStrings.ModSelectDescription; @@ -184,10 +186,15 @@ namespace osu.Game.Overlays.Mods LighterColour = colours.Pink1 }) }; + + availableMods.BindTo(game.AvailableMods); } protected override void LoadComplete() { + // this is called before base call so that the mod state is populated early, and the transition in `PopIn()` can play out properly. + availableMods.BindValueChanged(_ => createLocalMods(), true); + base.LoadComplete(); State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true); @@ -201,18 +208,11 @@ namespace osu.Game.Overlays.Mods { updateMultiplier(); updateCustomisation(val); - updateSelectionFromBindable(); + updateFromExternalSelection(); }, 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(() => @@ -244,7 +244,6 @@ namespace osu.Game.Overlays.Mods { 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 }; }); @@ -272,6 +271,34 @@ namespace osu.Game.Overlays.Mods } }; + private void createLocalMods() + { + localAvailableMods.Clear(); + + foreach (var (modType, mods) in availableMods.Value) + { + var modStates = mods.SelectMany(ModUtils.FlattenMod) + .Select(mod => new ModState(mod.DeepClone())) + .ToArray(); + + foreach (var modState in modStates) + modState.Active.BindValueChanged(_ => updateFromInternalSelection()); + + localAvailableMods[modType] = modStates; + } + + filterMods(); + + foreach (var column in columnFlow.Columns) + column.AvailableMods = localAvailableMods.GetValueOrDefault(column.ModType, Array.Empty()); + } + + private void filterMods() + { + foreach (var modState in allLocalAvailableMods) + modState.Filtered.Value = !modState.Mod.HasImplementation || !IsValidMod.Invoke(modState.Mod); + } + private void updateMultiplier() { if (multiplierDisplay == null) @@ -285,12 +312,6 @@ namespace osu.Game.Overlays.Mods 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) @@ -339,26 +360,53 @@ namespace osu.Game.Overlays.Mods 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); + /// + /// 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 change. + /// + private bool externalSelectionUpdateInProgress; - // and then, when done, replace the potentially-external mod references in `SelectedMods` with ones we own. - updateBindableFromSelection(); + private void updateFromExternalSelection() + { + if (externalSelectionUpdateInProgress) + return; + + externalSelectionUpdateInProgress = true; + + var newSelection = new List(); + + foreach (var modState in allLocalAvailableMods) + { + var matchingSelectedMod = SelectedMods.Value.SingleOrDefault(selected => selected.GetType() == modState.Mod.GetType()); + + if (matchingSelectedMod != null) + { + modState.Mod.CopyFrom(matchingSelectedMod); + modState.Active.Value = true; + newSelection.Add(modState.Mod); + } + else + { + modState.Mod.ResetSettingsToDefaults(); + modState.Active.Value = false; + } + } + + SelectedMods.Value = newSelection; + + externalSelectionUpdateInProgress = false; } - private void updateBindableFromSelection() + private void updateFromInternalSelection() { - 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))) + if (externalSelectionUpdateInProgress) return; + var candidateSelection = allLocalAvailableMods.Where(modState => modState.Active.Value) + .Select(modState => modState.Mod) + .ToArray(); + SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection); } @@ -383,10 +431,12 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - double delay = column.AllFiltered.Value ? 0 : nonFilteredColumnCount * 30; - double duration = column.AllFiltered.Value ? 0 : fade_in_duration; + bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + + double delay = allFiltered ? 0 : nonFilteredColumnCount * 30; + double duration = allFiltered ? 0 : fade_in_duration; float startingYPosition = 0; - if (!column.AllFiltered.Value) + if (!allFiltered) startingYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; column.TopLevelContent @@ -395,7 +445,7 @@ namespace osu.Game.Overlays.Mods .MoveToY(0, duration, Easing.OutQuint) .FadeIn(duration, Easing.OutQuint); - if (!column.AllFiltered.Value) + if (!allFiltered) nonFilteredColumnCount += 1; } } @@ -416,9 +466,11 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - double duration = column.AllFiltered.Value ? 0 : fade_out_duration; + bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + + double duration = allFiltered ? 0 : fade_out_duration; float newYPosition = 0; - if (!column.AllFiltered.Value) + if (!allFiltered) newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; column.FlushPendingSelections(); @@ -426,7 +478,7 @@ namespace osu.Game.Overlays.Mods .MoveToY(newYPosition, duration, Easing.OutQuint) .FadeOut(duration, Easing.OutQuint); - if (!column.AllFiltered.Value) + if (!allFiltered) nonFilteredColumnCount += 1; } } @@ -570,8 +622,8 @@ namespace osu.Game.Overlays.Mods protected override void LoadComplete() { base.LoadComplete(); - Active.BindValueChanged(_ => updateState()); - Column.AllFiltered.BindValueChanged(_ => updateState(), true); + + Active.BindValueChanged(_ => updateState(), true); FinishTransforms(); } @@ -581,8 +633,6 @@ namespace osu.Game.Overlays.Mods { Colour4 targetColour; - Column.Alpha = Column.AllFiltered.Value ? 0 : 1; - if (Column.Active.Value) targetColour = Colour4.White; else diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs new file mode 100644 index 0000000000..8fdd5db00b --- /dev/null +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -0,0 +1,35 @@ +// 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.Bindables; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + /// + /// Wrapper class used to store the current state of a mod shown on the . + /// Used primarily to decouple data from drawable logic. + /// + public class ModState + { + /// + /// The mod that whose state this instance describes. + /// + public Mod Mod { get; } + + /// + /// Whether the mod is currently selected. + /// + public BindableBool Active { get; } = new BindableBool(); + + /// + /// Whether the mod is currently filtered out due to not matching imposed criteria. + /// + public BindableBool Filtered { get; } = new BindableBool(); + + public ModState(Mod mod) + { + Mod = mod; + } + } +} diff --git a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs index 8ff5e28c8f..7100446730 100644 --- a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs @@ -47,7 +47,7 @@ namespace osu.Game.Overlays.Mods { } - protected override ModPanel CreateModPanel(Mod mod) => new IncompatibilityDisplayingModPanel(mod); + protected override ModPanel CreateModPanel(ModState modState) => new IncompatibilityDisplayingModPanel(modState); } } }