// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Layout; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Input; namespace osu.Game.Overlays.Mods { public abstract class ModSelectScreen : ShearedOverlayContainer { protected override OverlayColourScheme ColourScheme => OverlayColourScheme.Green; [Cached] public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); private Func isValidMod = m => true; public Func IsValidMod { get => isValidMod; set { isValidMod = value ?? throw new ArgumentNullException(nameof(value)); if (IsLoaded) updateAvailableMods(); } } /// /// Whether configurable s can be configured by the local user. /// protected virtual bool AllowCustomisation => true; /// /// Whether the total score multiplier calculated from the current selected set of mods should be shown. /// protected virtual bool ShowTotalMultiplier => true; protected virtual ModColumn CreateModColumn(ModType modType, Key[]? toggleKeys = null) => new ModColumn(modType, false, toggleKeys); private readonly BindableBool customisationVisible = new BindableBool(); private DifficultyMultiplierDisplay? multiplierDisplay; private ModSettingsArea modSettingsArea = null!; private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; [BackgroundDependencyLoader] private void load() { Header.Title = "Mod Select"; Header.Description = "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun."; AddRange(new Drawable[] { new ClickToReturnContainer { RelativeSizeAxes = Axes.Both, HandleMouse = { BindTarget = customisationVisible }, OnClicked = () => customisationVisible.Value = false }, modSettingsArea = new ModSettingsArea { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Height = 0 } }); MainAreaContent.AddRange(new Drawable[] { new Container { Padding = new MarginPadding { Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING, }, RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, Children = new Drawable[] { columnScroll = new ColumnScrollContainer { RelativeSizeAxes = Axes.Both, Masking = false, ClampExtension = 100, ScrollbarOverlapsContent = false, Child = columnFlow = new ColumnFlowContainer { Direction = FillDirection.Horizontal, Shear = new Vector2(SHEAR, 0), RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Spacing = new Vector2(10, 0), Margin = new MarginPadding { Horizontal = 70 }, Children = new[] { createModColumnContent(ModType.DifficultyReduction, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }), createModColumnContent(ModType.DifficultyIncrease, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }), createModColumnContent(ModType.Automation, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }), createModColumnContent(ModType.Conversion), createModColumnContent(ModType.Fun) } } } } } }); if (ShowTotalMultiplier) { MainAreaContent.Add(new Container { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.X, Height = DifficultyMultiplierDisplay.HEIGHT, Margin = new MarginPadding { Horizontal = 100 }, Child = multiplierDisplay = new DifficultyMultiplierDisplay { Anchor = Anchor.Centre, Origin = Anchor.Centre }, }); } if (AllowCustomisation) { Footer.Add(new ShearedToggleButton(200) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Margin = new MarginPadding { Vertical = PADDING, Left = 70 }, Text = "Mod Customisation", Active = { BindTarget = customisationVisible } }); } } private ColumnDimContainer createModColumnContent(ModType modType, Key[]? toggleKeys = null) => new ColumnDimContainer(CreateModColumn(modType, toggleKeys)) { AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, RequestScroll = column => columnScroll.ScrollTo(Math.Clamp(column.DrawPosition.X - 70, 0, columnScroll.ScrollableExtent)) }; protected override void LoadComplete() { base.LoadComplete(); ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); SelectedMods.BindValueChanged(val => { updateMultiplier(); updateCustomisation(val); updateSelectionFromBindable(); }, true); foreach (var column in columnFlow.Columns) { column.SelectedMods.BindValueChanged(updateBindableFromSelection); } customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); updateAvailableMods(); } private void updateMultiplier() { if (multiplierDisplay == null) return; double multiplier = 1.0; foreach (var mod in SelectedMods.Value) multiplier *= mod.ScoreMultiplier; multiplierDisplay.Current.Value = multiplier; } private void updateAvailableMods() { foreach (var column in columnFlow.Columns) column.Filter = isValidMod; } private void updateCustomisation(ValueChangedEvent> valueChangedEvent) { if (!AllowCustomisation) return; bool anyCustomisableMod = false; bool anyModWithRequiredCustomisationAdded = false; foreach (var mod in SelectedMods.Value) { anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); anyModWithRequiredCustomisationAdded |= !valueChangedEvent.OldValue.Contains(mod) && mod.RequiresConfiguration; } if (anyCustomisableMod) { customisationVisible.Disabled = false; if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) customisationVisible.Value = true; } else { if (customisationVisible.Value) customisationVisible.Value = false; customisationVisible.Disabled = true; } } private void updateCustomisationVisualState() { const double transition_duration = 300; MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); } 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. foreach (var column in columnFlow.Columns) column.SelectedMods.Value = SelectedMods.Value.Where(mod => mod.Type == column.ModType).ToArray(); } private bool selectionBindableSyncInProgress; private void updateBindableFromSelection(ValueChangedEvent> modSelectionChange) { if (selectionBindableSyncInProgress) return; selectionBindableSyncInProgress = true; SelectedMods.Value = ComputeNewModsFromSelection( modSelectionChange.NewValue.Except(modSelectionChange.OldValue), modSelectionChange.OldValue.Except(modSelectionChange.NewValue)); selectionBindableSyncInProgress = false; } protected virtual IReadOnlyList ComputeNewModsFromSelection(IEnumerable addedMods, IEnumerable removedMods) => columnFlow.Columns.SelectMany(column => column.SelectedMods.Value).ToArray(); protected override void PopIn() { const double fade_in_duration = 400; base.PopIn(); multiplierDisplay? .Delay(fade_in_duration * 0.65f) .FadeIn(fade_in_duration / 2, Easing.OutQuint) .ScaleTo(1, fade_in_duration, Easing.OutElastic); for (int i = 0; i < columnFlow.Count; i++) { columnFlow[i].Column .TopLevelContent .Delay(i * 30) .MoveToY(0, fade_in_duration, Easing.OutQuint) .FadeIn(fade_in_duration, Easing.OutQuint); } } protected override void PopOut() { const double fade_out_duration = 500; base.PopOut(); multiplierDisplay? .FadeOut(fade_out_duration / 2, Easing.OutQuint) .ScaleTo(0.75f, fade_out_duration, Easing.OutQuint); for (int i = 0; i < columnFlow.Count; i++) { 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); } } private 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; } } } private class ColumnFlowContainer : FillFlowContainer { public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); public ColumnFlowContainer() { AddLayout(drawSizeLayout); } public override void Add(ColumnDimContainer dimContainer) { base.Add(dimContainer); Debug.Assert(dimContainer != null); dimContainer.Column.Shear = Vector2.Zero; } protected override void Update() { base.Update(); if (!drawSizeLayout.IsValid) { Padding = new MarginPadding { Left = DrawHeight * SHEAR, Bottom = 10 }; drawSizeLayout.Validate(); } } } internal class ColumnDimContainer : Container { public ModColumn Column { get; } public readonly Bindable Active = new BindableBool(); public Action? RequestScroll { get; set; } [Resolved] private OsuColour colours { get; set; } = null!; public ColumnDimContainer(ModColumn column) { Child = Column = column; column.Active.BindTo(Active); } protected override void LoadComplete() { base.LoadComplete(); Active.BindValueChanged(_ => updateDim(), true); FinishTransforms(); } private void updateDim() { Colour4 targetColour; if (Active.Value) targetColour = Colour4.White; else targetColour = IsHovered ? colours.GrayC : colours.Gray8; this.FadeColour(targetColour, 300, Easing.OutQuint); } protected override bool OnClick(ClickEvent e) { if (!Active.Value) RequestScroll?.Invoke(this); return true; } protected override bool OnHover(HoverEvent e) { base.OnHover(e); updateDim(); return Active.Value; } protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); updateDim(); } } private class ClickToReturnContainer : Container { public BindableBool HandleMouse { get; } = new BindableBool(); public Action? OnClicked { get; set; } 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); } } } }