mirror of
https://github.com/ppy/osu.git
synced 2026-05-17 20:35:39 +08:00
523 lines
20 KiB
C#
523 lines
20 KiB
C#
// 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;
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
}
|