Merge pull request #20286 from peppy/gameplay-leaderboards

Add basic gameplay leaderboard display
This commit is contained in:
Dan Balasescu
2022-09-22 21:16:12 +09:00
committed by GitHub
18 changed files with 395 additions and 139 deletions

View File

@ -0,0 +1,72 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneSoloGameplayLeaderboard : OsuTestScene
{
[Cached]
private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset());
private readonly BindableList<ScoreInfo> scores = new BindableList<ScoreInfo>();
[SetUpSteps]
public void SetUpSteps()
{
AddStep("clear scores", () => scores.Clear());
AddStep("create component", () =>
{
var trackingUser = new APIUser
{
Username = "local user",
Id = 2,
};
Child = new SoloGameplayLeaderboard(trackingUser)
{
Scores = { BindTarget = scores },
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Expanded = { Value = true },
};
});
AddStep("add scores", () => scores.AddRange(createSampleScores()));
}
[Test]
public void TestLocalUser()
{
AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v);
AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v);
AddSliderStep("combo", 0, 1000, 0, v => scoreProcessor.Combo.Value = v);
}
private static List<ScoreInfo> createSampleScores()
{
return new[]
{
new ScoreInfo { User = new APIUser { Username = @"peppy" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"smoogipoo" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) },
new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) },
}.Concat(Enumerable.Range(0, 50).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList();
}
}
}

View File

@ -120,6 +120,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
=> AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time); => AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time);
private void assertCombo(int userId, int expectedCombo) private void assertCombo(int userId, int expectedCombo)
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo);
} }
} }

View File

@ -522,7 +522,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId); private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId);
private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId); private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.OnlineID == userId);
private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray(); private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray();
} }

View File

