1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-16 21:28:17 +08:00
Files
osu-lazer/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
T

1101 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
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.Pooling;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Screens.Select;
using Realms;
namespace osu.Game.Screens.SelectV2
{
[Cached]
public partial class BeatmapCarousel : Carousel<BeatmapInfo>
{
public Action<BeatmapInfo>? RequestPresentBeatmap { private get; init; }
/// <summary>
/// From the provided beatmaps, select the most appropriate one for the user's skill.
/// </summary>
public required Action<IEnumerable<GroupedBeatmap>> RequestRecommendedSelection { private get; init; }
/// <summary>
/// Selection requested for the provided beatmap.
/// </summary>
public required Action<GroupedBeatmap> RequestSelection { private get; init; }
public const float SPACING = 3f;
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
private readonly LoadingLayer loading;
private readonly BeatmapCarouselFilterGrouping grouping;
/// <summary>
/// Total number of beatmap difficulties displayed with the filter.
/// </summary>
public int MatchedBeatmapsCount => Filters.Last().BeatmapItemsCount;
protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom)
{
// Group panels do not overlap with any other panel but should overlap with themselves.
if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition))
return SPACING * 2;
if (grouping.BeatmapSetsGroupedTogether)
{
// Give some space around the expanded beatmap set, at the top..
if (bottom.Model is GroupedBeatmapSet && bottom.IsExpanded)
return SPACING * 2;
// ..and the bottom.
if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet)
return SPACING * 2;
// Beatmap difficulty panels do not overlap with themselves or any other panel.
if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap)
return SPACING;
}
else
{
// `CurrentSelectionItem` cannot be used here because it may not be correctly set yet.
if (CurrentSelection != null && (CheckModelEquality(top.Model, CurrentSelection) || CheckModelEquality(bottom.Model, CurrentSelection)))
return SPACING * 2;
}
return -SPACING;
}
public BeatmapCarousel()
{
DebounceDelay = 100;
DistanceOffscreenToPreload = 100;
// Account for the osu! logo being in the way.
Scroll.ScrollbarPaddingBottom = 70;
Filters = new ICarouselFilter[]
{
new BeatmapCarouselFilterMatching(() => Criteria!),
new BeatmapCarouselFilterSorting(() => Criteria!),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, GetAllCollections, GetBeatmapInfoGuidToTopRankMapping)
};
AddInternal(loading = new LoadingLayer());
}
[BackgroundDependencyLoader]
private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
{
setupPools();
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
loadSamples(audio);
config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm);
}
protected override void LoadComplete()
{
base.LoadComplete();
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
}
#region Beatmap source hookup
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) => Schedule(() =>
{
// This callback is scheduled to ensure there's no added overhead during gameplay.
// If this ever becomes an issue, it's important to note that the actual carousel filtering is already
// implemented in a way it will only run when at song select.
//
// The overhead we are avoiding here is that of this method directly things like Items.IndexOf calls
// that can be slow for very large beatmap libraries. There are definitely ways to optimise this further.
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
// right now we are managing this locally which is a bit of added overhead.
IEnumerable<BeatmapSetInfo>? newItems = changed.NewItems?.Cast<BeatmapSetInfo>();
IEnumerable<BeatmapSetInfo>? oldItems = changed.OldItems?.Cast<BeatmapSetInfo>();
switch (changed.Action)
{
case NotifyCollectionChangedAction.Add:
if (!newItems!.Any())
return;
Items.AddRange(newItems!.SelectMany(s => s.Beatmaps));
break;
case NotifyCollectionChangedAction.Remove:
bool selectedSetDeleted = false;
foreach (var set in oldItems!)
{
foreach (var beatmap in set.Beatmaps)
{
Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap);
}
}
// After removing all items in this batch, we want to make an immediate reselection
// based on adjacency to the previous selection if it was deleted.
//
// This needs to be done immediately to avoid song select making a random selection.
// This needs to be done in this class because we need to know final display order.
// This needs to be done with attention to detail of which beatmaps have not been deleted.
if (selectedSetDeleted && CurrentSelectionIndex != null)
{
var items = GetCarouselItems()!;
if (items.Count == 0)
break;
bool success = false;
// Try selecting forwards first
for (int i = CurrentSelectionIndex.Value + 1; i < items.Count; i++)
{
if (attemptSelection(items[i]))
{
success = true;
break;
}
}
if (success)
break;
// Then try backwards (we might be at the end of available items).
for (int i = Math.Min(items.Count - 1, CurrentSelectionIndex.Value); i >= 0; i--)
{
if (attemptSelection(items[i]))
break;
}
bool attemptSelection(CarouselItem item)
{
if (CheckValidForSetSelection(item))
{
if (item.Model is GroupedBeatmap groupedBeatmap)
{
// check the new selection wasn't deleted above
if (!Items.Contains(groupedBeatmap.Beatmap))
return false;
RequestSelection(groupedBeatmap);
return true;
}
if (item.Model is GroupedBeatmapSet groupedSet)
{
if (oldItems.Contains(groupedSet.BeatmapSet))
return false;
selectRecommendedDifficultyForBeatmapSet(groupedSet);
return true;
}
}
return false;
}
}
break;
case NotifyCollectionChangedAction.Move:
// We can ignore move operations as we are applying our own sort in all cases.
break;
case NotifyCollectionChangedAction.Replace:
var oldSetBeatmaps = oldItems!.Single().Beatmaps;
var newSetBeatmaps = newItems!.Single().Beatmaps.ToList();
// Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set.
// Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered
// by users editing the beatmap or by difficulty/metadata recomputation).
//
// In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update.
// We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties
// have been processed) if it becomes an issue for animation or performance reasons.
foreach (var beatmap in oldSetBeatmaps)
{
int previousIndex = Items.IndexOf(beatmap);
Debug.Assert(previousIndex >= 0);
// we're intentionally being lenient with there being two difficulties with equal online ID or difficulty name.
// this can be the case when the user modifies the beatmap using the editor's "external edit" feature.
BeatmapInfo? matchingNewBeatmap =
newSetBeatmaps.FirstOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ??
newSetBeatmaps.FirstOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset));
// The matching beatmap may have been deleted or invalidated in some way since this event was fired.
// Let's make sure we have the most up-to-date realm state.
if (matchingNewBeatmap?.ID is Guid matchingID)
matchingNewBeatmap = realm.Run(r => r.FindWithRefresh<BeatmapInfo>(matchingID)?.Detach());
if (matchingNewBeatmap != null)
{
// TODO: should this exist in song select instead of here?
// we need to ensure the global beatmap is also updated alongside changes.
if (CurrentBeatmap != null && beatmap.Equals(CurrentBeatmap))
// we don't know in which group the matching new beatmap is, but that's fine - we can keep the previous one for now.
// we are about to modify `Items`, which - if required - will trigger a re-filter,
// which will pick a correct group - if one is present - via `HandleFilterCompleted()`.
RequestSelection(new GroupedBeatmap(CurrentGroupedBeatmap?.Group, matchingNewBeatmap));
Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]);
newSetBeatmaps.Remove(matchingNewBeatmap);
}
else
{
Items.RemoveAt(previousIndex);
}
}
// Add any items which weren't found in the previous pass (difficulty names didn't match).
foreach (var beatmap in newSetBeatmaps)
Items.Add(beatmap);
break;
case NotifyCollectionChangedAction.Reset:
Items.Clear();
break;
}
});
#endregion
#region Selection handling
protected GroupDefinition? ExpandedGroup { get; private set; }
protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; }
protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) =>
grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap;
/// <summary>
/// The currently selected <see cref="GroupedBeatmap"/>.
/// </summary>
/// <remarks>
/// The selection is never reset due to not existing. It can be set to anything.
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
/// </remarks>
public GroupedBeatmap? CurrentGroupedBeatmap
{
get => CurrentSelection as GroupedBeatmap;
set => CurrentSelection = value;
}
/// <summary>
/// The currently selected <see cref="BeatmapInfo"/>.
/// </summary>
/// <remarks>
/// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap
/// (there could be multiple panels for one beatmap due to grouping).
/// Through this property, the carousel basically figures out what group to use internally.
/// </remarks>
public BeatmapInfo? CurrentBeatmap
{
get => CurrentGroupedBeatmap?.Beatmap;
set
{
if (value == null)
{
CurrentGroupedBeatmap = null;
return;
}
if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap))
return;
// it is not universally guaranteed that the carousel items will be materialised at the time this is set.
// therefore, in cases where it is known that they will not be, default to a null group.
// even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`.
CurrentGroupedBeatmap = IsLoaded && !IsFiltering
? GetCarouselItems()?.Select(item => item.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(value))
: new GroupedBeatmap(null, value);
}
}
protected override void HandleItemActivated(CarouselItem item)
{
try
{
switch (item.Model)
{
case GroupDefinition group:
// Special case collapsing an open group.
if (ExpandedGroup == group)
{
setExpansionStateOfGroup(ExpandedGroup, false);
ExpandedGroup = null;
return;
}
setExpandedGroup(group);
// If the active selection is within this group, it should get keyboard focus immediately.
if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb)
RequestSelection(gb);
return;
case GroupedBeatmapSet groupedSet:
selectRecommendedDifficultyForBeatmapSet(groupedSet);
return;
case GroupedBeatmap groupedBeatmap:
if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap))
{
RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap);
return;
}
RequestSelection(groupedBeatmap);
return;
}
}
finally
{
playActivationSound(item);
}
}
protected override void HandleItemSelected(object? model)
{
base.HandleItemSelected(model);
switch (model)
{
case GroupedBeatmapSet:
case GroupDefinition:
throw new InvalidOperationException("Groups should never become selected");
case GroupedBeatmap groupedBeatmap:
setExpandedGroup(groupedBeatmap.Group);
if (grouping.BeatmapSetsGroupedTogether)
setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!));
break;
}
}
protected override bool HandleItemsChanged(NotifyCollectionChangedEventArgs args)
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Add:
case NotifyCollectionChangedAction.Remove:
case NotifyCollectionChangedAction.Move:
case NotifyCollectionChangedAction.Reset:
return true;
case NotifyCollectionChangedAction.Replace:
var oldBeatmaps = args.OldItems!.OfType<BeatmapInfo>().ToList();
var newBeatmaps = args.NewItems!.OfType<BeatmapInfo>().ToList();
for (int i = 0; i < oldBeatmaps.Count; i++)
{
var oldBeatmap = oldBeatmaps[i];
var newBeatmap = newBeatmaps[i];
// Ignore changes which don't concern us.
//
// Here are some examples of things that can go wrong:
// - Background difficulty calculation runs and causes a realm update.
// We use `BeatmapDifficultyCache` and don't want to know about these.
// - Background user tag population runs and causes a realm update.
// We don't display user tags so want to ignore this.
bool equalForDisplayPurposes =
// covers metadata changes
oldBeatmap.Hash == newBeatmap.Hash &&
// sanity check
oldBeatmap.OnlineID == newBeatmap.OnlineID &&
// displayed on panel
oldBeatmap.Status == newBeatmap.Status &&
// displayed on panel
oldBeatmap.DifficultyName == newBeatmap.DifficultyName &&
// hidden changed, needs re-filter
oldBeatmap.Hidden == newBeatmap.Hidden &&
// might be used for grouping, returning from gameplay
oldBeatmap.LastPlayed == newBeatmap.LastPlayed;
if (equalForDisplayPurposes)
return false;
}
return true;
default:
throw new ArgumentOutOfRangeException();
}
}
protected override void HandleFilterCompleted()
{
base.HandleFilterCompleted();
attemptSelectSingleFilteredResult();
// Store selected group before handling selection (it may implicitly change the expanded group).
var groupForReselection = ExpandedGroup;
var currentGroupedBeatmap = CurrentSelection as GroupedBeatmap;
// The filter might have changed the set of available groups, which means that the current selection may point to a stale group.
// Check whether that is the case.
bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0;
bool groupStillExists = currentGroupedBeatmap?.Group != null && grouping.GroupItems.ContainsKey(currentGroupedBeatmap.Group);
if (groupingRemainsOff || groupStillExists)
{
// Only update the visual state of the selected item.
HandleItemSelected(currentGroupedBeatmap);
}
else if (currentGroupedBeatmap != null)
{
// If the group no longer exists, grab an arbitrary other instance of the beatmap under the first group encountered.
var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap));
// Only change the selection if we actually got a positive hit.
// This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place.
if (newSelection != null)
CurrentSelection = newSelection;
}
// If a group was selected that is not the one containing the selection, attempt to reselect it.
// If the original group was not found, ExpandedGroup will already have been updated to a valid value in `HandleItemSelected` above.
if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _))
setExpandedGroup(groupForReselection);
}
private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set)
{
// Selecting a set isn't valid let's re-select the first visible difficulty.
if (grouping.SetItems.TryGetValue(set, out var items))
{
var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();
RequestRecommendedSelection(beatmaps);
}
}
/// <summary>
/// If we don't have a selection and there's a single beatmap set returned, select it for the user.
/// </summary>
private void attemptSelectSingleFilteredResult()
{
var items = GetCarouselItems();
if (items == null || items.Count == 0) return;
BeatmapSetInfo? beatmapSetInfo = null;
foreach (var item in items)
{
if (item.Model is GroupedBeatmap groupedBeatmap)
{
var beatmapInfo = groupedBeatmap.Beatmap;
if (beatmapSetInfo == null)
{
beatmapSetInfo = beatmapInfo.BeatmapSet!;
continue;
}
// Found a beatmap with a different beatmap set, abort.
if (!beatmapSetInfo.Equals(beatmapInfo.BeatmapSet))
return;
}
}
var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();
if (beatmaps.Any(b => b.Equals(CurrentSelection as GroupedBeatmap)))
return;
RequestRecommendedSelection(beatmaps);
}
protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition;
protected override bool CheckValidForSetSelection(CarouselItem item)
{
switch (item.Model)
{
case GroupedBeatmapSet:
return true;
case GroupedBeatmap:
return !grouping.BeatmapSetsGroupedTogether;
case GroupDefinition:
return false;
default:
throw new ArgumentException($"Unsupported model type {item.Model}");
}
}
private void setExpandedGroup(GroupDefinition? group)
{
if (ExpandedGroup != null)
setExpansionStateOfGroup(ExpandedGroup, false);
ExpandedGroup = group;
if (ExpandedGroup != null)
setExpansionStateOfGroup(ExpandedGroup, true);
}
private void setExpansionStateOfGroup(GroupDefinition group, bool expanded)
{
if (grouping.GroupItems.TryGetValue(group, out var items))
{
if (expanded)
{
foreach (var i in items)
{
switch (i.Model)
{
case GroupDefinition:
i.IsExpanded = true;
break;
case GroupedBeatmapSet groupedSet:
// Case where there are set headers, header should be visible
// and items should use the set's expanded state.
i.IsVisible = true;
setExpansionStateOfSetItems(groupedSet, i.IsExpanded);
break;
default:
// Case where there are no set headers, all items should be visible.
if (!grouping.BeatmapSetsGroupedTogether)
i.IsVisible = true;
break;
}
}
}
else
{
foreach (var i in items)
{
switch (i.Model)
{
case GroupDefinition:
i.IsExpanded = false;
break;
default:
i.IsVisible = false;
break;
}
}
}
}
}
private void setExpandedSet(GroupedBeatmapSet set)
{
if (ExpandedBeatmapSet != null)
setExpansionStateOfSetItems(ExpandedBeatmapSet, false);
ExpandedBeatmapSet = set;
setExpansionStateOfSetItems(ExpandedBeatmapSet, true);
}
private void setExpansionStateOfSetItems(GroupedBeatmapSet set, bool expanded)
{
if (grouping.SetItems.TryGetValue(set, out var items))
{
foreach (var i in items)
{
if (i.Model is GroupedBeatmapSet)
i.IsExpanded = expanded;
else
i.IsVisible = expanded;
}
}
}
#endregion
#region Audio
private Sample? sampleChangeDifficulty;
private Sample? sampleChangeSet;
private Sample? sampleToggleGroup;
private double audioFeedbackLastPlaybackTime;
private void loadSamples(AudioManager audio)
{
sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty");
sampleChangeSet = audio.Samples.Get(@"SongSelect/select-expand");
sampleToggleGroup = audio.Samples.Get(@"SongSelect/select-group");
spinSample = audio.Samples.Get("SongSelect/random-spin");
randomSelectSample = audio.Samples.Get(@"SongSelect/select-random");
}
private void playActivationSound(CarouselItem item)
{
if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME)
{
switch (item.Model)
{
case GroupDefinition:
sampleToggleGroup?.Play();
return;
case GroupedBeatmapSet:
sampleChangeSet?.Play();
return;
case GroupedBeatmap:
sampleChangeDifficulty?.Play();
return;
}
audioFeedbackLastPlaybackTime = Time.Current;
}
}
#endregion
#region Animation
/// <summary>
/// Moves non-selected beatmaps to the right, hiding off-screen.
/// </summary>
public bool VisuallyFocusSelected { get; set; }
private float selectionFocusOffset;
protected override void Update()
{
base.Update();
selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed);
}
protected override float GetPanelXOffset(Drawable panel)
{
return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset);
}
#endregion
#region Filtering
public FilterCriteria? Criteria { get; private set; }
private ScheduledDelegate? loadingDebounce;
public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false)
{
bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria);
Criteria = criteria;
loadingDebounce ??= Scheduler.AddDelayed(() =>
{
if (loading.State.Value == Visibility.Visible)
return;
Scroll.FadeColour(OsuColour.Gray(0.5f), 1000, Easing.OutQuint);
loading.Show();
}, showLoadingImmediately ? 0 : 250);
FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() =>
{
loadingDebounce?.Cancel();
loadingDebounce = null;
Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint);
loading.Hide();
}));
}
protected override Task<IEnumerable<CarouselItem>> FilterAsync(bool clearExistingPanels = false)
{
if (Criteria == null)
return Task.FromResult(Enumerable.Empty<CarouselItem>());
return base.FilterAsync(clearExistingPanels);
}
#endregion
#region Database fetches for grouping support
[Resolved]
private RealmAccess realm { get; set; } = null!;
protected virtual List<BeatmapCollection> GetAllCollections() => realm.Run(r => r.All<BeatmapCollection>().AsEnumerable().Detach());
protected virtual Dictionary<Guid, ScoreRank> GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r =>
{
var topRankMapping = new Dictionary<Guid, ScoreRank>();
var allLocalScores = r.GetAllLocalScoresForUser(criteria.LocalUserId)
.Filter($@"{nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0", criteria.Ruleset?.ShortName)
.OrderByDescending(s => s.TotalScore)
.ThenBy(s => s.Date);
foreach (var score in allLocalScores)
{
Debug.Assert(score.BeatmapInfo != null);
if (topRankMapping.ContainsKey(score.BeatmapInfo.ID))
continue;
topRankMapping[score.BeatmapInfo.ID] = score.Rank;
}
return topRankMapping;
});
#endregion
#region Drawable pooling
private readonly DrawablePool<PanelBeatmap> beatmapPanelPool = new DrawablePool<PanelBeatmap>(100);
private readonly DrawablePool<PanelBeatmapStandalone> standalonePanelPool = new DrawablePool<PanelBeatmapStandalone>(100);
private readonly DrawablePool<PanelBeatmapSet> setPanelPool = new DrawablePool<PanelBeatmapSet>(100);
private readonly DrawablePool<PanelGroup> groupPanelPool = new DrawablePool<PanelGroup>(100);
private readonly DrawablePool<PanelGroupStarDifficulty> starsGroupPanelPool = new DrawablePool<PanelGroupStarDifficulty>(11);
private void setupPools()
{
AddInternal(starsGroupPanelPool);
AddInternal(groupPanelPool);
AddInternal(beatmapPanelPool);
AddInternal(standalonePanelPool);
AddInternal(setPanelPool);
}
protected override bool CheckModelEquality(object? x, object? y)
{
// In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale
// BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs.
//
// If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring
// it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged
// before changing matching requirements here.
if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY)
return groupedSetX.Equals(groupedSetY);
if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY)
return groupedBeatmapX.Equals(groupedBeatmapY);
if (x is GroupDefinition groupX && y is GroupDefinition groupY)
return groupX.Equals(groupY);
if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY)
return starX.Equals(starY);
return base.CheckModelEquality(x, y);
}
protected override Drawable GetDrawableForDisplay(CarouselItem item)
{
switch (item.Model)
{
case StarDifficultyGroupDefinition:
return starsGroupPanelPool.Get();
case GroupDefinition:
return groupPanelPool.Get();
case GroupedBeatmap:
if (!grouping.BeatmapSetsGroupedTogether)
return standalonePanelPool.Get();
return beatmapPanelPool.Get();
case GroupedBeatmapSet:
return setPanelPool.Get();
}
throw new InvalidOperationException();
}
#endregion
#region Random selection handling
private readonly Bindable<RandomSelectAlgorithm> randomAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly HashSet<BeatmapInfo> previouslyVisitedRandomBeatmaps = new HashSet<BeatmapInfo>();
private readonly List<GroupedBeatmap> randomHistory = new List<GroupedBeatmap>();
private Sample? spinSample;
private Sample? randomSelectSample;
public bool NextRandom()
{
var carouselItems = GetCarouselItems();
if (carouselItems?.Any() != true)
return false;
var selectionBefore = CurrentSelectionItem;
var beatmapBefore = selectionBefore?.Model as GroupedBeatmap;
bool success;
if (beatmapBefore != null)
{
// keep track of visited beatmaps and sets for rewind
randomHistory.Add(beatmapBefore);
// keep track of visited beatmaps for "RandomPermutation" random tracking.
// note that this is reset when we run out of beatmaps, while `randomHistory` is not.
previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap);
}
if (grouping.BeatmapSetsGroupedTogether)
success = nextRandomSet();
else
success = nextRandomBeatmap();
if (!success)
{
if (beatmapBefore != null)
randomHistory.RemoveAt(randomHistory.Count - 1);
return false;
}
// CurrentSelectionItem won't be valid until UpdateAfterChildren.
// We probably want to fix this at some point since a few places are working-around this quirk.
ScheduleAfterChildren(() =>
{
if (selectionBefore != null && CurrentSelectionItem != null)
playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem));
});
return true;
}
private bool nextRandomBeatmap()
{
ICollection<GroupedBeatmap> visibleBeatmaps = ExpandedGroup != null
// In the case of grouping, users expect random to only operate on the expanded group.
// This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
//
// If this becomes an issue, we could either store a mapping, or run the random algorithm many times
// using the `SetItems` method until we get a group HIT.
? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<GroupedBeatmap>().ToArray()
: GetCarouselItems()!.Select(i => i.Model).OfType<GroupedBeatmap>().ToArray();
GroupedBeatmap beatmap;
switch (randomAlgorithm.Value)
{
case RandomSelectAlgorithm.RandomPermutation:
{
ICollection<GroupedBeatmap> notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList();
if (!notYetVisitedBeatmaps.Any())
{
previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap));
notYetVisitedBeatmaps = visibleBeatmaps;
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList();
}
if (notYetVisitedBeatmaps.Count == 0)
return false;
beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count));
break;
}
case RandomSelectAlgorithm.Random:
beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count));
break;
default:
throw new ArgumentOutOfRangeException();
}
RequestSelection(beatmap);
return true;
}
private bool nextRandomSet()
{
ICollection<GroupedBeatmapSet> visibleGroupedSets = ExpandedGroup != null
// In the case of grouping, users expect random to only operate on the expanded group.
// This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
//
// If this becomes an issue, we could either store a mapping, or run the random algorithm many times
// using the `SetItems` method until we get a group HIT.
? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<GroupedBeatmapSet>().ToArray()
// This is the fastest way to retrieve sets for randomisation.
: grouping.SetItems.Keys;
GroupedBeatmapSet set;
switch (randomAlgorithm.Value)
{
case RandomSelectAlgorithm.RandomPermutation:
{
ICollection<GroupedBeatmapSet> notYetVisitedSets =
visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList();
if (!notYetVisitedSets.Any())
{
previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps));
notYetVisitedSets = visibleGroupedSets;
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList();
}
if (notYetVisitedSets.Count == 0)
return false;
set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count));
break;
}
case RandomSelectAlgorithm.Random:
set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count));
break;
default:
throw new ArgumentOutOfRangeException();
}
selectRecommendedDifficultyForBeatmapSet(set);
return true;
}
public bool PreviousRandom()
{
var carouselItems = GetCarouselItems();
if (carouselItems?.Any() != true)
return false;
while (randomHistory.Any())
{
var previousBeatmap = randomHistory[^1];
randomHistory.RemoveAt(randomHistory.Count - 1);
// when going back through rewind history, we may no longer be in the same grouping mode.
// the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap.
// going back to the same group is a nice-to-have, but a secondary concern.
var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap))
.MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group);
if (previousBeatmapItem == null)
return false;
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
{
if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap);
if (CurrentSelectionItem == null)
playSpinSample(0);
else
playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem));
}
RequestSelection((GroupedBeatmap)previousBeatmapItem.Model);
return true;
}
return false;
}
private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT);
private void playSpinSample(double distance)
{
var chan = spinSample?.GetChannel();
if (chan != null)
{
chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1);
chan.Play();
}
randomSelectSample?.Play();
}
#endregion
}
/// <summary>
/// Defines a grouping header for a set of carousel items.
/// </summary>
public record GroupDefinition
{
/// <summary>
/// The order of this group in the carousel, sorted using ascending order.
/// </summary>
public int Order { get; }
/// <summary>
/// The title of this group.
/// </summary>
public string Title { get; }
private readonly string uncasedTitle;
public GroupDefinition(int order, string title)
{
Order = order;
Title = title;
uncasedTitle = title.ToLowerInvariant();
}
public virtual bool Equals(GroupDefinition? other) => uncasedTitle == other?.uncasedTitle;
public override int GetHashCode() => HashCode.Combine(uncasedTitle);
}
/// <summary>
/// Defines a grouping header for a set of carousel items grouped by star difficulty.
/// </summary>
public record StarDifficultyGroupDefinition(int Order, string Title, StarDifficulty Difficulty) : GroupDefinition(Order, Title);
/// <summary>
/// Used to represent a portion of a <see cref="BeatmapSetInfo"/> under a <see cref="GroupDefinition"/>.
/// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it.
/// </summary>
public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet);
/// <summary>
/// Used to represent a <see cref="Beatmap"/> under a <see cref="GroupDefinition"/>.
/// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups
/// (most prominently, collections group mode).
/// </summary>
public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap);
}