Merge pull request #16703 from peppy/leaderboard-component-fixes

Rewrite `Leaderboard` component to bring up to current code standards
This commit is contained in:
Dan Balasescu 2022-01-31 14:33:04 +09:00 committed by GitHub
commit e9f3e7f5cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 321 additions and 375 deletions

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -100,7 +101,7 @@ namespace osu.Game.Tests.Visual.SongSelect
public void TestGlobalScoresDisplay() public void TestGlobalScoresDisplay()
{ {
AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Global); AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Global);
AddStep(@"New Scores", () => leaderboard.Scores = generateSampleScores(null)); AddStep(@"New Scores", () => leaderboard.SetScores(generateSampleScores(new BeatmapInfo())));
} }
[Test] [Test]
@ -113,24 +114,18 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test] [Test]
public void TestPlaceholderStates() public void TestPlaceholderStates()
{ {
AddStep(@"Empty Scores", () => leaderboard.SetRetrievalState(PlaceholderState.NoScores)); AddStep("ensure no scores displayed", () => leaderboard.SetScores(null));
AddStep(@"Network failure", () => leaderboard.SetRetrievalState(PlaceholderState.NetworkFailure));
AddStep(@"No supporter", () => leaderboard.SetRetrievalState(PlaceholderState.NotSupporter));
AddStep(@"Not logged in", () => leaderboard.SetRetrievalState(PlaceholderState.NotLoggedIn));
AddStep(@"Unavailable", () => leaderboard.SetRetrievalState(PlaceholderState.Unavailable));
AddStep(@"None selected", () => leaderboard.SetRetrievalState(PlaceholderState.NoneSelected));
}
[Test] AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure));
public void TestBeatmapStates() AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter));
{ AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn));
foreach (BeatmapOnlineStatus status in Enum.GetValues(typeof(BeatmapOnlineStatus))) AddStep(@"Unavailable", () => leaderboard.SetErrorState(LeaderboardState.Unavailable));
AddStep($"{status} beatmap", () => showBeatmapWithStatus(status)); AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected));
} }
private void showPersonalBestWithNullPosition() private void showPersonalBestWithNullPosition()
{ {
leaderboard.TopScore = new ScoreInfo leaderboard.SetScores(leaderboard.Scores, new ScoreInfo
{ {
Rank = ScoreRank.XH, Rank = ScoreRank.XH,
Accuracy = 1, Accuracy = 1,
@ -148,12 +143,12 @@ namespace osu.Game.Tests.Visual.SongSelect
FlagName = @"ES", FlagName = @"ES",
}, },
}, },
}; });
} }
private void showPersonalBest() private void showPersonalBest()
{ {
leaderboard.TopScore = new ScoreInfo leaderboard.SetScores(leaderboard.Scores, new ScoreInfo
{ {
Position = 999, Position = 999,
Rank = ScoreRank.XH, Rank = ScoreRank.XH,
@ -172,7 +167,7 @@ namespace osu.Game.Tests.Visual.SongSelect
FlagName = @"ES", FlagName = @"ES",
}, },
}, },
}; });
} }
private void loadMoreScores(Func<BeatmapInfo> beatmapInfo) private void loadMoreScores(Func<BeatmapInfo> beatmapInfo)
@ -407,21 +402,10 @@ namespace osu.Game.Tests.Visual.SongSelect
}; };
} }
private void showBeatmapWithStatus(BeatmapOnlineStatus status)
{
leaderboard.BeatmapInfo = new BeatmapInfo
{
OnlineID = 1113057,
Status = status,
};
}
private class FailableLeaderboard : BeatmapLeaderboard private class FailableLeaderboard : BeatmapLeaderboard
{ {
public void SetRetrievalState(PlaceholderState state) public new void SetErrorState(LeaderboardState state) => base.SetErrorState(state);
{ public new void SetScores(IEnumerable<ScoreInfo> scores, ScoreInfo userScore = default) => base.SetScores(scores, userScore);
PlaceholderState = state;
}
} }
} }
} }

View File

