1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-26 11:30:04 +08:00

Use actual score positions in gameplay leaderboard

Intends to close https://github.com/ppy/osu/issues/32859.

The difference between this and https://github.com/ppy/osu/pull/32942 is
that this PR takes the approach of completely moving the score sorting
behaviour to `IGameplayLeaderboardProvider` implementations.

This is going to be helpful for further work - to be precise, I am
looking to add a leaderboard position indicator in the bottom right of
multiplayer player to match stable, and having the position in the
provider will make the implementation of that *much* easier.
This commit is contained in:
Bartłomiej Dach
2025-04-25 11:47:25 +02:00
Unverified
parent a0044116f3
commit 06dc4235f0
8 changed files with 136 additions and 91 deletions
@@ -183,30 +183,6 @@ namespace osu.Game.Tests.Visual.Gameplay
() => Does.Contain("#FF549A"));
}
[Test]
public void TestTrackedScorePosition([Values] bool partial)
{
createLeaderboard(partial);
AddStep("add many scores in one go", () =>
{
for (int i = 0; i < 49; i++)
createRandomScore(new APIUser { Username = $"Player {i + 1}" });
// Add player at end to force an animation down the whole list.
playerScore.Value = 0;
createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true);
});
if (partial)
AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null);
else
AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50));
AddStep("move tracked player to top", () => leaderboard.TrackedScore!.TotalScore.Value = 8_000_000);
AddUntilStep("all players have non-null position", () => leaderboard.AllScores.Select(s => s.ScorePosition), () => Does.Not.Contain(null));
}
private void addLocalPlayer()
{
AddStep("add local player", () =>
@@ -216,12 +192,11 @@ namespace osu.Game.Tests.Visual.Gameplay
});
}
private void createLeaderboard(bool partial = false)
private void createLeaderboard()
{
AddStep("create leaderboard", () =>
{
leaderboardProvider.Scores.Clear();
leaderboardProvider.IsPartial = partial;
Child = leaderboard = new TestDrawableGameplayLeaderboard
{
Anchor = Anchor.Centre,
@@ -247,7 +222,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
var scoreItem = Flow.FirstOrDefault(i => i.User?.Username == username);
return scoreItem != null && scoreItem.ScorePosition == expectedPosition;
return scoreItem != null && scoreItem.ScorePosition.Value == expectedPosition;
}
public IEnumerable<DrawableGameplayLeaderboardScore> GetAllScoresForUsername(string username)
@@ -260,7 +235,6 @@ namespace osu.Game.Tests.Visual.Gameplay
{
IBindableList<GameplayLeaderboardScore> IGameplayLeaderboardProvider.Scores => Scores;
public BindableList<GameplayLeaderboardScore> Scores { get; } = new BindableList<GameplayLeaderboardScore>();
public bool IsPartial { get; set; }
}
}
}
@@ -125,7 +125,14 @@ namespace osu.Game.Online.Leaderboards
var result = LeaderboardScores.Success
(
response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)).OrderByTotalScore().ToArray(),
response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap))
.OrderByTotalScore()
.Select((s, idx) =>
{
s.Position = idx + 1;
return s;
})
.ToArray(),
response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap)
);
inFlightOnlineRequest = null;
@@ -5,7 +5,6 @@ using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
@@ -20,8 +19,6 @@ namespace osu.Game.Screens.Play.HUD
{
public partial class DrawableGameplayLeaderboard : CompositeDrawable
{
private readonly Cached sorting = new Cached();
public Bindable<bool> Expanded = new Bindable<bool>();
protected readonly FillFlowContainer<DrawableGameplayLeaderboardScore> Flow;
@@ -87,7 +84,6 @@ namespace osu.Game.Screens.Play.HUD
}, true);
}
Scheduler.AddDelayed(sort, 1000, true);
configVisibility.BindValueChanged(_ => this.FadeTo(configVisibility.Value ? 1 : 0, 100, Easing.OutQuint), true);
}
@@ -109,8 +105,8 @@ namespace osu.Game.Screens.Play.HUD
drawable.Expanded.BindTo(Expanded);
Flow.Add(drawable);
drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true);
drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true);
drawable.ScorePosition.BindValueChanged(_ => Scheduler.AddOnce(sort));
drawable.DisplayOrder.BindValueChanged(_ => Scheduler.AddOnce(sort), true);
int displayCount = Math.Min(Flow.Count, max_panels);
Height = displayCount * (DrawableGameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y);
@@ -179,22 +175,8 @@ namespace osu.Game.Screens.Play.HUD
private void sort()
{
if (sorting.IsValid)
return;
var orderedByScore = Flow
.OrderByDescending(i => i.TotalScore.Value)
.ThenBy(i => i.DisplayOrder.Value)
.ToList();
for (int i = 0; i < Flow.Count; i++)
{
var score = orderedByScore[i];
Flow.SetLayoutPosition(score, i);
score.ScorePosition = i + 1 == Flow.Count && leaderboardProvider?.IsPartial == true && score.Tracked ? null : i + 1;
}
sorting.Validate();
foreach (var score in Flow.ToArray())
Flow.SetLayoutPosition(score, score.DisplayOrder.Value);
}
private partial class InputDisabledScrollContainer : OsuScrollContainer
@@ -56,6 +56,7 @@ namespace osu.Game.Screens.Play.HUD
public BindableDouble Accuracy { get; } = new BindableDouble(1);
public BindableInt Combo { get; } = new BindableInt();
public BindableBool HasQuit { get; } = new BindableBool();
public Bindable<int?> ScorePosition { get; } = new Bindable<int?>();
public Bindable<long> DisplayOrder { get; } = new Bindable<long>();
private Func<ScoringMode, long>? getDisplayScoreFunction;
@@ -69,28 +70,6 @@ namespace osu.Game.Screens.Play.HUD
public Color4? TextColour { get; set; }
private int? scorePosition;
private bool scorePositionIsSet;
public int? ScorePosition
{
get => scorePosition;
set
{
// We always want to run once, as the incoming value may be null and require a visual update to "-".
if (value == scorePosition && scorePositionIsSet)
return;
scorePosition = value;
positionText.Text = scorePosition.HasValue ? $"#{scorePosition.Value.FormatRank()}" : "-";
scorePositionIsSet = true;
updateState();
}
}
public IUser? User { get; }
/// <summary>
@@ -123,6 +102,7 @@ namespace osu.Game.Screens.Play.HUD
Accuracy.BindTo(score.Accuracy);
Combo.BindTo(score.Combo);
HasQuit.BindTo(score.HasQuit);
ScorePosition.BindTo(score.Position);
DisplayOrder.BindTo(score.DisplayOrder);
GetDisplayScore = score.GetDisplayScore;
@@ -334,6 +314,7 @@ namespace osu.Game.Screens.Play.HUD
updateState();
Expanded.BindValueChanged(changeExpandedState, true);
ScorePosition.BindValueChanged(_ => updateState(), true);
FinishTransforms(true);
}
@@ -392,7 +373,9 @@ namespace osu.Game.Screens.Play.HUD
return;
}
if (scorePosition == 1)
positionText.Text = ScorePosition.Value.HasValue ? $"#{ScorePosition.Value.Value.FormatRank()}" : "-";
if (ScorePosition.Value == 1)
{
widthExtension = true;
panelColour = BackgroundColour ?? Color4Extensions.FromHex("7fcc33");
@@ -53,7 +53,7 @@ namespace osu.Game.Screens.Select.Leaderboards
/// An optional value to guarantee stable ordering.
/// Lower numbers will appear higher in cases of <see cref="TotalScore"/> ties.
/// </summary>
public Bindable<long> DisplayOrder { get; } = new BindableLong();
public long TotalScoreTiebreaker { get; init; }
/// <summary>
/// A custom function which handles converting a score to a display score using a provided <see cref="ScoringMode"/>.
@@ -68,6 +68,25 @@ namespace osu.Game.Screens.Select.Leaderboards
/// </summary>
public Colour4? TeamColour { get; init; }
/// <summary>
/// The initial position of the score on the leaderboard.
/// Mostly used for cases like the local user's best score on the global leaderboard (which will not be contiguous with the other scores).
/// </summary>
public int? InitialPosition { get; init; } = null;
/// <summary>
/// The displayed rank of the score on the leaderboard.
/// </summary>
public Bindable<int?> Position { get; } = new Bindable<int?>();
/// <summary>
/// The index of the score on the leaderboard.
/// This differs from <see cref="Position"/> in that it is required (must always be known)
/// and that it doesn't represent the score's position on global leaderboards.
/// It's a property completely local to and relative to all scores provided by the managing <see cref="IGameplayLeaderboardProvider"/>.
/// </summary>
public Bindable<long> DisplayOrder { get; } = new BindableLong();
public GameplayLeaderboardScore(IUser user, ScoreProcessor scoreProcessor, bool tracked)
{
User = user;
@@ -95,8 +114,9 @@ namespace osu.Game.Screens.Select.Leaderboards
TotalScore.Value = scoreInfo.TotalScore;
Accuracy.Value = scoreInfo.Accuracy;
Combo.Value = scoreInfo.Combo;
DisplayOrder.Value = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds();
TotalScoreTiebreaker = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds();
GetDisplayScore = scoreInfo.GetDisplayScore;
InitialPosition = scoreInfo.Position;
}
/// <remarks>
@@ -14,14 +14,5 @@ namespace osu.Game.Screens.Select.Leaderboards
/// List of all scores to display on the leaderboard.
/// </summary>
public IBindableList<GameplayLeaderboardScore> Scores { get; }
/// <summary>
/// Whether this leaderboard is a partial leaderboard (e.g. contains only the top 50 of all scores),
/// or is a full leaderboard (contains all scores that there will ever be).
/// </summary>
/// <remarks>
/// If this is <see langword="true"/> and a tracked score is last on the leaderboard, it will show an "unknown" score position.
/// </remarks>
bool IsPartial { get; }
}
}
@@ -8,6 +8,7 @@ using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
@@ -55,6 +56,8 @@ namespace osu.Game.Screens.Select.Leaderboards
[Resolved]
private OsuColour colours { get; set; } = null!;
private readonly Cached sorting = new Cached();
public MultiplayerLeaderboardProvider(MultiplayerRoomUser[] users)
{
this.users = users;
@@ -101,6 +104,8 @@ namespace osu.Game.Screens.Select.Leaderboards
HasQuit = { BindTarget = trackedUser.UserQuit },
TeamColour = UserScores[user.OnlineID].Team is int team ? getTeamColour(team) : null,
};
leaderboardScore.TotalScore.BindValueChanged(_ => sorting.Invalidate());
leaderboardScore.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true);
scores.Add(leaderboardScore);
}
});
@@ -124,6 +129,8 @@ namespace osu.Game.Screens.Select.Leaderboards
// new players are not supported.
playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds);
playingUserIds.BindCollectionChanged(playingUsersChanged);
Scheduler.AddDelayed(sort, 1000, true);
}
private void playingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e)
@@ -174,6 +181,26 @@ namespace osu.Game.Screens.Select.Leaderboards
}
}
private void sort()
{
if (sorting.IsValid)
return;
var orderedByScore = scores
.OrderByDescending(i => i.TotalScore.Value)
.ThenBy(i => i.TotalScoreTiebreaker)
.ToList();
for (int i = 0; i < orderedByScore.Count; i++)
{
var score = orderedByScore[i];
score.DisplayOrder.Value = i;
score.Position.Value = i + 1;
}
sorting.Validate();
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
@@ -1,8 +1,10 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Game.Online.Leaderboards;
using osu.Game.Scoring;
@@ -12,8 +14,6 @@ namespace osu.Game.Screens.Select.Leaderboards
{
public partial class SoloGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider
{
public bool IsPartial { get; private set; }
public IBindableList<GameplayLeaderboardScore> Scores => scores;
private readonly BindableList<GameplayLeaderboardScore> scores = new BindableList<GameplayLeaderboardScore>();
@@ -23,13 +23,16 @@ namespace osu.Game.Screens.Select.Leaderboards
[Resolved]
private GameplayState? gameplayState { get; set; }
private readonly Cached sorting = new Cached();
private bool isPartial;
protected override void LoadComplete()
{
base.LoadComplete();
var globalScores = leaderboardManager?.Scores.Value;
IsPartial = leaderboardManager?.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && globalScores?.TopScores.Count >= 50;
isPartial = leaderboardManager?.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && globalScores?.TopScores.Count >= 50;
if (globalScores != null)
{
@@ -39,12 +42,70 @@ namespace osu.Game.Screens.Select.Leaderboards
if (gameplayState != null)
{
scores.Add(new GameplayLeaderboardScore(gameplayState.Score.ScoreInfo.User, gameplayState.ScoreProcessor, true)
var localScore = new GameplayLeaderboardScore(gameplayState.Score.ScoreInfo.User, gameplayState.ScoreProcessor, true)
{
// Local score should always show lower than any existing scores in cases of ties.
DisplayOrder = { Value = long.MaxValue }
});
TotalScoreTiebreaker = long.MaxValue
};
localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate());
scores.Add(localScore);
}
Scheduler.AddDelayed(sort, 1000, true);
}
private void sort()
{
if (sorting.IsValid)
return;
var orderedByScore = scores
.OrderByDescending(i => i.TotalScore.Value)
.ThenBy(i => i.TotalScoreTiebreaker)
.ToList();
int delta = 0;
for (int i = 0; i < orderedByScore.Count; i++)
{
var score = orderedByScore[i];
score.DisplayOrder.Value = i + 1;
// if we know we have all scores there can ever be, we can do the simple and obvious thing.
if (!isPartial)
score.Position.Value = i + 1;
else
{
// we have a partial leaderboard, with potential gaps.
// we have initial score positions which were valid at the point of starting play.
// the assumption here is that non-tracked scores here cannot move around, only tracked ones can.
if (score.Tracked)
{
int? previousScorePosition = i > 0 ? orderedByScore[i - 1].InitialPosition : 0;
int? nextScorePosition = i < orderedByScore.Count - 1 ? orderedByScore[i + 1].InitialPosition : null;
// if the tracked score is perfectly between two scores which have known neighbouring initial positions,
// we can assign it the position of the previous score plus one...
if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition)
{
score.Position.Value = previousScorePosition + 1;
// but we also need to ensure all subsequent scores get shifted down one position, too.
delta++;
}
// conversely, if the tracked score is not between neighbouring two scores and the leaderboard is partial,
// we can't really assign a valid position at all. it could be any number between the two neighbours.
else
score.Position.Value = null;
}
// for non-tracked scores, we just need to apply any delta that might have come from the tracked scores
// which might have been encountered and assigned a position earlier.
else
score.Position.Value = score.InitialPosition + delta;
}
}
sorting.Validate();
}
}
}