diff --git a/osu.Game.Tests/Visual/TestCaseLeaderboard.cs b/osu.Game.Tests/Visual/TestCaseLeaderboard.cs index cc8923976c1f..30a442594c75 100644 --- a/osu.Game.Tests/Visual/TestCaseLeaderboard.cs +++ b/osu.Game.Tests/Visual/TestCaseLeaderboard.cs @@ -6,14 +6,44 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; +using osu.Framework.Allocation; using OpenTK; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; namespace osu.Game.Tests.Visual { [Description("PlaySongSelect leaderboard")] public class TestCaseLeaderboard : OsuTestCase { - private readonly Leaderboard leaderboard; + private RulesetStore rulesets; + + private readonly FailableLeaderboard leaderboard; + + public TestCaseLeaderboard() + { + Add(leaderboard = new FailableLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = LeaderboardScope.Global, + }); + + AddStep(@"New Scores", newScores); + AddStep(@"Empty Scores", () => leaderboard.SetRetrievalState(PlaceholderState.NoScores)); + AddStep(@"Network failure", () => leaderboard.SetRetrievalState(PlaceholderState.NetworkFailure)); + AddStep(@"No supporter", () => leaderboard.SetRetrievalState(PlaceholderState.NotSupporter)); + AddStep(@"Not logged in", () => leaderboard.SetRetrievalState(PlaceholderState.NotLoggedIn)); + AddStep(@"Real beatmap", realBeatmap); + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + this.rulesets = rulesets; + } private void newScores() { @@ -204,17 +234,44 @@ private void newScores() leaderboard.Scores = scores; } - public TestCaseLeaderboard() + private void realBeatmap() { - Add(leaderboard = new Leaderboard + leaderboard.Beatmap = new BeatmapInfo { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - }); + StarDifficulty = 1.36, + Version = @"BASIC", + OnlineBeatmapID = 1113057, + Ruleset = rulesets.GetRuleset(0), + BaseDifficulty = new BeatmapDifficulty + { + CircleSize = 4, + DrainRate = 6.5f, + OverallDifficulty = 6.5f, + ApproachRate = 5, + }, + OnlineInfo = new BeatmapOnlineInfo + { + Length = 115000, + CircleCount = 265, + SliderCount = 71, + PlayCount = 47906, + PassCount = 19899, + }, + Metrics = new BeatmapMetrics + { + Ratings = Enumerable.Range(0, 11), + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6), + }, + }; + } - AddStep(@"New Scores", newScores); - newScores(); + private class FailableLeaderboard : Leaderboard + { + public void SetRetrievalState(PlaceholderState state) + { + PlaceholderState = state; + } } } } diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index 3777e10a3105..065c77073888 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -10,19 +10,28 @@ using osu.Game.Users; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using osu.Framework.IO.Network; namespace osu.Game.Online.API.Requests { public class GetScoresRequest : APIRequest { private readonly BeatmapInfo beatmap; + private readonly LeaderboardScope scope; + private readonly RulesetInfo ruleset; - public GetScoresRequest(BeatmapInfo beatmap) + public GetScoresRequest(BeatmapInfo beatmap, RulesetInfo ruleset, LeaderboardScope scope = LeaderboardScope.Global) { if (!beatmap.OnlineBeatmapID.HasValue) throw new InvalidOperationException($"Cannot lookup a beatmap's scores without having a populated {nameof(BeatmapInfo.OnlineBeatmapID)}."); + if (scope == LeaderboardScope.Local) + throw new InvalidOperationException("Should not attempt to request online scores for a local scoped leaderboard"); + this.beatmap = beatmap; + this.scope = scope; + this.ruleset = ruleset ?? throw new ArgumentNullException(nameof(ruleset)); Success += onSuccess; } @@ -33,6 +42,17 @@ private void onSuccess(GetScoresResponse r) score.ApplyBeatmap(beatmap); } + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Timeout = 30000; + req.AddParameter(@"type", scope.ToString().ToLowerInvariant()); + req.AddParameter(@"mode", ruleset.ShortName); + + return req; + } + protected override string Target => $@"beatmaps/{beatmap.OnlineBeatmapID}/scores"; } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 0d658b346e44..0a88f586b54c 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -104,7 +104,7 @@ private void updateScores(BeatmapInfo beatmap) scores.IsLoading = true; - getScoresRequest = new GetScoresRequest(beatmap); + getScoresRequest = new GetScoresRequest(beatmap, beatmap.Ruleset); getScoresRequest.Success += r => { scores.Scores = r.Scores; diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs index a676516300f1..4403d412fcc6 100644 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailArea.cs @@ -52,6 +52,7 @@ public BeatmapDetailArea() default: Details.Hide(); + Leaderboard.Scope = (LeaderboardScope)tab - 1; Leaderboard.Show(); break; } diff --git a/osu.Game/Screens/Select/Leaderboards/Leaderboard.cs b/osu.Game/Screens/Select/Leaderboards/Leaderboard.cs index d896da531955..b3f2649ab69e 100644 --- a/osu.Game/Screens/Select/Leaderboards/Leaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/Leaderboard.cs @@ -18,14 +18,26 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using System.Linq; +using osu.Framework.Configuration; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Framework.Logging; +using osu.Game.Rulesets; +using osu.Framework.Input; namespace osu.Game.Screens.Select.Leaderboards { public class Leaderboard : Container { + private const double fade_duration = 200; + private readonly ScrollContainer scrollContainer; + private readonly Container placeholderContainer; + private FillFlowContainer scrollFlow; + private readonly Bindable ruleset = new Bindable(); + public Action ScoreSelected; private readonly LoadingAnimation loading; @@ -38,15 +50,18 @@ public IEnumerable Scores set { scores = value; - getScoresRequest?.Cancel(); - scrollFlow?.FadeOut(200); - scrollFlow?.Expire(); + scrollFlow?.FadeOut(fade_duration).Expire(); scrollFlow = null; - if (scores == null) + loading.Hide(); + + if (scores == null || !scores.Any()) return; + // ensure placeholder is hidden when displaying scores + PlaceholderState = PlaceholderState.Successful; + // schedule because we may not be loaded yet (LoadComponentAsync complains). Schedule(() => { @@ -74,6 +89,55 @@ public IEnumerable Scores } } + private LeaderboardScope scope; + public LeaderboardScope Scope + { + get { return scope; } + set + { + if (value == scope) return; + + scope = value; + updateScores(); + } + } + + private PlaceholderState placeholderState; + protected PlaceholderState PlaceholderState + { + get { return placeholderState; } + set + { + if (value == placeholderState) return; + + switch (placeholderState = value) + { + case PlaceholderState.NetworkFailure: + replacePlaceholder(new RetrievalFailurePlaceholder + { + OnRetry = updateScores, + }); + break; + + case PlaceholderState.NoScores: + replacePlaceholder(new MessagePlaceholder(@"No records yet!")); + break; + + case PlaceholderState.NotLoggedIn: + replacePlaceholder(new MessagePlaceholder(@"Please login to view online leaderboards!")); + break; + + case PlaceholderState.NotSupporter: + replacePlaceholder(new MessagePlaceholder(@"Please invest in a supporter tag to view this leaderboard!")); + break; + + default: + replacePlaceholder(null); + break; + } + } + } + public Leaderboard() { Children = new Drawable[] @@ -83,7 +147,14 @@ public Leaderboard() RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, }, - loading = new LoadingAnimation() + loading = new LoadingAnimation(), + placeholderContainer = new Container + { + Alpha = 0, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, }; } @@ -91,6 +162,8 @@ public Leaderboard() private BeatmapInfo beatmap; + private OsuGame osuGame; + private ScheduledDelegate pendingBeatmapSwitch; public BeatmapInfo Beatmap @@ -109,33 +182,115 @@ public BeatmapInfo Beatmap } [BackgroundDependencyLoader(permitNulls: true)] - private void load(APIAccess api) + private void load(APIAccess api, OsuGame osuGame) { this.api = api; + this.osuGame = osuGame; + + if (osuGame != null) + ruleset.BindTo(osuGame.Ruleset); + + ruleset.ValueChanged += r => updateScores(); + + if (api != null) + api.OnStateChange += handleApiStateChange; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (api != null) + api.OnStateChange -= handleApiStateChange; } private GetScoresRequest getScoresRequest; - private void updateScores() + private void handleApiStateChange(APIState oldState, APIState newState) { - if (!IsLoaded) return; + if (Scope == LeaderboardScope.Local) + // No need to respond to API state change while current scope is local + return; - Scores = null; + if (newState == APIState.Online) + updateScores(); + } + + private void updateScores() + { getScoresRequest?.Cancel(); + getScoresRequest = null; + Scores = null; - if (api == null || Beatmap?.OnlineBeatmapID == null) return; + if (Scope == LeaderboardScope.Local) + { + // TODO: get local scores from wherever here. + PlaceholderState = PlaceholderState.NoScores; + return; + } + + if (api?.IsLoggedIn != true) + { + PlaceholderState = PlaceholderState.NotLoggedIn; + return; + } + + if (Beatmap?.OnlineBeatmapID == null) + { + PlaceholderState = PlaceholderState.NetworkFailure; + return; + } + PlaceholderState = PlaceholderState.Retrieving; loading.Show(); - getScoresRequest = new GetScoresRequest(Beatmap); + if (Scope != LeaderboardScope.Global && !api.LocalUser.Value.IsSupporter) + { + loading.Hide(); + PlaceholderState = PlaceholderState.NotSupporter; + return; + } + + getScoresRequest = new GetScoresRequest(Beatmap, osuGame?.Ruleset.Value ?? Beatmap.Ruleset, Scope); getScoresRequest.Success += r => { Scores = r.Scores; - loading.Hide(); + PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores; }; + getScoresRequest.Failure += onUpdateFailed; + api.Queue(getScoresRequest); } + private void onUpdateFailed(Exception e) + { + if (e is OperationCanceledException) return; + + PlaceholderState = PlaceholderState.NetworkFailure; + Logger.Error(e, @"Couldn't fetch beatmap scores!"); + } + + private void replacePlaceholder(Placeholder placeholder) + { + if (placeholder == null) + { + placeholderContainer.FadeOutFromOne(fade_duration, Easing.OutQuint); + placeholderContainer.Clear(true); + return; + } + + var existingPlaceholder = placeholderContainer.Children.FirstOrDefault() as Placeholder; + + if (placeholder.Equals(existingPlaceholder)) + return; + + Scores = null; + + placeholderContainer.Clear(true); + placeholderContainer.Child = placeholder; + placeholderContainer.FadeInFromZero(fade_duration, Easing.OutQuint); + } + protected override void Update() { base.Update(); @@ -164,5 +319,118 @@ protected override void Update() } } } + + private abstract class Placeholder : FillFlowContainer, IEquatable + { + public virtual bool Equals(Placeholder other) => GetType() == other?.GetType(); + } + + private class MessagePlaceholder : Placeholder + { + private readonly string message; + + public MessagePlaceholder(string message) + { + Direction = FillDirection.Horizontal; + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.fa_exclamation_circle, + Size = new Vector2(26), + Margin = new MarginPadding { Right = 10 }, + }, + new OsuSpriteText + { + Text = this.message = message, + TextSize = 22, + }, + }; + } + + public override bool Equals(Placeholder other) => (other as MessagePlaceholder)?.message == message; + } + + private class RetrievalFailurePlaceholder : Placeholder + { + public Action OnRetry; + + public RetrievalFailurePlaceholder() + { + Direction = FillDirection.Horizontal; + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new RetryButton + { + Action = () => OnRetry?.Invoke(), + Margin = new MarginPadding { Right = 10 }, + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Text = @"Couldn't retrieve scores!", + TextSize = 22, + }, + }; + } + + private class RetryButton : OsuHoverContainer + { + private readonly SpriteIcon icon; + + public Action Action; + + public RetryButton() + { + Height = 26; + Width = 26; + Child = new OsuClickableContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => Action?.Invoke(), + Child = icon = new SpriteIcon + { + Icon = FontAwesome.fa_refresh, + Size = new Vector2(26), + Shadow = true, + }, + }; + } + + protected override bool OnMouseDown(InputState state, MouseDownEventArgs args) + { + icon.ScaleTo(0.8f, 4000, Easing.OutQuint); + return base.OnMouseDown(state, args); + } + + protected override bool OnMouseUp(InputState state, MouseUpEventArgs args) + { + icon.ScaleTo(1, 1000, Easing.OutElastic); + return base.OnMouseUp(state, args); + } + } + } + } + + public enum LeaderboardScope + { + Local, + Country, + Global, + Friend, + } + + public enum PlaceholderState + { + Successful, + Retrieving, + NetworkFailure, + NoScores, + NotLoggedIn, + NotSupporter, } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 4d5101447af7..68ee08e721b5 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -275,7 +275,7 @@ private void carouselSelectionChanged(BeatmapInfo beatmap) if (beatmap == Beatmap.Value.BeatmapInfo) performLoad(); else - selectionChangedDebounce = Scheduler.AddDelayed(performLoad, 100); + selectionChangedDebounce = Scheduler.AddDelayed(performLoad, 200); } } diff --git a/osu.Game/Tests/Visual/TestCasePerformancePoints.cs b/osu.Game/Tests/Visual/TestCasePerformancePoints.cs index 6da14e9b122b..f71bece2794c 100644 --- a/osu.Game/Tests/Visual/TestCasePerformancePoints.cs +++ b/osu.Game/Tests/Visual/TestCasePerformancePoints.cs @@ -244,7 +244,7 @@ private void beatmapChanged(WorkingBeatmap newBeatmap) if (!api.IsLoggedIn) return; - lastRequest = new GetScoresRequest(newBeatmap.BeatmapInfo); + lastRequest = new GetScoresRequest(newBeatmap.BeatmapInfo, newBeatmap.BeatmapInfo.Ruleset); lastRequest.Success += res => res.Scores.ForEach(s => scores.Add(new PerformanceDisplay(s, newBeatmap.Beatmap))); api.Queue(lastRequest); }