mirror of
https://github.com/ppy/osu.git
synced 2026-05-27 08:40:03 +08:00
9727d95ad9
- Part of https://github.com/ppy/osu/issues/37818
During review, I would like to direct particular attention to the
following changes:
## [Migrate song select to new score multiplier
API](https://github.com/ppy/osu/commit/945fd78539da3ae57d1550a5bbfb0f859d153cc4)
This was a confusing change to write because of the way song selects
hook their mod overlays up to global bindables. In particular different
things happen in different circumstances.
- When going through `SongSelect.CreateModOverlay()`, which is called by
the base `SongSelect`, the mod overlay is automatically bound to global
bindables via `SongSelect.on{ArrivingAt,Leaving}Screen()`.
- For multiplayer user mod select overlays, which are bolted on by
subclasses of `SongSelect`, manual hook-up is required.
- As for free mod select overlays, they don't show mod multipliers at
all, and don't have easy access to the ruleset, and thus the hookup is
skipped entirely as redundant.
## [Fix score multiplier registrations being shared between
implementations via superclass static
fields](https://github.com/ppy/osu/commit/ba0a7ad421e0c84c2d8162b6bbdd3a0683f5a6a6)
Revealed by `ScoreMultiplierCalculatorTest` starting to fail due to
interference from `OsuScoreMultiplierCalculator`.
It's not ideal from a performance standpoint but it's the simplest
choice for now. Tricks could be pulled to salvage the static. One is
```csharp
public class ScoreMultiplierCalculator<T>
where T : ScoreMultiplierCalculator<T>
{
}
```
This works because of generics internals; static instance members are
not shared between different specialisations of a generic class. It is
also very unintuitive, so I would rather not. (It trips a ReSharper
inspection too, which would have to be silenced.)
From a performance standpoint this is not ideal, but a significant chunk
of migrated usages already precede the construction of the calculator
via the known-expensive `RulesetInfo.CreateInstance()`, and the paths
that actually construct the calculator do not appear to be that hot. If
need be, this can be handled by actually caching ruleset instances and
their derivative subcomponents.
## [Introduce passing of context to score multiplier
calculator](https://github.com/ppy/osu/pull/37845/changes/9e9242b3221dddacd226f4b3b9c5632d7350e998)
This is required for two reasons:
- The upcoming mod rebalance will require out-of-band supplementary
information that is not available for reading from the mod instances
themselves for calculating the multiplier.
- This context, namely passing of `ScoreInfo`, will be used for
implementing backwards compatibility with old scores and their score
multipliers. This is required because it has turned out under inspection
that all server-side lazer replays recorded until now are missing
`TotalScoreWithoutMods` due to an omission of not sending it across the
wire to spectator server.
Because the score import flow uses replays, filtered through
`LegacyScoreDecoder`, to populate total score in the realm database, it
is basically impossible to ignore scores that are missing
`TotalScoreWithoutMods`, because that will result in bug reports that
the scores do not have the new score multipliers applied.
Thus, passing of `ScoreInfo` will facilitate implementation of
versioning score multipliers, which should result in less breakage than
not doing so.
An example of this is added in 341b2d6e55,
which should handle the case of mania mod multipliers having been
changed without any attempt to facilitate for it in
https://github.com/ppy/osu/pull/30506.
---------
Co-authored-by: Dean Herbert <pe@ppy.sh>
854 lines
34 KiB
C#
854 lines
34 KiB
C#
// 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;
|
|
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;
|
|
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 partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler<PlatformAction>
|
|
{
|
|
public const int BUTTON_WIDTH = 200;
|
|
|
|
protected override string PopInSampleName => "";
|
|
protected override string PopOutSampleName => @"SongSelect/mod-select-overlay-pop-out";
|
|
|
|
[Cached]
|
|
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
|
|
|
|
/// <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>>());
|
|
|
|
private Func<Mod, bool> isValidMod = _ => true;
|
|
|
|
/// <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));
|
|
filterMods();
|
|
}
|
|
}
|
|
|
|
public string SearchTerm
|
|
{
|
|
get => SearchTextBox.Current.Value;
|
|
set => SearchTextBox.Current.Value = value;
|
|
}
|
|
|
|
public ShearedSearchTextBox SearchTextBox { get; private set; } = null!;
|
|
|
|
/// <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>
|
|
public bool ShowPresets { get; init; }
|
|
|
|
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;
|
|
|
|
private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> globalAvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
|
|
|
|
public IEnumerable<ModState> AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value);
|
|
|
|
private Bindable<bool> 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<WorkingBeatmap?> Beatmap = new Bindable<WorkingBeatmap?>();
|
|
public readonly Bindable<RulesetInfo?> Ruleset = new Bindable<RulesetInfo?>();
|
|
|
|
[Resolved]
|
|
private ScreenFooter? footer { get; set; }
|
|
|
|
public 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 = OsuGame.SHEAR,
|
|
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<bool>(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<IReadOnlyList<Mod>>)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<ModColumn>().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 },
|
|
Ruleset = { BindTarget = Ruleset },
|
|
};
|
|
|
|
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 };
|
|
}
|
|
|
|
/// <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 }
|
|
});
|
|
}
|
|
|
|
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<ModType, IReadOnlyList<ModState>>();
|
|
|
|
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.Value = SelectedMods.Value.Any(selected => selected.GetType() == modState.Mod.GetType());
|
|
modState.Active.BindValueChanged(_ => updateFromInternalSelection());
|
|
}
|
|
|
|
newLocalAvailableMods[modType] = modStates;
|
|
}
|
|
|
|
AvailableMods.Value = newLocalAvailableMods;
|
|
filterMods();
|
|
|
|
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
|
|
column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty<ModState>());
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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<GlobalAction> 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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc cref="IKeyBindingHandler{PlatformAction}"/>
|
|
/// <remarks>
|
|
/// 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;
|
|
|
|
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<bool> samplePlaybackDisabled = new BindableBool(true);
|
|
IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled;
|
|
|
|
#endregion
|
|
|
|
/// <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 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.
|
|
double leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent);
|
|
double 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.
|
|
double leftMovementBound = Math.Min(Current, Target);
|
|
double 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.X, 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Manages layout of mod columns.
|
|
/// </summary>
|
|
internal partial class ColumnFlowContainer : FillFlowContainer<ColumnDimContainer>
|
|
{
|
|
public IEnumerable<ModSelectColumn> Columns => Children.Select(dimWrapper => dimWrapper.Column);
|
|
|
|
public override void Add(ColumnDimContainer dimContainer)
|
|
{
|
|
base.Add(dimContainer);
|
|
|
|
Debug.Assert(dimContainer != null);
|
|
dimContainer.Column.Shear = Vector2.Zero;
|
|
}
|
|
}
|
|
|
|
/// <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; }
|
|
|
|
/// <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();
|
|
|
|
/// <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!;
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|