diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index bd5c43d24225..aa452101bfc3 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -13,9 +13,12 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -23,6 +26,8 @@ using osu.Game.Rulesets.Taiko; using osu.Game.Tests.Resources; using osu.Game.Users; +using osu.Game.Utils; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -63,7 +68,7 @@ decimal getNecessaryPP(int? rulesetID) return 336; // recommended star rating of 2 case 1: - return 928; // SR 3 + return 973; // SR 3 case 2: return 1905; // SR 4 @@ -170,6 +175,45 @@ public void TestCorrectStarRatingIsUsed() presentAndConfirm(() => maniaSet, 5); } + [Test] + public void TestBeatmapListingFilter() + { + AddStep("set playmode to taiko", () => ((DummyAPIAccess)API).LocalUser.Value.PlayMode = "taiko"); + + AddStep("open beatmap listing", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.B); + InputManager.ReleaseKey(Key.B); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("wait for load", () => Game.ChildrenOfType().SingleOrDefault()?.IsLoaded, () => Is.True); + + checkRecommendedDifficulty(3); + + AddStep("change mode filter to osu!", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(1).TriggerClick()); + + checkRecommendedDifficulty(2); + + AddStep("change mode filter to osu!taiko", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(2).TriggerClick()); + + checkRecommendedDifficulty(3); + + AddStep("change mode filter to osu!catch", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(3).TriggerClick()); + + checkRecommendedDifficulty(4); + + AddStep("change mode filter to osu!mania", () => Game.ChildrenOfType().Single().ChildrenOfType>().ElementAt(4).TriggerClick()); + + checkRecommendedDifficulty(5); + + void checkRecommendedDifficulty(double starRating) + => AddAssert($"recommended difficulty is {starRating}", + () => Game.ChildrenOfType().Single().ChildrenOfType().ElementAt(1).Text.ToString(), + () => Is.EqualTo($"Recommended difficulty ({starRating.FormatStarRating()})")); + } + private BeatmapSetInfo importBeatmapSet(IEnumerable difficultyRulesets) { var rulesets = difficultyRulesets.ToArray(); diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index ae59e92e3329..cff86cf0a11b 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -15,6 +15,7 @@ using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Screens.Menu; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -207,7 +208,7 @@ private void refreshContent() Children = new Drawable[] { new DiffPiece(stats), - new DiffPiece(("Star Rating", $"{beatmap.StarRating:0.00}{srExtra}")) + new DiffPiece(("Star Rating", $"{beatmap.StarRating.FormatStarRating()}{srExtra}")) } }, new FillFlowContainer diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 31c9fcafe67a..510764694eaf 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -23,10 +20,12 @@ namespace osu.Game.Beatmaps /// public partial class DifficultyRecommender : Component { + public event Action? StarRatingUpdated; + private readonly LocalUserStatisticsProvider statisticsProvider; [Resolved] - private Bindable gameRuleset { get; set; } + private Bindable gameRuleset { get; set; } = null!; [Resolved] private RulesetStore rulesets { get; set; } = null!; @@ -83,8 +82,13 @@ private void updateMapping(RulesetInfo ruleset, UserStatistics statistics) ruleset.ShortName == @"taiko" ? Math.Pow((double)(statistics.PP ?? 0), 0.35) * 0.27 : Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; + + StarRatingUpdated?.Invoke(); } + public double? GetRecommendedStarRatingFor(RulesetInfo ruleset) + => recommendedDifficultyMapping.TryGetValue(ruleset.ShortName, out double starRating) ? starRating : null; + /// /// Find the recommended difficulty from a selection of available difficulties for the current local user. /// @@ -93,15 +97,14 @@ private void updateMapping(RulesetInfo ruleset, UserStatistics statistics) /// /// A collection of beatmaps to select a difficulty from. /// The recommended difficulty, or null if a recommendation could not be provided. - [CanBeNull] - public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) + public BeatmapInfo? GetRecommendedBeatmap(IEnumerable beatmaps) { foreach (string r in orderedRulesets) { if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation)) continue; - BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => + BeatmapInfo? beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => { double difference = b.StarRating - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 55ef6f705ee1..4119ffb63602 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -156,7 +156,7 @@ protected override void LoadComplete() displayedStars.BindValueChanged(s => { - starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.ToLocalisableString("0.00"); + starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.FormatStarRating(); background.Colour = colours.ForStarDifficulty(s.NewValue); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index bab64165cbec..72d7e0c752e7 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -29,7 +27,7 @@ public partial class BeatmapListingSearchControl : CompositeDrawable /// /// Any time the text box receives key events (even while masked). /// - public Action TypingStarted; + public Action? TypingStarted; public Bindable Query => textBox.Current; @@ -51,7 +49,7 @@ public partial class BeatmapListingSearchControl : CompositeDrawable public Bindable ExplicitContent => explicitContentFilter.Current; - public APIBeatmapSet BeatmapSet + public APIBeatmapSet? BeatmapSet { set { @@ -67,7 +65,7 @@ public APIBeatmapSet BeatmapSet } private readonly BeatmapSearchTextBox textBox; - private readonly BeatmapSearchMultipleSelectionFilterRow generalFilter; + private readonly BeatmapSearchGeneralFilterRow generalFilter; private readonly BeatmapSearchRulesetFilterRow modeFilter; private readonly BeatmapSearchFilterRow categoryFilter; private readonly BeatmapSearchFilterRow genreFilter; @@ -151,7 +149,7 @@ public BeatmapListingSearchControl() categoryFilter.Current.Value = SearchCategory.Leaderboard; } - private IBindable allowExplicitContent; + private IBindable allowExplicitContent = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, OsuConfigManager config) @@ -165,6 +163,13 @@ private void load(OverlayColourProvider colourProvider, OsuConfigManager config) }, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + generalFilter.Ruleset.BindTo(Ruleset); + } + public void TakeFocus() => textBox.TakeFocus(); private partial class BeatmapSearchTextBox : BasicSearchTextBox @@ -172,7 +177,7 @@ private partial class BeatmapSearchTextBox : BasicSearchTextBox /// /// Any time the text box receives key events (even while masked). /// - public Action TextChanged; + public Action? TextChanged; protected override Color4 SelectionColour => Color4.Gray; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs index 2d56c60de619..34b7d45a772a 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -1,18 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Overlays.Dialog; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK.Graphics; using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; @@ -20,27 +25,97 @@ namespace osu.Game.Overlays.BeatmapListing { public partial class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow { + public readonly IBindable Ruleset = new Bindable(); + public BeatmapSearchGeneralFilterRow() : base(BeatmapsStrings.ListingSearchFiltersGeneral) { } - protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter(); + protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter + { + Ruleset = { BindTarget = Ruleset } + }; private partial class GeneralFilter : MultipleSelectionFilter { + public readonly IBindable Ruleset = new Bindable(); + protected override MultipleSelectionFilterTabItem CreateTabItem(SearchGeneral value) { - if (value == SearchGeneral.FeaturedArtists) - return new FeaturedArtistsTabItem(); + switch (value) + { + case SearchGeneral.Recommended: + return new RecommendedDifficultyTabItem + { + Ruleset = { BindTarget = Ruleset } + }; - return new MultipleSelectionFilterTabItem(value); + case SearchGeneral.FeaturedArtists: + return new FeaturedArtistsTabItem(); + + default: + return new MultipleSelectionFilterTabItem(value); + } + } + } + + private partial class RecommendedDifficultyTabItem : MultipleSelectionFilterTabItem + { + public readonly IBindable Ruleset = new Bindable(); + + [Resolved] + private DifficultyRecommender? recommender { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public RecommendedDifficultyTabItem() + : base(SearchGeneral.Recommended) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (recommender != null) + recommender.StarRatingUpdated += updateText; + + Ruleset.BindValueChanged(_ => updateText(), true); + } + + private void updateText() + { + // fallback to profile default game mode if beatmap listing mode filter is set to Any + // TODO: find a way to update `PlayMode` when the profile default game mode has changed + RulesetInfo? ruleset = Ruleset.Value.IsLegacyRuleset() ? Ruleset.Value : rulesets.GetRuleset(api.LocalUser.Value.PlayMode); + + if (ruleset == null) return; + + double? starRating = recommender?.GetRecommendedStarRatingFor(ruleset); + + if (starRating != null) + Text.Text = LocalisableString.Interpolate($"{Value.GetLocalisableDescription()} ({starRating.Value.FormatStarRating()})"); + else + Text.Text = Value.GetLocalisableDescription(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (recommender != null) + recommender.StarRatingUpdated -= updateText; } } private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem { - private Bindable disclaimerShown; + private Bindable disclaimerShown = null!; public FeaturedArtistsTabItem() : base(SearchGeneral.FeaturedArtists) @@ -48,13 +123,13 @@ public FeaturedArtistsTabItem() } [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private SessionStatics sessionStatics { get; set; } + private SessionStatics sessionStatics { get; set; } = null!; - [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } protected override void LoadComplete() { diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 5f021803b04c..a7838651a95b 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -21,6 +21,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.BeatmapSet @@ -185,7 +186,7 @@ private void updateDisplay() OnHovered = beatmap => { showBeatmap(beatmap); - starRating.Text = beatmap.StarRating.ToLocalisableString(@"0.00"); + starRating.Text = beatmap.StarRating.FormatStarRating(); starRatingContainer.FadeIn(100); }, OnClicked = beatmap => { Beatmap.Value = beatmap; }, diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index f1c27434fad1..d9f7eedfb556 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -226,7 +226,7 @@ private LocalisableString getValueString(BeatmapAttribute attribute) return computeDifficulty().ApproachRate.ToLocalisableString(@"0.##"); case BeatmapAttribute.StarRating: - return (starDifficulty?.Stars ?? 0).ToLocalisableString(@"F2"); + return (starDifficulty?.Stars ?? 0).FormatStarRating(); case BeatmapAttribute.MaxPP: return Math.Round(starDifficulty?.PerformanceAttributes?.Total ?? 0, MidpointRounding.AwayFromZero).ToLocalisableString(); diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index cccad3711cc5..e93a494b65a0 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -32,6 +32,12 @@ public static LocalisableString FormatAccuracy(this double accuracy) /// The rank/position to be formatted. public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 100_000 ? 1 : 0); + /// + /// Formats the supplied star rating in a consistent, simplified way. + /// + /// The star rating to be formatted. + public static LocalisableString FormatStarRating(this double starRating) => starRating.ToLocalisableString("0.00"); + /// /// Finds the number of digits after the decimal. ///