From 82923e67697c43c3a97d867ee767ded8c8b0b82c Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Sun, 18 May 2025 00:30:13 +0300 Subject: [PATCH] Add grouping support for most modes --- .../SongSelectV2/TestScenePanelGroup.cs | 8 +- osu.Game/Beatmaps/BeatmapOnlineStatus.cs | 1 + osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 8 +- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 297 ++++++++++++++---- .../SelectV2/PanelGroupStarDifficulty.cs | 2 +- 5 files changed, 247 insertions(+), 69 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs b/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs index 2d1b7cd1b2..d91e7283d1 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs @@ -56,21 +56,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { new PanelGroupStarDifficulty { - Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")) + Item = new CarouselItem(new GroupDefinition(0, $"{star} Star(s)", new StarDifficulty(star, 0))) }, new PanelGroupStarDifficulty { - Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")), + Item = new CarouselItem(new GroupDefinition(1, $"{star} Star(s)", new StarDifficulty(star, 0))), KeyboardSelected = { Value = true }, }, new PanelGroupStarDifficulty { - Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")), + Item = new CarouselItem(new GroupDefinition(2, $"{star} Star(s)", new StarDifficulty(star, 0))), Expanded = { Value = true }, }, new PanelGroupStarDifficulty { - Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")), + Item = new CarouselItem(new GroupDefinition(3, $"{star} Star(s)", new StarDifficulty(star, 0))), Expanded = { Value = true }, KeyboardSelected = { Value = true }, }, diff --git a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs index d489aeda3f..bc1438d7c7 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs @@ -15,6 +15,7 @@ namespace osu.Game.Beatmaps /// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted. /// [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))] + [Description("Local")] LocallyModified = -4, [Description("Unknown")] diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 4c70b8c58f..fa5224b387 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -423,5 +423,11 @@ namespace osu.Game.Screens.SelectV2 #endregion } - public record GroupDefinition(object Data, string Title); + /// + /// Defines a grouping header for a set of carousel items. + /// + /// The order of this group in the carousel, sorted using ascending order. + /// The title of this group. + /// Additional data. Provide a for difficulty groups, or null for any other group. + public record GroupDefinition(int Order, string Title, object? Data = null); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index f8004282db..0286eadbcc 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; @@ -19,15 +21,15 @@ namespace osu.Game.Screens.SelectV2 /// /// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection. /// - public IDictionary> SetItems => setItems; + public IDictionary> SetItems => setMap; /// /// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection. /// - public IDictionary> GroupItems => groupItems; + public IDictionary> GroupItems => groupMap; - private readonly Dictionary> setItems = new Dictionary>(); - private readonly Dictionary> groupItems = new Dictionary>(); + private readonly Dictionary> setMap = new Dictionary>(); + private readonly Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; @@ -40,72 +42,70 @@ namespace osu.Game.Screens.SelectV2 { return await Task.Run(() => { - setItems.Clear(); - groupItems.Clear(); + setMap.Clear(); + groupMap.Clear(); var criteria = getCriteria(); var newItems = new List(); - BeatmapInfo? lastBeatmap = null; - - GroupDefinition? lastGroup = null; - CarouselItem? lastGroupItem = null; - - HashSet? currentGroupItems = null; - HashSet? currentSetItems = null; - BeatmapSetsGroupedTogether = criteria.Sort != SortMode.Difficulty; - foreach (var item in items) + var groups = getGroups((List)items, criteria); + + foreach (var (group, itemsInGroup) in groups) { cancellationToken.ThrowIfCancellationRequested(); - var beatmap = (BeatmapInfo)item.Model; + CarouselItem? groupItem = null; + HashSet? currentGroupItems = null; + HashSet? currentSetItems = null; + BeatmapInfo? lastBeatmap = null; - if (createGroupIfRequired(criteria, beatmap, lastGroup) is GroupDefinition newGroup) + if (group != null) { - // When reaching a new group, ensure we reset any beatmap set tracking. - currentSetItems = null; - lastBeatmap = null; + groupMap[group] = currentGroupItems = new HashSet(); - groupItems[newGroup] = currentGroupItems = new HashSet(); - lastGroup = newGroup; - - addItem(lastGroupItem = new CarouselItem(newGroup) + addItem(groupItem = new CarouselItem(group) { DrawHeight = PanelGroup.HEIGHT, DepthLayer = -2, }); } - if (BeatmapSetsGroupedTogether) + foreach (var item in itemsInGroup) { - bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID; + var beatmap = (BeatmapInfo)item.Model; - if (newBeatmapSet) + if (BeatmapSetsGroupedTogether) { - setItems[beatmap.BeatmapSet!] = currentSetItems = new HashSet(); + bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID; - if (lastGroupItem != null) - lastGroupItem.NestedItemCount++; - - addItem(new CarouselItem(beatmap.BeatmapSet!) + if (newBeatmapSet) { - DrawHeight = PanelBeatmapSet.HEIGHT, - DepthLayer = -1 - }); + if (!setMap.TryGetValue(beatmap.BeatmapSet!, out currentSetItems)) + setMap[beatmap.BeatmapSet!] = currentSetItems = new HashSet(); + + if (groupItem != null) + groupItem.NestedItemCount++; + + addItem(new CarouselItem(beatmap.BeatmapSet!) + { + DrawHeight = PanelBeatmapSet.HEIGHT, + DepthLayer = -1 + }); + } } - } - else - { - if (lastGroupItem != null) - lastGroupItem.NestedItemCount++; + else + { + if (groupItem != null) + groupItem.NestedItemCount++; - item.DrawHeight = PanelBeatmapStandalone.HEIGHT; - } + item.DrawHeight = PanelBeatmapStandalone.HEIGHT; + } - addItem(item); - lastBeatmap = beatmap; + addItem(item); + lastBeatmap = beatmap; + } void addItem(CarouselItem i) { @@ -114,7 +114,7 @@ namespace osu.Game.Screens.SelectV2 currentGroupItems?.Add(i); currentSetItems?.Add(i); - i.IsVisible = i.Model is GroupDefinition || (lastGroup == null && (i.Model is BeatmapSetInfo || currentSetItems == null)); + i.IsVisible = i.Model is GroupDefinition || (group == null && (i.Model is BeatmapSetInfo || currentSetItems == null)); } } @@ -122,40 +122,211 @@ namespace osu.Game.Screens.SelectV2 }, cancellationToken).ConfigureAwait(false); } - private GroupDefinition? createGroupIfRequired(FilterCriteria criteria, BeatmapInfo beatmap, GroupDefinition? lastGroup) + private List getGroups(List items, FilterCriteria criteria) { switch (criteria.Group) { +#pragma warning disable CS0618 // Type or member is obsolete + case GroupMode.All: +#pragma warning restore CS0618 // Type or member is obsolete + case GroupMode.NoGrouping: + return new List { new GroupMapping(null, items) }; + case GroupMode.Artist: - char groupChar = lastGroup?.Data as char? ?? (char)0; - char beatmapFirstChar = char.ToUpperInvariant(beatmap.Metadata.Artist[0]); + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Artist), items); - if (beatmapFirstChar > groupChar) - return new GroupDefinition(beatmapFirstChar, $"{beatmapFirstChar}"); + case GroupMode.Author: + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Author.Username), items); - break; + case GroupMode.Title: + return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Title), items); + + case GroupMode.DateAdded: + return getGroupsBy(b => defineGroupByDate(b.BeatmapSet!.DateAdded), items); + + case GroupMode.LastPlayed: + return getGroupsBy(b => + { + DateTimeOffset? maxLastPlayed = aggregateMax(b, items, bb => bb.LastPlayed); + + if (maxLastPlayed == null) + return new GroupDefinition(int.MaxValue, "Never"); + + return defineGroupByDate(maxLastPlayed.Value); + }, items); + + case GroupMode.RankedStatus: + return getGroupsBy(b => defineGroupByStatus(b.BeatmapSet!.Status), items); + + case GroupMode.BPM: + return getGroupsBy(b => + { + double maxBPM = aggregateMax(b, items, bb => bb.BPM); + return defineGroupByBPM(maxBPM); + }, items); case GroupMode.Difficulty: - var starGroup = lastGroup?.Data as StarDifficulty? ?? new StarDifficulty(-1, 0); - double beatmapStarRating = Math.Round(beatmap.StarRating, 2); + return getGroupsBy(b => defineGroupByStars(b.StarRating), items); - if (beatmapStarRating >= starGroup.Stars + 1) + case GroupMode.Length: + return getGroupsBy(b => { - starGroup = new StarDifficulty((int)Math.Floor(beatmapStarRating), 0); + double maxLength = aggregateMax(b, items, bb => bb.Length); + return defineGroupByLength(maxLength); + }, items); - if (starGroup.Stars == 0) - return new GroupDefinition(starGroup, "Below 1 Star"); + case GroupMode.Collections: + // todo: unsupported. + goto case GroupMode.NoGrouping; - if (starGroup.Stars == 1) - return new GroupDefinition(starGroup, "1 Star"); + case GroupMode.Favourites: + // todo: unsupported. + goto case GroupMode.NoGrouping; - return new GroupDefinition(starGroup, $"{starGroup.Stars} Stars"); - } + case GroupMode.MyMaps: + // todo: unsupported. + goto case GroupMode.NoGrouping; - break; + case GroupMode.RankAchieved: + // todo: unsupported. + goto case GroupMode.NoGrouping; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + private List getGroupsBy(Func getGroup, List items) + { + return items.GroupBy(i => getGroup((BeatmapInfo)i.Model)) + .OrderBy(s => s.Key.Order) + .Select(g => new GroupMapping(g.Key, g.ToList())) + .ToList(); + } + + private GroupDefinition defineGroupAlphabetically(string name) + { + char firstChar = name.FirstOrDefault(); + + if (char.IsAsciiDigit(firstChar)) + return new GroupDefinition(int.MinValue, "0-9"); + + if (char.IsAsciiLetter(firstChar)) + return new GroupDefinition(char.ToUpperInvariant(firstChar) - 'A', char.ToUpperInvariant(firstChar).ToString()); + + return new GroupDefinition(int.MaxValue, "Other"); + } + + private GroupDefinition defineGroupByDate(DateTimeOffset date) + { + var now = DateTimeOffset.Now; + var elapsed = now - date; + + if (elapsed.TotalDays < 1) + return new GroupDefinition(0, "Today"); + + if (elapsed.TotalDays < 2) + return new GroupDefinition(1, "Yesterday"); + + if (elapsed.TotalDays < 7) + return new GroupDefinition(2, "Last week"); + + if (elapsed.TotalDays < 30) + return new GroupDefinition(3, "1 month ago"); + + for (int i = 60; i <= 150; i += 30) + { + if (elapsed.TotalDays < i) + return new GroupDefinition(i, $"{i / 30} months ago"); } - return null; + return new GroupDefinition(int.MaxValue, "Over 5 months ago"); } + + private GroupDefinition defineGroupByStatus(BeatmapOnlineStatus status) + { + switch (status) + { + case BeatmapOnlineStatus.Ranked: + case BeatmapOnlineStatus.Approved: + return new GroupDefinition(0, BeatmapOnlineStatus.Ranked.GetDescription()); + + case BeatmapOnlineStatus.Qualified: + return new GroupDefinition(1, status.GetDescription()); + + case BeatmapOnlineStatus.WIP: + return new GroupDefinition(2, status.GetDescription()); + + case BeatmapOnlineStatus.Pending: + return new GroupDefinition(3, status.GetDescription()); + + case BeatmapOnlineStatus.Graveyard: + return new GroupDefinition(4, status.GetDescription()); + + case BeatmapOnlineStatus.LocallyModified: + return new GroupDefinition(5, status.GetDescription()); + + case BeatmapOnlineStatus.None: + return new GroupDefinition(6, status.GetDescription()); + + case BeatmapOnlineStatus.Loved: + return new GroupDefinition(7, status.GetDescription()); + + default: + throw new ArgumentOutOfRangeException(nameof(status), status, null); + } + } + + private GroupDefinition defineGroupByBPM(double bpm) + { + for (int i = 1; i < 6; i++) + { + if (bpm < i * 60) + return new GroupDefinition(i, $"Under {i * 60} BPM"); + } + + return new GroupDefinition(6, "Over 300 BPM"); + } + + private GroupDefinition defineGroupByStars(double stars) + { + int starInt = (int)Math.Round(stars, 2); + var groupData = new StarDifficulty(starInt, 0); + + if (starInt == 0) + return new GroupDefinition(0, "Below 1 Star", groupData); + + if (starInt == 1) + return new GroupDefinition(1, "1 Star", groupData); + + return new GroupDefinition(starInt, $"{starInt} Stars", groupData); + } + + private GroupDefinition defineGroupByLength(double length) + { + for (int i = 1; i < 6; i++) + { + if (length <= i * 60_000) + { + if (i == 1) + return new GroupDefinition(1, "1 minute or less"); + + return new GroupDefinition(i, $"{i} minutes or less"); + } + } + + if (length <= 10 * 60_000) + return new GroupDefinition(10, "10 minutes or less"); + + return new GroupDefinition(11, "Over 10 minutes"); + } + + private static T? aggregateMax(BeatmapInfo b, IEnumerable items, Func func) + { + var matchedBeatmaps = items.Select(i => i.Model).Cast().Where(beatmap => beatmap.BeatmapSet!.Equals(b.BeatmapSet)); + return matchedBeatmaps.Max(func); + } + + private record GroupMapping(GroupDefinition? Group, List ItemsInGroup); } } diff --git a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs index b042f34d22..f4d5bca1e2 100644 --- a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs +++ b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs @@ -140,7 +140,7 @@ namespace osu.Game.Screens.SelectV2 Debug.Assert(Item != null); var group = (GroupDefinition)Item.Model; - var stars = (StarDifficulty)group.Data; + var stars = (StarDifficulty)group.Data!; int starNumber = (int)stars.Stars; ratingColour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber);