diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 61d49e4018..4bcc887b9f 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -160,11 +160,14 @@ namespace osu.Game.Tests.Visual.Playlists Ruleset = { Value = new OsuRuleset().RulesetInfo } })); }); + + AddUntilStep("wait for load", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); } private void waitForDisplay() { AddUntilStep("wait for request to complete", () => requestComplete); + AddUntilStep("wait for panels to be visible", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); AddWaitStep("wait for display", 5); } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index ba6b6bd529..631455b727 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => { @@ -138,7 +138,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddStep("click expanded panel", () => { @@ -177,7 +177,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); ScorePanel expandedPanel = null; ScorePanel contractedPanel = null; @@ -223,6 +223,7 @@ namespace osu.Game.Tests.Visual.Ranking TestResultsScreen screen = null; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); AddAssert("download button is disabled", () => !screen.ChildrenOfType().Last().Enabled.Value); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs index e65dcb19b1..b2585c0509 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -159,6 +159,9 @@ namespace osu.Game.Tests.Visual.Ranking var firstScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); var secondScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + firstScore.User.Username = "A"; + secondScore.User.Username = "B"; + createListStep(() => new ScorePanelList()); AddStep("add scores and select first", () => @@ -168,6 +171,8 @@ namespace osu.Game.Tests.Visual.Ranking list.SelectedScore.Value = firstScore; }); + AddUntilStep("wait for load", () => list.AllPanelsVisible); + assertScoreState(firstScore, true); assertScoreState(secondScore, false); @@ -182,6 +187,22 @@ namespace osu.Game.Tests.Visual.Ranking assertExpandedPanelCentred(); } + [Test] + public void TestAddScoreImmediately() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => + { + var newList = new ScorePanelList { SelectedScore = { Value = score } }; + newList.AddScore(score); + return newList; + }); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + private void createListStep(Func creationFunc) { AddStep("create list", () => Child = list = creationFunc().With(d => diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 184a2e59da..29815ce9ff 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.SongSelect dependencies.Cache(rulesetStore = new RulesetStore(ContextFactory)); dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory, Scheduler)); return dependencies; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 3f9e0048dd..2e30ed9827 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface private BeatmapManager beatmapManager; private ScoreManager scoreManager; - private readonly List scores = new List(); + private readonly List importedScores = new List(); private BeatmapInfo beatmap; [Cached] @@ -82,7 +82,7 @@ namespace osu.Game.Tests.Visual.UserInterface dependencies.Cache(rulesetStore = new RulesetStore(ContextFactory)); dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory, Scheduler)); beatmap = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Beatmaps[0]; @@ -100,11 +100,9 @@ namespace osu.Game.Tests.Visual.UserInterface User = new User { Username = "TestUser" }, }; - scores.Add(scoreManager.Import(score).Result); + importedScores.Add(scoreManager.Import(score).Result); } - scores.Sort(Comparer.Create((s1, s2) => s2.TotalScore.CompareTo(s1.TotalScore))); - return dependencies; } @@ -134,9 +132,14 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestDeleteViaRightClick() { + ScoreInfo scoreBeingDeleted = null; AddStep("open menu for top score", () => { - InputManager.MoveMouseTo(leaderboard.ChildrenOfType().First()); + var leaderboardScore = leaderboard.ChildrenOfType().First(); + + scoreBeingDeleted = leaderboardScore.Score; + + InputManager.MoveMouseTo(leaderboardScore); InputManager.Click(MouseButton.Right); }); @@ -158,14 +161,14 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.Click(MouseButton.Left); }); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); } [Test] public void TestDeleteViaDatabase() { - AddStep("delete top score", () => scoreManager.Delete(scores[0])); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); } } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 934b905a1a..26749a23f9 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -34,6 +34,8 @@ namespace osu.Game.Online.Leaderboards { public const float HEIGHT = 60; + public readonly ScoreInfo Score; + private const float corner_radius = 5; private const float edge_margin = 5; private const float background_alpha = 0.25f; @@ -41,7 +43,6 @@ namespace osu.Game.Online.Leaderboards protected Container RankContainer { get; private set; } - private readonly ScoreInfo score; private readonly int? rank; private readonly bool allowHighlight; @@ -67,7 +68,8 @@ namespace osu.Game.Online.Leaderboards public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { - this.score = score; + Score = score; + this.rank = rank; this.allowHighlight = allowHighlight; @@ -78,9 +80,9 @@ namespace osu.Game.Online.Leaderboards [BackgroundDependencyLoader] private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager) { - var user = score.User; + var user = Score.User; - statisticsLabels = GetStatistics(score).Select(s => new ScoreComponentLabel(s)).ToList(); + statisticsLabels = GetStatistics(Score).Select(s => new ScoreComponentLabel(s)).ToList(); ClickableAvatar innerAvatar; @@ -198,7 +200,7 @@ namespace osu.Game.Online.Leaderboards { TextColour = Color4.White, GlowColour = Color4Extensions.FromHex(@"83ccfa"), - Current = scoreManager.GetBindableTotalScoreString(score), + Current = scoreManager.GetBindableTotalScoreString(Score), Font = OsuFont.Numeric.With(size: 23), }, RankContainer = new Container @@ -206,7 +208,7 @@ namespace osu.Game.Online.Leaderboards Size = new Vector2(40f, 20f), Children = new[] { - scoreRank = new UpdateableRank(score.Rank) + scoreRank = new UpdateableRank(Score.Rank) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -223,7 +225,7 @@ namespace osu.Game.Online.Leaderboards AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(1), - ChildrenEnumerable = score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) + ChildrenEnumerable = Score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) }, }, }, @@ -389,14 +391,14 @@ namespace osu.Game.Online.Leaderboards { List items = new List(); - if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); + if (Score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); - if (score.Files?.Count > 0) - items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(score))); + if (Score.Files?.Count > 0) + items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(Score))); - if (score.ID != 0) - items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + if (Score.ID != 0) + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); return items.ToArray(); } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index c0d220007f..f4db0f2603 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -243,7 +243,7 @@ namespace osu.Game dependencies.Cache(fileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => difficultyCache, LocalConfig)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); // this should likely be moved to ArchiveModelManager when another case appears where it is necessary diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index aff48919b4..fb1769fbe1 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -7,6 +7,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osuTK; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Game.Online.API.Requests.Responses; using osu.Game.Beatmaps; using osu.Game.Online.API; @@ -14,6 +16,7 @@ using osu.Game.Online.API.Requests; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; @@ -42,34 +45,46 @@ namespace osu.Game.Overlays.BeatmapSet.Scores [Resolved] private RulesetStore rulesets { get; set; } + [Resolved] + private ScoreManager scoreManager { get; set; } + private GetScoresRequest getScoresRequest; + private CancellationTokenSource loadCancellationSource; + protected APILegacyScores Scores { set => Schedule(() => { + loadCancellationSource?.Cancel(); + loadCancellationSource = new CancellationTokenSource(); + topScoresContainer.Clear(); + scoreTable.ClearScores(); + scoreTable.Hide(); if (value?.Scores.Any() != true) - { - scoreTable.ClearScores(); - scoreTable.Hide(); return; - } - var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); - var topScore = scoreInfos.First(); + scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => Schedule(() => + { + if (loadCancellationSource.IsCancellationRequested) + return; - scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status.GrantsPerformancePoints() == true); - scoreTable.Show(); + var topScore = ordered.Result.First(); - var userScore = value.UserScore; - var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); + scoreTable.DisplayScores(ordered.Result, topScore.Beatmap?.Status.GrantsPerformancePoints() == true); + scoreTable.Show(); - topScoresContainer.Add(new DrawableTopScore(topScore)); + var userScore = value.UserScore; + var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); - if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) - topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); + topScoresContainer.Add(new DrawableTopScore(topScore)); + + if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) + topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); + }), TaskContinuationOptions.OnlyOnRanToCompletion); }); } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 83bcac01ac..81e701f001 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -13,6 +13,7 @@ using Microsoft.EntityFrameworkCore; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; @@ -36,6 +37,7 @@ namespace osu.Game.Scoring private readonly RulesetStore rulesets; private readonly Func beatmaps; + private readonly Scheduler scheduler; [CanBeNull] private readonly Func difficulties; @@ -43,12 +45,13 @@ namespace osu.Game.Scoring [CanBeNull] private readonly OsuConfigManager configManager; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null, - Func difficulties = null, OsuConfigManager configManager = null) + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, Scheduler scheduler, + IIpcHost importHost = null, Func difficulties = null, OsuConfigManager configManager = null) : base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost) { this.rulesets = rulesets; this.beatmaps = beatmaps; + this.scheduler = scheduler; this.difficulties = difficulties; this.configManager = configManager; } @@ -103,6 +106,32 @@ namespace osu.Game.Scoring => base.CheckLocalAvailability(model, items) || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); + /// + /// Orders an array of s by total score. + /// + /// The array of s to reorder. + /// A to cancel the process. + /// The given ordered by decreasing total score. + public async Task OrderByTotalScoreAsync(ScoreInfo[] scores, CancellationToken cancellationToken = default) + { + var difficultyCache = difficulties?.Invoke(); + + if (difficultyCache != null) + { + // Compute difficulties asynchronously first to prevent blocking via the GetTotalScore() call below. + foreach (var s in scores) + { + await difficultyCache.GetDifficultyAsync(s.Beatmap, s.Ruleset, s.Mods, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + // We're calling .Result, but this should not be a blocking call due to the above GetDifficultyAsync() calls. + return scores.OrderByDescending(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken).Result) + .ThenBy(s => s.OnlineScoreID) + .ToArray(); + } + /// /// Retrieves a bindable that represents the total score of a . /// @@ -111,9 +140,9 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the total score. - public Bindable GetBindableTotalScore(ScoreInfo score) + public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) { - var bindable = new TotalScoreBindable(score, difficulties); + var bindable = new TotalScoreBindable(score, this); configManager?.BindWith(OsuSetting.ScoreDisplayMode, bindable.ScoringMode); return bindable; } @@ -126,7 +155,83 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the formatted total score string. - public Bindable GetBindableTotalScoreString(ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); + public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); + + /// + /// Retrieves the total score of a in the given . + /// The score is returned in a callback that is run on the update thread. + /// + /// The to calculate the total score of. + /// The callback to be invoked with the total score. + /// The to return the total score as. + /// A to cancel the process. + public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + { + GetTotalScoreAsync(score, mode, cancellationToken) + .ContinueWith(s => scheduler.Add(() => callback(s.Result)), TaskContinuationOptions.OnlyOnRanToCompletion); + } + + /// + /// Retrieves the total score of a in the given . + /// + /// The to calculate the total score of. + /// The to return the total score as. + /// A to cancel the process. + /// The total score. + public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + { + if (score.Beatmap == null) + return score.TotalScore; + + int beatmapMaxCombo; + double accuracy = score.Accuracy; + + if (score.IsLegacyScore) + { + if (score.RulesetID == 3) + { + // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. + // To get around this, recalculate accuracy based on the hit statistics. + // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. + double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); + double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); + if (maxBaseScore > 0) + accuracy = baseScore / maxBaseScore; + } + + // This score is guaranteed to be an osu!stable score. + // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. + if (score.Beatmap.MaxCombo != null) + beatmapMaxCombo = score.Beatmap.MaxCombo.Value; + else + { + if (score.Beatmap.ID == 0 || difficulties == null) + { + // We don't have enough information (max combo) to compute the score, so use the provided score. + return score.TotalScore; + } + + // We can compute the max combo locally after the async beatmap difficulty computation. + var difficulty = await difficulties().GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); + beatmapMaxCombo = difficulty.MaxCombo; + } + } + else + { + // This is guaranteed to be a non-legacy score. + // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. + beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); + } + + if (beatmapMaxCombo == 0) + return 0; + + var ruleset = score.Ruleset.CreateInstance(); + var scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.Mods.Value = score.Mods; + + return (long)Math.Round(scoreProcessor.GetScore(mode, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + } /// /// Provides the total score of a . Responds to changes in the currently-selected . @@ -136,99 +241,29 @@ namespace osu.Game.Scoring public readonly Bindable ScoringMode = new Bindable(); private readonly ScoreInfo score; - private readonly Func difficulties; + private readonly ScoreManager scoreManager; + + private CancellationTokenSource difficultyCalculationCancellationSource; /// /// Creates a new . /// /// The to provide the total score of. - /// A function to retrieve the . - public TotalScoreBindable(ScoreInfo score, Func difficulties) + /// The . + public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager) { this.score = score; - this.difficulties = difficulties; + this.scoreManager = scoreManager; ScoringMode.BindValueChanged(onScoringModeChanged, true); } - private IBindable difficultyBindable; - private CancellationTokenSource difficultyCancellationSource; - private void onScoringModeChanged(ValueChangedEvent mode) { - difficultyCancellationSource?.Cancel(); - difficultyCancellationSource = null; + difficultyCalculationCancellationSource?.Cancel(); + difficultyCalculationCancellationSource = new CancellationTokenSource(); - if (score.Beatmap == null) - { - Value = score.TotalScore; - return; - } - - int beatmapMaxCombo; - double accuracy = score.Accuracy; - - if (score.IsLegacyScore) - { - if (score.RulesetID == 3) - { - // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. - // To get around this, recalculate accuracy based on the hit statistics. - // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); - double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); - if (maxBaseScore > 0) - accuracy = baseScore / maxBaseScore; - } - - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. - if (score.Beatmap.MaxCombo == null) - { - if (score.Beatmap.ID == 0 || difficulties == null) - { - // We don't have enough information (max combo) to compute the score, so use the provided score. - Value = score.TotalScore; - return; - } - - // We can compute the max combo locally after the async beatmap difficulty computation. - difficultyBindable = difficulties().GetBindableDifficulty(score.Beatmap, score.Ruleset, score.Mods, (difficultyCancellationSource = new CancellationTokenSource()).Token); - difficultyBindable.BindValueChanged(d => - { - if (d.NewValue is StarDifficulty diff) - updateScore(diff.MaxCombo, accuracy); - }, true); - - return; - } - - beatmapMaxCombo = score.Beatmap.MaxCombo.Value; - } - else - { - // This is guaranteed to be a non-legacy score. - // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); - } - - updateScore(beatmapMaxCombo, accuracy); - } - - private void updateScore(int beatmapMaxCombo, double accuracy) - { - if (beatmapMaxCombo == 0) - { - Value = 0; - return; - } - - var ruleset = score.Ruleset.CreateInstance(); - var scoreProcessor = ruleset.CreateScoreProcessor(); - - scoreProcessor.Mods.Value = score.Mods; - - Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + scoreManager.GetTotalScore(score, s => Value = s, mode.NewValue, difficultyCalculationCancellationSource.Token); } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index 2b252f9db7..89bc659f63 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -32,6 +32,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private ScoreManager scoreManager { get; set; } + public PlaylistsResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true) : base(score, allowRetry, allowWatchingReplay) { @@ -166,23 +169,28 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// An optional pivot around which the scores were retrieved. private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) { - var scoreInfos = new List(scores.Select(s => s.CreateScoreInfo(playlistItem))); + var scoreInfos = scores.Select(s => s.CreateScoreInfo(playlistItem)).ToArray(); - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) + // Score panels calculate total score before displaying, which can take some time. In order to count that calculation as part of the loading spinner display duration, + // calculate the total scores locally before invoking the success callback. + scoreManager.OrderByTotalScoreAsync(scoreInfos).ContinueWith(_ => Schedule(() => { - Schedule(() => + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); - hideLoadingSpinners(pivot); + hideLoadingSpinners(pivot); + })); } private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index d44d1f2cc9..822ee1cf90 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -52,8 +52,7 @@ namespace osu.Game.Screens.Ranking private Drawable bottomPanel; private Container detachedPanelContainer; - private bool fetchedInitialScores; - private APIRequest nextPageRequest; + private bool lastFetchCompleted; private readonly bool allowRetry; private readonly bool allowWatchingReplay; @@ -191,8 +190,10 @@ namespace osu.Game.Screens.Ranking { base.Update(); - if (fetchedInitialScores && nextPageRequest == null) + if (lastFetchCompleted) { + APIRequest nextPageRequest = null; + if (ScorePanelList.IsScrolledToStart) nextPageRequest = FetchNextPage(-1, fetchScoresCallback); else if (ScorePanelList.IsScrolledToEnd) @@ -200,10 +201,7 @@ namespace osu.Game.Screens.Ranking if (nextPageRequest != null) { - // Scheduled after children to give the list a chance to update its scroll position and not potentially trigger a second request too early. - nextPageRequest.Success += () => ScheduleAfterChildren(() => nextPageRequest = null); - nextPageRequest.Failure += _ => ScheduleAfterChildren(() => nextPageRequest = null); - + lastFetchCompleted = false; api.Queue(nextPageRequest); } } @@ -229,7 +227,7 @@ namespace osu.Game.Screens.Ranking foreach (var s in scores) addScore(s); - fetchedInitialScores = true; + lastFetchCompleted = true; }); public override void OnEntering(IScreen last) diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index e170241ede..a847df344f 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -5,10 +5,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Scoring; using osuTK; @@ -36,12 +40,14 @@ namespace osu.Game.Screens.Ranking /// /// Whether this can be scrolled and is currently scrolled to the start. /// - public bool IsScrolledToStart => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; + public bool IsScrolledToStart => flow.Count > 0 && AllPanelsVisible && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; /// /// Whether this can be scrolled and is currently scrolled to the end. /// - public bool IsScrolledToEnd => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + public bool IsScrolledToEnd => flow.Count > 0 && AllPanelsVisible && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + + public bool AllPanelsVisible => flow.All(p => p.IsPresent); /// /// The current scroll position. @@ -60,6 +66,13 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); + [Resolved] + private ScoreManager scoreManager { get; set; } + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; private ScorePanel expandedPanel; @@ -90,6 +103,9 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); + foreach (var d in flow) + displayScore(d); + SelectedScore.BindValueChanged(selectedScoreChanged, true); } @@ -114,36 +130,56 @@ namespace osu.Game.Screens.Ranking }; }); - flow.Add(panel.CreateTrackingContainer().With(d => + var trackingContainer = panel.CreateTrackingContainer().With(d => { d.Anchor = Anchor.Centre; d.Origin = Anchor.Centre; - })); + d.Hide(); + }); + + flow.Add(trackingContainer); if (IsLoaded) - { - if (SelectedScore.Value == score) - { - SelectedScore.TriggerChange(); - } - else - { - // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. - // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. - if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) - { - // A somewhat hacky property is used here because we need to: - // 1) Scroll after the scroll container's visible range is updated. - // 2) Scroll before the scroll container's scroll position is updated. - // Without this, we would have a 1-frame positioning error which looks very jarring. - scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; - } - } - } + displayScore(trackingContainer); return panel; } + private void displayScore(ScorePanelTrackingContainer trackingContainer) + { + if (!IsLoaded) + return; + + var score = trackingContainer.Panel.Score; + + // Calculating score can take a while in extreme scenarios, so only display scores after the process completes. + scoreManager.GetTotalScoreAsync(score) + .ContinueWith(totalScore => Schedule(() => + { + flow.SetLayoutPosition(trackingContainer, totalScore.Result); + + trackingContainer.Show(); + + if (SelectedScore.Value == score) + { + SelectedScore.TriggerChange(); + } + else + { + // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. + // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. + if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) + { + // A somewhat hacky property is used here because we need to: + // 1) Scroll after the scroll container's visible range is updated. + // 2) Scroll before the scroll container's scroll position is updated. + // Without this, we would have a 1-frame positioning error which looks very jarring. + scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; + } + } + }), TaskContinuationOptions.OnlyOnRanToCompletion); + } + /// /// Brings a to the centre of the screen and expands it. /// @@ -267,6 +303,9 @@ namespace osu.Game.Screens.Ranking protected override bool OnKeyDown(KeyDownEvent e) { + if (expandedPanel == null) + return base.OnKeyDown(e); + var expandedPanelIndex = flow.GetPanelIndex(expandedPanel.Score); switch (e.Key) @@ -285,6 +324,12 @@ namespace osu.Game.Screens.Ranking return base.OnKeyDown(e); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + loadCancellationSource?.Cancel(); + } + private class Flow : FillFlowContainer { public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); @@ -292,24 +337,8 @@ namespace osu.Game.Screens.Ranking public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count(); private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() - .OrderByDescending(s => s.Panel.Score.TotalScore) + .OrderByDescending(GetLayoutPosition) .ThenBy(s => s.Panel.Score.OnlineScoreID); - - protected override int Compare(Drawable x, Drawable y) - { - var tX = (ScorePanelTrackingContainer)x; - var tY = (ScorePanelTrackingContainer)y; - - int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore); - - if (result != 0) - return result; - - if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null) - return base.Compare(x, y); - - return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value); - } } private class Scroll : OsuScrollContainer diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index a86a614a05..7820264505 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -66,6 +68,9 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + [Resolved] private IBindable ruleset { get; set; } @@ -120,8 +125,13 @@ namespace osu.Game.Screens.Select.Leaderboards protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; + private CancellationTokenSource loadCancellationSource; + protected override APIRequest FetchScores(Action> scoresCallback) { + loadCancellationSource?.Cancel(); + loadCancellationSource = new CancellationTokenSource(); + if (Beatmap == null) { PlaceholderState = PlaceholderState.NoneSelected; @@ -146,8 +156,8 @@ namespace osu.Game.Screens.Select.Leaderboards scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym))); } - Scores = scores.OrderByDescending(s => s.TotalScore).ToArray(); - PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores; + scoreManager.OrderByTotalScoreAsync(scores.ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => scoresCallback?.Invoke(ordered.Result), TaskContinuationOptions.OnlyOnRanToCompletion); return null; } @@ -182,8 +192,15 @@ namespace osu.Game.Screens.Select.Leaderboards req.Success += r => { - scoresCallback?.Invoke(r.Scores.Select(s => s.CreateScoreInfo(rulesets))); - TopScore = r.UserScore?.CreateScoreInfo(rulesets); + scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToArray(), loadCancellationSource.Token) + .ContinueWith(ordered => Schedule(() => + { + if (loadCancellationSource.IsCancellationRequested) + return; + + scoresCallback?.Invoke(ordered.Result); + TopScore = r.UserScore?.CreateScoreInfo(rulesets); + }), TaskContinuationOptions.OnlyOnRanToCompletion); }; return req;