// 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.Utils; using osu.Game.Audio; 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.Utils; using osuTK; 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 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 the total score multiplier calculated from the current selected set of mods should be shown. /// protected virtual bool ShowTotalMultiplier => true; /// /// 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 IEnumerable CreateFooterButtons() { if (AllowCustomisation) { yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH) { Text = ModSelectOverlayStrings.ModCustomisation, Active = { BindTarget = customisationVisible } }; } yield return deselectAllModsButton = new DeselectAllModsButton(this); } private readonly Bindable>> globalAvailableMods = new Bindable>>(); public IEnumerable AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value); private readonly BindableBool customisationVisible = new BindableBool(); private ModSettingsArea modSettingsArea = null!; private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private FillFlowContainer footerButtonFlow = null!; private DeselectAllModsButton deselectAllModsButton = null!; private Container aboveColumnsContent = null!; private DifficultyMultiplierDisplay? multiplierDisplay; protected ShearedButton BackButton { get; private set; } = null!; protected ShearedToggleButton? CustomisationButton { get; private set; } protected SelectAllModsButton? SelectAllModsButton { get; set; } private Sample? columnAppearSample; protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } [BackgroundDependencyLoader] private void load(OsuGameBase game, OsuColour colours, AudioManager audio) { Header.Title = ModSelectOverlayStrings.ModSelectTitle; Header.Description = ModSelectOverlayStrings.ModSelectDescription; columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in"); AddRange(new Drawable[] { new ClickToReturnContainer { RelativeSizeAxes = Axes.Both, HandleMouse = { BindTarget = customisationVisible }, OnClicked = () => customisationVisible.Value = false }, modSettingsArea = new ModSettingsArea { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Height = 0 } }); MainAreaContent.AddRange(new Drawable[] { aboveColumnsContent = new Container { RelativeSizeAxes = Axes.X, Height = ModsEffectDisplay.HEIGHT, Padding = new MarginPadding { Horizontal = 100 }, Child = SearchTextBox = new ShearedSearchTextBox { HoldFocus = false, Width = 300 } }, new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Child = new PopoverContainer { Padding = new MarginPadding { Top = ModsEffectDisplay.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(SHEAR, 0), RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Margin = new MarginPadding { Horizontal = 70 }, Padding = new MarginPadding { Bottom = 10 }, ChildrenEnumerable = createColumns() } } } } } }); if (ShowTotalMultiplier) { aboveColumnsContent.Add(multiplierDisplay = new DifficultyMultiplierDisplay { Anchor = Anchor.TopRight, Origin = Anchor.TopRight }); } FooterContent.Child = footerButtonFlow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Padding = new MarginPadding { Vertical = PADDING, Horizontal = 70 }, Spacing = new Vector2(10), ChildrenEnumerable = CreateFooterButtons().Prepend(BackButton = new ShearedButton(BUTTON_WIDTH) { Text = CommonStrings.Back, Action = Hide, DarkerColour = colours.Pink2, LighterColour = colours.Pink1 }) }; globalAvailableMods.BindTo(game.AvailableMods); } public override void Hide() { base.Hide(); // clear search for next user interaction with mod overlay SearchTextBox.Current.Value = string.Empty; } private ModSettingChangeTracker? modSettingChangeTracker; 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>)modSettingsArea.SelectedMods).BindTo(SelectedMods); SelectedMods.BindValueChanged(_ => { updateMultiplier(); updateFromExternalSelection(); updateCustomisation(); modSettingChangeTracker?.Dispose(); if (AllowCustomisation) { // Importantly, use SelectedMods.Value here (and not the ValueChanged NewValue) as the latter can // potentially be stale, due to complexities in the way change trackers work. // // See https://github.com/ppy/osu/pull/23284#issuecomment-1529056988 modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); modSettingChangeTracker.SettingChanged += _ => updateMultiplier(); } }, true); customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); SearchTextBox.Current.BindValueChanged(query => { foreach (var column in columnFlow.Columns) column.SearchTerm = query.NewValue; }, true); // Start scrolled slightly to the right to give the user a sense that // there is more horizontal content available. ScheduleAfterChildren(() => { columnScroll.ScrollTo(200, false); columnScroll.ScrollToStart(); }); } protected override void Update() { base.Update(); SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? Resources.Localisation.Web.CommonStrings.InputSearch : ModSelectOverlayStrings.TabToSearch; } /// /// 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.HasImplementation && IsValidMod.Invoke(modState.Mod); } private void updateMultiplier() { if (multiplierDisplay == null) return; double multiplier = 1.0; foreach (var mod in SelectedMods.Value) multiplier *= mod.ScoreMultiplier; multiplierDisplay.Current.Value = multiplier; } private void updateCustomisation() { if (CustomisationButton == null) 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) { customisationVisible.Disabled = false; if (anyModPendingConfiguration && !customisationVisible.Value) customisationVisible.Value = true; } else { if (customisationVisible.Value) customisationVisible.Value = false; customisationVisible.Disabled = true; } } private void updateCustomisationVisualState() { const double transition_duration = 300; MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); foreach (var button in footerButtonFlow) { if (button != CustomisationButton) button.Enabled.Value = !customisationVisible.Value; } float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); } /// /// 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; } } 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; } } #endregion #region Input handling public override bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) return false; switch (e.Action) { case GlobalAction.Back: // Pressing the back binding should only go back one step at a time. hideOverlay(false); return true; // This is handled locally here because this overlay is being registered at the game level // and therefore takes away keyboard focus from the screen stack. case GlobalAction.ToggleModSelection: // Pressing toggle should completely hide the overlay in one shot. hideOverlay(true); 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) { 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(true); return true; } ModState? firstMod = columnFlow.Columns.OfType().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible); if (firstMod is not null) firstMod.Active.Value = !firstMod.Active.Value; return true; } } return base.OnPressed(e); void hideOverlay(bool immediate) { if (customisationVisible.Value) { Debug.Assert(CustomisationButton != null); CustomisationButton.TriggerClick(); if (!immediate) return; } BackButton.TriggerClick(); } } /// /// /// 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 is 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; // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) if (SearchTextBox.HasFocus) SearchTextBox.KillFocus(); else SearchTextBox.TakeFocus(); return true; } #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 ColumnScrollContainer() : base(Direction.Horizontal) { } protected override void Update() { base.Update(); // the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space. // note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns. float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent); float rightVisibleBound = leftVisibleBound + DrawWidth; // if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass. // this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past. float leftMovementBound = Math.Min(Current, Target); float rightMovementBound = Math.Max(Current, Target) + DrawWidth; foreach (var column in Child) { // DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear, // so we have to manually compensate. var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent); var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * SHEAR, 0), ScrollContent); bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound) && Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X); bool isBeingScrolledToward = Precision.AlmostBigger(topLeft.X, leftMovementBound) && Precision.DefinitelyBigger(rightMovementBound, bottomRight.X); column.Active.Value = isCurrentlyVisible || isBeingScrolledToward; } } } /// /// Manages layout of mod columns. /// internal 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(() => GetContainingInputManager().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(); } } /// /// A container which blocks and handles input, managing the "return from customisation" state change. /// private partial class ClickToReturnContainer : Container { public BindableBool HandleMouse { get; } = new BindableBool(); public Action? OnClicked { get; set; } public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; protected override bool Handle(UIEvent e) { if (!HandleMouse.Value) return base.Handle(e); switch (e) { case ClickEvent: OnClicked?.Invoke(); return true; case MouseEvent: return true; } return base.Handle(e); } } } }