@ -39,7 +39,9 @@ namespace osu.Game.Online.Leaderboards
/// <summary> /// <summary>
/// The currently displayed scores. /// The currently displayed scores.
/// </summary> /// </summary>
public IEnumerable<TScoreInfo> Scores => scores; public IBindableList<TScoreInfo> Scores => scores;
private readonly BindableList<TScoreInfo> scores = new BindableList<TScoreInfo>();
/// <summary> /// <summary>
/// Whether the current scope should refetch in response to changes in API connectivity state. /// Whether the current scope should refetch in response to changes in API connectivity state.
@ -68,8 +70,6 @@ namespace osu.Game.Online.Leaderboards
private readonly IBindable<APIState> apiState = new Bindable<APIState>(); private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private ICollection<TScoreInfo> scores;
private TScope scope; private TScope scope;
public TScope Scope public TScope Scope
@ -169,7 +169,7 @@ namespace osu.Game.Online.Leaderboards
throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation."); throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation.");
} }
Debug.Assert(scores?.Any() != true); Debug.Assert(!scores.Any());
setState(state); setState(state);
} }
@ -181,15 +181,26 @@ namespace osu.Game.Online.Leaderboards
/// <param name="userScore">The user top score, if any.</param> /// <param name="userScore">The user top score, if any.</param>
protected void SetScores(IEnumerable<TScoreInfo> scores, TScoreInfo userScore = default) protected void SetScores(IEnumerable<TScoreInfo> scores, TScoreInfo userScore = default)
{ {
this.scores = scores?.ToList(); this.scores.Clear();
userScoreContainer.Score.Value = userScore; if (scores != null)
this.scores.AddRange(scores);
if (userScore == null) // Schedule needs to be non-delayed here for the weird logic in refetchScores to work.
userScoreContainer.Hide(); // If it is removed, the placeholder will be incorrectly updated to "no scores" rather than "retrieving".
else // This whole flow should be refactored in the future.
userScoreContainer.Show(); Scheduler.Add(applyNewScores, false);
Scheduler.Add(updateScoresDrawables, false); void applyNewScores()
{
userScoreContainer.Score.Value = userScore;
if (userScore == null)
userScoreContainer.Hide();
else
userScoreContainer.Show();
updateScoresDrawables();
}
} }
/// <summary> /// <summary>
@ -209,8 +220,8 @@ namespace osu.Game.Online.Leaderboards
Debug.Assert(ThreadSafety.IsUpdateThread); Debug.Assert(ThreadSafety.IsUpdateThread);
cancelPendingWork(); cancelPendingWork();
SetScores(null);
SetScores(null);
setState(LeaderboardState.Retrieving); setState(LeaderboardState.Retrieving);
currentFetchCancellationSource = new CancellationTokenSource(); currentFetchCancellationSource = new CancellationTokenSource();
@ -247,7 +258,7 @@ namespace osu.Game.Online.Leaderboards
.Expire(); .Expire();
scoreFlowContainer = null; scoreFlowContainer = null;
if (scores?.Any() != true) if (!scores.Any())
{ {
setState(LeaderboardState.NoScores); setState(LeaderboardState.NoScores);
return; return;

View File

@ -9,8 +9,6 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -21,7 +19,6 @@ using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
using osu.Game.Users; using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
@ -41,14 +38,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private readonly TaskCompletionSource<bool> resultsReady = new TaskCompletionSource<bool>(); private readonly TaskCompletionSource<bool> resultsReady = new TaskCompletionSource<bool>();
private MultiplayerGameplayLeaderboard leaderboard;
private readonly MultiplayerRoomUser[] users; private readonly MultiplayerRoomUser[] users;
private readonly Bindable<bool> leaderboardExpanded = new BindableBool();
private LoadingLayer loadingDisplay; private LoadingLayer loadingDisplay;
private FillFlowContainer leaderboardFlow;
private MultiplayerGameplayLeaderboard multiplayerLeaderboard;
/// <summary> /// <summary>
/// Construct a multiplayer player. /// Construct a multiplayer player.
@ -62,7 +56,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
AllowPause = false, AllowPause = false,
AllowRestart = false, AllowRestart = false,
AllowSkipping = room.AutoSkip.Value, AllowSkipping = room.AutoSkip.Value,
AutomaticallySkipIntro = room.AutoSkip.Value AutomaticallySkipIntro = room.AutoSkip.Value,
AlwaysShowLeaderboard = true,
}) })
{ {
this.users = users; this.users = users;
@ -74,45 +69,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!LoadedBeatmapSuccessfully) if (!LoadedBeatmapSuccessfully)
return; return;
HUDOverlay.Add(leaderboardFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5)
});
HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);
// todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area.
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(users), l =>
{
if (!LoadedBeatmapSuccessfully)
return;
leaderboard.Expanded.BindTo(leaderboardExpanded);
leaderboardFlow.Insert(0, l);
if (leaderboard.TeamScores.Count >= 2)
{
LoadComponentAsync(new GameplayMatchScoreDisplay
{
Team1Score = { BindTarget = leaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value },
Expanded = { BindTarget = HUDOverlay.ShowHud },
}, scoreDisplay => leaderboardFlow.Insert(1, scoreDisplay));
}
});
LoadComponentAsync(new GameplayChatDisplay(Room) LoadComponentAsync(new GameplayChatDisplay(Room)
{ {
Expanded = { BindTarget = leaderboardExpanded }, Expanded = { BindTarget = LeaderboardExpandedState },
}, chat => leaderboardFlow.Insert(2, chat)); }, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat));
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
} }
protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users);
protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard)
{
Debug.Assert(leaderboard == multiplayerLeaderboard);
HUDOverlay.LeaderboardFlow.Insert(0, leaderboard);
if (multiplayerLeaderboard.TeamScores.Count >= 2)
{
LoadComponentAsync(new GameplayMatchScoreDisplay
{
Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value },
Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value },
Expanded = { BindTarget = HUDOverlay.ShowHud },
}, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay));
}
}
protected override void LoadAsyncComplete() protected override void LoadAsyncComplete()
{ {
base.LoadAsyncComplete(); base.LoadAsyncComplete();
@ -167,9 +150,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
} }
} }
private void updateLeaderboardExpandedState() =>
leaderboardExpanded.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;
private void failAndBail(string message = null) private void failAndBail(string message = null)
{ {
if (!string.IsNullOrEmpty(message)) if (!string.IsNullOrEmpty(message))
@ -178,23 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Schedule(() => PerformExit(false)); Schedule(() => PerformExit(false));
} }
protected override void Update()
{
base.Update();
if (!LoadedBeatmapSuccessfully)
return;
adjustLeaderboardPosition();
}
private void adjustLeaderboardPosition()
{
const float padding = 44; // enough margin to avoid the hit error display.
leaderboardFlow.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight);
}
private void onGameplayStarted() => Scheduler.Add(() => private void onGameplayStarted() => Scheduler.Add(() =>
{ {
if (!this.IsCurrentScreen()) if (!this.IsCurrentScreen())
@ -232,8 +195,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
Debug.Assert(Room.RoomID.Value != null); Debug.Assert(Room.RoomID.Value != null);
return leaderboard.TeamScores.Count == 2 return multiplayerLeaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores) ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores)
: new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem); : new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem);
} }

View File

@ -1,11 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching; using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
@ -13,15 +10,14 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Users;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD namespace osu.Game.Screens.Play.HUD
{ {
public class GameplayLeaderboard : CompositeDrawable public abstract class GameplayLeaderboard : CompositeDrawable
{ {
private readonly int maxPanels;
private readonly Cached sorting = new Cached(); private readonly Cached sorting = new Cached();
public Bindable<bool> Expanded = new Bindable<bool>(); public Bindable<bool> Expanded = new Bindable<bool>();
@ -31,16 +27,15 @@ namespace osu.Game.Screens.Play.HUD
private bool requiresScroll; private bool requiresScroll;
private readonly OsuScrollContainer scroll; private readonly OsuScrollContainer scroll;
private GameplayLeaderboardScore trackedScore; private GameplayLeaderboardScore? trackedScore;
private const int max_panels = 8;
/// <summary> /// <summary>
/// Create a new leaderboard. /// Create a new leaderboard.
/// </summary> /// </summary>
/// <param name="maxPanels">The maximum panels to show at once. Defines the maximum height of this component.</param> protected GameplayLeaderboard()
public GameplayLeaderboard(int maxPanels = 8)
{ {
this.maxPanels = maxPanels;
Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH;
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
@ -77,7 +72,7 @@ namespace osu.Game.Screens.Play.HUD
/// Whether the player should be tracked on the leaderboard. /// Whether the player should be tracked on the leaderboard.
/// Set to <c>true</c> for the local player or a player whose replay is currently being played. /// Set to <c>true</c> for the local player or a player whose replay is currently being played.
/// </param> /// </param>
public ILeaderboardScore Add([CanBeNull] APIUser user, bool isTracked) public ILeaderboardScore Add(IUser? user, bool isTracked)
{ {
var drawable = CreateLeaderboardScoreDrawable(user, isTracked); var drawable = CreateLeaderboardScoreDrawable(user, isTracked);
@ -93,8 +88,9 @@ namespace osu.Game.Screens.Play.HUD
Flow.Add(drawable); Flow.Add(drawable);
drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true); drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true);
drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true);
int displayCount = Math.Min(Flow.Count, maxPanels); int displayCount = Math.Min(Flow.Count, max_panels);
Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y);
// Add extra margin space to flow equal to height of leaderboard. // Add extra margin space to flow equal to height of leaderboard.
// This ensures the content is always on screen, but also accounts for the fact that scroll operations // This ensures the content is always on screen, but also accounts for the fact that scroll operations
@ -116,7 +112,7 @@ namespace osu.Game.Screens.Play.HUD
scroll.ScrollToStart(false); scroll.ScrollToStart(false);
} }
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) => protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) =>
new GameplayLeaderboardScore(user, isTracked); new GameplayLeaderboardScore(user, isTracked);
protected override void Update() protected override void Update()
@ -173,7 +169,10 @@ namespace osu.Game.Screens.Play.HUD
if (sorting.IsValid) if (sorting.IsValid)
return; return;
var orderedByScore = Flow.OrderByDescending(i => i.TotalScore.Value).ToList(); var orderedByScore = Flow
.OrderByDescending(i => i.TotalScore.Value)
.ThenBy(i => i.DisplayOrder.Value)
.ToList();
for (int i = 0; i < Flow.Count; i++) for (int i = 0; i < Flow.Count; i++)
{ {

View File

@ -12,7 +12,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses; using osu.Game.Users;
using osu.Game.Users.Drawables; using osu.Game.Users.Drawables;
using osu.Game.Utils; using osu.Game.Utils;
using osuTK; using osuTK;
@ -55,6 +55,7 @@ namespace osu.Game.Screens.Play.HUD
public BindableDouble Accuracy { get; } = new BindableDouble(1); public BindableDouble Accuracy { get; } = new BindableDouble(1);
public BindableInt Combo { get; } = new BindableInt(); public BindableInt Combo { get; } = new BindableInt();
public BindableBool HasQuit { get; } = new BindableBool(); public BindableBool HasQuit { get; } = new BindableBool();
public Bindable<long> DisplayOrder { get; } = new Bindable<long>();
public Color4? BackgroundColour { get; set; } public Color4? BackgroundColour { get; set; }
@ -81,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
} }
[CanBeNull] [CanBeNull]
public APIUser User { get; } public IUser User { get; }
/// <summary> /// <summary>
/// Whether this score is the local user or a replay player (and should be focused / always visible). /// Whether this score is the local user or a replay player (and should be focused / always visible).
@ -103,7 +104,7 @@ namespace osu.Game.Screens.Play.HUD
/// </summary> /// </summary>
/// <param name="user">The score's player.</param> /// <param name="user">The score's player.</param>
/// <param name="tracked">Whether the player is the local user or a replay player.</param> /// <param name="tracked">Whether the player is the local user or a replay player.</param>
public GameplayLeaderboardScore([CanBeNull] APIUser user, bool tracked) public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked)
{ {
User = user; User = user;
Tracked = tracked; Tracked = tracked;

View File

@ -14,5 +14,11 @@ namespace osu.Game.Screens.Play.HUD
BindableInt Combo { get; } BindableInt Combo { get; }
BindableBool HasQuit { get; } BindableBool HasQuit { get; }
/// <summary>
/// An optional value to guarantee stable ordering.
/// Lower numbers will appear higher in cases of <see cref="TotalScore"/> ties.
/// </summary>
Bindable<long> DisplayOrder { get; }
} }
} }

View File

@ -21,6 +21,7 @@ using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Users;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD namespace osu.Game.Screens.Play.HUD
@ -125,11 +126,11 @@ namespace osu.Game.Screens.Play.HUD
playingUserIds.BindCollectionChanged(playingUsersChanged); playingUserIds.BindCollectionChanged(playingUsersChanged);
} }
protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser user, bool isTracked)
{ {
var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked);
if (UserScores[user.Id].Team is int team) if (UserScores[user.OnlineID].Team is int team)
{ {
leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f);
leaderboardScore.TextColour = Color4.White; leaderboardScore.TextColour = Color4.White;

View File

@ -0,0 +1,73 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Users;
namespace osu.Game.Screens.Play.HUD
{
public class SoloGameplayLeaderboard : GameplayLeaderboard
{
private readonly IUser trackingUser;
public readonly IBindableList<ScoreInfo> Scores = new BindableList<ScoreInfo>();
// hold references to ensure bindables are updated.
private readonly List<Bindable<long>> scoreBindables = new List<Bindable<long>>();
[Resolved]
private ScoreProcessor scoreProcessor { get; set; } = null!;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
public SoloGameplayLeaderboard(IUser trackingUser)
{
this.trackingUser = trackingUser;
}
protected override void LoadComplete()
{
base.LoadComplete();
Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true);
}
private void showScores()
{
Clear();
scoreBindables.Clear();
if (!Scores.Any())
return;
foreach (var s in Scores)
{
var score = Add(s.User, false);
var bindableTotal = scoreManager.GetBindableTotalScore(s);
// Direct binding not possible due to differing types (see https://github.com/ppy/osu/issues/20298).
bindableTotal.BindValueChanged(total => score.TotalScore.Value = total.NewValue, true);
scoreBindables.Add(bindableTotal);
score.Accuracy.Value = s.Accuracy;
score.Combo.Value = s.MaxCombo;
score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds();
}
ILeaderboardScore local = Add(trackingUser, true);
local.TotalScore.BindTarget = scoreProcessor.TotalScore;
local.Accuracy.BindTarget = scoreProcessor.Accuracy;
local.Combo.BindTarget = scoreProcessor.Combo;
// Local score should always show lower than any existing scores in cases of ties.
local.DisplayOrder.Value = long.MaxValue;
}
}
}

View File

@ -9,7 +9,6 @@ using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
@ -35,11 +34,6 @@ namespace osu.Game.Screens.Play
public const Easing FADE_EASING = Easing.OutQuint; public const Easing FADE_EASING = Easing.OutQuint;
/// <summary>
/// The total height of all the top of screen scoring elements.
/// </summary>
public float TopScoringElementsHeight { get; private set; }
/// <summary> /// <summary>
/// The total height of all the bottom of screen scoring elements. /// The total height of all the bottom of screen scoring elements.
/// </summary> /// </summary>
@ -80,9 +74,15 @@ namespace osu.Game.Screens.Play
private readonly SkinnableTargetContainer mainComponents; private readonly SkinnableTargetContainer mainComponents;
private IEnumerable<Drawable> hideTargets => new Drawable[] { mainComponents, KeyCounter, topRightElements }; /// <summary>
/// A flow which sits at the left side of the screen to house leaderboard (and related) components.
/// Will automatically be positioned to avoid colliding with top scoring elements.
/// </summary>
public readonly FillFlowContainer LeaderboardFlow;
public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods) private readonly List<Drawable> hideTargets;
public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods, bool alwaysShowLeaderboard = true)
{ {
this.drawableRuleset = drawableRuleset; this.drawableRuleset = drawableRuleset;
this.mods = mods; this.mods = mods;
@ -127,8 +127,20 @@ namespace osu.Game.Screens.Play
HoldToQuit = CreateHoldForMenuButton(), HoldToQuit = CreateHoldForMenuButton(),
} }
}, },
clicksPerSecondCalculator = new ClicksPerSecondCalculator() LeaderboardFlow = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Padding = new MarginPadding(44), // enough margin to avoid the hit error display
Spacing = new Vector2(5)
},
clicksPerSecondCalculator = new ClicksPerSecondCalculator(),
}; };
hideTargets = new List<Drawable> { mainComponents, KeyCounter, topRightElements };
if (!alwaysShowLeaderboard)
hideTargets.Add(LeaderboardFlow);
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
@ -177,22 +189,36 @@ namespace osu.Game.Screens.Play
{ {
base.Update(); base.Update();
Vector2? lowestTopScreenSpace = null; float? lowestTopScreenSpaceLeft = null;
float? lowestTopScreenSpaceRight = null;
Vector2? highestBottomScreenSpace = null; Vector2? highestBottomScreenSpace = null;
// LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes. // LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes.
foreach (var element in mainComponents.Components.Cast<Drawable>()) foreach (var element in mainComponents.Components.Cast<Drawable>())
{ {
// for now align top-right components with the bottom-edge of the lowest top-anchored hud element. // for now align some top components with the bottom-edge of the lowest top-anchored hud element.
if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X)) if (element.Anchor.HasFlagFast(Anchor.y0))
{ {
// health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area.
if (element is LegacyHealthDisplay) if (element is LegacyHealthDisplay)
continue; continue;
var bottomRight = element.ScreenSpaceDrawQuad.BottomRight; float bottom = element.ScreenSpaceDrawQuad.BottomRight.Y;
if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y)
lowestTopScreenSpace = bottomRight; bool isRelativeX = element.RelativeSizeAxes == Axes.X;
if (element.Anchor.HasFlagFast(Anchor.TopRight) || isRelativeX)
{
if (lowestTopScreenSpaceRight == null || bottom > lowestTopScreenSpaceRight.Value)
lowestTopScreenSpaceRight = bottom;
}
if (element.Anchor.HasFlagFast(Anchor.TopLeft) || isRelativeX)
{
if (lowestTopScreenSpaceLeft == null || bottom > lowestTopScreenSpaceLeft.Value)
lowestTopScreenSpaceLeft = bottom;
}
} }
// and align bottom-right components with the top-edge of the highest bottom-anchored hud element. // and align bottom-right components with the top-edge of the highest bottom-anchored hud element.
else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X)) else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X))
@ -203,11 +229,16 @@ namespace osu.Game.Screens.Play
} }
} }
if (lowestTopScreenSpace.HasValue) if (lowestTopScreenSpaceRight.HasValue)
topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight); topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight);
else else
topRightElements.Y = 0; topRightElements.Y = 0;
if (lowestTopScreenSpaceLeft.HasValue)
LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight);
else
LeaderboardFlow.Y = 0;
if (highestBottomScreenSpace.HasValue) if (highestBottomScreenSpace.HasValue)
bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight);
else else

View File

@ -9,6 +9,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Audio.Sample; using osu.Framework.Audio.Sample;
@ -34,6 +35,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Scoring.Legacy; using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
using osu.Game.Skinning; using osu.Game.Skinning;
using osu.Game.Users; using osu.Game.Users;
@ -375,6 +377,8 @@ namespace osu.Game.Screens.Play
if (Configuration.AutomaticallySkipIntro) if (Configuration.AutomaticallySkipIntro)
skipIntroOverlay.SkipWhenReady(); skipIntroOverlay.SkipWhenReady();
loadLeaderboard();
} }
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);
@ -417,7 +421,7 @@ namespace osu.Game.Screens.Play
// display the cursor above some HUD elements. // display the cursor above some HUD elements.
DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.Cursor?.CreateProxy() ?? new Container(),
DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(),
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard)
{ {
HoldToQuit = HoldToQuit =
{ {
@ -820,6 +824,41 @@ namespace osu.Game.Screens.Play
return mouseWheelDisabled.Value && !e.AltPressed; return mouseWheelDisabled.Value && !e.AltPressed;
} }
#region Gameplay leaderboard
protected readonly Bindable<bool> LeaderboardExpandedState = new BindableBool();
private void loadLeaderboard()
{
HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);
var gameplayLeaderboard = CreateGameplayLeaderboard();
if (gameplayLeaderboard != null)
{
LoadComponentAsync(gameplayLeaderboard, leaderboard =>
{
if (!LoadedBeatmapSuccessfully)
return;
leaderboard.Expanded.BindTo(LeaderboardExpandedState);
AddLeaderboardToHUD(leaderboard);
});
}
}
[CanBeNull]
protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null;
protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard);
private void updateLeaderboardExpandedState() =>
LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;
#endregion
#region Fail Logic #region Fail Logic
protected FailOverlay FailOverlay { get; private set; } protected FailOverlay FailOverlay { get; private set; }

View File

@ -36,5 +36,10 @@ namespace osu.Game.Screens.Play
/// Whether the intro should be skipped by default. /// Whether the intro should be skipped by default.
/// </summary> /// </summary>
public bool AutomaticallySkipIntro { get; set; } public bool AutomaticallySkipIntro { get; set; }
/// <summary>
/// Whether the gameplay leaderboard should always be shown (usually in a contracted state).
/// </summary>
public bool AlwaysShowLeaderboard { get; set; }
} }
} }

View File

@ -7,6 +7,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
@ -14,6 +15,7 @@ using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
@ -55,6 +57,14 @@ namespace osu.Game.Screens.Play
// Don't re-import replay scores as they're already present in the database. // Don't re-import replay scores as they're already present in the database.
protected override Task ImportScore(Score score) => Task.CompletedTask; protected override Task ImportScore(Score score) => Task.CompletedTask;
public readonly BindableList<ScoreInfo> LeaderboardScores = new BindableList<ScoreInfo>();
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
Scores = { BindTarget = LeaderboardScores }
};
protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false);
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)

View File

@ -5,12 +5,15 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Extensions; using osu.Game.Extensions;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Online.Solo; using osu.Game.Online.Solo;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.Play namespace osu.Game.Screens.Play
{ {
@ -40,8 +43,26 @@ namespace osu.Game.Screens.Play
return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash); return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash);
} }
public readonly BindableList<ScoreInfo> LeaderboardScores = new BindableList<ScoreInfo>();
protected override GameplayLeaderboard CreateGameplayLeaderboard() =>
new SoloGameplayLeaderboard(Score.ScoreInfo.User)
{
Scores = { BindTarget = LeaderboardScores }
};
protected override bool HandleTokenRetrievalFailure(Exception exception) => false; protected override bool HandleTokenRetrievalFailure(Exception exception) => false;
protected override Task ImportScore(Score score)
{
// Before importing a score, stop binding the leaderboard with its score source.
// This avoids a case where the imported score may cause a leaderboard refresh
// (if the leaderboard's source is local).
LeaderboardScores.UnbindBindings();
return base.ImportScore(score);
}
protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token) protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token)
{ {
IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo;

View File

@ -41,6 +41,11 @@ namespace osu.Game.Screens.Select.Leaderboards
return; return;
beatmapInfo = value; beatmapInfo = value;
// Refetch is scheduled, which can cause scores to be outdated if the leaderboard is not currently updating.
// As scores are potentially used by other components, clear them eagerly to ensure a more correct state.
SetScores(null);
RefetchScores(); RefetchScores();
} }
} }
@ -148,12 +153,10 @@ namespace osu.Game.Screens.Select.Leaderboards
var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods);
req.Success += r => Schedule(() => req.Success += r => SetScores(
{ scoreManager.OrderByTotalScore(r.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))),
SetScores( r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo)
scoreManager.OrderByTotalScore(r.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))), );
r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo));
});
return req; return req;
} }
@ -208,7 +211,7 @@ namespace osu.Game.Screens.Select.Leaderboards
scores = scoreManager.OrderByTotalScore(scores.Detach()); scores = scoreManager.OrderByTotalScore(scores.Detach());
Schedule(() => SetScores(scores)); SetScores(scores);
} }
} }

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -24,27 +22,38 @@ namespace osu.Game.Screens.Select
{ {
public class PlaySongSelect : SongSelect public class PlaySongSelect : SongSelect
{ {
private OsuScreen playerLoader; private OsuScreen? playerLoader;
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private INotificationOverlay notifications { get; set; } private INotificationOverlay? notifications { get; set; }
public override bool AllowExternalScreenChange => true; public override bool AllowExternalScreenChange => true;
protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap();
private PlayBeatmapDetailArea playBeatmapDetailArea = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(OsuColour colours) private void load(OsuColour colours)
{ {
BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit());
((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore;
} }
protected void PresentScore(ScoreInfo score) => protected void PresentScore(ScoreInfo score) =>
FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false)));
protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); protected override BeatmapDetailArea CreateBeatmapDetailArea()
{
playBeatmapDetailArea = new PlayBeatmapDetailArea
{
Leaderboard =
{
ScoreSelected = PresentScore
}
};
return playBeatmapDetailArea;
}
protected override bool OnKeyDown(KeyDownEvent e) protected override bool OnKeyDown(KeyDownEvent e)
{ {
@ -61,9 +70,9 @@ namespace osu.Game.Screens.Select
return base.OnKeyDown(e); return base.OnKeyDown(e);
} }
private IReadOnlyList<Mod> modsAtGameplayStart; private IReadOnlyList<Mod>? modsAtGameplayStart;
private ModAutoplay getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod();
protected override bool OnStart() protected override bool OnStart()
{ {
@ -100,14 +109,26 @@ namespace osu.Game.Screens.Select
Player createPlayer() Player createPlayer()
{ {
Player player;
var replayGeneratingMod = Mods.Value.OfType<ICreateReplayData>().FirstOrDefault(); var replayGeneratingMod = Mods.Value.OfType<ICreateReplayData>().FirstOrDefault();
if (replayGeneratingMod != null) if (replayGeneratingMod != null)
{ {
return new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods))
{
LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores }
};
}
else
{
player = new SoloPlayer
{
LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores }
};
} }
return new SoloPlayer(); return player;
} }
} }

View File

@ -14,13 +14,13 @@ namespace osu.Game.Users.Drawables
[LongRunningLoad] [LongRunningLoad]
public class DrawableAvatar : Sprite public class DrawableAvatar : Sprite
{ {
private readonly APIUser user; private readonly IUser user;
/// <summary> /// <summary>
/// A simple, non-interactable avatar sprite for the specified user. /// A simple, non-interactable avatar sprite for the specified user.
/// </summary> /// </summary>
/// <param name="user">The user. A null value will get a placeholder avatar.</param> /// <param name="user">The user. A null value will get a placeholder avatar.</param>
public DrawableAvatar(APIUser user = null) public DrawableAvatar(IUser user = null)
{ {
this.user = user; this.user = user;
@ -33,10 +33,10 @@ namespace osu.Game.Users.Drawables
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(LargeTextureStore textures) private void load(LargeTextureStore textures)
{ {
if (user != null && user.Id > 1) if (user != null && user.OnlineID > 1)
// TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise // TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise
// in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed. // in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed.
Texture = textures.Get(user.AvatarUrl ?? $@"https://a.ppy.sh/{user.Id}"); Texture = textures.Get((user as APIUser)?.AvatarUrl ?? $@"https://a.ppy.sh/{user.OnlineID}");
Texture ??= textures.Get(@"Online/avatar-guest"); Texture ??= textures.Get(@"Online/avatar-guest");
} }