diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs index 0023866124..628d08a314 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs @@ -3,14 +3,24 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Threading.Tasks; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Multi.Ranking; +using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; using osu.Game.Users; @@ -18,43 +28,134 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneTimeshiftResultsScreen : ScreenTestScene { - private bool roomsReceived; + private const int scores_per_result = 10; + + private TestResultsScreen resultsScreen; + private int currentScoreId; + private bool requestComplete; [SetUp] public void Setup() => Schedule(() => { - roomsReceived = false; + currentScoreId = 0; + requestComplete = false; bindHandler(); }); [Test] - public void TestShowResultsWithScore() + public void TestShowWithUserScore() { - createResults(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - AddWaitStep("wait for display", 5); + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); } [Test] - public void TestShowResultsNullScore() + public void TestShowNullUserScore() { - createResults(null); - AddWaitStep("wait for display", 5); + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } [Test] - public void TestShowResultsNullScoreWithDelay() + public void TestShowUserScoreWithDelay() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(3000, userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + } + + [Test] + public void TestShowNullUserScoreWithDelay() { AddStep("bind delayed handler", () => bindHandler(3000)); - createResults(null); - AddUntilStep("wait for rooms to be received", () => roomsReceived); - AddWaitStep("wait for display", 5); + + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } - private void createResults(ScoreInfo score) + [Test] + public void TestFetchWhenScrolledToTheRight() + { + createResults(); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + } + } + + [Test] + public void TestFetchWhenScrolledToTheLeft() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to left", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToStart(false)); + + AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); + } + } + + private void createResults(Func getScore = null) { AddStep("load results", () => { - LoadScreen(new TimeshiftResultsScreen(score, 1, new PlaylistItem + LoadScreen(resultsScreen = new TestResultsScreen(getScore?.Invoke(), 1, new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo } @@ -62,62 +163,213 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private void bindHandler(double delay = 0) + private void waitForDisplay() { - var roomScores = new List(); + AddUntilStep("wait for request to complete", () => requestComplete); + AddWaitStep("wait for display", 5); + } - for (int i = 0; i < 10; i++) + private void bindHandler(double delay = 0, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => + { + requestComplete = false; + + if (failRequests) { - roomScores.Add(new MultiplayerScore + triggerFail(request, delay); + return; + } + + switch (request) + { + case ShowPlaylistUserScoreRequest s: + if (userScore == null) + triggerFail(s, delay); + else + triggerSuccess(s, createUserResponse(userScore), delay); + break; + + case IndexPlaylistScoresRequest i: + triggerSuccess(i, createIndexResponse(i), delay); + break; + } + }; + + private void triggerSuccess(APIRequest req, T result, double delay) + where T : class + { + if (delay == 0) + success(); + else + { + Task.Run(async () => { - ID = i, - Accuracy = 0.9 - 0.01 * i, - EndedAt = DateTimeOffset.Now.Subtract(TimeSpan.FromHours(i)), + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + Schedule(success); + }); + } + + void success() + { + requestComplete = true; + req.TriggerSuccess(result); + } + } + + private void triggerFail(APIRequest req, double delay) + { + if (delay == 0) + fail(); + else + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + Schedule(fail); + }); + } + + void fail() + { + requestComplete = true; + req.TriggerFailure(new WebException("Failed.")); + } + } + + private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) + { + var multiplayerUserScore = new MultiplayerScore + { + ID = (int)(userScore.OnlineScoreID ?? currentScoreId++), + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = userScore.Passed, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore, + User = userScore.User, + Statistics = userScore.Statistics, + ScoresAround = new MultiplayerScoresAround + { + Higher = new MultiplayerScores(), + Lower = new MultiplayerScores() + } + }; + + for (int i = 1; i <= scores_per_result; i++) + { + multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, Passed = true, - Rank = ScoreRank.B, - MaxCombo = 999, - TotalScore = 999999 - i * 1000, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore - i, User = new User { Id = 2, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, - Statistics = + Statistics = userScore.Statistics + }); + + multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = true, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore + i, + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = userScore.Statistics + }); + } + + addCursor(multiplayerUserScore.ScoresAround.Lower); + addCursor(multiplayerUserScore.ScoresAround.Higher); + + return multiplayerUserScore; + } + + private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req) + { + var result = new IndexedMultiplayerScores(); + + long startTotalScore = req.Cursor?.Properties["total_score"].ToObject() ?? 1000000; + string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; + + for (int i = 1; i <= scores_per_result; i++) + { + result.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.X, + MaxCombo = 1000, + TotalScore = startTotalScore + (sort == "score_asc" ? i : -i), + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = new Dictionary { { HitResult.Miss, 1 }, { HitResult.Meh, 50 }, { HitResult.Good, 100 }, - { HitResult.Great, 300 }, + { HitResult.Great, 300 } } }); } - ((DummyAPIAccess)API).HandleRequest = request => + addCursor(result); + + return result; + } + + private void addCursor(MultiplayerScores scores) + { + scores.Cursor = new Cursor { - switch (request) + Properties = new Dictionary { - case GetRoomPlaylistScoresRequest r: - if (delay == 0) - success(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(success); - }); - } + { "total_score", JToken.FromObject(scores.Scores[^1].TotalScore) }, + { "score_id", JToken.FromObject(scores.Scores[^1].ID) }, + } + }; - void success() - { - r.TriggerSuccess(new RoomPlaylistScores { Scores = roomScores }); - roomsReceived = true; - } - - break; + scores.Params = new IndexScoresParams + { + Properties = new Dictionary + { + { "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") } } }; } + + private class TestResultsScreen : TimeshiftResultsScreen + { + public new LoadingSpinner LeftSpinner => base.LeftSpinner; + public new LoadingSpinner CentreSpinner => base.CentreSpinner; + public new LoadingSpinner RightSpinner => base.RightSpinner; + public new ScorePanelList ScorePanelList => base.ScorePanelList; + + public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + : base(score, roomId, playlistItem, allowRetry) + { + } + } } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 2115326cc2..6912d9b629 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -136,6 +136,11 @@ namespace osu.Game.Online.API Success?.Invoke(); } + internal void TriggerFailure(Exception e) + { + Failure?.Invoke(e); + } + public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) @@ -166,7 +171,7 @@ namespace osu.Game.Online.API } Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); - pendingFailure = () => Failure?.Invoke(e); + pendingFailure = () => TriggerFailure(e); checkAndScheduleFailure(); } diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs index f21445ca32..3de8db770c 100644 --- a/osu.Game/Online/API/Requests/Cursor.cs +++ b/osu.Game/Online/API/Requests/Cursor.cs @@ -15,6 +15,6 @@ namespace osu.Game.Online.API.Requests { [UsedImplicitly] [JsonExtensionData] - public IDictionary Properties; + public IDictionary Properties { get; set; } = new Dictionary(); } } diff --git a/osu.Game/Online/Multiplayer/GetRoomPlaylistScoresRequest.cs b/osu.Game/Online/Multiplayer/GetRoomPlaylistScoresRequest.cs deleted file mode 100644 index 3d3bd20ff3..0000000000 --- a/osu.Game/Online/Multiplayer/GetRoomPlaylistScoresRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using Newtonsoft.Json; -using osu.Game.Online.API; - -namespace osu.Game.Online.Multiplayer -{ - public class GetRoomPlaylistScoresRequest : APIRequest - { - private readonly int roomId; - private readonly int playlistItemId; - - public GetRoomPlaylistScoresRequest(int roomId, int playlistItemId) - { - this.roomId = roomId; - this.playlistItemId = playlistItemId; - } - - protected override string Target => $@"rooms/{roomId}/playlist/{playlistItemId}/scores"; - } - - public class RoomPlaylistScores - { - [JsonProperty("scores")] - public List Scores { get; set; } - } -} diff --git a/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs new file mode 100644 index 0000000000..684d0aecd8 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using JetBrains.Annotations; +using osu.Framework.IO.Network; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// Returns a list of scores for the specified playlist item. + /// + public class IndexPlaylistScoresRequest : APIRequest + { + public readonly int RoomId; + public readonly int PlaylistItemId; + + [CanBeNull] + public readonly Cursor Cursor; + + [CanBeNull] + public readonly IndexScoresParams IndexParams; + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId) + { + RoomId = roomId; + PlaylistItemId = playlistItemId; + } + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) + : this(roomId, playlistItemId) + { + Cursor = cursor; + IndexParams = indexParams; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (Cursor != null) + { + Debug.Assert(IndexParams != null); + + req.AddCursor(Cursor); + + foreach (var (key, value) in IndexParams.Properties) + req.AddParameter(key, value.ToString()); + } + + return req; + } + + protected override string Target => $@"rooms/{RoomId}/playlist/{PlaylistItemId}/scores"; + } +} diff --git a/osu.Game/Online/Multiplayer/IndexScoresParams.cs b/osu.Game/Online/Multiplayer/IndexScoresParams.cs new file mode 100644 index 0000000000..a511e9a780 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexScoresParams.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A collection of parameters which should be passed to the index endpoint to fetch the next page. + /// + public class IndexScoresParams + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary Properties { get; set; } = new Dictionary(); + } +} diff --git a/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs new file mode 100644 index 0000000000..e237b7e3fb --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A object returned via a . + /// + public class IndexedMultiplayerScores : MultiplayerScores + { + /// + /// The total scores in the playlist item. + /// + [JsonProperty("total")] + public int? TotalScores { get; set; } + + /// + /// The user's score, if any. + /// + [JsonProperty("user_score")] + [CanBeNull] + public MultiplayerScore UserScore { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerScore.cs b/osu.Game/Online/Multiplayer/MultiplayerScore.cs index 3bbf19b11f..1793ba72ef 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerScore.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerScore.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using osu.Game.Online.API; @@ -47,6 +48,19 @@ namespace osu.Game.Online.Multiplayer [JsonProperty("ended_at")] public DateTimeOffset EndedAt { get; set; } + /// + /// The position of this score, starting at 1. + /// + [JsonProperty("position")] + public int? Position { get; set; } + + /// + /// Any scores in the room around this score. + /// + [JsonProperty("scores_around")] + [CanBeNull] + public MultiplayerScoresAround ScoresAround { get; set; } + public ScoreInfo CreateScoreInfo(PlaylistItem playlistItem) { var rulesetInstance = playlistItem.Ruleset.Value.CreateInstance(); diff --git a/osu.Game/Online/Multiplayer/MultiplayerScores.cs b/osu.Game/Online/Multiplayer/MultiplayerScores.cs new file mode 100644 index 0000000000..7b9dcff828 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScores.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// An object which contains scores and related data for fetching next pages. + /// + public class MultiplayerScores : ResponseWithCursor + { + /// + /// The scores. + /// + [JsonProperty("scores")] + public List Scores { get; set; } = new List(); + + /// + /// The parameters to be used to fetch the next page. + /// + [JsonProperty("params")] + public IndexScoresParams Params { get; set; } = new IndexScoresParams(); + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs new file mode 100644 index 0000000000..2ac62d0300 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// An object which stores scores higher and lower than the user's score. + /// + public class MultiplayerScoresAround + { + /// + /// Scores sorted "higher" than the user's score, depending on the sorting order. + /// + [JsonProperty("higher")] + [CanBeNull] + public MultiplayerScores Higher { get; set; } + + /// + /// Scores sorted "lower" than the user's score, depending on the sorting order. + /// + [JsonProperty("lower")] + [CanBeNull] + public MultiplayerScores Lower { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs new file mode 100644 index 0000000000..936b8bbe89 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Multiplayer +{ + public class ShowPlaylistUserScoreRequest : APIRequest + { + private readonly int roomId; + private readonly int playlistItemId; + private readonly long userId; + + public ShowPlaylistUserScoreRequest(int roomId, int playlistItemId, long userId) + { + this.roomId = roomId; + this.playlistItemId = playlistItemId; + this.userId = userId; + } + + protected override string Target => $"rooms/{roomId}/playlist/{playlistItemId}/scores/users/{userId}"; + } +} diff --git a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs index f367d44347..e212bd4a82 100644 --- a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs +++ b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,7 +22,15 @@ namespace osu.Game.Screens.Multi.Ranking private readonly int roomId; private readonly PlaylistItem playlistItem; - private LoadingSpinner loadingLayer; + protected LoadingSpinner LeftSpinner { get; private set; } + protected LoadingSpinner CentreSpinner { get; private set; } + protected LoadingSpinner RightSpinner { get; private set; } + + private MultiplayerScores higherScores; + private MultiplayerScores lowerScores; + + [Resolved] + private IAPIProvider api { get; set; } public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) : base(score, allowRetry) @@ -32,29 +42,173 @@ namespace osu.Game.Screens.Multi.Ranking [BackgroundDependencyLoader] private void load() { - AddInternal(loadingLayer = new LoadingLayer + AddInternal(new Container { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - X = -10, - State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, - Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y } + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y }, + Children = new Drawable[] + { + LeftSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + }, + CentreSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, + }, + RightSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + }, + } }); } protected override APIRequest FetchScores(Action> scoresCallback) { - var req = new GetRoomPlaylistScoresRequest(roomId, playlistItem.ID); + // This performs two requests: + // 1. A request to show the user's score (and scores around). + // 2. If that fails, a request to index the room starting from the highest score. - req.Success += r => + var userScoreReq = new ShowPlaylistUserScoreRequest(roomId, playlistItem.ID, api.LocalUser.Value.Id); + + userScoreReq.Success += userScore => { - scoresCallback?.Invoke(r.Scores.Where(s => s.ID != Score?.OnlineScoreID).Select(s => s.CreateScoreInfo(playlistItem))); - loadingLayer.Hide(); + var allScores = new List { userScore }; + + if (userScore.ScoresAround?.Higher != null) + { + allScores.AddRange(userScore.ScoresAround.Higher.Scores); + higherScores = userScore.ScoresAround.Higher; + } + + if (userScore.ScoresAround?.Lower != null) + { + allScores.AddRange(userScore.ScoresAround.Lower.Scores); + lowerScores = userScore.ScoresAround.Lower; + } + + performSuccessCallback(scoresCallback, allScores); }; - req.Failure += _ => loadingLayer.Hide(); + // On failure, fallback to a normal index. + userScoreReq.Failure += _ => api.Queue(createIndexRequest(scoresCallback)); - return req; + return userScoreReq; + } + + protected override APIRequest FetchNextPage(int direction, Action> scoresCallback) + { + Debug.Assert(direction == 1 || direction == -1); + + MultiplayerScores pivot = direction == -1 ? higherScores : lowerScores; + + if (pivot?.Cursor == null) + return null; + + if (pivot == higherScores) + LeftSpinner.Show(); + else + RightSpinner.Show(); + + return createIndexRequest(scoresCallback, pivot); + } + + /// + /// Creates a with an optional score pivot. + /// + /// Does not queue the request. + /// The callback to perform with the resulting scores. + /// An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score. + /// The indexing . + private APIRequest createIndexRequest(Action> scoresCallback, [CanBeNull] MultiplayerScores pivot = null) + { + var indexReq = pivot != null + ? new IndexPlaylistScoresRequest(roomId, playlistItem.ID, pivot.Cursor, pivot.Params) + : new IndexPlaylistScoresRequest(roomId, playlistItem.ID); + + indexReq.Success += r => + { + if (pivot == lowerScores) + lowerScores = r; + else + higherScores = r; + + performSuccessCallback(scoresCallback, r.Scores, r); + }; + + indexReq.Failure += _ => hideLoadingSpinners(pivot); + + return indexReq; + } + + /// + /// Transforms returned into s, ensure the is put into a sane state, and invokes a given success callback. + /// + /// The callback to invoke with the final s. + /// The s that were retrieved from s. + /// 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))); + + // 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) + { + 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)); + + hideLoadingSpinners(pivot); + } + + private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) + { + CentreSpinner.Hide(); + + if (pivot == lowerScores) + RightSpinner.Hide(); + else if (pivot == higherScores) + LeftSpinner.Hide(); + } + + private class PanelListLoadingSpinner : LoadingSpinner + { + private readonly ScorePanelList list; + + /// + /// Creates a new . + /// + /// The list to track. + /// Whether the spinner should have a surrounding black box for visibility. + public PanelListLoadingSpinner(ScorePanelList list, bool withBox = true) + : base(withBox) + { + this.list = list; + } + + protected override void Update() + { + base.Update(); + + float panelOffset = list.DrawWidth / 2 - ScorePanel.EXPANDED_WIDTH; + + if ((Anchor & Anchor.x0) > 0) + X = (float)(panelOffset - list.Current); + else if ((Anchor & Anchor.x2) > 0) + X = (float)(list.ScrollableExtent - list.Current - panelOffset); + } } } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 44458d8c8e..c95cf1066e 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -37,7 +37,8 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); public readonly ScoreInfo Score; - private readonly bool allowRetry; + + protected ScorePanelList ScorePanelList { get; private set; } [Resolved(CanBeNull = true)] private Player player { get; set; } @@ -47,9 +48,13 @@ namespace osu.Game.Screens.Ranking private StatisticsPanel statisticsPanel; private Drawable bottomPanel; - private ScorePanelList scorePanelList; private Container detachedPanelContainer; + private bool fetchedInitialScores; + private APIRequest nextPageRequest; + + private readonly bool allowRetry; + protected ResultsScreen(ScoreInfo score, bool allowRetry = true) { Score = score; @@ -84,7 +89,7 @@ namespace osu.Game.Screens.Ranking RelativeSizeAxes = Axes.Both, Score = { BindTarget = SelectedScore } }, - scorePanelList = new ScorePanelList + ScorePanelList = new ScorePanelList { RelativeSizeAxes = Axes.Both, SelectedScore = { BindTarget = SelectedScore }, @@ -142,7 +147,7 @@ namespace osu.Game.Screens.Ranking }; if (Score != null) - scorePanelList.AddScore(Score); + ScorePanelList.AddScore(Score); if (player != null && allowRetry) { @@ -164,11 +169,7 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - var req = FetchScores(scores => Schedule(() => - { - foreach (var s in scores) - addScore(s); - })); + var req = FetchScores(fetchScoresCallback); if (req != null) api.Queue(req); @@ -176,6 +177,28 @@ namespace osu.Game.Screens.Ranking statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); } + protected override void Update() + { + base.Update(); + + if (fetchedInitialScores && nextPageRequest == null) + { + if (ScorePanelList.IsScrolledToStart) + nextPageRequest = FetchNextPage(-1, fetchScoresCallback); + else if (ScorePanelList.IsScrolledToEnd) + nextPageRequest = FetchNextPage(1, fetchScoresCallback); + + 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); + + api.Queue(nextPageRequest); + } + } + } + /// /// Performs a fetch/refresh of scores to be displayed. /// @@ -183,6 +206,22 @@ namespace osu.Game.Screens.Ranking /// An responsible for the fetch operation. This will be queued and performed automatically. protected virtual APIRequest FetchScores(Action> scoresCallback) => null; + /// + /// Performs a fetch of the next page of scores. This is invoked every frame until a non-null is returned. + /// + /// The fetch direction. -1 to fetch scores greater than the current start of the list, and 1 to fetch scores lower than the current end of the list. + /// A callback which should be called when fetching is completed. Scheduling is not required. + /// An responsible for the fetch operation. This will be queued and performed automatically. + protected virtual APIRequest FetchNextPage(int direction, Action> scoresCallback) => null; + + private void fetchScoresCallback(IEnumerable scores) => Schedule(() => + { + foreach (var s in scores) + addScore(s); + + fetchedInitialScores = true; + }); + public override void OnEntering(IScreen last) { base.OnEntering(last); @@ -213,7 +252,7 @@ namespace osu.Game.Screens.Ranking private void addScore(ScoreInfo score) { - var panel = scorePanelList.AddScore(score); + var panel = ScorePanelList.AddScore(score); if (detachedPanel != null) panel.Alpha = 0; @@ -226,11 +265,11 @@ namespace osu.Game.Screens.Ranking if (state.NewValue == Visibility.Visible) { // Detach the panel in its original location, and move into the desired location in the local container. - var expandedPanel = scorePanelList.GetPanelForScore(SelectedScore.Value); + var expandedPanel = ScorePanelList.GetPanelForScore(SelectedScore.Value); var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft; // Detach and move into the local container. - scorePanelList.Detach(expandedPanel); + ScorePanelList.Detach(expandedPanel); detachedPanelContainer.Add(expandedPanel); // Move into its original location in the local container first, then to the final location. @@ -240,9 +279,9 @@ namespace osu.Game.Screens.Ranking .MoveTo(new Vector2(StatisticsPanel.SIDE_PADDING, origLocation.Y), 150, Easing.OutQuint); // Hide contracted panels. - foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) contracted.FadeOut(150, Easing.OutQuint); - scorePanelList.HandleInput = false; + ScorePanelList.HandleInput = false; // Dim background. Background.FadeTo(0.1f, 150); @@ -255,7 +294,7 @@ namespace osu.Game.Screens.Ranking // Remove from the local container and re-attach. detachedPanelContainer.Remove(detachedPanel); - scorePanelList.Attach(detachedPanel); + ScorePanelList.Attach(detachedPanel); // Move into its original location in the attached container first, then to the final location. var origLocation = detachedPanel.Parent.ToLocalSpace(screenSpacePos); @@ -264,9 +303,9 @@ namespace osu.Game.Screens.Ranking .MoveTo(new Vector2(0, origLocation.Y), 150, Easing.OutQuint); // Show contracted panels. - foreach (var contracted in scorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) contracted.FadeIn(150, Easing.OutQuint); - scorePanelList.HandleInput = true; + ScorePanelList.HandleInput = true; // Un-dim background. Background.FadeTo(0.5f, 150); diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 0f8bc82ac0..10bd99c8ce 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -26,6 +26,31 @@ namespace osu.Game.Screens.Ranking /// private const float expanded_panel_spacing = 15; + /// + /// Minimum distance from either end point of the list that the list can be considered scrolled to the end point. + /// + private const float scroll_endpoint_distance = 100; + + /// + /// 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; + + /// + /// 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); + + /// + /// The current scroll position. + /// + public double Current => scroll.Current; + + /// + /// The scrollable extent. + /// + public double ScrollableExtent => scroll.ScrollableExtent; + /// /// An action to be invoked if a is clicked while in an expanded state. ///