Merge pull request #9706 from smoogipoo/multi-pagination

This commit is contained in:
Dean Herbert
2020-07-31 23:08:34 +09:00
committed by GitHub
14 changed files with 751 additions and 107 deletions

View File

@ -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();
}

View File

@ -15,6 +15,6 @@ namespace osu.Game.Online.API.Requests
{
[UsedImplicitly]
[JsonExtensionData]
public IDictionary<string, JToken> Properties;
public IDictionary<string, JToken> Properties { get; set; } = new Dictionary<string, JToken>();
}
}

View File

@ -1,29 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<RoomPlaylistScores>
{
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<MultiplayerScore> Scores { get; set; }
}
}

View File

@ -0,0 +1,59 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Returns a list of scores for the specified playlist item.
/// </summary>
public class IndexPlaylistScoresRequest : APIRequest<IndexedMultiplayerScores>
{
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";
}
}

View File

@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A collection of parameters which should be passed to the index endpoint to fetch the next page.
/// </summary>
public class IndexScoresParams
{
[UsedImplicitly]
[JsonExtensionData]
public IDictionary<string, JToken> Properties { get; set; } = new Dictionary<string, JToken>();
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A <see cref="MultiplayerScores"/> object returned via a <see cref="IndexPlaylistScoresRequest"/>.
/// </summary>
public class IndexedMultiplayerScores : MultiplayerScores
{
/// <summary>
/// The total scores in the playlist item.
/// </summary>
[JsonProperty("total")]
public int? TotalScores { get; set; }
/// <summary>
/// The user's score, if any.
/// </summary>
[JsonProperty("user_score")]
[CanBeNull]
public MultiplayerScore UserScore { get; set; }
}
}

View File

@ -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; }
/// <summary>
/// The position of this score, starting at 1.
/// </summary>
[JsonProperty("position")]
public int? Position { get; set; }
/// <summary>
/// Any scores in the room around this score.
/// </summary>
[JsonProperty("scores_around")]
[CanBeNull]
public MultiplayerScoresAround ScoresAround { get; set; }
public ScoreInfo CreateScoreInfo(PlaylistItem playlistItem)
{
var rulesetInstance = playlistItem.Ruleset.Value.CreateInstance();

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// An object which contains scores and related data for fetching next pages.
/// </summary>
public class MultiplayerScores : ResponseWithCursor
{
/// <summary>
/// The scores.
/// </summary>
[JsonProperty("scores")]
public List<MultiplayerScore> Scores { get; set; } = new List<MultiplayerScore>();
/// <summary>
/// The parameters to be used to fetch the next page.
/// </summary>
[JsonProperty("params")]
public IndexScoresParams Params { get; set; } = new IndexScoresParams();
}
}

View File

@ -0,0 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// An object which stores scores higher and lower than the user's score.
/// </summary>
public class MultiplayerScoresAround
{
/// <summary>
/// Scores sorted "higher" than the user's score, depending on the sorting order.
/// </summary>
[JsonProperty("higher")]
[CanBeNull]
public MultiplayerScores Higher { get; set; }
/// <summary>
/// Scores sorted "lower" than the user's score, depending on the sorting order.
/// </summary>
[JsonProperty("lower")]
[CanBeNull]
public MultiplayerScores Lower { get; set; }
}
}

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<MultiplayerScore>
{
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}";
}
}

View File

@ -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<IEnumerable<ScoreInfo>> 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<MultiplayerScore> { 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<IEnumerable<ScoreInfo>> 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);
}
/// <summary>
/// Creates a <see cref="IndexPlaylistScoresRequest"/> with an optional score pivot.
/// </summary>
/// <remarks>Does not queue the request.</remarks>
/// <param name="scoresCallback">The callback to perform with the resulting scores.</param>
/// <param name="pivot">An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score.</param>
/// <returns>The indexing <see cref="APIRequest"/>.</returns>
private APIRequest createIndexRequest(Action<IEnumerable<ScoreInfo>> 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;
}
/// <summary>
/// Transforms returned <see cref="MultiplayerScores"/> into <see cref="ScoreInfo"/>s, ensure the <see cref="ScorePanelList"/> is put into a sane state, and invokes a given success callback.
/// </summary>
/// <param name="callback">The callback to invoke with the final <see cref="ScoreInfo"/>s.</param>
/// <param name="scores">The <see cref="MultiplayerScore"/>s that were retrieved from <see cref="APIRequest"/>s.</param>
/// <param name="pivot">An optional pivot around which the scores were retrieved.</param>
private void performSuccessCallback([NotNull] Action<IEnumerable<ScoreInfo>> callback, [NotNull] List<MultiplayerScore> scores, [CanBeNull] MultiplayerScores pivot = null)
{
var scoreInfos = new List<ScoreInfo>(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;
/// <summary>
/// Creates a new <see cref="PanelListLoadingSpinner"/>.
/// </summary>
/// <param name="list">The list to track.</param>
/// <param name="withBox">Whether the spinner should have a surrounding black box for visibility.</param>
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);
}
}
}
}

View File

@ -37,7 +37,8 @@ namespace osu.Game.Screens.Ranking
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
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<ScorePanel> 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);
}
}
}
/// <summary>
/// Performs a fetch/refresh of scores to be displayed.
/// </summary>
@ -183,6 +206,22 @@ namespace osu.Game.Screens.Ranking
/// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns>
protected virtual APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback) => null;
/// <summary>
/// Performs a fetch of the next page of scores. This is invoked every frame until a non-null <see cref="APIRequest"/> is returned.
/// </summary>
/// <param name="direction">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.</param>
/// <param name="scoresCallback">A callback which should be called when fetching is completed. Scheduling is not required.</param>
/// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns>
protected virtual APIRequest FetchNextPage(int direction, Action<IEnumerable<ScoreInfo>> scoresCallback) => null;
private void fetchScoresCallback(IEnumerable<ScoreInfo> 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);

View File

@ -26,6 +26,31 @@ namespace osu.Game.Screens.Ranking
/// </summary>
private const float expanded_panel_spacing = 15;
/// <summary>
/// Minimum distance from either end point of the list that the list can be considered scrolled to the end point.
/// </summary>
private const float scroll_endpoint_distance = 100;
/// <summary>
/// Whether this <see cref="ScorePanelList"/> can be scrolled and is currently scrolled to the start.
/// </summary>
public bool IsScrolledToStart => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance;
/// <summary>
/// Whether this <see cref="ScorePanelList"/> can be scrolled and is currently scrolled to the end.
/// </summary>
public bool IsScrolledToEnd => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance);
/// <summary>
/// The current scroll position.
/// </summary>
public double Current => scroll.Current;
/// <summary>
/// The scrollable extent.
/// </summary>
public double ScrollableExtent => scroll.ScrollableExtent;
/// <summary>
/// An action to be invoked if a <see cref="ScorePanel"/> is clicked while in an expanded state.
/// </summary>