Path: blob/master/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs
2264 views
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.PolygonExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Leaderboards; using osu.Game.Online.Placeholders; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.SelectV2 { public partial class BeatmapLeaderboardWedge : VisibilityContainer { public const float SPACING_BETWEEN_SCORES = 4; public IBindable<BeatmapLeaderboardScope> Scope { get; } = new Bindable<BeatmapLeaderboardScope>(); public IBindable<LeaderboardSortMode> Sorting { get; } = new Bindable<LeaderboardSortMode>(); public IBindable<bool> FilterBySelectedMods { get; } = new BindableBool(); [Resolved] private LeaderboardManager leaderboardManager { get; set; } = null!; [Resolved] private IBindable<WorkingBeatmap> beatmap { get; set; } = null!; [Resolved] private IBindable<RulesetInfo> ruleset { get; set; } = null!; [Resolved] private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; [Resolved] private ISongSelect? songSelect { get; set; } [Resolved] private IAPIProvider api { get; set; } = null!; private Container<Placeholder> placeholderContainer = null!; private Placeholder? placeholder; private Container scoresContainer = null!; private OsuScrollContainer scoresScroll = null!; private Container personalBestDisplay = null!; private Container<BeatmapLeaderboardScore> personalBestScoreContainer = null!; private OsuSpriteText personalBestText = null!; private LoadingLayer loading = null!; private CancellationTokenSource? cancellationTokenSource; private readonly IBindable<LeaderboardScores?> fetchedScores = new Bindable<LeaderboardScores?>(); private const float personal_best_height = 112; // Blocking mouse down is required to avoid song select's background reveal logic happening while hovering scores. // Our horizontal alignment doesn't really align with the rest of the sheared components (protrudes a touch to the right) which makes // it complicated to handle this at a higher level. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => scoresScroll.ReceivePositionalInputAt(screenSpacePos); protected override bool OnMouseDown(MouseDownEvent e) => true; private Sample? swishSample; private readonly List<ScheduledDelegate> scoreSfxDelegates = new List<ScheduledDelegate>(); [BackgroundDependencyLoader] private void load(AudioManager audio) { RelativeSizeAxes = Axes.Both; Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { scoresScroll = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, Shear = OsuGame.SHEAR, Child = scoresContainer = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Top = 5, // Left padding offsets the shear to create a visually appealing list display. Left = 80f, // Bottom padding ensures the last entry's full width is displayed // (ie it is fully on screen after shear is considered). Bottom = BeatmapLeaderboardScore.HEIGHT * 3 }, }, }, personalBestDisplay = new Container { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, Height = personal_best_height, Shear = OsuGame.SHEAR, Margin = new MarginPadding { Left = -40f, }, CornerRadius = 10f, Masking = true, // push the personal best 1px down to hide masking issues Y = 1f, X = -100f, Alpha = 0f, Children = new Drawable[] { new WedgeBackground(), // Required because wedge background blocks input from passing through // to the main context menu container above. new OsuContextMenuContainer { Shear = -OsuGame.SHEAR, RelativeSizeAxes = Axes.Both, Child = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Top = 5f, Bottom = 5f, Left = 70f, Right = 10f }, Children = new Drawable[] { personalBestText = new OsuSpriteText { Colour = colourProvider.Content2, Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), }, personalBestScoreContainer = new Container<BeatmapLeaderboardScore> { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Top = 20f }, }, } }, } }, }, placeholderContainer = new Container<Placeholder> { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, loading = new LoadingLayer(), } }; swishSample = audio.Samples.Get(@"SongSelect/leaderboard-score"); } protected override void LoadComplete() { base.LoadComplete(); Scope.BindValueChanged(_ => RefetchScores()); Sorting.BindValueChanged(_ => RefetchScores()); FilterBySelectedMods.BindValueChanged(_ => RefetchScores()); beatmap.BindValueChanged(_ => RefetchScores()); ruleset.BindValueChanged(_ => RefetchScores()); mods.BindValueChanged(_ => refetchScoresFromMods()); RefetchScores(); } protected override void PopIn() { this.FadeIn(300, Easing.OutQuint); } protected override void PopOut() { this.FadeOut(300, Easing.OutQuint); } private void refetchScoresFromMods() { if (FilterBySelectedMods.Value) RefetchScores(); } private bool initialFetchComplete; private ScheduledDelegate? refetchOperation; public void RefetchScores() { SetScores(Array.Empty<ScoreInfo>()); if (beatmap.IsDefault) { SetState(LeaderboardState.NoneSelected); return; } SetState(LeaderboardState.Retrieving); refetchOperation?.Cancel(); refetchOperation = Scheduler.AddDelayed(() => { var fetchBeatmapInfo = beatmap.Value.BeatmapInfo; var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; var fetchSorting = Scope.Value == BeatmapLeaderboardScope.Local ? Sorting.Value : LeaderboardSortMode.Score; // For now, we forcefully refresh to keep things simple. // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios // (like returning from gameplay after setting a new score, returning to song select after main menu). leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null, fetchSorting), forceRefresh: true); if (!initialFetchComplete) { // only bind this after the first fetch to avoid reading stale scores. fetchedScores.BindTo(leaderboardManager.Scores); fetchedScores.BindValueChanged(_ => updateScores(), true); initialFetchComplete = true; } }, initialFetchComplete ? 300 : 0); } private void updateScores() { var scores = fetchedScores.Value; if (scores == null) return; if (scores.FailState != null) SetState((LeaderboardState)scores.FailState); else SetScores(scores.TopScores, scores.UserScore, scores.TotalScores); } protected void SetScores(IEnumerable<ScoreInfo> scores, ScoreInfo? userScore = null, int? totalCount = null) { cancellationTokenSource?.Cancel(); cancellationTokenSource = new CancellationTokenSource(); clearScores(); SetState(LeaderboardState.Success); if (!scores.Any()) { SetState(LeaderboardState.NoScores); return; } LoadComponentsAsync(scores.Select((s, i) => { BeatmapLeaderboardScore.HighlightType? highlightType = null; if (s.OnlineID == userScore?.OnlineID) highlightType = BeatmapLeaderboardScore.HighlightType.Own; else if (api.Friends.Any(r => r.TargetID == s.UserID) && Scope.Value != BeatmapLeaderboardScope.Friend) highlightType = BeatmapLeaderboardScore.HighlightType.Friend; return new BeatmapLeaderboardScore(s) { Rank = i + 1, Highlight = highlightType, SelectedMods = { BindTarget = mods }, Action = () => onLeaderboardScoreClicked(s), }; }), loadedScores => { int delay = 200; int i = 0; foreach (var d in loadedScores) { d.Y = (BeatmapLeaderboardScore.HEIGHT + SPACING_BETWEEN_SCORES) * i; // This is a bit of a weird one. We're already in a sheared state and don't want top-level // shear applied, but still need the `BeatmapLeaderboardScore` to be in "sheared" mode (see ctor). d.Shear = Vector2.Zero; scoresContainer.Add(d); d.FadeOut() .MoveToX(-20f) .Delay(delay) .FadeIn(300, Easing.OutQuint) .MoveToX(0f, 300, Easing.OutQuint); bool visible = d.ScreenSpaceDrawQuad.TopLeft.Y < d.Parent!.ChildMaskingBounds.BottomLeft.Y; if (visible) { var del = Scheduler.AddDelayed(() => { var chan = swishSample?.GetChannel(); if (chan == null) return; chan.Balance.Value = -OsuGameBase.SFX_STEREO_STRENGTH / 2; chan.Frequency.Value = 0.98f + RNG.NextDouble(0.04f); chan.Play(); }, delay); scoreSfxDelegates.Add(del); } delay += 30; i++; } }, cancellation: cancellationTokenSource.Token); if (userScore != null) { personalBestDisplay.MoveToX(0, 600, Easing.OutQuint); personalBestDisplay.FadeIn(600, Easing.OutQuint); personalBestScoreContainer.Child = new BeatmapLeaderboardScore(userScore) { Highlight = BeatmapLeaderboardScore.HighlightType.Own, Rank = userScore.Position, SelectedMods = { BindTarget = mods }, Action = () => onLeaderboardScoreClicked(userScore), }; scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding { Bottom = personal_best_height }, 300, Easing.OutQuint); if (totalCount != null && userScore.Position != null) personalBestText.Text = $"Personal Best (#{userScore.Position:N0} of {totalCount.Value:N0})"; else personalBestText.Text = "Personal Best"; } } private void clearScores() { float delay = 0; foreach (var d in scoresContainer) { // Avoid applying animations a second time to drawables which are already fading out. if (d.LifetimeEnd != double.MaxValue) continue; d.Delay(delay) .MoveToX(-10f, 120, Easing.Out) .FadeOut(120, Easing.Out) .Expire(); // If the user is scrolled down in the list, start delaying only from the current visible range to // avoid the perceived transition from taking longer than expected. if (d.ScreenSpaceDrawQuad.Intersects(scoresScroll.ScreenSpaceDrawQuad)) delay += 20; } personalBestDisplay.MoveToX(-100, 300, Easing.OutQuint); personalBestDisplay.FadeOut(300, Easing.OutQuint); scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding(), 300, Easing.OutQuint); scoreSfxDelegates.ForEach(d => d.Cancel()); scoreSfxDelegates.Clear(); } private void onLeaderboardScoreClicked(ScoreInfo score) => songSelect?.PresentScore(score); private LeaderboardState displayedState; protected void SetState(LeaderboardState state) { if (state == displayedState) return; if (state == LeaderboardState.Retrieving) loading.Show(); else loading.Hide(); displayedState = state; placeholder?.FadeOut(150, Easing.OutQuint).Expire(); placeholder = getPlaceholderFor(state); if (placeholder == null) return; clearScores(); placeholderContainer.Child = placeholder; placeholder.ScaleTo(0.8f).Then().ScaleTo(1, 900, Easing.OutQuint); placeholder.FadeInFromZero(300, Easing.OutQuint); } #region Fade handling protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); const int height = BeatmapLeaderboardScore.HEIGHT; float fadeBottom = (float)(scoresScroll.Current + scoresScroll.DrawHeight); float fadeTop = (float)(scoresScroll.Current); fadeTop += (float)Math.Min(height, Math.Log10(Math.Max(fadeTop, 0) + 1) * height); foreach (var c in scoresContainer) { float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scoresContainer).Y; float bottomY = topY + height; bool requireBottomFade = bottomY >= fadeBottom; bool requireTopFade = topY < fadeTop; if (!requireBottomFade && !requireTopFade) { c.Colour = Color4.White; continue; } if (topY > fadeBottom + height || bottomY < fadeTop - height) { c.Colour = Color4.Transparent; continue; } if (requireBottomFade) { c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / height, 1)), Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / height, 1))); } else { Debug.Assert(requireTopFade); c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / height, 1)), Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / height, 1))); } } } #endregion private Placeholder? getPlaceholderFor(LeaderboardState state) { switch (state) { case LeaderboardState.NetworkFailure: return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync) { Action = RefetchScores }; case LeaderboardState.NoneSelected: return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap); case LeaderboardState.RulesetUnavailable: return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset); case LeaderboardState.BeatmapUnavailable: return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap); case LeaderboardState.NoScores: return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet); case LeaderboardState.NotLoggedIn: return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards); case LeaderboardState.NotSupporter: return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); case LeaderboardState.NoTeam: return new MessagePlaceholder(LeaderboardStrings.NoTeam); case LeaderboardState.Retrieving: return null; case LeaderboardState.Success: return null; default: throw new ArgumentOutOfRangeException(nameof(state)); } } } }