@ -128,21 +128,16 @@ namespace osu.Game.Tests.Visual.UserInterface
scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList()); scoreManager.Undelete(r.All<ScoreInfo>().Where(s => s.DeletePending).ToList());
}); });
leaderboard.Scores = null;
leaderboard.FinishTransforms(true); // After setting scores, we may be waiting for transforms to expire drawables
leaderboard.BeatmapInfo = beatmapInfo; leaderboard.BeatmapInfo = beatmapInfo;
leaderboard.RefreshScores(); // Required in the case that the beatmap hasn't changed leaderboard.RefetchScores(); // Required in the case that the beatmap hasn't changed
}); });
[SetUpSteps] [SetUpSteps]
public void SetupSteps() public void SetupSteps()
{ {
// Ensure the leaderboard has finished async-loading drawables
AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any());
// Ensure the leaderboard items have finished showing up // Ensure the leaderboard items have finished showing up
AddStep("finish transforms", () => leaderboard.FinishTransforms(true)); AddStep("finish transforms", () => leaderboard.FinishTransforms(true));
AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType<LeaderboardScore>().Any());
} }
[Test] [Test]

View File

@ -3,16 +3,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Threading;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -23,98 +25,48 @@ using osuTK.Graphics;
namespace osu.Game.Online.Leaderboards namespace osu.Game.Online.Leaderboards
{ {
public abstract class Leaderboard<TScope, TScoreInfo> : Container /// <summary>
/// A leaderboard which displays a scrolling list of top scores, along with a single "user best"
/// for the local user.
/// </summary>
/// <typeparam name="TScope">The scope of the leaderboard (ie. global or local).</typeparam>
/// <typeparam name="TScoreInfo">The score model class.</typeparam>
public abstract class Leaderboard<TScope, TScoreInfo> : CompositeDrawable
{ {
/// <summary>
/// The currently displayed scores.
/// </summary>
public IEnumerable<TScoreInfo> Scores => scores;
/// <summary>
/// Whether the current scope should refetch in response to changes in API connectivity state.
/// </summary>
protected abstract bool IsOnlineScope { get; }
private const double fade_duration = 300; private const double fade_duration = 300;
private readonly OsuScrollContainer scrollContainer; private readonly OsuScrollContainer scrollContainer;
private readonly Container placeholderContainer; private readonly Container placeholderContainer;
private readonly UserTopScoreContainer<TScoreInfo> topScoreContainer; private readonly UserTopScoreContainer<TScoreInfo> userScoreContainer;
private FillFlowContainer<LeaderboardScore> scrollFlow; private FillFlowContainer<LeaderboardScore> scoreFlowContainer;
private readonly LoadingSpinner loading; private readonly LoadingSpinner loading;
private ScheduledDelegate showScoresDelegate; private CancellationTokenSource currentFetchCancellationSource;
private CancellationTokenSource showScoresCancellationSource; private CancellationTokenSource currentScoresAsyncLoadCancellationSource;
private bool scoresLoadedOnce; private APIRequest fetchScoresRequest;
private readonly Container content; private LeaderboardState state;
protected override Container<Drawable> Content => content; [Resolved(CanBeNull = true)]
private IAPIProvider api { get; set; }
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private ICollection<TScoreInfo> scores; private ICollection<TScoreInfo> scores;
public ICollection<TScoreInfo> Scores
{
get => scores;
set
{
scores = value;
scoresLoadedOnce = true;
scrollFlow?.FadeOut(fade_duration, Easing.OutQuint).Expire();
scrollFlow = null;
showScoresDelegate?.Cancel();
showScoresCancellationSource?.Cancel();
if (scores == null || !scores.Any())
{
loading.Hide();
return;
}
// ensure placeholder is hidden when displaying scores
PlaceholderState = PlaceholderState.Successful;
var scoreFlow = CreateScoreFlow();
scoreFlow.ChildrenEnumerable = scores.Select((s, index) => CreateDrawableScore(s, index + 1));
// schedule because we may not be loaded yet (LoadComponentAsync complains).
showScoresDelegate = Schedule(() => LoadComponentAsync(scoreFlow, _ =>
{
scrollContainer.Add(scrollFlow = scoreFlow);
int i = 0;
foreach (var s in scrollFlow.Children)
{
using (s.BeginDelayedSequence(i++ * 50))
s.Show();
}
scrollContainer.ScrollTo(0f, false);
loading.Hide();
}, (showScoresCancellationSource = new CancellationTokenSource()).Token));
}
}
public TScoreInfo TopScore
{
get => topScoreContainer.Score.Value;
set
{
topScoreContainer.Score.Value = value;
if (value == null)
topScoreContainer.Hide();
else
topScoreContainer.Show();
}
}
protected virtual FillFlowContainer<LeaderboardScore> CreateScoreFlow()
=> new FillFlowContainer<LeaderboardScore>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 5f),
Padding = new MarginPadding { Top = 10, Bottom = 5 },
};
private TScope scope; private TScope scope;
public TScope Scope public TScope Scope
@ -126,62 +78,7 @@ namespace osu.Game.Online.Leaderboards
return; return;
scope = value; scope = value;
RefreshScores(); RefetchScores();
}
}
private PlaceholderState placeholderState;
/// <summary>
/// Update the placeholder visibility.
/// Setting this to anything other than PlaceholderState.Successful will cancel all existing retrieval requests and hide scores.
/// </summary>
protected PlaceholderState PlaceholderState
{
get => placeholderState;
set
{
if (value != PlaceholderState.Successful)
{
Reset();
}
if (value == placeholderState)
return;
switch (placeholderState = value)
{
case PlaceholderState.NetworkFailure:
replacePlaceholder(new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync)
{
Action = RefreshScores
});
break;
case PlaceholderState.NoneSelected:
replacePlaceholder(new MessagePlaceholder(@"Please select a beatmap!"));
break;
case PlaceholderState.Unavailable:
replacePlaceholder(new MessagePlaceholder(@"Leaderboards are not available for this beatmap!"));
break;
case PlaceholderState.NoScores:
replacePlaceholder(new MessagePlaceholder(@"No records yet!"));
break;
case PlaceholderState.NotLoggedIn:
replacePlaceholder(new LoginPlaceholder(@"Please sign in to view online leaderboards!"));
break;
case PlaceholderState.NotSupporter:
replacePlaceholder(new MessagePlaceholder(@"Please invest in an osu!supporter tag to view this leaderboard!"));
break;
default:
replacePlaceholder(null);
break;
}
} }
} }
@ -213,12 +110,7 @@ namespace osu.Game.Online.Leaderboards
}, },
new Drawable[] new Drawable[]
{ {
content = new Container userScoreContainer = new UserTopScoreContainer<TScoreInfo>(CreateDrawableTopScore)
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Child = topScoreContainer = new UserTopScoreContainer<TScoreInfo>(CreateDrawableTopScore)
},
}, },
}, },
}, },
@ -231,120 +123,228 @@ namespace osu.Game.Online.Leaderboards
}; };
} }
protected virtual void Reset() protected override void LoadComplete()
{ {
getScoresRequest?.Cancel(); base.LoadComplete();
getScoresRequest = null;
Scores = null;
}
[Resolved(CanBeNull = true)]
private IAPIProvider api { get; set; }
private ScheduledDelegate pendingUpdateScores;
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
[BackgroundDependencyLoader]
private void load()
{
if (api != null) if (api != null)
{
apiState.BindTo(api.State); apiState.BindTo(api.State);
apiState.BindValueChanged(state =>
{
switch (state.NewValue)
{
case APIState.Online:
case APIState.Offline:
if (IsOnlineScope)
RefetchScores();
apiState.BindValueChanged(onlineStateChanged, true); break;
}
});
}
RefetchScores();
} }
private APIRequest getScoresRequest; /// <summary>
private ScheduledDelegate getScoresRequestCallback; /// Perform a full refetch of scores using current criteria.
/// </summary>
public void RefetchScores() => Scheduler.AddOnce(refetchScores);
protected abstract bool IsOnlineScope { get; } /// <summary>
/// Call when a retrieval or display failure happened to show a relevant message to the user.
private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => /// </summary>
/// <param name="state">The state to display.</param>
protected void SetErrorState(LeaderboardState state)
{ {
switch (state.NewValue) switch (state)
{ {
case APIState.Online: case LeaderboardState.NoScores:
case APIState.Offline: case LeaderboardState.Retrieving:
if (IsOnlineScope) case LeaderboardState.Success:
RefreshScores(); throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation.");
break;
} }
});
public void RefreshScores() => Scheduler.AddOnce(UpdateScores); Debug.Assert(scores?.Any() != true);
protected void UpdateScores() setState(state);
}
/// <summary>
/// Call when retrieved scores are ready to be displayed.
/// </summary>
/// <param name="scores">The scores to display.</param>
/// <param name="userScore">The user top score, if any.</param>
protected void SetScores(IEnumerable<TScoreInfo> scores, TScoreInfo userScore = default)
{ {
// don't display any scores or placeholder until the first Scores_Set has been called. this.scores = scores?.ToList();
// this avoids scope changes flickering a "no scores" placeholder before initialisation of song select is finished. userScoreContainer.Score.Value = userScore;
if (!scoresLoadedOnce) return;
getScoresRequest?.Cancel(); if (userScore == null)
getScoresRequest = null; userScoreContainer.Hide();
else
userScoreContainer.Show();
getScoresRequestCallback?.Cancel(); Scheduler.Add(updateScoresDrawables, false);
getScoresRequestCallback = null;
pendingUpdateScores?.Cancel();
pendingUpdateScores = Schedule(() =>
{
PlaceholderState = PlaceholderState.Retrieving;
loading.Show();
getScoresRequest = FetchScores(scores => getScoresRequestCallback = Schedule(() =>
{
Scores = scores.ToArray();
PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores;
}));
if (getScoresRequest == null)
return;
getScoresRequest.Failure += e => getScoresRequestCallback = Schedule(() =>
{
if (e is OperationCanceledException)
return;
PlaceholderState = PlaceholderState.NetworkFailure;
});
api?.Queue(getScoresRequest);
});
} }
/// <summary> /// <summary>
/// Performs a fetch/refresh of scores to be displayed. /// Performs a fetch/refresh of scores to be displayed.
/// </summary> /// </summary>
/// <param name="scoresCallback">A callback which should be called when fetching is completed. Scheduling is not required.</param> /// <param name="cancellationToken"></param>
/// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns> /// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns>
protected abstract APIRequest FetchScores(Action<IEnumerable<TScoreInfo>> scoresCallback); [CanBeNull]
protected abstract APIRequest FetchScores(CancellationToken cancellationToken);
private Placeholder currentPlaceholder; protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index);
private void replacePlaceholder(Placeholder placeholder) protected abstract LeaderboardScore CreateDrawableTopScore(TScoreInfo model);
private void refetchScores()
{ {
if (placeholder != null && placeholder.Equals(currentPlaceholder)) Debug.Assert(ThreadSafety.IsUpdateThread);
cancelPendingWork();
SetScores(null);
setState(LeaderboardState.Retrieving);
currentFetchCancellationSource = new CancellationTokenSource();
fetchScoresRequest = FetchScores(currentFetchCancellationSource.Token);
if (fetchScoresRequest == null)
return; return;
currentPlaceholder?.FadeOut(150, Easing.OutQuint).Expire(); fetchScoresRequest.Failure += e => Schedule(() =>
if (placeholder == null)
{ {
currentPlaceholder = null; if (e is OperationCanceledException || currentFetchCancellationSource.IsCancellationRequested)
return;
SetErrorState(LeaderboardState.NetworkFailure);
});
api?.Queue(fetchScoresRequest);
}
private void cancelPendingWork()
{
currentFetchCancellationSource?.Cancel();
currentScoresAsyncLoadCancellationSource?.Cancel();
fetchScoresRequest?.Cancel();
}
private void updateScoresDrawables()
{
currentScoresAsyncLoadCancellationSource?.Cancel();
scoreFlowContainer?
.FadeOut(fade_duration, Easing.OutQuint)
.Expire();
scoreFlowContainer = null;
if (scores?.Any() != true)
{
setState(LeaderboardState.NoScores);
return; return;
} }
LoadComponentAsync(new FillFlowContainer<LeaderboardScore>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 5f),
Padding = new MarginPadding { Top = 10, Bottom = 5 },
ChildrenEnumerable = scores.Select((s, index) => CreateDrawableScore(s, index + 1))
}, newFlow =>
{
setState(LeaderboardState.Success);
scrollContainer.Add(scoreFlowContainer = newFlow);
double delay = 0;
foreach (var s in scoreFlowContainer.Children)
{
using (s.BeginDelayedSequence(delay))
s.Show();
delay += 50;
}
scrollContainer.ScrollToStart(false);
}, (currentScoresAsyncLoadCancellationSource = new CancellationTokenSource()).Token);
}
#region Placeholder handling
private Placeholder placeholder;
private void setState(LeaderboardState state)
{
if (state == this.state)
return;
if (state == LeaderboardState.Retrieving)
loading.Show();
else
loading.Hide();
this.state = state;
placeholder?.FadeOut(150, Easing.OutQuint).Expire();
placeholder = getPlaceholderFor(state);
if (placeholder == null)
return;
placeholderContainer.Child = placeholder; placeholderContainer.Child = placeholder;
placeholder.ScaleTo(0.8f).Then().ScaleTo(1, fade_duration * 3, Easing.OutQuint); placeholder.ScaleTo(0.8f).Then().ScaleTo(1, fade_duration * 3, Easing.OutQuint);
placeholder.FadeInFromZero(fade_duration, Easing.OutQuint); placeholder.FadeInFromZero(fade_duration, Easing.OutQuint);
currentPlaceholder = placeholder;
} }
protected virtual bool FadeBottom => true; private Placeholder getPlaceholderFor(LeaderboardState state)
protected virtual bool FadeTop => false; {
switch (state)
{
case LeaderboardState.NetworkFailure:
return new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync)
{
Action = RefetchScores
};
case LeaderboardState.NoneSelected:
return new MessagePlaceholder(@"Please select a beatmap!");
case LeaderboardState.Unavailable:
return new MessagePlaceholder(@"Leaderboards are not available for this beatmap!");
case LeaderboardState.NoScores:
return new MessagePlaceholder(@"No records yet!");
case LeaderboardState.NotLoggedIn:
return new LoginPlaceholder(@"Please sign in to view online leaderboards!");
case LeaderboardState.NotSupporter:
return new MessagePlaceholder(@"Please invest in an osu!supporter tag to view this leaderboard!");
case LeaderboardState.Retrieving:
return null;
case LeaderboardState.Success:
return null;
default:
throw new ArgumentOutOfRangeException();
}
}
#endregion
#region Fade handling
protected override void UpdateAfterChildren() protected override void UpdateAfterChildren()
{ {
@ -356,30 +356,29 @@ namespace osu.Game.Online.Leaderboards
if (!scrollContainer.IsScrolledToEnd()) if (!scrollContainer.IsScrolledToEnd())
fadeBottom -= LeaderboardScore.HEIGHT; fadeBottom -= LeaderboardScore.HEIGHT;
if (scrollFlow == null) if (scoreFlowContainer == null)
return; return;
foreach (var c in scrollFlow.Children) foreach (var c in scoreFlowContainer.Children)
{ {
float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scrollFlow).Y; float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scoreFlowContainer).Y;
float bottomY = topY + LeaderboardScore.HEIGHT; float bottomY = topY + LeaderboardScore.HEIGHT;
bool requireTopFade = FadeTop && topY <= fadeTop; bool requireBottomFade = bottomY >= fadeBottom;
bool requireBottomFade = FadeBottom && bottomY >= fadeBottom;
if (!requireTopFade && !requireBottomFade) if (!requireBottomFade)
c.Colour = Color4.White; c.Colour = Color4.White;
else if (topY > fadeBottom + LeaderboardScore.HEIGHT || bottomY < fadeTop - LeaderboardScore.HEIGHT) else if (topY > fadeBottom + LeaderboardScore.HEIGHT || bottomY < fadeTop - LeaderboardScore.HEIGHT)
c.Colour = Color4.Transparent; c.Colour = Color4.Transparent;
else else
{ {
if (bottomY - fadeBottom > 0 && FadeBottom) if (bottomY - fadeBottom > 0)
{ {
c.Colour = ColourInfo.GradientVertical( c.Colour = ColourInfo.GradientVertical(
Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / LeaderboardScore.HEIGHT, 1)),
Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / LeaderboardScore.HEIGHT, 1))); Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / LeaderboardScore.HEIGHT, 1)));
} }
else if (FadeTop) else
{ {
c.Colour = ColourInfo.GradientVertical( c.Colour = ColourInfo.GradientVertical(
Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / LeaderboardScore.HEIGHT, 1)),
@ -389,8 +388,6 @@ namespace osu.Game.Online.Leaderboards
} }
} }
protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index); #endregion
protected abstract LeaderboardScore CreateDrawableTopScore(TScoreInfo model);
} }
} }

