1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-13 19:54:15 +08:00

Merge pull request #33175 from frenzibyte/carousel-grouping

Add support for most grouping modes
This commit is contained in:
Dean Herbert
2025-05-25 01:31:55 +09:00
committed by GitHub
Unverified
14 changed files with 629 additions and 92 deletions
@@ -0,0 +1,363 @@
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.SongSelectV2
{
[TestFixture]
public partial class BeatmapCarouselFilterGroupingTest
{
#region No grouping
[Test]
public async Task TestNoGrouping()
{
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyTitle('E'), beatmapSets, out var beatmap1);
addBeatmapSet(applyArtist('D'), beatmapSets, out var beatmap2);
addBeatmapSet(applyAuthor('H'), beatmapSets, out var beatmap3);
addBeatmapSet(applyLength(65_000), beatmapSets, out var beatmap4);
BeatmapInfo[] allBeatmaps =
[
..beatmap1.Beatmaps,
..beatmap2.Beatmaps,
..beatmap3.Beatmaps,
..beatmap4.Beatmaps
];
var results = await runGrouping(GroupMode.NoGrouping, beatmapSets);
Assert.That(results.Select(r => r.Model).OfType<BeatmapSetInfo>(), Is.EquivalentTo(beatmapSets));
Assert.That(results.Select(r => r.Model).OfType<BeatmapInfo>(), Is.EquivalentTo(allBeatmaps));
assertTotal(results, beatmapSets.Count + allBeatmaps.Length);
}
#endregion
#region Alphabetical grouping
[Test]
public async Task TestGroupingByArtist() => await testAlphabeticGroupingMode(GroupMode.Artist, applyArtist);
[Test]
public async Task TestGroupingByAuthor() => await testAlphabeticGroupingMode(GroupMode.Author, applyAuthor);
[Test]
public async Task TestGroupingByTitle() => await testAlphabeticGroupingMode(GroupMode.Title, applyTitle);
private async Task testAlphabeticGroupingMode(GroupMode mode, Func<char, Action<BeatmapSetInfo>> applyBeatmap)
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyBeatmap('4'), beatmapSets, out var fourBeatmap);
addBeatmapSet(applyBeatmap('5'), beatmapSets, out var fiveBeatmap);
addBeatmapSet(applyBeatmap('A'), beatmapSets, out var aBeatmap);
addBeatmapSet(applyBeatmap('F'), beatmapSets, out var fBeatmap);
addBeatmapSet(applyBeatmap('Z'), beatmapSets, out var zBeatmap);
addBeatmapSet(applyBeatmap('-'), beatmapSets, out var dashBeatmap);
addBeatmapSet(applyBeatmap('_'), beatmapSets, out var underscoreBeatmap);
var results = await runGrouping(mode, beatmapSets);
assertGroup(results, 0, "0-9", new[] { fiveBeatmap, fourBeatmap }, ref total);
assertGroup(results, 1, "A", new[] { aBeatmap }, ref total);
assertGroup(results, 2, "F", new[] { fBeatmap }, ref total);
assertGroup(results, 3, "Z", new[] { zBeatmap }, ref total);
assertGroup(results, 4, "Other", new[] { dashBeatmap, underscoreBeatmap }, ref total);
assertTotal(results, total);
}
private Action<BeatmapSetInfo> applyArtist(char first)
{
return s => s.Beatmaps[0].Metadata.Artist = $"{first}-artist";
}
private Action<BeatmapSetInfo> applyAuthor(char first)
{
return s => s.Beatmaps[0].Metadata.Author.Username = $"{first}-author";
}
private Action<BeatmapSetInfo> applyTitle(char first)
{
return s => s.Beatmaps[0].Metadata.Title = $"{first}-title";
}
#endregion
#region Date grouping
[Test]
public async Task TestGroupingByDateAdded()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddHours(-5), beatmapSets, out var todayBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-1), beatmapSets, out var yesterdayBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-4), beatmapSets, out var lastWeekBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-21), beatmapSets, out var oneMonthBeatmap);
addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-2).AddDays(-3), beatmapSets, out var threeMonthBeatmap);
var results = await runGrouping(GroupMode.DateAdded, beatmapSets);
assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total);
assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total);
assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total);
assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total);
assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total);
assertTotal(results, total);
}
[Test]
public async Task TestGroupingByLastPlayed()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddHours(-5)), beatmapSets, out var todayBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-1)), beatmapSets, out var yesterdayBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-4)), beatmapSets, out var lastWeekBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-21)), beatmapSets, out var oneMonthBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-2).AddDays(-3)), beatmapSets, out var threeMonthBeatmap);
addBeatmapSet(applyLastPlayed(null), beatmapSets, out var neverBeatmap);
var results = await runGrouping(GroupMode.LastPlayed, beatmapSets);
assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total);
assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total);
assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total);
assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total);
assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total);
assertGroup(results, 5, "Never", new[] { neverBeatmap }, ref total);
assertTotal(results, total);
}
[Test]
public async Task TestGroupingByLastPlayed_BeatmapPartiallyPlayed()
{
var set = TestResources.CreateTestBeatmapSetInfo(3);
set.Beatmaps[0].LastPlayed = null;
set.Beatmaps[1].LastPlayed = null;
set.Beatmaps[2].LastPlayed = DateTimeOffset.Now;
List<BeatmapSetInfo> beatmapSets = new List<BeatmapSetInfo> { set };
var results = await runGrouping(GroupMode.LastPlayed, beatmapSets);
int total = 0;
assertGroup(results, 0, "Today", new[] { set }, ref total);
assertTotal(results, total);
}
[Test]
public async Task TestGroupingByLastPlayed_NeverBelowOverFiveMonthsAgo()
{
List<BeatmapSetInfo> beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyLastPlayed(null), beatmapSets, out var neverBeatmap);
addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-6)), beatmapSets, out var overFiveMonthsBeatmap);
var results = await runGrouping(GroupMode.LastPlayed, beatmapSets);
int total = 0;
assertGroup(results, 0, "Over 5 months ago", new[] { overFiveMonthsBeatmap }, ref total);
assertGroup(results, 1, "Never", new[] { neverBeatmap }, ref total);
assertTotal(results, total);
}
private Action<BeatmapSetInfo> applyLastPlayed(DateTimeOffset? lastPlayed)
{
return s => s.Beatmaps.ForEach(b => b.LastPlayed = lastPlayed);
}
#endregion
#region Ranked Status
[Test]
public async Task TestGroupingByRankedStatus()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Ranked, beatmapSets, out var rankedBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Approved, beatmapSets, out var approvedBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Qualified, beatmapSets, out var qualifiedBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Loved, beatmapSets, out var lovedBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.WIP, beatmapSets, out var wipBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Pending, beatmapSets, out var pendingBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.Graveyard, beatmapSets, out var graveyardBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.None, beatmapSets, out var noneBeatmap);
addBeatmapSet(s => s.Status = BeatmapOnlineStatus.LocallyModified, beatmapSets, out var localBeatmap);
var results = await runGrouping(GroupMode.RankedStatus, beatmapSets);
assertGroup(results, 0, "Ranked", new[] { rankedBeatmap, approvedBeatmap }, ref total);
assertGroup(results, 1, "Qualified", new[] { qualifiedBeatmap }, ref total);
assertGroup(results, 2, "WIP", new[] { wipBeatmap }, ref total);
assertGroup(results, 3, "Pending", new[] { pendingBeatmap }, ref total);
assertGroup(results, 4, "Graveyard", new[] { graveyardBeatmap }, ref total);
assertGroup(results, 5, "Local", new[] { localBeatmap }, ref total);
assertGroup(results, 6, "Unknown", new[] { noneBeatmap }, ref total);
assertGroup(results, 7, "Loved", new[] { lovedBeatmap }, ref total);
assertTotal(results, total);
}
#endregion
#region BPM grouping
[Test]
public async Task TestGroupingByBPM()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyBPM(30), beatmapSets, out var beatmap30);
addBeatmapSet(applyBPM(60), beatmapSets, out var beatmap60);
addBeatmapSet(applyBPM(90), beatmapSets, out var beatmap90);
addBeatmapSet(applyBPM(120), beatmapSets, out var beatmap120);
addBeatmapSet(applyBPM(270), beatmapSets, out var beatmap270);
addBeatmapSet(applyBPM(300), beatmapSets, out var beatmap300);
addBeatmapSet(applyBPM(330), beatmapSets, out var beatmap330);
var results = await runGrouping(GroupMode.BPM, beatmapSets);
assertGroup(results, 0, "Under 60 BPM", new[] { beatmap30 }, ref total);
assertGroup(results, 1, "Under 120 BPM", new[] { beatmap60, beatmap90 }, ref total);
assertGroup(results, 2, "Under 180 BPM", new[] { beatmap120 }, ref total);
assertGroup(results, 3, "Under 300 BPM", new[] { beatmap270 }, ref total);
assertGroup(results, 4, "Over 300 BPM", new[] { beatmap300, beatmap330 }, ref total);
assertTotal(results, total);
}
private Action<BeatmapSetInfo> applyBPM(double bpm)
{
return s => s.Beatmaps.ForEach(b => b.BPM = bpm);
}
#endregion
#region Difficulty grouping
[Test]
public async Task TestGroupingByDifficulty()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyStars(0.5), beatmapSets, out var beatmapBelow1);
addBeatmapSet(applyStars(1.9), beatmapSets, out var beatmapAbove1);
addBeatmapSet(applyStars(1.995), beatmapSets, out var beatmapAlmost2);
addBeatmapSet(applyStars(2), beatmapSets, out var beatmap2);
addBeatmapSet(applyStars(2.1), beatmapSets, out var beatmapAbove2);
addBeatmapSet(applyStars(7), beatmapSets, out var beatmap7);
var results = await runGrouping(GroupMode.Difficulty, beatmapSets);
assertGroup(results, 0, "Below 1 Star", new[] { beatmapBelow1 }, ref total);
assertGroup(results, 1, "1 Star", new[] { beatmapAbove1 }, ref total);
assertGroup(results, 2, "2 Stars", new[] { beatmapAlmost2, beatmap2, beatmapAbove2 }, ref total);
assertGroup(results, 3, "7 Stars", new[] { beatmap7 }, ref total);
assertTotal(results, total);
}
private Action<BeatmapSetInfo> applyStars(double stars)
{
return s => s.Beatmaps.ForEach(b => b.StarRating = stars);
}
#endregion
#region Length grouping
[Test]
public async Task TestGroupingByLength()
{
int total = 0;
var beatmapSets = new List<BeatmapSetInfo>();
addBeatmapSet(applyLength(30_000), beatmapSets, out var beatmap30Sec);
addBeatmapSet(applyLength(60_000), beatmapSets, out var beatmap1Min);
addBeatmapSet(applyLength(90_000), beatmapSets, out var beatmap1Min30Sec);
addBeatmapSet(applyLength(120_000), beatmapSets, out var beatmap2Min);
addBeatmapSet(applyLength(300_000), beatmapSets, out var beatmap5Min);
addBeatmapSet(applyLength(360_000), beatmapSets, out var beatmap6Min);
addBeatmapSet(applyLength(600_000), beatmapSets, out var beatmap10Min);
addBeatmapSet(applyLength(630_000), beatmapSets, out var beatmap10Min30Sec);
var results = await runGrouping(GroupMode.Length, beatmapSets);
assertGroup(results, 0, "1 minute or less", new[] { beatmap30Sec, beatmap1Min }, ref total);
assertGroup(results, 1, "2 minutes or less", new[] { beatmap1Min30Sec, beatmap2Min }, ref total);
assertGroup(results, 2, "5 minutes or less", new[] { beatmap5Min }, ref total);
assertGroup(results, 3, "10 minutes or less", new[] { beatmap6Min, beatmap10Min }, ref total);
assertGroup(results, 4, "Over 10 minutes", new[] { beatmap10Min30Sec }, ref total);
assertTotal(results, total);
}
private Action<BeatmapSetInfo> applyLength(double length)
{
return s => s.Beatmaps.ForEach(b => b.Length = length);
}
#endregion
private static async Task<List<CarouselItem>> runGrouping(GroupMode group, List<BeatmapSetInfo> beatmapSets)
{
var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group });
var carouselItems = await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None);
// sanity check to ensure no detection of two group items with equal order value.
var groups = carouselItems.Select(i => i.Model).OfType<GroupDefinition>();
foreach (var header in groups)
{
var sameOrder = groups.FirstOrDefault(g => g != header && g.Order == header.Order);
if (sameOrder != null)
Assert.Fail($"Detected two groups with equal order number: \"{header.Title}\" vs. \"{sameOrder.Title}\"");
}
return carouselItems;
}
private static void assertGroup(List<CarouselItem> items, int index, string expectedTitle, IEnumerable<BeatmapSetInfo> expectedBeatmapSets, ref int totalItems)
{
var groupItem = items.Where(i => i.Model is GroupDefinition).ElementAtOrDefault(index);
if (groupItem == null)
{
Assert.Fail($"Expected group at index {index}, but that is out of bounds");
return;
}
var itemsInGroup = items.SkipWhile(i => i != groupItem).Skip(1).TakeWhile(i => i.Model is not GroupDefinition);
var groupModel = (GroupDefinition)groupItem.Model;
Assert.That(groupModel.Title, Is.EqualTo(expectedTitle));
Assert.That(itemsInGroup.Select(i => i.Model).OfType<BeatmapInfo>(), Is.EquivalentTo(expectedBeatmapSets.SelectMany(bs => bs.Beatmaps)));
totalItems += itemsInGroup.Count() + 1;
}
private static void assertTotal(List<CarouselItem> items, int total)
{
Assert.That(items.Count, Is.EqualTo(total));
}
private static void addBeatmapSet(Action<BeatmapSetInfo> change, List<BeatmapSetInfo> list, out BeatmapSetInfo added)
{
var set = TestResources.CreateTestBeatmapSetInfo();
change(set);
list.Add(set);
added = set;
}
}
}
@@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectedMods.SetDefault();
Config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title);
Config.SetValue(OsuSetting.SongSelectGroupingMode, GroupMode.All);
Config.SetValue(OsuSetting.SongSelectGroupMode, GroupMode.NoGrouping);
SongSelect = null!;
});
@@ -35,7 +35,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
[Explicit]
public void TestSorting()
{
SortAndGroupBy(SortMode.Artist, GroupMode.All);
SortAndGroupBy(SortMode.Artist, GroupMode.NoGrouping);
SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty);
SortAndGroupBy(SortMode.Artist, GroupMode.Artist);
}
@@ -243,7 +243,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddBeatmaps(2, 3);
WaitForDrawablePanels();
SortAndGroupBy(SortMode.Difficulty, GroupMode.All);
SortAndGroupBy(SortMode.Difficulty, GroupMode.NoGrouping);
WaitForFiltering();
AddUntilStep("standalone panels displayed", () => GetVisiblePanels<PanelBeatmapStandalone>().Any());
@@ -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 StarDifficultyGroupDefinition(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 StarDifficultyGroupDefinition(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 StarDifficultyGroupDefinition(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 StarDifficultyGroupDefinition(3, $"{star} Star(s)", new StarDifficulty(star, 0))),
Expanded = { Value = true },
KeyboardSelected = { Value = true },
},
@@ -30,8 +30,8 @@ namespace osu.Game.Tests.Visual.UserInterface
Position = new Vector2(275, 5)
});
filter.PinItem(GroupMode.All);
filter.PinItem(GroupMode.RecentlyPlayed);
filter.PinItem(GroupMode.NoGrouping);
filter.PinItem(GroupMode.LastPlayed);
filter.Current.ValueChanged += grouping =>
{
+1
View File
@@ -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.
/// </summary>
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))]
[Description("Local")]
LocallyModified = -4,
[Description("Unknown")]
+2 -2
View File
@@ -47,7 +47,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1);
SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1);
SetDefault(OsuSetting.SongSelectGroupingMode, GroupMode.All);
SetDefault(OsuSetting.SongSelectGroupMode, GroupMode.NoGrouping);
SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title);
SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation);
@@ -389,7 +389,7 @@ namespace osu.Game.Configuration
SaveUsername,
DisplayStarsMinimum,
DisplayStarsMaximum,
SongSelectGroupingMode,
SongSelectGroupMode,
SongSelectSortingMode,
RandomSelectAlgorithm,
ModSelectHotkeyStyle,
+5 -8
View File
@@ -7,8 +7,8 @@ namespace osu.Game.Screens.Select.Filter
{
public enum GroupMode
{
[Description("All")]
All,
[Description("No Grouping")]
NoGrouping,
[Description("Artist")]
Artist,
@@ -37,19 +37,16 @@ namespace osu.Game.Screens.Select.Filter
[Description("My Maps")]
MyMaps,
[Description("No Grouping")]
NoGrouping,
[Description("Rank Achieved")]
RankAchieved,
[Description("Ranked Status")]
RankedStatus,
[Description("Recently Played")]
RecentlyPlayed,
[Description("Last Played")]
LastPlayed,
[Description("Title")]
Title
Title,
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ namespace osu.Game.Screens.Select
private void load(OsuColour colours, OsuConfigManager config)
{
sortMode = config.GetBindable<SortMode>(OsuSetting.SongSelectSortingMode);
groupMode = config.GetBindable<GroupMode>(OsuSetting.SongSelectGroupingMode);
groupMode = config.GetBindable<GroupMode>(OsuSetting.SongSelectGroupMode);
Children = new Drawable[]
{
+14 -4
View File
@@ -411,10 +411,10 @@ namespace osu.Game.Screens.SelectV2
{
switch (item.Model)
{
case GroupDefinition group:
if (group.Data is StarDifficulty)
return starsGroupPanelPool.Get();
case StarDifficultyGroupDefinition:
return starsGroupPanelPool.Get();
case GroupDefinition:
return groupPanelPool.Get();
case BeatmapInfo:
@@ -433,5 +433,15 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
public record GroupDefinition(object Data, string Title);
/// <summary>
/// Defines a grouping header for a set of carousel items.
/// </summary>
/// <param name="Order">The order of this group in the carousel, sorted using ascending order.</param>
/// <param name="Title">The title of this group.</param>
public record GroupDefinition(int Order, string Title);
/// <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);
}
@@ -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
/// <summary>
/// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection.
/// </summary>
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems;
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setMap;
/// <summary>
/// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection.
/// </summary>
public IDictionary<GroupDefinition, HashSet<CarouselItem>> GroupItems => groupItems;
public IDictionary<GroupDefinition, HashSet<CarouselItem>> GroupItems => groupMap;
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
private readonly Dictionary<GroupDefinition, HashSet<CarouselItem>> groupItems = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setMap = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
private readonly Dictionary<GroupDefinition, HashSet<CarouselItem>> groupMap = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
private readonly Func<FilterCriteria> 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<CarouselItem>();
BeatmapInfo? lastBeatmap = null;
BeatmapSetsGroupedTogether = criteria.Sort != SortMode.Difficulty && criteria.Group != GroupMode.Difficulty;
GroupDefinition? lastGroup = null;
CarouselItem? lastGroupItem = null;
var groups = getGroups((List<CarouselItem>)items, criteria);
HashSet<CarouselItem>? currentGroupItems = null;
HashSet<CarouselItem>? currentSetItems = null;
BeatmapSetsGroupedTogether = criteria.Sort != SortMode.Difficulty;
foreach (var item in items)
foreach (var (group, itemsInGroup) in groups)
{
cancellationToken.ThrowIfCancellationRequested();
var beatmap = (BeatmapInfo)item.Model;
CarouselItem? groupItem = null;
HashSet<CarouselItem>? currentGroupItems = null;
HashSet<CarouselItem>? 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<CarouselItem>();
groupItems[newGroup] = currentGroupItems = new HashSet<CarouselItem>();
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<CarouselItem>();
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<CarouselItem>();
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,208 @@ namespace osu.Game.Screens.SelectV2
}, cancellationToken).ConfigureAwait(false);
}
private GroupDefinition? createGroupIfRequired(FilterCriteria criteria, BeatmapInfo beatmap, GroupDefinition? lastGroup)
private List<GroupMapping> getGroups(List<CarouselItem> items, FilterCriteria criteria)
{
switch (criteria.Group)
{
case GroupMode.NoGrouping:
return new List<GroupMapping> { 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: needs implementation
goto case GroupMode.NoGrouping;
if (starGroup.Stars == 1)
return new GroupDefinition(starGroup, "1 Star");
case GroupMode.Favourites:
// TODO: needs implementation
goto case GroupMode.NoGrouping;
return new GroupDefinition(starGroup, $"{starGroup.Stars} Stars");
}
case GroupMode.MyMaps:
// TODO: needs implementation
goto case GroupMode.NoGrouping;
break;
case GroupMode.RankAchieved:
// TODO: needs implementation
goto case GroupMode.NoGrouping;
default:
throw new ArgumentOutOfRangeException();
}
}
private List<GroupMapping> getGroupsBy(Func<BeatmapInfo, GroupDefinition> getGroup, List<CarouselItem> 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(151, "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 starDifficulty = new StarDifficulty(starInt, 0);
if (starInt == 0)
return new StarDifficultyGroupDefinition(0, "Below 1 Star", starDifficulty);
if (starInt == 1)
return new StarDifficultyGroupDefinition(1, "1 Star", starDifficulty);
return new StarDifficultyGroupDefinition(starInt, $"{starInt} Stars", starDifficulty);
}
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<T>(BeatmapInfo b, IEnumerable<CarouselItem> items, Func<BeatmapInfo, T> func)
{
var matchedBeatmaps = items.Select(i => i.Model).Cast<BeatmapInfo>().Where(beatmap => beatmap.BeatmapSet!.Equals(b.BeatmapSet));
return matchedBeatmaps.Max(func);
}
private record GroupMapping(GroupDefinition? Group, List<CarouselItem> ItemsInGroup);
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ namespace osu.Game.Screens.SelectV2
difficultyRangeSlider.UpperBound = config.GetBindable<double>(OsuSetting.DisplayStarsMaximum);
config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active);
config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current);
config.BindWith(OsuSetting.SongSelectGroupingMode, groupDropdown.Current);
config.BindWith(OsuSetting.SongSelectGroupMode, groupDropdown.Current);
ruleset.BindValueChanged(_ => updateCriteria());
mods.BindValueChanged(m =>
@@ -10,7 +10,6 @@ using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Sprites;
@@ -139,9 +138,8 @@ namespace osu.Game.Screens.SelectV2
Debug.Assert(Item != null);
var group = (GroupDefinition)Item.Model;
var stars = (StarDifficulty)group.Data;
int starNumber = (int)stars.Stars;
var group = (StarDifficultyGroupDefinition)Item.Model;
int starNumber = (int)group.Difficulty.Stars;
ratingColour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber);