1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-24 19:50:38 +08:00
Files
osu-lazer/osu.Game/Screens/Select/BeatmapLeaderboardWedge.cs
T
Bartłomiej Dach 6b10ef8709 Disable presenting scores in online song selects (#36826)
RFC. Closes https://github.com/ppy/osu/issues/36815 I guess.

Song select V1 completely disabled the ability to view a score outside
of solo play in ways that are very easy to let fly under the radar:


https://github.com/ppy/osu/blob/5fc836d1f09cebf983313c9b91a5c252890c607a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs#L26
https://github.com/ppy/osu/blob/46db3ad96d0e1cd6ba4176b9b474cb79a338965d/osu.Game/Screens/Select/PlaySongSelect.cs#L47-L53

therefore the issue this is trying to close would never even manifest
there.

The direct cause of the issue is that the results screen is pushed to
the relevant online screen's *substack* of screens rather than the
game-global parent stack. That means that when the back button is
pressed, the following logic fires:


https://github.com/ppy/osu/blob/6fa4a7152f144ed2524f20ecf7cfd26492bbe61d/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs#L174-L189

This logic fires *on the parent screen* even though *the child screen*
is the one the user is attempting to back out of. And none of the
exemptions for the screen substack inside of the above method fire
because the subscreen is not an `IOnlinePlaySubScreen` (it's
`SoloResultsScreen` in this case).

Now the direct cause here is probably fixable, although possibly not
without some significant pulling of footer-shaped teeth. *However*, I
kind of question as to why viewing scores should be permitted on online
song selects in the first place - it kind of distracts from the primary
purpose of the screens which is to *just pick a map already*.
2026-03-06 00:19:10 +09:00

548 lines
21 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.Play.Leaderboards;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Select
{
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);
var fetchScope = Scope.Value;
refetchOperation?.Cancel();
refetchOperation = Scheduler.AddDelayed(() =>
{
var fetchBeatmapInfo = beatmap.Value.BeatmapInfo;
var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset;
var fetchSorting = fetchScope == 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, fetchScope, 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 && fetchScope != BeatmapLeaderboardScope.Local ? 300 : 0);
}
private void updateScores()
{
var scores = fetchedScores.Value;
if (scores == null) return;
// because leaderboard refetches are debounced, it is technically possible for the global leaderboard manager
// to contain scores for a different beatmap than the ones the wedge is currently on.
// in this case, ignore the incoming scores to avoid briefly flashing the wrong leaderboard.
if (leaderboardManager.CurrentCriteria?.Beatmap?.Equals(beatmap.Value.BeatmapInfo) != true)
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.LocalUserState.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 = songSelect?.CanPresentScore == true
? () => songSelect.PresentScore(s)
: null,
ShowReplay = songSelect?.CanPresentScore == true
? info => songSelect.PresentScore(info, ScorePresentType.Gameplay)
: null
};
}), 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 = BeatmapLeaderboardWedgeStrings.PersonalBestWithPosition(userScore.Position.Value, totalCount.Value);
else
personalBestText.Text = BeatmapLeaderboardWedgeStrings.PersonalBest;
}
}
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;
private ScheduledDelegate? loadingShowDelegate;
protected void SetState(LeaderboardState state)
{
if (state == displayedState)
return;
if (state == LeaderboardState.Retrieving)
{
// Slight delay so this doesn't display for a few silly frames for local score retrievals.
loadingShowDelegate ??= Scheduler.AddDelayed(() => loading.Show(), 200);
}
else
{
loadingShowDelegate?.Cancel();
loadingShowDelegate = null;
loading.Hide();
}
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));
}
}
}
}