View File

@ -3,9 +3,9 @@
namespace osu.Game.Online.Leaderboards namespace osu.Game.Online.Leaderboards
{ {
public enum PlaceholderState public enum LeaderboardState
{ {
Successful, Success,
Retrieving, Retrieving,
NetworkFailure, NetworkFailure,
Unavailable, Unavailable,

View File

@ -1,8 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System; using System.Threading;
using System.Collections.Generic;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Online.API; using osu.Game.Online.API;
@ -25,14 +24,14 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
if (id.NewValue == null) if (id.NewValue == null)
return; return;
Scores = null; SetScores(null);
UpdateScores(); RefetchScores();
}, true); }, true);
} }
protected override bool IsOnlineScope => true; protected override bool IsOnlineScope => true;
protected override APIRequest FetchScores(Action<IEnumerable<APIUserScoreAggregate>> scoresCallback) protected override APIRequest FetchScores(CancellationToken cancellationToken)
{ {
if (roomId.Value == null) if (roomId.Value == null)
return null; return null;
@ -41,8 +40,10 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components
req.Success += r => req.Success += r =>
{ {
scoresCallback?.Invoke(r.Leaderboard); if (cancellationToken.IsCancellationRequested)
TopScore = r.UserScore; return;
SetScores(r.Leaderboard, r.UserScore);
}; };
return req; return req;

View File

@ -220,7 +220,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(Room, SelectedItem.Value) protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(Room, SelectedItem.Value)
{ {
Exited = () => leaderboard.RefreshScores() Exited = () => leaderboard.RefetchScores()
}); });
} }
} }

