1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-21 13:22:57 +08:00
osu-lazer/osu.Game/Overlays/Mods/ModSelectOverlay.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1005 lines
39 KiB
C#
Raw Normal View History

2022-03-27 05:43:17 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
2022-07-01 19:43:12 +08:00
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
2022-03-27 05:43:17 +08:00
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;
2022-03-27 05:43:17 +08:00
using osu.Framework.Input.Events;
2024-02-25 03:58:23 +08:00
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Audio;
2023-09-09 01:32:55 +08:00
using osu.Game.Beatmaps;
2022-03-27 05:43:17 +08:00
using osu.Game.Configuration;
using osu.Game.Graphics;
2022-03-27 05:43:17 +08:00
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
2022-03-27 05:43:17 +08:00
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
2022-03-27 05:43:17 +08:00
using osu.Game.Rulesets.Mods;
2022-05-12 01:02:45 +08:00
using osu.Game.Utils;
2022-03-27 05:43:17 +08:00
using osuTK;
using osuTK.Input;
2022-03-27 05:43:17 +08:00
namespace osu.Game.Overlays.Mods
{
public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler<PlatformAction>
2022-03-27 05:43:17 +08:00
{
public const int BUTTON_WIDTH = 200;
2022-07-01 19:43:12 +08:00
protected override string PopInSampleName => "";
protected override string PopOutSampleName => @"SongSelect/mod-select-overlay-pop-out";
2022-03-27 05:43:17 +08:00
[Cached]
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
2022-03-27 05:43:17 +08:00
/// <summary>
/// Contains a list of mods which <see cref="ModSelectOverlay"/> should read from to display effects on the selected beatmap.
/// </summary>
/// <remarks>
/// This is different from <see cref="SelectedMods"/> in screens like online-play rooms, where there are required mods activated from the playlist.
/// </remarks>
public Bindable<IReadOnlyList<Mod>> ActiveMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
/// <summary>
/// Contains a dictionary with the current <see cref="ModState"/> of all mods applicable for the current ruleset.
/// </summary>
/// <remarks>
/// Contrary to <see cref="OsuGameBase.AvailableMods"/> and <see cref="globalAvailableMods"/>, the <see cref="Mod"/> instances
/// inside the <see cref="ModState"/> objects are owned solely by this <see cref="ModSelectOverlay"/> instance.
/// </remarks>
public Bindable<Dictionary<ModType, IReadOnlyList<ModState>>> AvailableMods { get; } =
new Bindable<Dictionary<ModType, IReadOnlyList<ModState>>>(new Dictionary<ModType, IReadOnlyList<ModState>>());
2022-06-24 20:25:23 +08:00
private Func<Mod, bool> isValidMod = _ => true;
2022-05-08 00:49:29 +08:00
/// <summary>
/// A function determining whether each mod in the column should be displayed.
/// A return value of <see langword="true"/> means that the mod is not filtered and therefore its corresponding panel should be displayed.
/// A return value of <see langword="false"/> means that the mod is filtered out and therefore its corresponding panel should be hidden.
/// </summary>
public Func<Mod, bool> IsValidMod
{
get => isValidMod;
set
{
isValidMod = value ?? throw new ArgumentNullException(nameof(value));
2022-05-12 01:02:45 +08:00
filterMods();
}
}
public string SearchTerm
{
2023-06-04 22:02:46 +08:00
get => SearchTextBox.Current.Value;
2023-06-18 20:28:26 +08:00
set => SearchTextBox.Current.Value = value;
}
2023-06-04 22:02:46 +08:00
public ShearedSearchTextBox SearchTextBox { get; private set; } = null!;
/// <summary>
/// Whether the effects (on score multiplier, on or beatmap difficulty) of the current selected set of mods should be shown.
/// </summary>
protected virtual bool ShowModEffects => true;
/// <summary>
/// Whether per-mod customisation controls are visible.
/// </summary>
protected virtual bool AllowCustomisation => true;
/// <summary>
/// Whether the column with available mod presets should be shown.
/// </summary>
protected virtual bool ShowPresets => false;
protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false);
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
protected virtual IReadOnlyList<Mod> ComputeActiveMods() => SelectedMods.Value;
protected virtual IEnumerable<ShearedButton> 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<Dictionary<ModType, IReadOnlyList<Mod>>> globalAvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
public IEnumerable<ModState> AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value);
2022-05-12 01:02:45 +08:00
2022-03-27 05:43:17 +08:00
private readonly BindableBool customisationVisible = new BindableBool();
private Bindable<bool> textSearchStartsActive = null!;
2022-03-27 05:43:17 +08:00
private ModSettingsArea modSettingsArea = null!;
private ColumnScrollContainer columnScroll = null!;
private ColumnFlowContainer columnFlow = null!;
private FillFlowContainer<ShearedButton> footerButtonFlow = null!;
private FillFlowContainer footerContentFlow = null!;
private DeselectAllModsButton deselectAllModsButton = null!;
2023-05-02 19:15:33 +08:00
private Container aboveColumnsContent = null!;
2024-02-20 19:00:59 +08:00
private RankingInformationDisplay? rankingInformationDisplay;
private BeatmapAttributesDisplay? beatmapAttributesDisplay;
2024-02-18 09:28:24 +08:00
protected ShearedButton BackButton { get; private set; } = null!;
protected ShearedToggleButton? CustomisationButton { get; private set; }
protected SelectAllModsButton? SelectAllModsButton { get; set; }
2022-04-04 14:45:44 +08:00
2022-07-01 19:43:12 +08:00
private Sample? columnAppearSample;
private WorkingBeatmap? beatmap;
2023-09-09 01:32:55 +08:00
public WorkingBeatmap? Beatmap
2023-09-09 01:32:55 +08:00
{
get => beatmap;
set
{
if (beatmap == value) return;
beatmap = value;
2024-02-20 19:00:59 +08:00
if (IsLoaded && beatmapAttributesDisplay != null)
beatmapAttributesDisplay.BeatmapInfo.Value = beatmap?.BeatmapInfo;
2023-09-09 01:32:55 +08:00
}
}
protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green)
: base(colourScheme)
{
}
2022-03-27 05:43:17 +08:00
[BackgroundDependencyLoader]
private void load(OsuGameBase game, OsuColour colours, AudioManager audio, OsuConfigManager configManager)
2022-03-27 05:43:17 +08:00
{
Header.Title = ModSelectOverlayStrings.ModSelectTitle;
Header.Description = ModSelectOverlayStrings.ModSelectDescription;
2022-03-27 05:43:17 +08:00
2022-07-01 19:43:12 +08:00
columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in");
AddRange(new Drawable[]
2022-03-27 05:43:17 +08:00
{
new ClickToReturnContainer
{
RelativeSizeAxes = Axes.Both,
HandleMouse = { BindTarget = customisationVisible },
OnClicked = () => customisationVisible.Value = false
},
modSettingsArea = new ModSettingsArea
2022-03-27 05:43:17 +08:00
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Height = 0
2024-05-24 18:59:24 +08:00
},
});
MainAreaContent.AddRange(new Drawable[]
2022-03-27 05:43:17 +08:00
{
2023-05-02 19:15:33 +08:00
aboveColumnsContent = new Container
{
RelativeSizeAxes = Axes.X,
Height = RankingInformationDisplay.HEIGHT,
2023-05-02 19:15:33 +08:00
Padding = new MarginPadding { Horizontal = 100 },
2023-06-04 22:02:46 +08:00
Child = SearchTextBox = new ShearedSearchTextBox
2023-05-02 19:15:33 +08:00
{
HoldFocus = false,
Width = 300
}
},
new OsuContextMenuContainer
{
2022-03-27 05:43:17 +08:00
RelativeSizeAxes = Axes.Both,
Child = new PopoverContainer
2022-03-27 05:43:17 +08:00
{
Padding = new MarginPadding
{
Top = RankingInformationDisplay.HEIGHT + PADDING,
Bottom = PADDING
},
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
Children = new Drawable[]
2022-03-27 05:43:17 +08:00
{
columnScroll = new ColumnScrollContainer
2022-03-27 05:43:17 +08:00
{
RelativeSizeAxes = Axes.Both,
Masking = false,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = columnFlow = new ColumnFlowContainer
2022-03-27 05:43:17 +08:00
{
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()
}
}
}
2022-03-27 05:43:17 +08:00
}
}
});
FooterContent.Add(footerButtonFlow = new FillFlowContainer<ShearedButton>
{
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)
2022-05-07 04:30:07 +08:00
{
Text = CommonStrings.Back,
Action = Hide,
DarkerColour = colours.Pink2,
LighterColour = colours.Pink1
})
});
2022-05-12 01:02:45 +08:00
2023-09-11 16:19:02 +08:00
if (ShowModEffects)
{
FooterContent.Add(footerContentFlow = new FillFlowContainer
2023-09-11 16:19:02 +08:00
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(30, 10),
2023-09-11 16:19:02 +08:00
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Margin = new MarginPadding
{
Vertical = PADDING,
Horizontal = 20
2023-09-11 16:19:02 +08:00
},
Children = new Drawable[]
{
2024-02-20 19:00:59 +08:00
rankingInformationDisplay = new RankingInformationDisplay
{
2023-09-13 16:55:25 +08:00
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight
},
2024-02-23 22:30:31 +08:00
beatmapAttributesDisplay = new BeatmapAttributesDisplay
{
2024-02-23 22:30:31 +08:00
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
BeatmapInfo = { Value = Beatmap?.BeatmapInfo },
},
}
2023-09-11 16:19:02 +08:00
});
}
globalAvailableMods.BindTo(game.AvailableMods);
textSearchStartsActive = configManager.GetBindable<bool>(OsuSetting.ModSelectTextSearchStartsActive);
2022-03-27 05:43:17 +08:00
}
2023-09-03 07:17:04 +08:00
public override void Hide()
{
base.Hide();
2023-06-18 20:34:33 +08:00
// clear search for next user interaction with mod overlay
2023-06-04 22:02:46 +08:00
SearchTextBox.Current.Value = string.Empty;
}
private ModSettingChangeTracker? modSettingChangeTracker;
2022-03-27 05:43:17 +08:00
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);
2022-05-12 01:02:45 +08:00
base.LoadComplete();
State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true);
2022-05-10 14:07:08 +08:00
// 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<IReadOnlyList<Mod>>)modSettingsArea.SelectedMods).BindTo(SelectedMods);
SelectedMods.BindValueChanged(_ =>
2022-03-27 05:43:17 +08:00
{
updateFromExternalSelection();
updateCustomisation();
ActiveMods.Value = ComputeActiveMods();
}, true);
ActiveMods.BindValueChanged(_ =>
{
updateOverlayInformation();
Fix leak of `ModSettingChangeTracker` instances The `SelectedMods.BindValueChanged()` callback in `ModSelectOverlay` can in some instances run recursively. This is most heavily leaned on in scenarios where `SelectedMods` is updated by an external component. In such cases, the mod select overlay needs to replace the mod instances received externally with mod instances which it owns, so that the changes made on the overlay can propagate outwards. This in particular means that prior to this commit, it was possible to encounter the following scenario: modSettingChangeTracker?.Dispose(); updateFromExternalSelection(); // mutates SelectedMods to perform the replacement // therefore causing a recursive call modSettingChangeTracker?.Dispose(); // inner call continues modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); // outer call continues modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); This leaks one `modSettingChangeTracker` instance from the inner call, which is never disposed. To avoid this, move the disposal to the same side of the recursion that the creation happens on, changing the call pattern to: updateFromExternalSelection(); // mutates SelectedMods to perform the replacement // therefore causing a recursive call modSettingChangeTracker?.Dispose(); // inner call continues modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); modSettingChangeTracker?.Dispose(); // outer call continues modSettingChangeTracker = new ModSettingChangeTracker(SelectedMods.Value); which, while slightly wasteful, does not cause any leaks. The solution is definitely suboptimal, but addressing this properly would entail a major rewrite of the mod instance management in the mods overlay, which is probably not the wisest move to make right now.
2023-04-30 23:24:07 +08:00
modSettingChangeTracker?.Dispose();
if (AllowCustomisation)
{
// Importantly, use ActiveMods.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(ActiveMods.Value);
modSettingChangeTracker.SettingChanged += _ => updateOverlayInformation();
}
2022-03-27 05:43:17 +08:00
}, true);
2022-03-27 05:43:17 +08:00
customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true);
2023-06-04 22:02:46 +08:00
SearchTextBox.Current.BindValueChanged(query =>
2023-05-02 19:15:33 +08:00
{
foreach (var column in columnFlow.Columns)
column.SearchTerm = query.NewValue;
}, true);
// Start scrolling from the end, to give the user a sense that
// there is more horizontal content available.
ScheduleAfterChildren(() =>
{
columnScroll.ScrollToEnd(false);
2024-03-08 13:59:04 +08:00
columnScroll.ScrollTo(0);
});
2022-03-27 05:43:17 +08:00
}
2024-02-25 03:58:23 +08:00
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();
2024-02-25 03:58:23 +08:00
SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? input_search_placeholder : tab_to_search_placeholder;
2024-02-20 19:00:59 +08:00
if (beatmapAttributesDisplay != null)
{
2024-02-25 15:36:15 +08:00
float rightEdgeOfLastButton = footerButtonFlow[^1].ScreenSpaceDrawQuad.TopRight.X;
// this is cheating a bit; the 640 value is hardcoded based on how wide the expanded panel _generally_ is.
// due to the transition applied, the raw screenspace quad of the panel cannot be used, as it will trigger an ugly feedback cycle of expanding and collapsing.
float projectedLeftEdgeOfExpandedBeatmapAttributesDisplay = footerButtonFlow.ToScreenSpace(footerButtonFlow.DrawSize - new Vector2(640, 0)).X;
bool screenIsntWideEnough = rightEdgeOfLastButton > projectedLeftEdgeOfExpandedBeatmapAttributesDisplay;
2023-09-13 16:55:25 +08:00
// only update preview panel's collapsed state after we are fully visible, to ensure all the buttons are where we expect them to be.
if (Alpha == 1)
2024-02-20 19:00:59 +08:00
beatmapAttributesDisplay.Collapsed.Value = screenIsntWideEnough;
2023-09-13 16:55:25 +08:00
footerContentFlow.LayoutDuration = 200;
footerContentFlow.LayoutEasing = Easing.OutQuint;
footerContentFlow.Direction = screenIsntWideEnough ? FillDirection.Vertical : FillDirection.Horizontal;
}
}
/// <summary>
/// Select all visible mods in all columns.
/// </summary>
public void SelectAll()
{
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.SelectAll();
}
/// <summary>
/// Deselect all visible mods in all columns.
/// </summary>
public void DeselectAll()
{
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.DeselectAll();
}
private IEnumerable<ColumnDimContainer> createColumns()
{
if (ShowPresets)
{
yield return new ColumnDimContainer(new ModPresetColumn
{
Margin = new MarginPadding { Right = 10 }
2024-04-19 17:11:18 +08:00
});
}
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 };
});
2024-04-19 17:11:18 +08:00
return new ColumnDimContainer(column);
}
2022-05-12 01:02:45 +08:00
private void createLocalMods()
{
var newLocalAvailableMods = new Dictionary<ModType, IReadOnlyList<ModState>>();
2022-05-12 01:02:45 +08:00
foreach (var (modType, mods) in globalAvailableMods.Value)
2022-05-12 01:02:45 +08:00
{
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;
2022-05-12 01:02:45 +08:00
}
AvailableMods.Value = newLocalAvailableMods;
2022-05-12 01:02:45 +08:00
filterMods();
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty<ModState>());
2022-05-12 01:02:45 +08:00
}
private void filterMods()
{
foreach (var modState in AllAvailableMods)
modState.ValidForSelection.Value = modState.Mod.Type != ModType.System && modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod);
2022-05-12 01:02:45 +08:00
}
/// <summary>
/// Updates any information displayed on the overlay regarding the effects of the active mods.
/// This reads from <see cref="ActiveMods"/> instead of <see cref="SelectedMods"/>.
/// </summary>
private void updateOverlayInformation()
2022-03-27 05:43:17 +08:00
{
if (rankingInformationDisplay != null)
{
double multiplier = 1.0;
foreach (var mod in ActiveMods.Value)
multiplier *= mod.ScoreMultiplier;
2022-03-27 05:43:17 +08:00
rankingInformationDisplay.ModMultiplier.Value = multiplier;
rankingInformationDisplay.Ranked.Value = ActiveMods.Value.All(m => m.Ranked);
}
2022-03-27 05:43:17 +08:00
if (beatmapAttributesDisplay != null)
beatmapAttributesDisplay.Mods.Value = ActiveMods.Value;
2022-03-27 05:43:17 +08:00
}
private void updateCustomisation()
2022-03-27 05:43:17 +08:00
{
if (CustomisationButton == null)
return;
bool anyCustomisableModActive = false;
bool anyModPendingConfiguration = false;
2022-03-27 05:43:17 +08:00
foreach (var modState in AllAvailableMods)
2022-03-27 05:43:17 +08:00
{
anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any();
anyModPendingConfiguration |= modState.PendingConfiguration;
modState.PendingConfiguration = false;
2022-03-27 05:43:17 +08:00
}
if (anyCustomisableModActive)
2022-03-27 05:43:17 +08:00
{
customisationVisible.Disabled = false;
if (anyModPendingConfiguration && !customisationVisible.Value)
2022-03-27 05:43:17 +08:00
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);
2022-03-27 05:43:17 +08:00
foreach (var button in footerButtonFlow)
{
if (button != CustomisationButton)
button.Enabled.Value = !customisationVisible.Value;
}
2022-03-27 05:43:17 +08:00
float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0;
modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic);
TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic);
if (customisationVisible.Value)
SearchTextBox.KillFocus();
else
setTextBoxFocus(textSearchStartsActive.Value);
2022-03-27 05:43:17 +08:00
}
/// <summary>
/// This flag helps to determine the source of changes to <see cref="SelectedMods"/>.
/// If the value is false, then <see cref="SelectedMods"/> are changing due to a user selection on the UI.
/// If the value is true, then <see cref="SelectedMods"/> are changing due to an external <see cref="SelectedMods"/> change.
/// </summary>
private bool externalSelectionUpdateInProgress;
private void updateFromExternalSelection()
{
if (externalSelectionUpdateInProgress)
return;
externalSelectionUpdateInProgress = true;
var newSelection = new List<Mod>();
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;
2022-03-27 05:43:17 +08:00
protected override void PopIn()
{
2022-04-05 17:38:31 +08:00
const double fade_in_duration = 400;
2022-04-04 14:45:44 +08:00
2022-03-27 05:43:17 +08:00
base.PopIn();
2022-04-04 14:45:44 +08:00
2023-06-02 17:00:03 +08:00
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;
2023-06-02 16:33:38 +08:00
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);
2022-07-01 19:43:12 +08:00
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.
2022-07-01 19:43:12 +08:00
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;
2022-07-01 19:43:12 +08:00
// 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);
2022-03-27 05:43:17 +08:00
}
protected override void PopOut()
{
2022-04-04 14:45:44 +08:00
const double fade_out_duration = 500;
2022-03-27 05:43:17 +08:00
base.PopOut();
2022-04-04 14:45:44 +08:00
2023-06-02 17:00:03 +08:00
aboveColumnsContent
2022-04-05 17:38:31 +08:00
.FadeOut(fade_out_duration / 2, Easing.OutQuint)
.MoveToY(-distance, fade_out_duration / 2, Easing.OutQuint);
2022-04-05 17:25:27 +08:00
int nonFilteredColumnCount = 0;
for (int i = 0; i < columnFlow.Count; i++)
{
var column = columnFlow[i].Column;
bool allFiltered = false;
if (column is ModColumn modColumn)
{
2023-06-02 16:33:38 +08:00
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;
}
2022-03-27 05:43:17 +08:00
}
#endregion
#region Input handling
2022-03-27 05:43:17 +08:00
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> 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;
2023-06-18 20:34:33 +08:00
// 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.
2023-07-27 03:56:04 +08:00
// 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;
}
2023-06-02 16:33:38 +08:00
ModState? firstMod = columnFlow.Columns.OfType<ModColumn>().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible);
if (firstMod is not null)
{
firstMod.Active.Value = !firstMod.Active.Value;
SearchTextBox.SelectAll();
}
return true;
}
}
return base.OnPressed(e);
void hideOverlay(bool immediate)
{
if (customisationVisible.Value)
{
Debug.Assert(CustomisationButton != null);
CustomisationButton.TriggerClick();
if (!immediate)
return;
}
BackButton.TriggerClick();
}
}
/// <inheritdoc cref="IKeyBindingHandler{PlatformAction}"/>
/// <remarks>
2023-06-18 20:34:33 +08:00
/// 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.
/// </remarks>>
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
if (e.Repeat || e.Action != PlatformAction.SelectAll || SelectAllModsButton == null)
return false;
SelectAllModsButton.TriggerClick();
return true;
}
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> 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`, `/`)
setTextBoxFocus(!SearchTextBox.HasFocus);
return true;
}
private void setTextBoxFocus(bool focus)
{
if (focus)
SearchTextBox.TakeFocus();
else
SearchTextBox.KillFocus();
}
#endregion
#region Sample playback control
private readonly Bindable<bool> samplePlaybackDisabled = new BindableBool(true);
IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled;
#endregion
2022-05-08 01:03:28 +08:00
/// <summary>
/// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility.
/// </summary>
[Cached]
internal partial class ColumnScrollContainer : OsuScrollContainer<ColumnFlowContainer>
{
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;
}
}
}
2022-05-08 01:03:28 +08:00
/// <summary>
/// Manages layout of mod columns.
2022-05-08 01:03:28 +08:00
/// </summary>
internal partial class ColumnFlowContainer : FillFlowContainer<ColumnDimContainer>
2022-03-27 05:43:17 +08:00
{
public IEnumerable<ModSelectColumn> Columns => Children.Select(dimWrapper => dimWrapper.Column);
public override void Add(ColumnDimContainer dimContainer)
2022-03-27 05:43:17 +08:00
{
base.Add(dimContainer);
2022-03-27 05:43:17 +08:00
Debug.Assert(dimContainer != null);
dimContainer.Column.Shear = Vector2.Zero;
2022-03-27 05:43:17 +08:00
}
}
2022-05-08 01:03:28 +08:00
/// <summary>
/// Encapsulates a column and provides dim and input blocking based on an externally managed "active" state.
/// </summary>
internal partial class ColumnDimContainer : Container
{
public ModSelectColumn Column { get; }
2022-05-08 01:03:28 +08:00
/// <summary>
/// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen.
/// </summary>
public readonly Bindable<bool> Active = new BindableBool();
2022-05-08 01:03:28 +08:00
/// <summary>
/// Invoked when the column is clicked while not active, requesting a scroll to be performed to bring it on-screen.
/// </summary>
public Action<ColumnDimContainer>? RequestScroll { get; set; }
[Resolved]
private OsuColour colours { get; set; } = null!;
2024-04-19 17:11:18 +08:00
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;
2022-04-28 13:59:39 +08:00
this.FadeColour(targetColour, 800, Easing.OutQuint);
}
protected override bool OnClick(ClickEvent e)
{
if (!Active.Value)
RequestScroll?.Invoke(this);
2023-06-18 20:34:33 +08:00
// 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();
}
}
2022-05-08 01:03:28 +08:00
/// <summary>
/// A container which blocks and handles input, managing the "return from customisation" state change.
/// </summary>
2022-03-27 05:43:17 +08:00
private partial class ClickToReturnContainer : Container
{
public BindableBool HandleMouse { get; } = new BindableBool();
public Action? OnClicked { get; set; }
2022-03-27 05:43:17 +08:00
public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value;
2022-03-27 05:43:17 +08:00
protected override bool Handle(UIEvent e)
{
if (!HandleMouse.Value)
return base.Handle(e);
switch (e)
{
2022-06-24 20:25:23 +08:00
case ClickEvent:
2022-03-27 05:43:17 +08:00
OnClicked?.Invoke();
return true;
case HoverEvent:
return false;
2022-06-24 20:25:23 +08:00
case MouseEvent:
2022-03-27 05:43:17 +08:00
return true;
}
return base.Handle(e);
}
}
}
}