// 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.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Footer; using osu.Game.Utils; using osuTK; using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods { public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler { public const int BUTTON_WIDTH = 200; protected override string PopInSampleName => ""; protected override string PopOutSampleName => @"SongSelect/mod-select-overlay-pop-out"; [Cached] public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); /// /// Contains a list of mods which should read from to display effects on the selected beatmap. /// /// /// This is different from in screens like online-play rooms, where there are required mods activated from the playlist. /// public Bindable> ActiveMods { get; private set; } = new Bindable>(Array.Empty()); /// /// Contains a dictionary with the current of all mods applicable for the current ruleset. /// /// /// Contrary to and , the instances /// inside the objects are owned solely by this instance. /// public Bindable>> AvailableMods { get; } = new Bindable>>(new Dictionary>()); private Func isValidMod = _ => 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)); filterMods(); } } public string SearchTerm { get => SearchTextBox.Current.Value; set => SearchTextBox.Current.Value = value; } public ShearedSearchTextBox SearchTextBox { get; private set; } = null!; /// /// Whether per-mod customisation controls are visible. /// protected virtual bool AllowCustomisation => true; /// /// Whether the column with available mod presets should be shown. /// protected virtual bool ShowPresets => false; protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false); protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; protected virtual IReadOnlyList ComputeActiveMods() => SelectedMods.Value; private readonly Bindable>> globalAvailableMods = new Bindable>>(); public IEnumerable AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value); private Bindable textSearchStartsActive = null!; private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private Container aboveColumnsContent = null!; private ModCustomisationPanel customisationPanel = null!; protected virtual SelectAllModsButton? SelectAllModsButton => null; private Sample? columnAppearSample; public readonly Bindable Beatmap = new Bindable(); [Resolved] private ScreenFooter? footer { get; set; } protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } [BackgroundDependencyLoader] private void load(OsuGameBase game, OsuColour colours, AudioManager audio, OsuConfigManager configManager) { Header.Title = ModSelectOverlayStrings.ModSelectTitle; Header.Description = ModSelectOverlayStrings.ModSelectDescription; columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in"); MainAreaContent.Add(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { new Container { Padding = new MarginPadding { Top = RankingInformationDisplay.HEIGHT + 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 { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Direction = FillDirection.Horizontal, Shear = new Vector2(OsuGame.SHEAR, 0), RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Margin = new MarginPadding { Horizontal = 70 }, Padding = new MarginPadding { Bottom = 10 }, ChildrenEnumerable = createColumns() } } } }, aboveColumnsContent = new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = 100, Bottom = 15f }, Children = new Drawable[] { SearchTextBox = new ShearedSearchTextBox { HoldFocus = false, Width = 300, }, customisationPanel = new ModCustomisationPanel { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Width = 400, State = { Value = Visibility.Visible }, } } } }, } }); globalAvailableMods.BindTo(game.AvailableMods); textSearchStartsActive = configManager.GetBindable(OsuSetting.ModSelectTextSearchStartsActive); } public override void Hide() { base.Hide(); // clear search for next user interaction with mod overlay SearchTextBox.Current.Value = string.Empty; } 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. globalAvailableMods.BindValueChanged(_ => createLocalMods(), true); base.LoadComplete(); State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true); // 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 (AllowCustomisation) ((IBindable>)customisationPanel.SelectedMods).BindTo(SelectedMods); SelectedMods.BindValueChanged(_ => { updateFromExternalSelection(); updateCustomisation(); ActiveMods.Value = ComputeActiveMods(); }, true); customisationPanel.ExpandedState.BindValueChanged(_ => updateCustomisationVisualState(), true); SearchTextBox.Current.BindValueChanged(query => { foreach (var column in columnFlow.Columns) column.SearchTerm = query.NewValue; if (SearchTextBox.HasFocus) preselectMod(); }, true); // Start scrolling from the end, to give the user a sense that // there is more horizontal content available. ScheduleAfterChildren(() => { columnScroll.ScrollToEnd(false); columnScroll.ScrollTo(0); }); } private void preselectMod() { var visibleMods = columnFlow.Columns.OfType().Where(c => c.IsPresent).SelectMany(c => c.AvailableMods.Where(m => m.Visible)); // Search for an exact acronym or name match, or otherwise default to the first visible mod. ModState? matchingMod = visibleMods.FirstOrDefault(m => m.Mod.Acronym.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase) || m.Mod.Name.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase)) ?? visibleMods.FirstOrDefault(); var preselectedMod = matchingMod; foreach (var mod in AllAvailableMods) mod.Preselected.Value = mod == preselectedMod && SearchTextBox.Current.Value.Length > 0; } private void clearPreselection() { foreach (var mod in AllAvailableMods) mod.Preselected.Value = false; } public new ModSelectFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as ModSelectFooterContent; public override VisibilityContainer CreateFooterContent() => new ModSelectFooterContent(this) { Beatmap = { BindTarget = Beatmap }, ActiveMods = { BindTarget = ActiveMods }, }; private static readonly LocalisableString input_search_placeholder = Resources.Localisation.Web.CommonStrings.InputSearch; private static readonly LocalisableString tab_to_search_placeholder = ModSelectOverlayStrings.TabToSearch; protected override void Update() { base.Update(); SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? input_search_placeholder : tab_to_search_placeholder; aboveColumnsContent.Padding = aboveColumnsContent.Padding with { Bottom = DisplayedFooterContent?.DisplaysStackedVertically == true ? 75f : 15f }; } /// /// Select all visible mods in all columns. /// public void SelectAll() { foreach (var column in columnFlow.Columns.OfType()) column.SelectAll(); } /// /// Deselect all visible mods in all columns. /// public void DeselectAll() { foreach (var column in columnFlow.Columns.OfType()) column.DeselectAll(); } private IEnumerable createColumns() { if (ShowPresets) { yield return new ColumnDimContainer(new ModPresetColumn { Margin = new MarginPadding { Right = 10 } }); } yield return createModColumnContent(ModType.DifficultyReduction); yield return createModColumnContent(ModType.DifficultyIncrease); yield return createModColumnContent(ModType.Automation); yield return createModColumnContent(ModType.Conversion); yield return createModColumnContent(ModType.Fun); } private ColumnDimContainer createModColumnContent(ModType modType) { var column = CreateModColumn(modType).With(column => { // 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); } private void createLocalMods() { var newLocalAvailableMods = new Dictionary>(); foreach (var (modType, mods) in globalAvailableMods.Value) { var modStates = mods.SelectMany(ModUtils.FlattenMod) .Select(mod => new ModState(mod.DeepClone())) .ToArray(); foreach (var modState in modStates) modState.Active.BindValueChanged(_ => updateFromInternalSelection()); newLocalAvailableMods[modType] = modStates; } AvailableMods.Value = newLocalAvailableMods; filterMods(); foreach (var column in columnFlow.Columns.OfType()) column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty()); } private void filterMods() { foreach (var modState in AllAvailableMods) modState.ValidForSelection.Value = modState.Mod.Type != ModType.System && modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod); } private void updateCustomisation() { if (!AllowCustomisation) return; bool anyCustomisableModActive = false; bool anyModPendingConfiguration = false; foreach (var modState in AllAvailableMods) { anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any(); anyModPendingConfiguration |= modState.PendingConfiguration; modState.PendingConfiguration = false; } if (anyCustomisableModActive) { customisationPanel.Enabled.Value = true; if (anyModPendingConfiguration) customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.ExpandedByMod; } else { customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.Collapsed; customisationPanel.Enabled.Value = false; } } private void updateCustomisationVisualState() { if (customisationPanel.ExpandedState.Value != ModCustomisationPanel.ModCustomisationPanelState.Collapsed) { columnScroll.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); SearchTextBox.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); setTextBoxFocus(false); } else { columnScroll.FadeColour(Color4.White, 400, Easing.OutQuint); SearchTextBox.FadeColour(Color4.White, 400, Easing.OutQuint); setTextBoxFocus(textSearchStartsActive.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; private void updateFromExternalSelection() { if (externalSelectionUpdateInProgress) return; externalSelectionUpdateInProgress = true; var newSelection = new List(); foreach (var modState in AllAvailableMods) { 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 updateFromInternalSelection() { if (externalSelectionUpdateInProgress) return; var candidateSelection = AllAvailableMods.Where(modState => modState.Active.Value) .Select(modState => modState.Mod) .ToArray(); 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(); aboveColumnsContent .FadeIn(fade_in_duration, Easing.OutQuint) .MoveToY(0, fade_in_duration, Easing.OutQuint); int nonFilteredColumnCount = 0; for (int i = 0; i < columnFlow.Count; i++) { var column = columnFlow[i].Column; bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => !modState.Visible); double delay = allFiltered ? 0 : nonFilteredColumnCount * 30; double duration = allFiltered ? 0 : fade_in_duration; float startingYPosition = 0; if (!allFiltered) startingYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; column.TopLevelContent .MoveToY(startingYPosition) .Delay(delay) .MoveToY(0, duration, Easing.OutQuint) .FadeIn(duration, Easing.OutQuint); if (allFiltered) continue; int columnNumber = nonFilteredColumnCount; Scheduler.AddDelayed(() => { var channel = columnAppearSample?.GetChannel(); if (channel == null) return; // Still play sound effects for off-screen columns up to a certain point. if (columnNumber > 5 && !column.Active.Value) return; // use X position of the column on screen as a basis for panning the sample float balance = column.Parent!.BoundingBox.Centre.X / RelativeToAbsoluteFactor.X; // dip frequency and ramp volume of sample over the first 5 displayed columns float progress = Math.Min(1, columnNumber / 5f); channel.Frequency.Value = 1.3 - (progress * 0.3) + RNG.NextDouble(0.1); channel.Volume.Value = Math.Max(progress, 0.2); channel.Balance.Value = -1 + balance * 2; channel.Play(); }, delay); nonFilteredColumnCount += 1; } setTextBoxFocus(textSearchStartsActive.Value); } protected override void PopOut() { const double fade_out_duration = 500; base.PopOut(); aboveColumnsContent .FadeOut(fade_out_duration / 2, Easing.OutQuint) .MoveToY(-distance, fade_out_duration / 2, Easing.OutQuint); int nonFilteredColumnCount = 0; for (int i = 0; i < columnFlow.Count; i++) { var column = columnFlow[i].Column; bool allFiltered = false; if (column is ModColumn modColumn) { allFiltered = modColumn.AvailableMods.All(modState => !modState.Visible); modColumn.FlushPendingSelections(); } double duration = allFiltered ? 0 : fade_out_duration; float newYPosition = 0; if (!allFiltered) newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; column.TopLevelContent .MoveToY(newYPosition, duration, Easing.OutQuint) .FadeOut(duration, Easing.OutQuint); if (!allFiltered) nonFilteredColumnCount += 1; } customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.Collapsed; } #endregion #region Input handling public override bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) return false; switch (e.Action) { // If the customisation panel is expanded, the back action will be handled by it first. case GlobalAction.Back: // 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: hideOverlay(); return true; // This is handled locally here due to conflicts in input handling between the search text box and the deselect all mods button. // Attempting to handle this action locally in both places leads to a possible scenario // wherein activating the binding will both change the contents of the search text box and deselect all mods. case GlobalAction.DeselectAllMods: { if (!SearchTextBox.HasFocus && customisationPanel.ExpandedState.Value == ModCustomisationPanel.ModCustomisationPanelState.Collapsed) { DisplayedFooterContent?.DeselectAllModsButton?.TriggerClick(); return true; } break; } case GlobalAction.Select: { // Pressing select should select first filtered mod if a search is in progress. // If there is no search in progress, it should exit the dialog (a bit weird, but this is the expectation from stable). if (string.IsNullOrEmpty(SearchTerm)) { hideOverlay(); return true; } var matchingMod = AllAvailableMods.SingleOrDefault(m => m.Preselected.Value); if (matchingMod is not null) { matchingMod.Active.Value = !matchingMod.Active.Value; SearchTextBox.SelectAll(); } return true; } } return base.OnPressed(e); void hideOverlay() { if (footer != null) footer.BackButton.TriggerClick(); else Hide(); } } /// /// /// This is handled locally here due to conflicts in input handling between the search text box and the select all mods button. /// Attempting to handle this action locally in both places leads to a possible scenario /// wherein activating the "select all" platform binding will both select all text in the search box and select all mods. /// public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat || e.Action != PlatformAction.SelectAll || SelectAllModsButton == null) return false; SelectAllModsButton.TriggerClick(); return true; } public void OnReleased(KeyBindingReleaseEvent e) { } protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat || e.Key != Key.Tab) return false; if (customisationPanel.ExpandedState.Value != ModCustomisationPanel.ModCustomisationPanelState.Collapsed) return true; // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) setTextBoxFocus(!SearchTextBox.HasFocus); return true; } private void setTextBoxFocus(bool focus) { if (focus) { SearchTextBox.TakeFocus(); preselectMod(); } else { SearchTextBox.KillFocus(); clearPreselection(); } } #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. /// [Cached] internal partial class ColumnScrollContainer : OsuScrollContainer { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; 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 * OsuGame.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 partial 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 partial class ColumnDimContainer : Container { public ModSelectColumn 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(ModSelectColumn column) { AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; Child = Column = column; column.Active.BindTo(Active); } [BackgroundDependencyLoader] private void load(ColumnScrollContainer columnScroll) { RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140); } protected override void LoadComplete() { base.LoadComplete(); Active.BindValueChanged(_ => updateState(), true); FinishTransforms(); } protected override bool RequiresChildrenUpdate { get { bool result = base.RequiresChildrenUpdate; if (Column is ModColumn modColumn) result |= !modColumn.ItemsLoaded || modColumn.SelectionAnimationRunning; return result; } } private void updateState() { Colour4 targetColour; 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); // Killing focus is done here because it's the only feasible place on ModSelectOverlay you can click on without triggering any action. Scheduler.Add(() => GetContainingFocusManager()!.ChangeFocus(null)); 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(); } } } }