View File

@ -25,12 +25,6 @@ namespace osu.Game.Screens.Select.Leaderboards
{ {
public Action<ScoreInfo> ScoreSelected; public Action<ScoreInfo> ScoreSelected;
[Resolved]
private RulesetStore rulesets { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
private BeatmapInfo beatmapInfo; private BeatmapInfo beatmapInfo;
public BeatmapInfo BeatmapInfo public BeatmapInfo BeatmapInfo
@ -39,26 +33,13 @@ namespace osu.Game.Screens.Select.Leaderboards
set set
{ {
if (beatmapInfo == null && value == null) if (beatmapInfo == null && value == null)
{
// always null scores to ensure a correct initial display.
// see weird `scoresLoadedOnce` logic in base implementation.
Scores = null;
return; return;
}
if (beatmapInfo?.Equals(value) == true) if (beatmapInfo?.Equals(value) == true)
return; return;
beatmapInfo = value; beatmapInfo = value;
Scores = null; RefetchScores();
if (IsOnlineScope)
UpdateScores();
else
{
if (IsLoaded)
refreshRealmSubscription();
}
} }
} }
@ -77,7 +58,7 @@ namespace osu.Game.Screens.Select.Leaderboards
filterMods = value; filterMods = value;
UpdateScores(); RefetchScores();
} }
} }
@ -93,115 +74,58 @@ namespace osu.Game.Screens.Select.Leaderboards
[Resolved] [Resolved]
private IAPIProvider api { get; set; } private IAPIProvider api { get; set; }
[BackgroundDependencyLoader] [Resolved]
private void load() private RulesetStore rulesets { get; set; }
{
ruleset.ValueChanged += _ => UpdateScores();
mods.ValueChanged += _ =>
{
if (filterMods)
UpdateScores();
};
}
protected override void LoadComplete() [Resolved]
{ private RealmAccess realm { get; set; }
base.LoadComplete();
refreshRealmSubscription();
}
private IDisposable scoreSubscription; private IDisposable scoreSubscription;
private void refreshRealmSubscription() [BackgroundDependencyLoader]
private void load()
{ {
scoreSubscription?.Dispose(); ruleset.ValueChanged += _ => RefetchScores();
scoreSubscription = null; mods.ValueChanged += _ =>
{
if (beatmapInfo == null) if (filterMods)
return; RefetchScores();
};
scoreSubscription = realm.RegisterForNotifications(r =>
r.All<ScoreInfo>()
.Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} = $0", beatmapInfo.ID),
(_, changes, ___) =>
{
if (!IsOnlineScope)
RefreshScores();
});
}
protected override void Reset()
{
base.Reset();
TopScore = null;
} }
protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local;
private CancellationTokenSource loadCancellationSource; protected override APIRequest FetchScores(CancellationToken cancellationToken)
protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback)
{ {
loadCancellationSource?.Cancel();
loadCancellationSource = new CancellationTokenSource();
var cancellationToken = loadCancellationSource.Token;
var fetchBeatmapInfo = BeatmapInfo; var fetchBeatmapInfo = BeatmapInfo;
if (fetchBeatmapInfo == null) if (fetchBeatmapInfo == null)
{ {
PlaceholderState = PlaceholderState.NoneSelected; SetErrorState(LeaderboardState.NoneSelected);
return null; return null;
} }
if (Scope == BeatmapLeaderboardScope.Local) if (Scope == BeatmapLeaderboardScope.Local)
{ {
realm.Run(r => subscribeToLocalScores(cancellationToken);
{
var scores = r.All<ScoreInfo>()
.AsEnumerable()
// TODO: update to use a realm filter directly (or at least figure out the beatmap part to reduce scope).
.Where(s => !s.DeletePending && s.BeatmapInfo.ID == fetchBeatmapInfo.ID && s.Ruleset.ShortName == ruleset.Value.ShortName);
if (filterMods && !mods.Value.Any())
{
// we need to filter out all scores that have any mods to get all local nomod scores
scores = scores.Where(s => !s.Mods.Any());
}
else if (filterMods)
{
// otherwise find all the scores that have *any* of the currently selected mods (similar to how web applies mod filters)
// we're creating and using a string list representation of selected mods so that it can be translated into the DB query itself
var selectedMods = mods.Value.Select(m => m.Acronym);
scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym)));
}
scores = scores.Detach();
scoreManager.OrderByTotalScoreAsync(scores.ToArray(), cancellationToken)
.ContinueWith(ordered => scoresCallback?.Invoke(ordered.GetResultSafely()), TaskContinuationOptions.OnlyOnRanToCompletion);
});
return null; return null;
} }
if (api?.IsLoggedIn != true) if (api?.IsLoggedIn != true)
{ {
PlaceholderState = PlaceholderState.NotLoggedIn; SetErrorState(LeaderboardState.NotLoggedIn);
return null; return null;
} }
if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending)
{ {
PlaceholderState = PlaceholderState.Unavailable; SetErrorState(LeaderboardState.Unavailable);
return null; return null;
} }
if (!api.LocalUser.Value.IsSupporter && (Scope != BeatmapLeaderboardScope.Global || filterMods)) if (!api.LocalUser.Value.IsSupporter && (Scope != BeatmapLeaderboardScope.Global || filterMods))
{ {
PlaceholderState = PlaceholderState.NotSupporter; SetErrorState(LeaderboardState.NotSupporter);
return null; return null;
} }
@ -223,8 +147,7 @@ namespace osu.Game.Screens.Select.Leaderboards
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
return; return;
scoresCallback?.Invoke(task.GetResultSafely()); SetScores(task.GetResultSafely(), r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo));
TopScore = r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo);
}), TaskContinuationOptions.OnlyOnRanToCompletion); }), TaskContinuationOptions.OnlyOnRanToCompletion);
}; };
@ -241,10 +164,56 @@ namespace osu.Game.Screens.Select.Leaderboards
Action = () => ScoreSelected?.Invoke(model) Action = () => ScoreSelected?.Invoke(model)
}; };
private void subscribeToLocalScores(CancellationToken cancellationToken)
{
scoreSubscription?.Dispose();
scoreSubscription = null;
if (beatmapInfo == null)
return;
scoreSubscription = realm.RegisterForNotifications(r =>
r.All<ScoreInfo>().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0"
+ $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1"
+ $" AND {nameof(ScoreInfo.DeletePending)} == false"
, beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged);
void localScoresChanged(IRealmCollection<ScoreInfo> sender, ChangeSet changes, Exception exception)
{
if (cancellationToken.IsCancellationRequested)
return;
var scores = sender.AsEnumerable();
if (filterMods && !mods.Value.Any())
{
// we need to filter out all scores that have any mods to get all local nomod scores
scores = scores.Where(s => !s.Mods.Any());
}
else if (filterMods)
{
// otherwise find all the scores that have *any* of the currently selected mods (similar to how web applies mod filters)
// we're creating and using a string list representation of selected mods so that it can be translated into the DB query itself
var selectedMods = mods.Value.Select(m => m.Acronym);
scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym)));
}
scores = scores.Detach();
scoreManager.OrderByTotalScoreAsync(scores.ToArray(), cancellationToken)
.ContinueWith(ordered => Schedule(() =>
{
if (cancellationToken.IsCancellationRequested)
return;
SetScores(ordered.GetResultSafely());
}), TaskContinuationOptions.OnlyOnRanToCompletion);
}
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
scoreSubscription?.Dispose(); scoreSubscription?.Dispose();
} }
} }

View File

@ -53,7 +53,7 @@ namespace osu.Game.Screens.Select
{ {
base.Refresh(); base.Refresh();
Leaderboard.RefreshScores(); Leaderboard.RefetchScores();
} }
protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods)