From 2a01e3d148ea69f86e955e5aac771d1cf23d5aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 30 Oct 2025 14:54:07 +0100 Subject: [PATCH 1/9] Add failing test case --- .../TestSceneBeatmapCarouselArtistGrouping.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs index 2c3013af12..2390261cdb 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs @@ -4,9 +4,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.SongSelectV2 @@ -322,5 +325,38 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SelectNextSet(); AddUntilStep("no beatmap panels visible", () => GetVisiblePanels().Count(), () => Is.Zero); } + + [Test] + public void TestGroupChangedAfterEngagingArtistGrouping() + { + RemoveAllBeatmaps(); + AddStep("add test beatmaps", () => + { + for (int i = 0; i < 5; ++i) + { + var baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(3); + + var metadata = new BeatmapMetadata + { + Artist = $"{(char)('A' + i)} artist", + Title = $"{(char)('A' + 4 - i)} title", + }; + + foreach (var b in baseTestBeatmap.Beatmaps) + b.Metadata = metadata; + + Realm.Write(r => r.Add(baseTestBeatmap, update: true)); + BeatmapSets.Add(baseTestBeatmap.Detach()); + } + + SortAndGroupBy(SortMode.Title, GroupMode.Title); + SelectNextSet(); + SelectNextSet(); + WaitForExpandedGroup(1); + + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); + WaitForExpandedGroup(3); + }); + } } } From 73e05e3fae13e2b1e2f94825753125a547d7626b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 30 Oct 2025 14:29:40 +0100 Subject: [PATCH 2/9] Switch active carousel group if current selection no longer exists in the previous group This was primarily written to fix https://github.com/ppy/osu/issues/35538, but also incidentally targets some other scenarios, such as: - When switching from artist filtering to title filtering, selection sometimes would stay at the group under which the selection's artist was filed, rather than moving to the group under which the selection's title is filed (in other words, the group that *the selection is currently under*). - When simply assigning a beatmap to a collection such that it would be moved out of the current group, the selection will now follow to the new collection's group rather than staying at its previous position. Whether this is desired is highly likely to be extremely situational, but I don't want to introduce complications unless it's absolutely necessary. This has a significant performance overhead because `CheckModelEquality()` isn't free, but it doesn't seem horrible in profiling. --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 5e84ba0722..5991771d00 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -497,25 +497,35 @@ namespace osu.Game.Screens.SelectV2 // The filter might have changed the set of available groups, which means that the current selection may point to a stale group. // Check whether that is the case. bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0; - bool groupStillExists = currentGroupedBeatmap?.Group != null && grouping.GroupItems.ContainsKey(currentGroupedBeatmap.Group); - if (groupingRemainsOff || groupStillExists) + bool groupStillValid = false; + + if (currentGroupedBeatmap?.Group != null) + { + groupStillValid = grouping.GroupItems.TryGetValue(currentGroupedBeatmap.Group, out var items) + && items.Any(i => CheckModelEquality(i.Model, currentGroupedBeatmap)); + } + + if (groupingRemainsOff || groupStillValid) { // Only update the visual state of the selected item. HandleItemSelected(currentGroupedBeatmap); } else if (currentGroupedBeatmap != null) { - // If the group no longer exists, grab an arbitrary other instance of the beatmap under the first group encountered. + // If the group no longer exists (or the item no longer exists in the previous group), grab an arbitrary other instance of the beatmap under the first group encountered. var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap)); + // Only change the selection if we actually got a positive hit. // This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place. if (newSelection != null) + { CurrentSelection = newSelection; + groupForReselection = newSelection.Group; + } } // If a group was selected that is not the one containing the selection, attempt to reselect it. - // If the original group was not found, ExpandedGroup will already have been updated to a valid value in `HandleItemSelected` above. if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _)) setExpandedGroup(groupForReselection); } From 14cdc40f0fef4cab84b0893f2dee324d9bd1db55 Mon Sep 17 00:00:00 2001 From: Marvefect Date: Sun, 2 Nov 2025 02:04:48 +0300 Subject: [PATCH 3/9] Added Tooltip --- .../Profile/Header/Components/MainDetails.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index 10bb69f0f5..c337299673 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -158,6 +158,7 @@ namespace osu.Game.Overlays.Profile.Header.Components medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; + ppInfo.ContentTooltipText = getPPInfoTooltipText(user); foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; @@ -234,6 +235,29 @@ namespace osu.Game.Overlays.Profile.Header.Components return result ?? default; } + private static LocalisableString getPPInfoTooltipText(APIUser? user) + { + var variants = user?.Statistics?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.PP.ToLocalisableString("#,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + + } + } + + return result ?? default; + } + private partial class ScoreRankInfo : CompositeDrawable { private readonly OsuSpriteText rankCount; From 65fb5311ea36755f2d7c56bd1d037f67cf2142ff Mon Sep 17 00:00:00 2001 From: Marvefect Date: Sun, 2 Nov 2025 02:27:39 +0300 Subject: [PATCH 4/9] Removed unneccesary blank space, reran dotnet format --- osu.Game/Overlays/Profile/Header/Components/MainDetails.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index c337299673..e0f3b0a3e5 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -251,7 +251,6 @@ namespace osu.Game.Overlays.Profile.Header.Components result = variantText; else result = LocalisableString.Interpolate($"{result}\n{variantText}"); - } } From 2413e981083cfca8e24fe97c67ef8fdf3a5c88f8 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 2 Nov 2025 11:58:09 +0900 Subject: [PATCH 5/9] Fix file and class name mismatch --- .../Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs | 2 +- osu.Game/Screens/Menu/MainMenu.cs | 2 +- osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs | 4 ++-- .../Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs index 5193d58ee6..07d0fe6ed9 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Matchmaking { base.SetUpSteps(); - AddStep("load screen", () => LoadScreen(new IntroScreen())); + AddStep("load screen", () => LoadScreen(new ScreenIntro())); } [Test] diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index c4ba3145b5..2296213dd6 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -482,7 +482,7 @@ namespace osu.Game.Screens.Menu private void loadSongSelect() => this.Push(new SoloSongSelect()); - private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.IntroScreen()); + private void joinOrLeaveMatchmakingQueue() => this.Push(new OnlinePlay.Matchmaking.Intro.ScreenIntro()); private partial class MobileDisclaimerDialog : PopupDialog { diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs index b3fff7dc00..093d9f6117 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Intro/ScreenIntro.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro /// /// A brief intro animation that introduces matchmaking to the user. /// - public partial class IntroScreen : OsuScreen + public partial class ScreenIntro : OsuScreen { public override bool DisallowExternalBeatmapRulesetChanges => false; @@ -55,7 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Intro protected override BackgroundScreen CreateBackground() => new MatchmakingIntroBackgroundScreen(colourProvider); - public IntroScreen() + public ScreenIntro() { ValidForResume = false; } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 468e024a65..353f5ac24f 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -165,7 +165,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue CompletionClickAction = () => { client.MatchmakingAcceptInvitation().FireAndForget(); - performer?.PerformFromScreen(s => s.Push(new IntroScreen())); + performer?.PerformFromScreen(s => s.Push(new ScreenIntro())); Close(false); return true; From 1ab017d4e201afcc9cd4cebda6370ecb478b3cfa Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 2 Nov 2025 12:44:01 +0900 Subject: [PATCH 6/9] Fix quick play notification not setting "accepted" state --- .../OnlinePlay/Matchmaking/Queue/QueueController.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 353f5ac24f..f72f26f26e 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -119,7 +119,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue if (backgroundNotification != null) return; - notifications?.Post(backgroundNotification = new BackgroundQueueNotification()); + notifications?.Post(backgroundNotification = new BackgroundQueueNotification(this)); } private void closeNotifications() @@ -154,9 +154,16 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue [Resolved] private MultiplayerClient client { get; set; } = null!; + private readonly QueueController controller; + private Notification? foundNotification; private Sample? matchFoundSample; + public BackgroundQueueNotification(QueueController controller) + { + this.controller = controller; + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -165,6 +172,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue CompletionClickAction = () => { client.MatchmakingAcceptInvitation().FireAndForget(); + controller.CurrentState.Value = ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom; + performer?.PerformFromScreen(s => s.Push(new ScreenIntro())); Close(false); From 89b443bccc172f709839962d9d9523151c10985f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8D=E4=BA=88?= Date: Mon, 3 Nov 2025 20:29:46 +0800 Subject: [PATCH 7/9] Add GitHub link button to the wiki overlay header (#35595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Github link button to wiki overlay header * Localize jump link string * Mark ILinkHandler dependency as nullable * Make the button actually look like it does on the website * Use existing web string instead of inventing a new one * Bind value change callback more reliably --------- Co-authored-by: Bartłomiej Dach --- osu.Game/Overlays/Wiki/WikiHeader.cs | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index d64d6b934a..a5129eaefd 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -5,13 +5,20 @@ using System; using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.Wiki { @@ -19,11 +26,15 @@ namespace osu.Game.Overlays.Wiki { public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; + private const string github_wiki_base = @"https://github.com/ppy/osu-wiki/blob/master/wiki"; + public readonly Bindable WikiPageData = new Bindable(); public Action ShowIndexPage; public Action ShowParentPage; + private readonly Bindable githubPath = new Bindable(); + public WikiHeader() { TabControl.AddItem(IndexPageString); @@ -35,6 +46,9 @@ namespace osu.Game.Overlays.Wiki private void onWikiPageChange(ValueChangedEvent e) { + // Clear the path beforehand in case we got an error page. + githubPath.Value = null; + if (e.NewValue == null) return; @@ -42,6 +56,7 @@ namespace osu.Game.Overlays.Wiki Current.Value = null; TabControl.AddItem(IndexPageString); + githubPath.Value = $"{github_wiki_base}/{e.NewValue.Path}/{e.NewValue.Locale}.md"; if (e.NewValue.Path == WikiOverlay.INDEX_PATH) { @@ -56,6 +71,27 @@ namespace osu.Game.Overlays.Wiki Current.Value = e.NewValue.Title; } + protected override Drawable CreateTabControlContent() + { + return new FillFlowContainer + { + Height = 40, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new ShowOnGitHubButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(32), + TargetPath = { BindTarget = githubPath }, + }, + }, + }; + } + private void onCurrentChange(ValueChangedEvent e) { if (e.NewValue == TabControl.Items.LastOrDefault()) @@ -83,5 +119,39 @@ namespace osu.Game.Overlays.Wiki Icon = OsuIcon.Wiki; } } + + private partial class ShowOnGitHubButton : RoundedButton + { + public override LocalisableString TooltipText => WikiStrings.ShowEditLink; + + public readonly Bindable TargetPath = new Bindable(); + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] ILinkHandler linkHandler) + { + Width = 42; + + Add(new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Brands.Github, + }); + + Action = () => linkHandler?.HandleLink(TargetPath.Value); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TargetPath.BindValueChanged(e => + { + this.FadeTo(e.NewValue != null ? 1 : 0); + Enabled.Value = e.NewValue != null; + }, true); + } + } } } From 73f1849365717e0f3144db8154f533f2f8b9fd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 4 Nov 2025 01:46:09 +0100 Subject: [PATCH 8/9] Fix signalr connector connection failure logging eating exception stack trace (#35598) As seen in https://discord.com/channels/188630481301012481/1097318920991559880/1434899538123952128, wherein precisely zero useful detail can be gleaned (and nothing is reported to sentry either). --- osu.Game/Online/PersistentEndpointClientConnector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/PersistentEndpointClientConnector.cs b/osu.Game/Online/PersistentEndpointClientConnector.cs index 9e7543ce2b..2674c29103 100644 --- a/osu.Game/Online/PersistentEndpointClientConnector.cs +++ b/osu.Game/Online/PersistentEndpointClientConnector.cs @@ -150,7 +150,7 @@ namespace osu.Game.Online // compare: https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/Online/BanchoClient.cs#L539 retryDelay = Math.Min(120000, (int)(retryDelay * 1.5)); - Logger.Log($"{ClientName} connect attempt failed: {exception.Message}. Next attempt in {thisDelay / 1000:N0} seconds.", LoggingTarget.Network); + Logger.Log($"{ClientName} connect attempt failed. Next attempt in {thisDelay / 1000:N0} seconds.\n{exception}", LoggingTarget.Network); await Task.Delay(thisDelay, cancellationToken).ConfigureAwait(false); } From 645d27bb3245e6324e203412963940b584eee34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 4 Nov 2025 02:47:33 +0100 Subject: [PATCH 9/9] Add tiered colours for global rank (#35597) * Add new API property backing for tiered rank * Slightly refactor `ProfileValueDisplay` for direct access to things that will need direct access * Extract separate component for global rank display * Add tiered colours for global rank --- .../Online/TestSceneGlobalRankDisplay.cs | 67 +++++++++ .../Header/Components/GlobalRankDisplay.cs | 137 ++++++++++++++++++ .../Profile/Header/Components/MainDetails.cs | 59 ++------ .../Header/Components/ProfileValueDisplay.cs | 19 +-- .../Header/Components/TotalPlayTime.cs | 6 +- osu.Game/Users/UserRankPanel.cs | 19 ++- osu.Game/Users/UserStatistics.cs | 3 + 7 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs create mode 100644 osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs diff --git a/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs new file mode 100644 index 0000000000..07fe8c6172 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneGlobalRankDisplay.cs @@ -0,0 +1,67 @@ +// 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 System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Tests.Visual.UserInterface; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneGlobalRankDisplay : ThemeComparisonTestScene + { + public TestSceneGlobalRankDisplay() + : base(false) + { + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + Padding = new MarginPadding(20), + Spacing = new Vector2(40), + ChildrenEnumerable = new int?[] { 64, 423, 1453, 3468, 18_367, 48_342, 178_432, 375_231, 897_783, null }.Select(createDisplay) + }; + + private GlobalRankDisplay createDisplay(int? rank) => new GlobalRankDisplay + { + UserStatistics = + { + Value = new UserStatistics + { + GlobalRank = rank, + GlobalRankPercent = rank / 1_000_000f, + Variants = + [ + new UserStatistics.Variant + { + VariantType = UserStatistics.RulesetVariant.FourKey, + GlobalRank = rank / 3, + }, + new UserStatistics.Variant + { + VariantType = UserStatistics.RulesetVariant.SevenKey, + GlobalRank = 2 * rank / 3, + } + ] + }, + }, + HighestRank = + { + Value = rank == null + ? null + : new APIUser.UserRankHighest + { + Rank = rank.Value / 2, + UpdatedAt = DateTimeOffset.Now.AddMonths(-3), + } + } + }; + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs new file mode 100644 index 0000000000..3560986925 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class GlobalRankDisplay : CompositeDrawable + { + public Bindable UserStatistics = new Bindable(); + public Bindable HighestRank = new Bindable(); + + private ProfileValueDisplay info = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public GlobalRankDisplay() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = info = new ProfileValueDisplay(big: true) + { + Title = UsersStrings.ShowRankGlobalSimple + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + UserStatistics.BindValueChanged(_ => updateState()); + HighestRank.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + info.Content.Text = UserStatistics.Value?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + info.Content.TooltipText = getGlobalRankTooltipText(); + + var tier = getRankingTier(); + info.Content.Colour = tier == null ? colourProvider.Content2 : OsuColour.ForRankingTier(tier.Value); + info.Content.Font = info.Content.Font.With(weight: tier == null || tier == RankingTier.Iron ? FontWeight.Regular : FontWeight.Bold); + } + + /// + private RankingTier? getRankingTier() + { + var stats = UserStatistics.Value; + + int? rank = stats?.GlobalRank; + float? percent = stats?.GlobalRankPercent; + + if (rank == null || percent == null) + return null; + + if (rank <= 100) + return RankingTier.Lustrous; + + if (percent < 0.0005) + return RankingTier.Radiant; + + if (percent < 0.0025) + return RankingTier.Rhodium; + + if (percent < 0.005) + return RankingTier.Platinum; + + if (percent < 0.025) + return RankingTier.Gold; + + if (percent < 0.05) + return RankingTier.Silver; + + if (percent < 0.25) + return RankingTier.Bronze; + + if (percent < 0.5) + return RankingTier.Iron; + + return null; + } + + private LocalisableString getGlobalRankTooltipText() + { + var rankHighest = HighestRank.Value; + var variants = UserStatistics.Value?.Variants; + + LocalisableString? result = null; + + if (variants?.Count > 0) + { + foreach (var variant in variants) + { + if (variant.GlobalRank != null) + { + var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.GlobalRank.ToLocalisableString("\\##,##0")}"); + + if (result == null) + result = variantText; + else + result = LocalisableString.Interpolate($"{result}\n{variantText}"); + } + } + } + + if (rankHighest != null) + { + var rankHighestText = UsersStrings.ShowRankHighest( + rankHighest.Rank.ToLocalisableString("\\##,##0"), + rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); + + if (result == null) + result = rankHighestText; + else + result = LocalisableString.Interpolate($"{result}\n{rankHighestText}"); + } + + return result ?? default; + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index e0f3b0a3e5..029de96c41 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly Dictionary scoreRankInfos = new Dictionary(); private ProfileValueDisplay medalInfo = null!; private ProfileValueDisplay ppInfo = null!; - private ProfileValueDisplay detailGlobalRank = null!; + private GlobalRankDisplay detailGlobalRank = null!; private ProfileValueDisplay detailCountryRank = null!; private RankGraph rankGraph = null!; @@ -64,10 +64,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { new[] { - detailGlobalRank = new ProfileValueDisplay(true) - { - Title = UsersStrings.ShowRankGlobalSimple, - }, + detailGlobalRank = new GlobalRankDisplay(), Empty(), detailCountryRank = new ProfileValueDisplay(true) { @@ -156,60 +153,22 @@ namespace osu.Game.Overlays.Profile.Header.Components { var user = data?.User; - medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; - ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; - ppInfo.ContentTooltipText = getPPInfoTooltipText(user); + medalInfo.Content.Text = user?.Achievements?.Length.ToString() ?? "0"; + ppInfo.Content.Text = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; + ppInfo.Content.TooltipText = getPPInfoTooltipText(user); foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - detailGlobalRank.ContentTooltipText = getGlobalRankTooltipText(user); + detailGlobalRank.HighestRank.Value = user?.RankHighest; + detailGlobalRank.UserStatistics.Value = user?.Statistics; - detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - detailCountryRank.ContentTooltipText = getCountryRankTooltipText(user); + detailCountryRank.Content.Text = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content.TooltipText = getCountryRankTooltipText(user); rankGraph.Statistics.Value = user?.Statistics; } - private static LocalisableString getGlobalRankTooltipText(APIUser? user) - { - var rankHighest = user?.RankHighest; - var variants = user?.Statistics?.Variants; - - LocalisableString? result = null; - - if (variants?.Count > 0) - { - foreach (var variant in variants) - { - if (variant.GlobalRank != null) - { - var variantText = LocalisableString.Interpolate($"{variant.VariantType.GetLocalisableDescription()}: {variant.GlobalRank.ToLocalisableString("\\##,##0")}"); - - if (result == null) - result = variantText; - else - result = LocalisableString.Interpolate($"{result}\n{variantText}"); - } - } - } - - if (rankHighest != null) - { - var rankHighestText = UsersStrings.ShowRankHighest( - rankHighest.Rank.ToLocalisableString("\\##,##0"), - rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); - - if (result == null) - result = rankHighestText; - else - result = LocalisableString.Interpolate($"{result}\n{rankHighestText}"); - } - - return result ?? default; - } - private static LocalisableString getCountryRankTooltipText(APIUser? user) { var variants = user?.Statistics?.Variants; diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs index b2c23458b1..db384ed9d7 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs @@ -14,22 +14,13 @@ namespace osu.Game.Overlays.Profile.Header.Components public partial class ProfileValueDisplay : CompositeDrawable { private readonly OsuSpriteText title; - private readonly ContentText content; public LocalisableString Title { set => title.Text = value; } - public LocalisableString Content - { - set => content.Text = value; - } - - public LocalisableString ContentTooltipText - { - set => content.TooltipText = value; - } + public ContentText Content { get; } public ProfileValueDisplay(bool big = false, int minimumWidth = 60) { @@ -44,9 +35,9 @@ namespace osu.Game.Overlays.Profile.Header.Components { Font = OsuFont.GetFont(size: 12) }, - content = new ContentText + Content = new ContentText { - Font = OsuFont.GetFont(size: big ? 30 : 20, weight: FontWeight.Light), + Font = OsuFont.GetFont(size: big ? 30 : 20, weight: big ? FontWeight.Regular : FontWeight.Light), }, new Container // Add a minimum size to the FillFlowContainer { @@ -60,10 +51,10 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load(OverlayColourProvider colourProvider) { title.Colour = colourProvider.Content1; - content.Colour = colourProvider.Content2; + Content.Colour = colourProvider.Content2; } - private partial class ContentText : OsuSpriteText, IHasTooltip + public partial class ContentText : OsuSpriteText, IHasTooltip { public LocalisableString TooltipText { get; set; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs index a3c22d61d2..3cc7bc15e8 100644 --- a/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs @@ -26,7 +26,7 @@ namespace osu.Game.Overlays.Profile.Header.Components InternalChild = info = new ProfileValueDisplay(minimumWidth: 140) { Title = UsersStrings.ShowStatsPlayTime, - ContentTooltipText = "0 hours", + Content = { TooltipText = "0 hours", } }; User.BindValueChanged(updateTime, true); @@ -35,8 +35,8 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateTime(ValueChangedEvent user) { int? playTime = user.NewValue?.User.Statistics?.PlayTime; - info.ContentTooltipText = (playTime ?? 0) / 3600 + " hours"; - info.Content = formatTime(playTime); + info.Content.TooltipText = (playTime ?? 0) / 3600 + " hours"; + info.Content.Text = formatTime(playTime); } private string formatTime(int? secondsNull) diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs index ff8adf055c..251c21a89a 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -27,7 +27,7 @@ namespace osu.Game.Users private const int padding = 10; private const int main_content_height = 80; - private ProfileValueDisplay globalRankDisplay = null!; + private GlobalRankDisplay globalRankDisplay = null!; private ProfileValueDisplay countryRankDisplay = null!; private LoadingLayer loadingLayer = null!; @@ -71,8 +71,13 @@ namespace osu.Game.Users var statistics = statisticsProvider?.GetStatisticsFor(ruleset.Value); loadingLayer.State.Value = statistics == null ? Visibility.Visible : Visibility.Hidden; - globalRankDisplay.Content = statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? "-"; - countryRankDisplay.Content = statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; + + // TODO: implement highest rank tooltip + // `RankHighest` resides in `APIUser`, but `api.LocalUser` doesn't update + // maybe move to `UserStatistics` in api, so `UserStatisticsWatcher` can update the value + globalRankDisplay.UserStatistics.Value = statistics; + + countryRankDisplay.Content.Text = statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; } protected override Drawable CreateLayout() @@ -187,13 +192,7 @@ namespace osu.Game.Users { new Drawable[] { - globalRankDisplay = new ProfileValueDisplay(true) - { - Title = UsersStrings.ShowRankGlobalSimple, - // TODO: implement highest rank tooltip - // `RankHighest` resides in `APIUser`, but `api.LocalUser` doesn't update - // maybe move to `UserStatistics` in api, so `UserStatisticsWatcher` can update the value - }, + globalRankDisplay = new GlobalRankDisplay(), countryRankDisplay = new ProfileValueDisplay(true) { Title = UsersStrings.ShowRankCountrySimple, diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 687dd52594..65bea41e20 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -40,6 +40,9 @@ namespace osu.Game.Users [JsonProperty(@"global_rank")] public int? GlobalRank; + [JsonProperty(@"global_rank_percent")] + public float? GlobalRankPercent; + [JsonProperty(@"country_rank")] public int? CountryRank;