diff --git a/Directory.Build.props b/Directory.Build.props index 3acb86ee0c..580e61dafb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,10 @@ 12.0 enable + + false + + $(NoWarn);CA1416 $(MSBuildThisFileDirectory)app.manifest diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 046840a691..7aa2ecb06c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -312,8 +312,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("set game volume to max", () => Game.Dependencies.Get().SetValue(FrameworkSetting.VolumeUniversal, 1d)); - AddStep("move to metadata wedge", () => InputManager.MoveMouseTo( - songSelect.ChildrenOfType().Single())); + AddStep("move to details area", () => InputManager.MoveMouseTo( + songSelect.ChildrenOfType().Single())); AddStep("scroll down", () => InputManager.ScrollVerticalBy(-1)); AddAssert("carousel didn't move", getCarouselScrollPosition, () => Is.EqualTo(scrollPosition)); diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index ca2c5d415e..7f34d7a901 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ..beatmap4.Beatmaps ]; - var results = await runGrouping(GroupMode.NoGrouping, beatmapSets); + var results = await runGrouping(GroupMode.None, beatmapSets); Assert.That(results.Select(r => r.Model).OfType(), Is.EquivalentTo(beatmapSets)); Assert.That(results.Select(r => r.Model).OfType(), Is.EquivalentTo(allBeatmaps)); assertTotal(results, beatmapSets.Count + allBeatmaps.Length); diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index f9f7f3e89c..f58d879141 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -149,6 +149,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 TextAnchor = Anchor.CentreLeft, }, }; + + Carousel.Filter(new FilterCriteria()); }); // Prefer title sorting so that order of carousel panels match order of BeatmapSets bindable. @@ -171,7 +173,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { AddStep(description, () => { - var criteria = Carousel.Criteria; + var criteria = Carousel.Criteria ?? new FilterCriteria(); apply?.Invoke(criteria); Carousel.Filter(criteria); }); @@ -191,6 +193,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2 protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null); protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null); + protected void CheckRequestPresentCount(int expected) => + AddAssert($"check present count is {expected}", () => Carousel.RequestPresentBeatmapCount, () => Is.EqualTo(expected)); + + protected void CheckActivationCount(int expected) => + AddAssert($"check activation count is {expected}", () => Carousel.ActivationCount, () => Is.EqualTo(expected)); + protected void CheckDisplayedBeatmapsCount(int expected) { AddAssert($"{expected} diffs displayed", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); @@ -356,7 +364,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 """); createHeader("carousel"); stats.AddParagraph($""" - sorting: {Carousel.IsFiltering} + filtering: {Carousel.IsFiltering} (total {Carousel.FilterCount} times) tracked: {Carousel.ItemsTracked} displayable: {Carousel.DisplayableItems} displayed: {Carousel.VisibleItems} @@ -375,6 +383,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public partial class TestBeatmapCarousel : BeatmapCarousel { + public int ActivationCount { get; private set; } + public int RequestPresentBeatmapCount { get; private set; } + public int FilterDelay = 0; public IEnumerable PostFilterBeatmaps = null!; @@ -385,12 +396,23 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public new BeatmapSetInfo? ExpandedBeatmapSet => base.ExpandedBeatmapSet; public new GroupDefinition? ExpandedGroup => base.ExpandedGroup; + public TestBeatmapCarousel() + { + RequestPresentBeatmap = _ => RequestPresentBeatmapCount++; + } + + protected override void HandleItemActivated(CarouselItem item) + { + ActivationCount++; + base.HandleItemActivated(item); + } + protected override async Task> FilterAsync(bool clearExistingPanels = false) { - var items = await base.FilterAsync(clearExistingPanels); + var items = await base.FilterAsync(clearExistingPanels).ConfigureAwait(true); if (FilterDelay != 0) - await Task.Delay(FilterDelay); + await Task.Delay(FilterDelay).ConfigureAwait(true); PostFilterBeatmaps = items.Select(i => i.Model).OfType(); return items; diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs index c7c56f30f4..75996fe158 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs @@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SelectedMods.SetDefault(); Config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title); - Config.SetValue(OsuSetting.SongSelectGroupMode, GroupMode.NoGrouping); + Config.SetValue(OsuSetting.SongSelectGroupMode, GroupMode.None); SongSelect = null!; }); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs index 56351eed97..ce671c7e7f 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs @@ -6,9 +6,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.SongSelectV2 @@ -35,7 +38,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Explicit] public void TestSorting() { - SortAndGroupBy(SortMode.Artist, GroupMode.NoGrouping); + SortAndGroupBy(SortMode.Artist, GroupMode.None); SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); SortAndGroupBy(SortMode.Artist, GroupMode.Artist); } @@ -53,7 +56,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public void TestLoadingDisplay() { AddStep("induce slow filtering", () => Carousel.FilterDelay = 2000); - SortAndGroupBy(SortMode.Artist, GroupMode.NoGrouping); + SortAndGroupBy(SortMode.Artist, GroupMode.None); } [Test] @@ -92,6 +95,25 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }); } + [Test] + public void TestHighChurnUpdatesStillShowsPanels() + { + ScheduledDelegate updateTask = null!; + + AddBeatmaps(1, 1); + + AddStep("start constantly updating beatmap in background", () => + { + updateTask = Scheduler.AddDelayed(() => { BeatmapSets.ReplaceRange(0, 1, [BeatmapSets.First()]); }, 1, true); + }); + + CreateCarousel(); + + AddUntilStep("panels loaded", () => Carousel.ChildrenOfType(), () => Is.Not.Empty); + + AddStep("end task", () => updateTask.Cancel()); + } + [Test] [Explicit] public void TestPerformanceWithManyBeatmaps() @@ -116,5 +138,16 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated)); } + + [Test] + public void TestSingleItemDisplayed() + { + CreateCarousel(); + RemoveAllBeatmaps(); + + SortAndGroupBy(SortMode.Difficulty, GroupMode.None); + AddBeatmaps(1, fixedDifficultiesPerSet: 1); + AddUntilStep("single item is shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs index e72a373d63..ea9d396316 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs @@ -190,6 +190,58 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForSelection(4, 0); } + [Test] + public void TestSingleItemTraversal() + { + CheckNoSelection(); + AddBeatmaps(1, 3); + + WaitForSelection(0, 0); + CheckActivationCount(0); + + SelectNextGroup(); + WaitForSelection(0, 0); + + // In the case of a grouped beatmap set, the header gets activated and re-selects the recommended difficulty. + // This is probably fine. + CheckActivationCount(1); + // We don't want it to request present though, which would start gameplay. + CheckRequestPresentCount(0); + + SelectPrevGroup(); + WaitForSelection(0, 0); + + CheckActivationCount(1); + CheckRequestPresentCount(0); + } + + [Test] + public void TestSingleItemTraversal_DifficultySplit() + { + SortBy(SortMode.Difficulty); + + CheckNoSelection(); + AddBeatmaps(1, 1); + + WaitForSelection(0, 0); + CheckActivationCount(0); + + SelectNextGroup(); + WaitForSelection(0, 0); + + // In the case of a grouped beatmap set, the header gets activated and re-selects the recommended difficulty. + // This is probably fine. + CheckActivationCount(0); + // We don't want it to request present though, which would start gameplay. + CheckRequestPresentCount(0); + + SelectPrevGroup(); + WaitForSelection(0, 0); + + CheckActivationCount(0); + CheckRequestPresentCount(0); + } + [Test] public void TestEmptyTraversal() { @@ -243,7 +295,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddBeatmaps(2, 3); WaitForDrawablePanels(); - SortAndGroupBy(SortMode.Difficulty, GroupMode.NoGrouping); + SortAndGroupBy(SortMode.Difficulty, GroupMode.None); WaitForFiltering(); AddUntilStep("standalone panels displayed", () => GetVisiblePanels().Any()); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index b81484d3da..dcd745395b 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -78,6 +78,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddUntilStep("wait for results screen", () => Stack.CurrentScreen is ResultsScreen); } + [Test] + public void TestSingleFilterWhenEntering() + { + ImportBeatmapForRuleset(0); + LoadSongSelect(); + + AddAssert("single filter", () => Carousel.FilterCount, () => Is.EqualTo(1)); + } + [Test] public void TestCookieDoesNothingIfNothingSelected() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs index bf75d07c2c..49eb1f092c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.UserInterface Position = new Vector2(275, 5) }); - filter.PinItem(GroupMode.NoGrouping); + filter.PinItem(GroupMode.None); filter.PinItem(GroupMode.LastPlayed); filter.Current.ValueChanged += grouping => diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 8f6fc214e1..df3e7d88af 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -40,14 +40,14 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Ruleset, string.Empty); SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString()); - SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Local); + SetDefault(OsuSetting.BeatmapDetailTab, BeatmapDetailTab.Local); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); SetDefault(OsuSetting.ShowConvertedBeatmaps, true); SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); - SetDefault(OsuSetting.SongSelectGroupMode, GroupMode.NoGrouping); + SetDefault(OsuSetting.SongSelectGroupMode, GroupMode.None); SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title); SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index edfffad070..19e56bce59 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -12,6 +12,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Caching; +using osu.Framework.Development; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -71,6 +72,11 @@ namespace osu.Game.Graphics.Carousel /// public bool IsFiltering => !filterTask.IsCompleted; + /// + /// The number of times filter operations have been triggered. + /// + internal int FilterCount { get; private set; } + /// /// The number of displayable items currently being tracked (before filtering). /// @@ -181,9 +187,13 @@ namespace osu.Game.Graphics.Carousel /// Whether all existing drawable panels should be reset post filter. protected virtual Task> FilterAsync(bool clearExistingPanels = false) { + FilterCount++; + if (clearExistingPanels) filterReusesPanels.Invalidate(); + filterAfterItemsChanged.Validate(); + filterTask = performFilter(); filterTask.FireAndForget(); return filterTask; @@ -267,7 +277,7 @@ namespace osu.Game.Graphics.Carousel RelativeSizeAxes = Axes.Both, }; - Items.BindCollectionChanged((_, _) => FilterAsync()); + Items.BindCollectionChanged((_, _) => filterAfterItemsChanged.Invalidate()); } [BackgroundDependencyLoader] @@ -290,22 +300,29 @@ namespace osu.Game.Graphics.Carousel private Task> filterTask = Task.FromResult(Enumerable.Empty()); private CancellationTokenSource cancellationSource = new CancellationTokenSource(); + /// + /// For background re-filters, ensure we wait for the previous filter operation to complete before starting another. + /// This avoids the carousel never updating its display in high churn scenarios. + /// + private readonly Cached filterAfterItemsChanged = new Cached(); + private async Task> performFilter() { Stopwatch stopwatch = Stopwatch.StartNew(); var cts = new CancellationTokenSource(); var previousCancellationSource = Interlocked.Exchange(ref cancellationSource, cts); - await previousCancellationSource.CancelAsync().ConfigureAwait(false); + await previousCancellationSource.CancelAsync().ConfigureAwait(true); if (DebounceDelay > 0) { log($"Filter operation queued, waiting for {DebounceDelay} ms debounce"); - await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(false); + await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true); } // Copy must be performed on update thread for now (see ConfigureAwait above). // Could potentially be optimised in the future if it becomes an issue. + Debug.Assert(ThreadSafety.IsUpdateThread); List items = new List(Items.Select(m => new CarouselItem(m))); await Task.Run(async () => @@ -524,6 +541,10 @@ namespace osu.Game.Graphics.Carousel do { newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + + if (newIndex == originalIndex) + break; + var newItem = carouselItems[newIndex]; if (CheckValidForGroupSelection(newItem)) @@ -531,7 +552,7 @@ namespace osu.Game.Graphics.Carousel HandleItemActivated(newItem); return; } - } while (newIndex != originalIndex); + } while (true); } #endregion @@ -611,13 +632,13 @@ namespace osu.Game.Graphics.Carousel { var item = carouselItems[i]; - updateItemYPosition(item, ref lastVisible, ref yPos); - if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!)) currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition, i); if (CheckModelEquality(item.Model, currentSelection.Model!)) currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition, i); + + updateItemYPosition(item, ref lastVisible, ref yPos); } // If a keyboard selection is currently made, we want to keep the view stable around the selection. @@ -721,6 +742,9 @@ namespace osu.Game.Graphics.Carousel c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; c.Expanded.Value = c.Item.IsExpanded; } + + if (!filterAfterItemsChanged.IsValid && !IsFiltering) + FilterAsync(); } protected virtual float GetPanelXOffset(Drawable panel) @@ -751,6 +775,9 @@ namespace osu.Game.Graphics.Carousel { Debug.Assert(carouselItems != null); + if (carouselItems.Count == 0) + return DisplayRange.EMPTY; + // Find index range of all items that should be on-screen carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload; int firstIndex = carouselItems.BinarySearch(carouselBoundsItem); @@ -770,7 +797,7 @@ namespace osu.Game.Graphics.Carousel { Debug.Assert(carouselItems != null); - List toDisplay = range.Last - range.First == 0 + List toDisplay = range == DisplayRange.EMPTY ? new List() : carouselItems.GetRange(range.First, range.Last - range.First + 1); @@ -889,7 +916,10 @@ namespace osu.Game.Graphics.Carousel /// The index of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); - private record DisplayRange(int First, int Last); + private record DisplayRange(int First, int Last) + { + public static readonly DisplayRange EMPTY = new DisplayRange(-1, -1); + } /// /// Implementation of scroll container which handles very large vertical lists by internally using double precision diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 3907907158..1baa4ae0ef 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -49,8 +50,13 @@ namespace osu.Game.Screens.Footer private Container hiddenButtonsContainer = null!; private LogoTrackingContainer logoTrackingContainer = null!; + // TODO: This has some weird update logic local in this class, but it only works for overlay containers. + // This is not what we want. The footer is to be displayed on *screens* with different colour schemes. + // It needs to update on screen switch. + // + // For now it's locked to Blue to match song select (the most prominent usage). [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public ScreenFooter(BackReceptor? receptor = null) { @@ -167,6 +173,7 @@ namespace osu.Game.Screens.Footer temporarilyHiddenButtons.Clear(); overlays.Clear(); + this.HidePopover(); clearActiveOverlayContainer(); var oldButtons = buttonsFlow.ToArray(); @@ -312,6 +319,8 @@ namespace osu.Game.Screens.Footer private void showOverlay(OverlayContainer overlay) { + this.HidePopover(); + foreach (var o in overlays.Where(o => o != overlay)) o.Hide(); diff --git a/osu.Game/Screens/Select/BeatmapDetailTab.cs b/osu.Game/Screens/Select/BeatmapDetailTab.cs new file mode 100644 index 0000000000..cd219a4830 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailTab.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.Select +{ + public enum BeatmapDetailTab + { + /// + /// Beatmap details. + /// + Details, + + /// + /// Local leaderboards. + /// + Local, + + /// + /// Country leaderboards. + /// + Country, + + /// + /// Global leaderboards. + /// + Global, + + /// + /// Friend leaderboards. + /// + Friends, + + /// + /// Team leaderboards. + /// + Team + } +} diff --git a/osu.Game/Screens/Select/Filter/GroupMode.cs b/osu.Game/Screens/Select/Filter/GroupMode.cs index 9f693177d8..b3a4f36c91 100644 --- a/osu.Game/Screens/Select/Filter/GroupMode.cs +++ b/osu.Game/Screens/Select/Filter/GroupMode.cs @@ -7,8 +7,8 @@ namespace osu.Game.Screens.Select.Filter { public enum GroupMode { - [Description("No Grouping")] - NoGrouping, + [Description("None")] + None, [Description("Artist")] Artist, @@ -19,8 +19,8 @@ namespace osu.Game.Screens.Select.Filter [Description("BPM")] BPM, - [Description("Collections")] - Collections, + // [Description("Collections")] + // Collections, [Description("Date Added")] DateAdded, @@ -31,17 +31,17 @@ namespace osu.Game.Screens.Select.Filter [Description("Difficulty")] Difficulty, - [Description("Favourites")] - Favourites, + // [Description("Favourites")] + // Favourites, [Description("Length")] Length, - [Description("My Maps")] - MyMaps, + // [Description("My Maps")] + // MyMaps, - [Description("Rank Achieved")] - RankAchieved, + // [Description("Rank Achieved")] + // RankAchieved, [Description("Ranked Status")] RankedStatus, diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index 5b62d5e8d7..ae318de754 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Select } } - private Bindable selectedTab; + private Bindable selectedTab; private Bindable selectedModsFilter; @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); + selectedTab = config.GetBindable(OsuSetting.BeatmapDetailTab); selectedModsFilter = config.GetBindable(OsuSetting.BeatmapDetailModsFilter); selectedTab.BindValueChanged(tab => CurrentTab.Value = getTabItemFromTabType(tab.NewValue), true); @@ -86,26 +86,26 @@ namespace osu.Game.Screens.Select new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team), }).ToArray(); - private BeatmapDetailAreaTabItem getTabItemFromTabType(TabType type) + private BeatmapDetailAreaTabItem getTabItemFromTabType(BeatmapDetailTab type) { switch (type) { - case TabType.Details: + case BeatmapDetailTab.Details: return new BeatmapDetailAreaDetailTabItem(); - case TabType.Local: + case BeatmapDetailTab.Local: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local); - case TabType.Global: + case BeatmapDetailTab.Global: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Global); - case TabType.Country: + case BeatmapDetailTab.Country: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Country); - case TabType.Friends: + case BeatmapDetailTab.Friends: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Friend); - case TabType.Team: + case BeatmapDetailTab.Team: return new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Team); default: @@ -113,30 +113,30 @@ namespace osu.Game.Screens.Select } } - private TabType getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) + private BeatmapDetailTab getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) { switch (item) { case BeatmapDetailAreaDetailTabItem: - return TabType.Details; + return BeatmapDetailTab.Details; case BeatmapDetailAreaLeaderboardTabItem leaderboardTab: switch (leaderboardTab.Scope) { case BeatmapLeaderboardScope.Local: - return TabType.Local; + return BeatmapDetailTab.Local; case BeatmapLeaderboardScope.Country: - return TabType.Country; + return BeatmapDetailTab.Country; case BeatmapLeaderboardScope.Global: - return TabType.Global; + return BeatmapDetailTab.Global; case BeatmapLeaderboardScope.Friend: - return TabType.Friends; + return BeatmapDetailTab.Friends; case BeatmapLeaderboardScope.Team: - return TabType.Team; + return BeatmapDetailTab.Team; default: throw new ArgumentOutOfRangeException(nameof(item)); @@ -146,15 +146,5 @@ namespace osu.Game.Screens.Select throw new ArgumentOutOfRangeException(nameof(item)); } } - - public enum TabType - { - Details, - Local, - Country, - Global, - Friends, - Team - } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index e007ae54ce..b85b7cba45 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -7,6 +7,7 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -60,9 +61,25 @@ namespace osu.Game.Screens.SelectV2 if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition)) return SPACING * 2; - // Beatmap difficulty panels do not overlap with themselves or any other panel. - if (grouping.BeatmapSetsGroupedTogether && (top.Model is BeatmapInfo || bottom.Model is BeatmapInfo)) - return SPACING; + if (grouping.BeatmapSetsGroupedTogether) + { + // Give some space around the expanded beatmap set, at the top.. + if (bottom.Model is BeatmapSetInfo && bottom.IsExpanded) + return SPACING * 2; + + // ..and the bottom. + if (top.Model is BeatmapInfo && bottom.Model is BeatmapSetInfo) + return SPACING * 2; + + // Beatmap difficulty panels do not overlap with themselves or any other panel. + if (top.Model is BeatmapInfo || bottom.Model is BeatmapInfo) + return SPACING; + } + else + { + if (top == CurrentSelectionItem || bottom == CurrentSelectionItem) + return SPACING * 2; + } return -SPACING; } @@ -74,9 +91,9 @@ namespace osu.Game.Screens.SelectV2 Filters = new ICarouselFilter[] { - matching = new BeatmapCarouselFilterMatching(() => Criteria), - new BeatmapCarouselFilterSorting(() => Criteria), - grouping = new BeatmapCarouselFilterGrouping(() => Criteria), + matching = new BeatmapCarouselFilterMatching(() => Criteria!), + new BeatmapCarouselFilterSorting(() => Criteria!), + grouping = new BeatmapCarouselFilterGrouping(() => Criteria!), }; AddInternal(loading = new LoadingLayer()); @@ -86,20 +103,20 @@ namespace osu.Game.Screens.SelectV2 private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) { setupPools(); - setupBeatmaps(beatmapStore, cancellationToken); + detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); loadSamples(audio); config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); } - #region Beatmap source hookup - - private void setupBeatmaps(BeatmapStore beatmapStore, CancellationToken? cancellationToken) + protected override void LoadComplete() { - detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + base.LoadComplete(); detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); } + #region Beatmap source hookup + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) { // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. @@ -467,7 +484,7 @@ namespace osu.Game.Screens.SelectV2 #region Filtering - public FilterCriteria Criteria { get; private set; } = new FilterCriteria(); + public FilterCriteria? Criteria { get; private set; } private ScheduledDelegate? loadingDebounce; @@ -493,6 +510,14 @@ namespace osu.Game.Screens.SelectV2 })); } + protected override Task> FilterAsync(bool clearExistingPanels = false) + { + if (Criteria == null) + return Task.FromResult(Enumerable.Empty()); + + return base.FilterAsync(clearExistingPanels); + } + #endregion #region Drawable pooling diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 86256ad99e..8720378ad6 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -124,14 +124,22 @@ namespace osu.Game.Screens.SelectV2 public static bool ShouldGroupBeatmapsTogether(FilterCriteria criteria) { - return criteria.Sort != SortMode.Difficulty && criteria.Group != GroupMode.Difficulty; + // In certain cases, we intentionally split out difficulties + // where it's more relevant or convenient to view them as individual items. + if (criteria.Sort == SortMode.Difficulty || criteria.Group == GroupMode.Difficulty) + return false; + if (criteria.Sort == SortMode.LastPlayed && criteria.Group == GroupMode.LastPlayed) + return false; + + // In the majority case we group sets together for display. + return true; } private List getGroups(List items, FilterCriteria criteria) { switch (criteria.Group) { - case GroupMode.NoGrouping: + case GroupMode.None: return new List { new GroupMapping(null, items) }; case GroupMode.Artist: @@ -152,12 +160,15 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.LastPlayed: return getGroupsBy(b => { - DateTimeOffset? maxLastPlayed = aggregateMax(b, items, bb => bb.LastPlayed); + var date = b.LastPlayed; - if (maxLastPlayed == null) + if (BeatmapSetsGroupedTogether) + date = aggregateMax(b, static b => (b.LastPlayed ?? DateTimeOffset.MinValue)); + + if (date == null || date == DateTimeOffset.MinValue) return new GroupDefinition(int.MaxValue, "Never"); - return defineGroupByDate(maxLastPlayed.Value); + return defineGroupByDate(date.Value); }, items); case GroupMode.RankedStatus: @@ -166,8 +177,12 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.BPM: return getGroupsBy(b => { - double maxBPM = aggregateMax(b, items, bb => bb.BPM); - return defineGroupByBPM(maxBPM); + double bpm = b.BPM; + + if (BeatmapSetsGroupedTogether) + bpm = aggregateMax(b, bb => bb.BPM); + + return defineGroupByBPM(bpm); }, items); case GroupMode.Difficulty: @@ -176,25 +191,27 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.Length: return getGroupsBy(b => { - double maxLength = aggregateMax(b, items, bb => bb.Length); - return defineGroupByLength(maxLength); + double length = b.Length; + + if (BeatmapSetsGroupedTogether) + length = aggregateMax(b, bb => bb.Length); + + return defineGroupByLength(length); }, items); - case GroupMode.Collections: - // TODO: needs implementation - goto case GroupMode.NoGrouping; - - case GroupMode.Favourites: - // TODO: needs implementation - goto case GroupMode.NoGrouping; - - case GroupMode.MyMaps: - // TODO: needs implementation - goto case GroupMode.NoGrouping; - - case GroupMode.RankAchieved: - // TODO: needs implementation - goto case GroupMode.NoGrouping; + // TODO: need implementation + // + // case GroupMode.Collections: + // goto case GroupMode.None; + // + // case GroupMode.Favourites: + // goto case GroupMode.None; + // + // case GroupMode.MyMaps: + // goto case GroupMode.None; + // + // case GroupMode.RankAchieved: + // goto case GroupMode.None; default: throw new ArgumentOutOfRangeException(); @@ -334,10 +351,10 @@ namespace osu.Game.Screens.SelectV2 return new GroupDefinition(11, "Over 10 minutes"); } - private static T? aggregateMax(BeatmapInfo b, IEnumerable items, Func func) + private static T? aggregateMax(BeatmapInfo b, Func func) { - var matchedBeatmaps = items.Select(i => i.Model).Cast().Where(beatmap => beatmap.BeatmapSet!.Equals(b.BeatmapSet)); - return matchedBeatmaps.Max(func); + var beatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); + return beatmaps.Max(func); } private record GroupMapping(GroupDefinition? Group, List ItemsInGroup); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs index eb39b499de..0ebfc084bd 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs @@ -27,19 +27,27 @@ namespace osu.Game.Screens.SelectV2 { var criteria = getCriteria(); + bool groupedSets = BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria); + return items.Order(Comparer.Create((a, b) => { var ab = (BeatmapInfo)a.Model; var bb = (BeatmapInfo)b.Model; - if (ab.BeatmapSet!.Equals(bb.BeatmapSet)) - return compareDifficulty(ab, bb); + if (groupedSets) + { + if (ab.BeatmapSet!.Equals(bb.BeatmapSet)) + return compareDifficulty(ab, bb, criteria.Sort); - return compare(ab, bb, criteria.Sort); + // If we're grouping by sets, all fallback sorts need to be aggregates for the set. + return compare(ab, bb, criteria.Sort, aggregate: true); + } + + return compare(ab, bb, criteria.Sort, aggregate: false); })).ToList(); }, cancellationToken).ConfigureAwait(false); - private static int compare(BeatmapInfo a, BeatmapInfo b, SortMode sort) + private static int compare(BeatmapInfo a, BeatmapInfo b, SortMode sort, bool aggregate) { int comparison; @@ -80,15 +88,24 @@ namespace osu.Game.Screens.SelectV2 break; case SortMode.LastPlayed: - comparison = -compareUsingAggregateMax(a, b, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); + if (aggregate) + comparison = compareUsingAggregateMax(b, a, static b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); + else + comparison = Nullable.Compare(b.LastPlayed, a.LastPlayed); break; case SortMode.BPM: - comparison = compareUsingAggregateMax(a, b, static b => b.BPM); + if (aggregate) + comparison = compareUsingAggregateMax(a, b, static b => b.BPM); + else + comparison = a.BPM.CompareTo(b.BPM); break; case SortMode.Length: - comparison = compareUsingAggregateMax(a, b, static b => b.Length); + if (aggregate) + comparison = compareUsingAggregateMax(a, b, static b => b.Length); + else + comparison = a.Length.CompareTo(b.Length); break; default: @@ -108,7 +125,7 @@ namespace osu.Game.Screens.SelectV2 return comparison; } - private static int compareDifficulty(BeatmapInfo a, BeatmapInfo b) + private static int compareDifficulty(BeatmapInfo a, BeatmapInfo b, SortMode sort) { int comparison = a.Ruleset.CompareTo(b.Ruleset); diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs index ee93001b86..76734e110f 100644 --- a/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs @@ -7,8 +7,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Select; using osu.Game.Screens.Select.Leaderboards; using osuTK; @@ -28,10 +30,12 @@ namespace osu.Game.Screens.SelectV2 public IBindable Scope => scopeDropdown.Current; + private readonly Bindable configDetailTab = new Bindable(); + public IBindable FilterBySelectedMods => selectedModsToggle.Active; [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { InternalChildren = new Drawable[] { @@ -98,18 +102,95 @@ namespace osu.Game.Screens.SelectV2 }, }, }; + + config.BindWith(OsuSetting.BeatmapDetailTab, configDetailTab); + config.BindWith(OsuSetting.BeatmapDetailModsFilter, selectedModsToggle.Active); } protected override void LoadComplete() { base.LoadComplete(); + scopeDropdown.Current.Value = tryMapDetailTabToLeaderboardScope(configDetailTab.Value) ?? scopeDropdown.Current.Value; + scopeDropdown.Current.BindValueChanged(_ => updateConfigDetailTab()); + + tabControl.Current.Value = configDetailTab.Value == BeatmapDetailTab.Details ? Selection.Details : Selection.Ranking; tabControl.Current.BindValueChanged(v => { leaderboardControls.FadeTo(v.NewValue == Selection.Ranking ? 1 : 0, 300, Easing.OutQuint); + updateConfigDetailTab(); }, true); } + #region Reading / writing state from / to configuration + + private void updateConfigDetailTab() + { + switch (tabControl.Current.Value) + { + case Selection.Details: + configDetailTab.Value = BeatmapDetailTab.Details; + return; + + case Selection.Ranking: + configDetailTab.Value = mapLeaderboardScopeToDetailTab(scopeDropdown.Current.Value); + return; + + default: + throw new ArgumentOutOfRangeException(nameof(tabControl.Current.Value), tabControl.Current.Value, null); + } + } + + private static BeatmapLeaderboardScope? tryMapDetailTabToLeaderboardScope(BeatmapDetailTab tab) + { + switch (tab) + { + case BeatmapDetailTab.Local: + return BeatmapLeaderboardScope.Local; + + case BeatmapDetailTab.Country: + return BeatmapLeaderboardScope.Country; + + case BeatmapDetailTab.Global: + return BeatmapLeaderboardScope.Global; + + case BeatmapDetailTab.Friends: + return BeatmapLeaderboardScope.Friend; + + case BeatmapDetailTab.Team: + return BeatmapLeaderboardScope.Team; + + default: + return null; + } + } + + private static BeatmapDetailTab mapLeaderboardScopeToDetailTab(BeatmapLeaderboardScope scope) + { + switch (scope) + { + case BeatmapLeaderboardScope.Local: + return BeatmapDetailTab.Local; + + case BeatmapLeaderboardScope.Country: + return BeatmapDetailTab.Country; + + case BeatmapLeaderboardScope.Global: + return BeatmapDetailTab.Global; + + case BeatmapLeaderboardScope.Friend: + return BeatmapDetailTab.Friends; + + case BeatmapLeaderboardScope.Team: + return BeatmapDetailTab.Team; + + default: + throw new ArgumentOutOfRangeException(nameof(scope), scope, null); + } + } + + #endregion + public enum Selection { Details, diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs index 113894ab8a..6a810a83b4 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.SelectV2 private const float username_min_width = 120; private const float statistics_regular_min_width = 165; private const float statistics_compact_min_width = 90; - private const float rank_label_width = 60; + private const float rank_label_width = 40; private const int corner_radius = 10; private const int transition_duration = 200; @@ -117,6 +117,15 @@ namespace osu.Game.Screens.SelectV2 private readonly bool sheared; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + var inputRectangle = DrawRectangle; + + inputRectangle = inputRectangle.Inflate(new MarginPadding { Vertical = BeatmapLeaderboardWedge.SPACING_BETWEEN_SCORES / 2 }); + + return inputRectangle.Contains(ToLocalSpace(screenSpacePos)); + } + public BeatmapLeaderboardScore(ScoreInfo score, bool sheared = true) { this.score = score; @@ -144,6 +153,7 @@ namespace osu.Game.Screens.SelectV2 { background = new Box { + Alpha = 0.4f, RelativeSizeAxes = Axes.Both, Colour = backgroundColour }, @@ -190,6 +200,7 @@ namespace osu.Game.Screens.SelectV2 { foreground = new Box { + Alpha = 0.4f, RelativeSizeAxes = Axes.Both, Colour = foregroundColour }, @@ -312,8 +323,8 @@ namespace osu.Game.Screens.SelectV2 Child = statisticsContainer = new FillFlowContainer { Name = @"Statistics container", - Padding = new MarginPadding { Right = 40 }, - Spacing = new Vector2(25, 0), + Padding = new MarginPadding { Right = 10 }, + Spacing = new Vector2(20, 0), Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -567,13 +578,13 @@ namespace osu.Game.Screens.SelectV2 private DisplayMode getCurrentDisplayMode() { - if (DrawWidth >= HEIGHT + username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) + if (DrawWidth >= username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) return DisplayMode.Full; - if (DrawWidth >= HEIGHT + username_min_width + statistics_regular_min_width + expanded_right_content_width) + if (DrawWidth >= username_min_width + statistics_regular_min_width + expanded_right_content_width) return DisplayMode.Regular; - if (DrawWidth >= HEIGHT + username_min_width + statistics_compact_min_width + expanded_right_content_width) + if (DrawWidth >= username_min_width + statistics_compact_min_width + expanded_right_content_width) return DisplayMode.Compact; return DisplayMode.Minimal; diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs index 29affaa9af..e3d52adef5 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs @@ -3,14 +3,16 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.PolygonExtensions; using osu.Framework.Graphics; +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; @@ -27,11 +29,14 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.SelectV2 { public partial class BeatmapLeaderboardWedge : VisibilityContainer { + public const float SPACING_BETWEEN_SCORES = 4; + public IBindable Scope { get; } = new Bindable(); public IBindable FilterBySelectedMods { get; } = new BindableBool(); @@ -70,7 +75,7 @@ namespace osu.Game.Screens.SelectV2 private readonly IBindable fetchedScores = new Bindable(); - private const float personal_best_height = 80; + private const float personal_best_height = 100; [BackgroundDependencyLoader] private void load() @@ -109,7 +114,10 @@ namespace osu.Game.Screens.SelectV2 RelativeSizeAxes = Axes.X, Height = personal_best_height, Shear = OsuGame.SHEAR, - Margin = new MarginPadding { Left = -40f }, + Margin = new MarginPadding + { + Left = -40f, + }, CornerRadius = 10f, Masking = true, // push the personal best 1px down to hide masking issues @@ -118,11 +126,7 @@ namespace osu.Game.Screens.SelectV2 Alpha = 0f, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4, - }, + new WedgeBackground(), new Container { RelativeSizeAxes = Axes.X, @@ -256,10 +260,10 @@ namespace osu.Game.Screens.SelectV2 foreach (var d in loadedScores) { - d.Y = (BeatmapLeaderboardScore.HEIGHT + 4f) * i; + d.Y = (BeatmapLeaderboardScore.HEIGHT + SPACING_BETWEEN_SCORES) * i; // This is a bit of a weird one. We're already in a sheared state and don't want top-level - // shear applied, but still need the `BeatmapLeadeboardScore` to be in "sheared" mode (see ctor). + // shear applied, but still need the `BeatmapLeaderboardScore` to be in "sheared" mode (see ctor). d.Shear = Vector2.Zero; scoresContainer.Add(d); @@ -352,6 +356,59 @@ namespace osu.Game.Screens.SelectV2 placeholder.FadeInFromZero(300, Easing.OutQuint); } + #region Fade handling + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + const int height = BeatmapLeaderboardScore.HEIGHT; + + float fadeBottom = (float)(scoresScroll.Current + scoresScroll.DrawHeight); + float fadeTop = (float)(scoresScroll.Current); + + if (!scoresScroll.IsScrolledToStart()) + fadeTop += height; + + foreach (var c in scoresContainer) + { + float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scoresContainer).Y; + float bottomY = topY + height; + + bool requireBottomFade = bottomY >= fadeBottom; + bool requireTopFade = topY < fadeTop; + + if (!requireBottomFade && !requireTopFade) + { + c.Colour = Color4.White; + continue; + } + + if (topY > fadeBottom + height || bottomY < fadeTop - height) + { + c.Colour = Color4.Transparent; + continue; + } + + if (requireBottomFade) + { + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / height, 1)), + Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / height, 1))); + } + else + { + Debug.Assert(requireTopFade); + + c.Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / height, 1)), + Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / height, 1))); + } + } + } + + #endregion + private Placeholder? getPlaceholderFor(LeaderboardState state) { switch (state) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 8362f5b6a7..734d768241 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -253,7 +253,7 @@ namespace osu.Game.Screens.SelectV2 mapperText.Text = beatmap.Value.Metadata.Author.Username; } - starRatingDisplay.Current = (Bindable)difficultyCache.GetBindableDifficulty(beatmap.Value.BeatmapInfo, cancellationSource.Token, 200); + starRatingDisplay.Current = (Bindable)difficultyCache.GetBindableDifficulty(beatmap.Value.BeatmapInfo, cancellationSource.Token, SongSelect.SELECTION_DEBOUNCE); updateCountStatistics(cancellationSource.Token); updateDifficultyStatistics(); diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 8b360688fa..05429c2c12 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -17,7 +17,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; -using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; @@ -142,9 +141,9 @@ namespace osu.Game.Screens.SelectV2 RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, ColumnDimensions = new[] { - new Dimension(maxSize: 210), + new Dimension(maxSize: 180), new Dimension(GridSizeMode.Absolute, 5), - new Dimension(maxSize: 230), + new Dimension(maxSize: 180), new Dimension(GridSizeMode.Absolute, 5), new Dimension(), }, @@ -152,14 +151,14 @@ namespace osu.Game.Screens.SelectV2 { new[] { - sortDropdown = new ShearedDropdown(SortStrings.Default) + sortDropdown = new ShearedDropdown("Sort") { RelativeSizeAxes = Axes.X, Items = Enum.GetValues(), }, Empty(), // todo: pending localisation - groupDropdown = new ShearedDropdown("Group by") + groupDropdown = new ShearedDropdown("Group") { RelativeSizeAxes = Axes.X, Items = Enum.GetValues(), diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions_Popover.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions_Popover.cs index 3031dcb8f7..039020d7c4 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonOptions_Popover.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonOptions_Popover.cs @@ -68,6 +68,10 @@ namespace osu.Game.Screens.SelectV2 foreach (OsuMenuItem item in SongSelect.GetForwardActions(beatmap.BeatmapInfo)) { + // We can't display menus with child items here, so just ignore them. + if (item.Items.Any()) + continue; + if (item is OsuMenuItemSpacer) { buttonFlow.Add(new Container diff --git a/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs index 8caa559550..46f8859255 100644 --- a/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,10 +21,14 @@ namespace osu.Game.Screens.SelectV2 { public partial class NoResultsPlaceholder : VisibilityContainer { + public Action? RequestClearFilterText { get; init; } + private FilterCriteria? filter; private LinkFlowContainer textFlow = null!; + private SpriteIcon icon = null!; + [Resolved] private BeatmapManager beatmaps { get; set; } = null!; @@ -50,8 +55,7 @@ namespace osu.Game.Screens.SelectV2 [BackgroundDependencyLoader] private void load() { - Width = 400; - AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.Both; Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -61,11 +65,13 @@ namespace osu.Game.Screens.SelectV2 new FillFlowContainer { Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, AutoSizeAxes = Axes.Y, Children = new Drawable[] { - new SpriteIcon + icon = new SpriteIcon { Icon = FontAwesome.Solid.Ghost, Anchor = Anchor.TopCentre, @@ -78,7 +84,7 @@ namespace osu.Game.Screens.SelectV2 Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.Style.Title, - Text = "No beatmaps found" + Text = "No matching beatmaps" }, textFlow = new LinkFlowContainer { @@ -115,6 +121,9 @@ namespace osu.Game.Screens.SelectV2 this.ScaleTo(0.9f) .ScaleTo(1f, 1000, Easing.OutQuint); + icon.ScaleTo(new Vector2(-1, 1)) + .ScaleTo(new Vector2(1, 1), 500, Easing.InOutSine); + textFlow.FadeInFromZero(800, Easing.OutQuint); textFlow.Clear(); @@ -131,6 +140,18 @@ namespace osu.Game.Screens.SelectV2 textFlow.AddParagraph("No beatmaps match your filter criteria!"); textFlow.AddParagraph(string.Empty); + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("clearing", () => + { + RequestClearFilterText?.Invoke(); + }); + + textFlow.AddText(" your current search criteria."); + } + if (filter?.UserStarDifficulty.HasFilter == true) { addBulletPoint(); diff --git a/osu.Game/Screens/SelectV2/Panel.cs b/osu.Game/Screens/SelectV2/Panel.cs index de559be4a9..f17567f9ba 100644 --- a/osu.Game/Screens/SelectV2/Panel.cs +++ b/osu.Game/Screens/SelectV2/Panel.cs @@ -26,7 +26,7 @@ namespace osu.Game.Screens.SelectV2 { private const float corner_radius = 10; - private const float active_x_offset = 50f; + private const float active_x_offset = 25f; protected const float DURATION = 400; @@ -34,7 +34,6 @@ namespace osu.Game.Screens.SelectV2 private Box backgroundBorder = null!; private Box backgroundGradient = null!; - private Box backgroundAccentGradient = null!; private Container backgroundLayerHorizontalPadding = null!; private Container backgroundContainer = null!; private Container iconContainer = null!; @@ -65,7 +64,7 @@ namespace osu.Game.Screens.SelectV2 set { accentColour = value; - updateDisplay(); + updateAccentColour(); } } @@ -95,8 +94,8 @@ namespace osu.Game.Screens.SelectV2 EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, - Offset = new Vector2(1f), - Radius = 10, + Hollow = true, + Radius = 2, }, Children = new Drawable[] { @@ -108,7 +107,7 @@ namespace osu.Game.Screens.SelectV2 backgroundBorder = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + Colour = Color4.Black, }, backgroundLayerHorizontalPadding = new Container { @@ -124,10 +123,6 @@ namespace osu.Game.Screens.SelectV2 { RelativeSizeAxes = Axes.Both, }, - backgroundAccentGradient = new Box - { - RelativeSizeAxes = Axes.Both, - }, backgroundContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -158,10 +153,9 @@ namespace osu.Game.Screens.SelectV2 selectionLayer = new Box { Alpha = 0, - Colour = ColourInfo.GradientHorizontal(colours.BlueDark.Opacity(0), colours.BlueDark.Opacity(0.6f)), - Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, - Width = 0.3f, + Width = 0.6f, + Blending = BlendingParameters.Additive, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }, @@ -190,15 +184,15 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); - Expanded.BindValueChanged(_ => updateDisplay(), true); - - Selected.BindValueChanged(selected => + Expanded.BindValueChanged(_ => { - if (selected.NewValue) - selectionLayer.FadeIn(100, Easing.OutQuint); - else - selectionLayer.FadeOut(200, Easing.OutQuint); + updateSelectedState(); + updateXOffset(); + }); + Selected.BindValueChanged(_ => + { + updateSelectedState(); updateXOffset(); }, true); @@ -217,6 +211,9 @@ namespace osu.Game.Screens.SelectV2 { base.PrepareForUse(); + updateAccentColour(); + updateXOffset(); + this.FadeIn(DURATION, Easing.OutQuint); } @@ -236,18 +233,28 @@ namespace osu.Game.Screens.SelectV2 return true; } - private void updateDisplay() + private void updateAccentColour() { var backgroundColour = accentColour ?? Color4.White; + + backgroundBorder.Colour = backgroundColour; + + selectionLayer.Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour.Opacity(0.5f)); + + updateSelectedState(animated: false); + } + + private void updateSelectedState(bool animated = true) + { + bool selectedOrExpanded = Expanded.Value || Selected.Value; + var edgeEffectColour = accentColour ?? Color4Extensions.FromHex(@"4EBFFF"); + TopLevelContent.FadeEdgeEffectTo(selectedOrExpanded ? edgeEffectColour.Opacity(0.8f) : Color4.Black.Opacity(0.4f), animated ? DURATION : 0, Easing.OutQuint); - backgroundAccentGradient.FadeColour(ColourInfo.GradientHorizontal(backgroundColour.Opacity(0.25f), backgroundColour.Opacity(0f)), DURATION, Easing.OutQuint); - backgroundBorder.FadeColour(backgroundColour, DURATION, Easing.OutQuint); - - TopLevelContent.FadeEdgeEffectTo(Expanded.Value ? edgeEffectColour.Opacity(0.5f) : Color4.Black.Opacity(0.4f), DURATION, Easing.OutQuint); - - updateXOffset(); - updateHover(); + if (selectedOrExpanded) + selectionLayer.FadeIn(100, Easing.OutQuint); + else + selectionLayer.FadeOut(200, Easing.OutQuint); } private void updateXOffset() @@ -255,31 +262,28 @@ namespace osu.Game.Screens.SelectV2 float x = PanelXOffset + corner_radius; if (!Expanded.Value && !Selected.Value) - x += active_x_offset; + { + if (this is PanelBeatmap) + x += active_x_offset * 2; + else + x += active_x_offset * 4; + } if (!KeyboardSelected.Value) - x += active_x_offset * 0.5f; + x += active_x_offset; TopLevelContent.MoveToX(x, DURATION, Easing.OutQuint); } - private void updateHover() - { - if (IsHovered) - hoverLayer.FadeIn(100, Easing.OutQuint); - else - hoverLayer.FadeOut(1000, Easing.OutQuint); - } - protected override bool OnHover(HoverEvent e) { - updateHover(); + hoverLayer.FadeIn(100, Easing.OutQuint); return true; } protected override void OnHoverLost(HoverLostEvent e) { - updateHover(); + hoverLayer.FadeOut(1000, Easing.OutQuint); base.OnHoverLost(e); } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 190e563c46..e785448c9a 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -7,12 +7,16 @@ using System.Diagnostics; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -36,10 +40,15 @@ namespace osu.Game.Screens.SelectV2 private PanelLocalRankDisplay localRank = null!; private OsuSpriteText difficultyText = null!; private OsuSpriteText authorText = null!; + private FillFlowContainer mainFill = null!; private IBindable? starDifficultyBindable; private CancellationTokenSource? starDifficultyCancellationSource; + private Box backgroundAccentGradient = null!; + + private TrianglesV2 triangles = null!; + [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -58,6 +67,11 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private ISongSelect? songSelect { get; set; } + public PanelBeatmap() + { + PanelXOffset = 60; + } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) { var inputRectangle = TopLevelContent.DrawRectangle; @@ -78,80 +92,108 @@ namespace osu.Game.Screens.SelectV2 Icon = difficultyIcon = new ConstrainedIconContainer { - Size = new Vector2(16f), - Margin = new MarginPadding { Horizontal = 5f }, + Size = new Vector2(9f), + Margin = new MarginPadding { Left = 2.5f, Right = 1.5f }, Colour = colourProvider.Background5, }; - Content.Children = new[] + Background = new Container { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Left = 10f }, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + backgroundAccentGradient = new Box { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + }, + triangles = new TrianglesV2 + { + ScaleAdjust = 1.2f, + Thickness = 0.01f, + Velocity = 0.3f, + RelativeSizeAxes = Axes.Both, + }, + } + }; + + Content.Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(3), + Margin = new MarginPadding { Left = 5 }, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + localRank = new PanelLocalRankDisplay + { + Scale = new Vector2(0.8f), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + mainFill = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(3, 0), - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + new FillFlowContainer { - localRank = new PanelLocalRankDisplay + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 4 }, + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.65f) - }, - starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.875f), - }, - starCounter = new StarCounter - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.4f) + keyCountText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, + difficultyText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 3f }, + }, + authorText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + } } - } - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Children = new[] + }, + new FillFlowContainer { - keyCountText = new OsuSpriteText + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] { - Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Alpha = 0, + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.4f) + } }, - difficultyText = new OsuSpriteText - { - Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Right = 3f }, - }, - authorText = new OsuSpriteText - { - Colour = colourProvider.Content2, - Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft - } } } } - }, + } }; } @@ -210,7 +252,11 @@ namespace osu.Game.Screens.SelectV2 var beatmap = (BeatmapInfo)Item.Model; starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE); - starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); + starDifficultyBindable.BindValueChanged(starDifficulty => + { + starRatingDisplay.Current.Value = starDifficulty.NewValue; + starCounter.Current = (float)starDifficulty.NewValue.Stars; + }, true); } protected override void Update() @@ -225,7 +271,21 @@ namespace osu.Game.Screens.SelectV2 // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. // I can't find a better way to do this. - starRatingDisplay.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + mainFill.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + + var diffColour = starRatingDisplay.DisplayedDifficultyColour; + + if (AccentColour != diffColour) + { + AccentColour = diffColour; + starCounter.Colour = diffColour; + + backgroundAccentGradient.Colour = ColourInfo.GradientHorizontal(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); + + difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; + + triangles.Colour = ColourInfo.GradientVertical(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); + } } private void updateKeyCount() @@ -249,22 +309,6 @@ namespace osu.Game.Screens.SelectV2 keyCountText.Alpha = 0; } - private void updateDisplay() - { - const float duration = 500; - - var starDifficulty = starDifficultyBindable?.Value ?? default; - - starRatingDisplay.Current.Value = starDifficulty; - starCounter.Current = (float)starDifficulty.Stars; - - difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); - - var starRatingColour = colours.ForStarDifficulty(starDifficulty.Stars); - starCounter.FadeColour(starRatingColour, duration, Easing.OutQuint); - AccentColour = starRatingColour; - } - public override MenuItem[] ContextMenuItems { get diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs index a41c5c75ae..425ca02e5a 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -14,6 +14,8 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Sprites; @@ -188,6 +190,12 @@ namespace osu.Game.Screens.SelectV2 difficultiesDisplay.BeatmapSet = null; } + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + public override MenuItem[] ContextMenuItems { get @@ -215,6 +223,17 @@ namespace osu.Game.Screens.SelectV2 items.Add(new OsuMenuItemSpacer()); } + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(createCollectionMenuItem) + .ToList(); + + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => songSelect?.RestoreAllHidden(beatmapSet))); @@ -222,5 +241,51 @@ namespace osu.Game.Screens.SelectV2 return items.ToArray(); } } + + private MenuItem createCollectionMenuItem(BeatmapCollection collection) + { + var beatmapSet = (BeatmapSetInfo)Item!.Model; + + Debug.Assert(beatmapSet != null); + + TernaryState state; + + int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash)); + + if (countExisting == beatmapSet.Beatmaps.Count) + state = TernaryState.True; + else if (countExisting > 0) + state = TernaryState.Indeterminate; + else + state = TernaryState.False; + + var liveCollection = collection.ToLive(realm); + + return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => + { + liveCollection.PerformWrite(c => + { + foreach (var b in beatmapSet.Beatmaps) + { + switch (s) + { + case TernaryState.True: + if (c.BeatmapMD5Hashes.Contains(b.MD5Hash)) + continue; + + c.BeatmapMD5Hashes.Add(b.MD5Hash); + break; + + case TernaryState.False: + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + break; + } + } + }); + }) + { + State = { Value = state } + }; + } } } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index 86f8374088..d461653dcb 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -63,13 +63,29 @@ namespace osu.Game.Screens.SelectV2 private BeatmapSetOnlineStatusPill statusPill = null!; private ConstrainedIconContainer difficultyIcon = null!; - private FillFlowContainer difficultyLine = null!; private StarRatingDisplay starRatingDisplay = null!; private StarCounter starCounter = null!; private PanelLocalRankDisplay localRank = null!; private OsuSpriteText keyCountText = null!; private OsuSpriteText difficultyText = null!; private OsuSpriteText authorText = null!; + private FillFlowContainer mainFill = null!; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + var inputRectangle = TopLevelContent.DrawRectangle; + + if (Selected.Value) + { + // Cover the gaps introduced by the spacing between BeatmapPanels so that clicks will not fall through the carousel. + // + // Caveat is that for simplicity, we are covering the full spacing, so panels with frontmost depth will have a slightly + // larger hit target. + inputRectangle = inputRectangle.Inflate(new MarginPadding { Vertical = BeatmapCarousel.SPACING * 2 }); + } + + return inputRectangle.Contains(TopLevelContent.ToLocalSpace(screenSpacePos)); + } public PanelBeatmapStandalone() { @@ -83,8 +99,8 @@ namespace osu.Game.Screens.SelectV2 Icon = difficultyIcon = new ConstrainedIconContainer { - Size = new Vector2(16), - Margin = new MarginPadding { Horizontal = 5f }, + Size = new Vector2(12), + Margin = new MarginPadding { Left = 4f, Right = 3f }, Colour = colourProvider.Background5, }; @@ -95,93 +111,105 @@ namespace osu.Game.Screens.SelectV2 Content.Child = new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Left = 10f }, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, + Spacing = new Vector2(3), + Margin = new MarginPadding { Left = 5 }, + Direction = FillDirection.Horizontal, Children = new Drawable[] { - titleText = new OsuSpriteText + localRank = new PanelLocalRankDisplay { - Font = OsuFont.Style.Heading2.With(typeface: Typeface.TorusAlternate, weight: FontWeight.Bold), + Scale = new Vector2(0.8f), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, - artistText = new OsuSpriteText + mainFill = new FillFlowContainer { - Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), - Padding = new MarginPadding { Top = -2 }, - }, - difficultyLine = new FillFlowContainer - { - Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Bottom = 2 }, AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 4 }, Children = new Drawable[] { - statusPill = new BeatmapSetOnlineStatusPill + titleText = new OsuSpriteText { - Animated = false, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - TextSize = OsuFont.Style.Caption2.Size, - Margin = new MarginPadding { Right = 5f }, + Font = OsuFont.Style.Heading2.With(typeface: Typeface.TorusAlternate, weight: FontWeight.Bold), }, - updateButton = new PanelUpdateBeatmapButton + artistText = new OsuSpriteText { - Scale = new Vector2(0.7f), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Right = 5f, Top = -2f }, - }, - keyCountText = new OsuSpriteText - { - Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Alpha = 0, - }, - difficultyText = new OsuSpriteText - { - Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Right = 3f }, - }, - authorText = new OsuSpriteText - { - Colour = colourProvider.Content2, Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft + Padding = new MarginPadding { Top = -2 }, + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 2, Bottom = 2 }, + Children = new Drawable[] + { + statusPill = new BeatmapSetOnlineStatusPill + { + Animated = false, + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + TextSize = OsuFont.Style.Caption2.Size, + Margin = new MarginPadding { Right = 4f }, + }, + updateButton = new PanelUpdateBeatmapButton + { + Scale = new Vector2(0.8f), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 4f, Bottom = -1f }, + }, + keyCountText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Alpha = 0, + }, + difficultyText = new OsuSpriteText + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 3f }, + }, + authorText = new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft + } + } + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.875f), + }, + starCounter = new StarCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.4f) + } + }, } } - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Spacing = new Vector2(3), - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - localRank = new PanelLocalRankDisplay - { - Scale = new Vector2(0.65f), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - }, - starRatingDisplay = new StarRatingDisplay(default, StarRatingDisplaySize.Small, animated: true) - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Scale = new Vector2(0.875f), - }, - starCounter = new StarCounter - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.4f) - } - }, } } }; @@ -229,9 +257,9 @@ namespace osu.Game.Screens.SelectV2 localRank.Beatmap = beatmap; difficultyText.Text = beatmap.DifficultyName; authorText.Text = BeatmapsetsStrings.ShowDetailsMappedBy(beatmap.Metadata.Author.Username); - difficultyLine.Show(); computeStarRating(); + updateKeyCount(); } protected override void FreeAfterUse() @@ -257,7 +285,11 @@ namespace osu.Game.Screens.SelectV2 var beatmap = (BeatmapInfo)Item.Model; starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE); - starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); + starDifficultyBindable.BindValueChanged(starDifficulty => + { + starRatingDisplay.Current.Value = starDifficulty.NewValue; + starCounter.Current = (float)starDifficulty.NewValue.Stars; + }, true); } protected override void Update() @@ -272,7 +304,14 @@ namespace osu.Game.Screens.SelectV2 // Dirty hack to make sure we don't take up spacing in parent fill flow when not displaying a rank. // I can't find a better way to do this. - starRatingDisplay.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + mainFill.Margin = new MarginPadding { Left = 1 / starRatingDisplay.Scale.X * (localRank.HasRank ? 0 : -3) }; + + var diffColour = starRatingDisplay.DisplayedDifficultyColour; + + AccentColour = diffColour; + starCounter.Colour = diffColour; + + difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; } private void updateKeyCount() @@ -296,22 +335,6 @@ namespace osu.Game.Screens.SelectV2 keyCountText.Alpha = 0; } - private void updateDisplay() - { - const float duration = 500; - - var starDifficulty = starDifficultyBindable?.Value ?? default; - - starRatingDisplay.Current.Value = starDifficulty; - starCounter.Current = (float)starDifficulty.Stars; - - difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); - - var starRatingColour = colours.ForStarDifficulty(starDifficulty.Stars); - starCounter.FadeColour(starRatingColour, duration, Easing.OutQuint); - AccentColour = colours.ForStarDifficulty(starDifficulty.Stars); - } - public override MenuItem[] ContextMenuItems { get diff --git a/osu.Game/Screens/SelectV2/PanelSetBackground.cs b/osu.Game/Screens/SelectV2/PanelSetBackground.cs index 99dbf90556..dd07be0410 100644 --- a/osu.Game/Screens/SelectV2/PanelSetBackground.cs +++ b/osu.Game/Screens/SelectV2/PanelSetBackground.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -61,34 +62,27 @@ namespace osu.Game.Screens.SelectV2 Direction = FillDirection.Horizontal, // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle Shear = new Vector2(0.8f, 0), - Alpha = 0.5f, Children = new[] { // The left half with no gradient applied new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, + Colour = Color4.Black.Opacity(0.5f), Width = 0.4f, }, - // Piecewise-linear gradient with 3 segments to make it appear smoother new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), - Width = 0.05f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.5f), Color4.Black.Opacity(0.3f)), Width = 0.2f, }, new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), - Width = 0.05f, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0.2f)), + // Slightly more than 1.0 in total to account for shear. + Width = 0.45f, }, } }, diff --git a/osu.Game/Screens/SelectV2/SoloSongSelect.cs b/osu.Game/Screens/SelectV2/SoloSongSelect.cs index ea27ffef37..a136e682c4 100644 --- a/osu.Game/Screens/SelectV2/SoloSongSelect.cs +++ b/osu.Game/Screens/SelectV2/SoloSongSelect.cs @@ -73,6 +73,9 @@ namespace osu.Game.Screens.SelectV2 yield return new OsuMenuItemSpacer(); } + foreach (var i in CreateCollectionMenuActions(beatmap)) + yield return i; + // TODO: replace with "remove from played" button when beatmap is already played. yield return new OsuMenuItem(SongSelectStrings.MarkAsPlayed, MenuItemType.Standard, () => beatmaps.MarkPlayed(beatmap)) { Icon = FontAwesome.Solid.TimesCircle }; yield return new OsuMenuItem(SongSelectStrings.ClearAllLocalScores, MenuItemType.Standard, () => dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmap))) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 504a55a4f8..097abc7da8 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -21,6 +21,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -70,15 +71,20 @@ namespace osu.Game.Screens.SelectV2 /// protected bool ControlGlobalMusic { get; init; } = true; - private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Aquamarine) + // Colour scheme for mod overlay is left as default (green) to match mods button. + // Not sure about this, but we'll iterate based on feedback. + private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay { ShowPresets = true, }; private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; + // Blue is the most neutral choice, so I'm using that for now. + // Purple makes the most sense to match the "gameplay" flow, but it's a bit too strong for the current design. + // TODO: Colour scheme choice should probably be customisable by the user. [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private BeatmapCarousel carousel = null!; @@ -146,9 +152,9 @@ namespace osu.Game.Screens.SelectV2 RelativeSizeAxes = Axes.Both, ColumnDimensions = new[] { - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 850), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 660), new Dimension(), - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 750), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 620), }, Content = new[] { @@ -162,6 +168,11 @@ namespace osu.Game.Screens.SelectV2 // screen-wide scroll handling. Depth = float.MinValue, Shear = OsuGame.SHEAR, + Padding = new MarginPadding + { + Top = -CORNER_RADIUS_HIDE_OFFSET, + Left = -CORNER_RADIUS_HIDE_OFFSET, + }, Children = new Drawable[] { new Container @@ -177,11 +188,6 @@ namespace osu.Game.Screens.SelectV2 wedgesContainer = new FillFlowContainer { RelativeSizeAxes = Axes.Both, - Margin = new MarginPadding - { - Top = -CORNER_RADIUS_HIDE_OFFSET, - Left = -CORNER_RADIUS_HIDE_OFFSET - }, Spacing = new Vector2(0f, 4f), Direction = FillDirection.Vertical, Children = new Drawable[] @@ -196,8 +202,13 @@ namespace osu.Game.Screens.SelectV2 new Container { RelativeSizeAxes = Axes.Both, - Children = new CompositeDrawable[] + Children = new Drawable[] { + new Box + { + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.0f), Color4.Black.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + }, new Container { RelativeSizeAxes = Axes.Both, @@ -218,7 +229,10 @@ namespace osu.Game.Screens.SelectV2 RequestRecommendedSelection = selectRecommendedBeatmap, NewItemsPresented = newItemsPresented, }, - noResultsPlaceholder = new NoResultsPlaceholder(), + noResultsPlaceholder = new NoResultsPlaceholder + { + RequestClearFilterText = () => filterControl.Search(string.Empty) + } } }, filterControl = new FilterControl @@ -401,6 +415,7 @@ namespace osu.Game.Screens.SelectV2 backgroundModeBeatmap.BlurAmount.Value = 0; backgroundModeBeatmap.Beatmap = beatmap; backgroundModeBeatmap.IgnoreUserSettings.Value = true; + backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = 0.1f; backgroundModeBeatmap.FadeColour(Color4.White, 250); }); } @@ -547,12 +562,18 @@ namespace osu.Game.Screens.SelectV2 private void criteriaChanged(FilterCriteria criteria) { + // The first filter needs to be applied immediately as this triggers the initial carousel load. + double filterDelay = filterDebounce == null ? 0 : filter_delay; + filterDebounce?.Cancel(); - filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria); }, filter_delay); + filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria); }, filterDelay); } private void newItemsPresented(IEnumerable carouselItems) { + if (carousel.Criteria == null) + return; + int count = carousel.MatchedBeatmapsCount; if (count == 0) @@ -658,6 +679,12 @@ namespace osu.Game.Screens.SelectV2 #region Beatmap management + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + public virtual IEnumerable GetForwardActions(BeatmapInfo beatmap) { yield return new OsuMenuItem("Select", MenuItemType.Highlighted, () => SelectAndStart(beatmap)) @@ -674,6 +701,23 @@ namespace osu.Game.Screens.SelectV2 if (beatmap.GetOnlineURL(api, Ruleset.Value) is string url) yield return new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => (game as OsuGame)?.CopyToClipboard(url)); } + + yield return new OsuMenuItemSpacer(); + + foreach (var i in CreateCollectionMenuActions(beatmap)) + yield return i; + } + + protected IEnumerable CreateCollectionMenuActions(BeatmapInfo beatmap) + { + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); + + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, () => manageCollectionsDialog?.Show())); + + yield return new OsuMenuItem("Collections") { Items = collectionItems }; } public void ManageCollections() => collectionsDialog?.Show();