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);
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 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();
}

View File

@ -39,7 +39,9 @@ namespace osu.Game.Online.Leaderboards
/// <summary>
/// The currently displayed scores.
/// </summary>
public IEnumerable<TScoreInfo> Scores => scores;
public IBindableList<TScoreInfo> Scores => scores;
private readonly BindableList<TScoreInfo> scores = new BindableList<TScoreInfo>();
/// <summary>
/// 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 ICollection<TScoreInfo> scores;
private 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.");
}
Debug.Assert(scores?.Any() != true);
Debug.Assert(!scores.Any());
setState(state);
}
@ -181,15 +181,26 @@ namespace osu.Game.Online.Leaderboards
/// <param name="userScore">The user top score, if any.</param>
protected void SetScores(IEnumerable<TScoreInfo> scores, TScoreInfo userScore = default)
{
this.scores = scores?.ToList();
userScoreContainer.Score.Value = userScore;
this.scores.Clear();
if (scores != null)
this.scores.AddRange(scores);
if (userScore == null)
userScoreContainer.Hide();
else
userScoreContainer.Show();
// Schedule needs to be non-delayed here for the weird logic in refetchScores to work.
// If it is removed, the placeholder will be incorrectly updated to "no scores" rather than "retrieving".
// This whole flow should be refactored in the future.
Scheduler.Add(applyNewScores, false);
Scheduler.Add(updateScoresDrawables, false);
void applyNewScores()
{
userScoreContainer.Score.Value = userScore;
if (userScore == null)
userScoreContainer.Hide();
else
userScoreContainer.Show();
updateScoresDrawables();
}
}
/// <summary>
@ -209,8 +220,8 @@ namespace osu.Game.Online.Leaderboards
Debug.Assert(ThreadSafety.IsUpdateThread);
cancelPendingWork();
SetScores(null);
SetScores(null);
setState(LeaderboardState.Retrieving);
currentFetchCancellationSource = new CancellationTokenSource();
@ -247,7 +258,7 @@ namespace osu.Game.Online.Leaderboards
.Expire();
scoreFlowContainer = null;
if (scores?.Any() != true)
if (!scores.Any())
{
setState(LeaderboardState.NoScores);
return;

View File

@ -9,8 +9,6 @@ using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Graphics.UserInterface;
@ -21,7 +19,6 @@ using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Users;
using osuTK;
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 MultiplayerGameplayLeaderboard leaderboard;
private readonly MultiplayerRoomUser[] users;
private readonly Bindable<bool> leaderboardExpanded = new BindableBool();
private LoadingLayer loadingDisplay;
private FillFlowContainer leaderboardFlow;
private MultiplayerGameplayLeaderboard multiplayerLeaderboard;
/// <summary>
/// Construct a multiplayer player.
@ -62,7 +56,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
AllowPause = false,
AllowRestart = false,
AllowSkipping = room.AutoSkip.Value,
AutomaticallySkipIntro = room.AutoSkip.Value
AutomaticallySkipIntro = room.AutoSkip.Value,
AlwaysShowLeaderboard = true,
})
{
this.users = users;
@ -74,45 +69,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!LoadedBeatmapSuccessfully)
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)
{
Expanded = { BindTarget = leaderboardExpanded },
}, chat => leaderboardFlow.Insert(2, chat));
Expanded = { BindTarget = LeaderboardExpandedState },
}, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat));
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()
{
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)
{
if (!string.IsNullOrEmpty(message))
@ -178,23 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
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(() =>
{
if (!this.IsCurrentScreen())
@ -232,8 +195,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
Debug.Assert(Room.RoomID.Value != null);
return leaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores)
return multiplayerLeaderboard.TeamScores.Count == 2
? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores)
: 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.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
@ -13,15 +10,14 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users;
using osuTK;
using osuTK.Graphics;
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();
public Bindable<bool> Expanded = new Bindable<bool>();
@ -31,16 +27,15 @@ namespace osu.Game.Screens.Play.HUD
private bool requiresScroll;
private readonly OsuScrollContainer scroll;
private GameplayLeaderboardScore trackedScore;
private GameplayLeaderboardScore? trackedScore;
private const int max_panels = 8;
/// <summary>
/// Create a new leaderboard.
/// </summary>
/// <param name="maxPanels">The maximum panels to show at once. Defines the maximum height of this component.</param>
public GameplayLeaderboard(int maxPanels = 8)
protected GameplayLeaderboard()
{
this.maxPanels = maxPanels;
Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH;
InternalChildren = new Drawable[]
@ -77,7 +72,7 @@ namespace osu.Game.Screens.Play.HUD
/// 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.
/// </param>
public ILeaderboardScore Add([CanBeNull] APIUser user, bool isTracked)
public ILeaderboardScore Add(IUser? user, bool isTracked)
{
var drawable = CreateLeaderboardScoreDrawable(user, isTracked);
@ -93,8 +88,9 @@ namespace osu.Game.Screens.Play.HUD
Flow.Add(drawable);
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);
// 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
@ -116,7 +112,7 @@ namespace osu.Game.Screens.Play.HUD
scroll.ScrollToStart(false);
}
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) =>
protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) =>
new GameplayLeaderboardScore(user, isTracked);
protected override void Update()
@ -173,7 +169,10 @@ namespace osu.Game.Screens.Play.HUD
if (sorting.IsValid)
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++)
{

View File

@ -12,7 +12,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Users;
using osu.Game.Users.Drawables;
using osu.Game.Utils;
using osuTK;
@ -55,6 +55,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<long> DisplayOrder { get; } = new Bindable<long>();
public Color4? BackgroundColour { get; set; }
@ -81,7 +82,7 @@ namespace osu.Game.Screens.Play.HUD
}
[CanBeNull]
public APIUser User { get; }
public IUser User { get; }
/// <summary>
/// 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>
/// <param name="user">The score's 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;
Tracked = tracked;

View File

@ -14,5 +14,11 @@ namespace osu.Game.Screens.Play.HUD
BindableInt Combo { 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.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Users;
using osuTK.Graphics;
namespace osu.Game.Screens.Play.HUD
@ -125,11 +126,11 @@ namespace osu.Game.Screens.Play.HUD
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);
if (UserScores[user.Id].Team is int team)
if (UserScores[user.OnlineID].Team is int team)
{
leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f);
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.Bindables;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
@ -35,11 +34,6 @@ namespace osu.Game.Screens.Play
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>
/// The total height of all the bottom of screen scoring elements.
/// </summary>
@ -80,9 +74,15 @@ namespace osu.Game.Screens.Play
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.mods = mods;
@ -127,8 +127,20 @@ namespace osu.Game.Screens.Play
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)]
@ -177,22 +189,36 @@ namespace osu.Game.Screens.Play
{
base.Update();
Vector2? lowestTopScreenSpace = null;
float? lowestTopScreenSpaceLeft = null;
float? lowestTopScreenSpaceRight = null;
Vector2? highestBottomScreenSpace = null;
// LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes.
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.
if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X))
// for now align some top components with the bottom-edge of the lowest top-anchored hud element.
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.
if (element is LegacyHealthDisplay)
continue;
var bottomRight = element.ScreenSpaceDrawQuad.BottomRight;
if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y)
lowestTopScreenSpace = bottomRight;
float bottom = element.ScreenSpaceDrawQuad.BottomRight.Y;
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.
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)
topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight);
if (lowestTopScreenSpaceRight.HasValue)
topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight);
else
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)
bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight);
else

View File

@ -9,6 +9,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@ -34,6 +35,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Skinning;
using osu.Game.Users;
@ -375,6 +377,8 @@ namespace osu.Game.Screens.Play
if (Configuration.AutomaticallySkipIntro)
skipIntroOverlay.SkipWhenReady();
loadLeaderboard();
}
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.
DrawableRuleset.Cursor?.CreateProxy() ?? new Container(),
DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(),
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods)
HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard)
{
HoldToQuit =
{
@ -820,6 +824,41 @@ namespace osu.Game.Screens.Play
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
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.
/// </summary>
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.Linq;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
@ -14,6 +15,7 @@ using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
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.
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);
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)

View File

@ -5,12 +5,15 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.Play
{
@ -40,8 +43,26 @@ namespace osu.Game.Screens.Play
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 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)
{
IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo;

View File

@ -41,6 +41,11 @@ namespace osu.Game.Screens.Select.Leaderboards
return;
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();
}
}
@ -148,12 +153,10 @@ namespace osu.Game.Screens.Select.Leaderboards
var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods);
req.Success += r => Schedule(() =>
{
SetScores(
scoreManager.OrderByTotalScore(r.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))),
r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo));
});
req.Success += r => SetScores(
scoreManager.OrderByTotalScore(r.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))),
r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo)
);
return req;
}
@ -208,7 +211,7 @@ namespace osu.Game.Screens.Select.Leaderboards
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.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@ -24,27 +22,38 @@ namespace osu.Game.Screens.Select
{
public class PlaySongSelect : SongSelect
{
private OsuScreen playerLoader;
private OsuScreen? playerLoader;
[Resolved(CanBeNull = true)]
private INotificationOverlay notifications { get; set; }
private INotificationOverlay? notifications { get; set; }
public override bool AllowExternalScreenChange => true;
protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap();
private PlayBeatmapDetailArea playBeatmapDetailArea = null!;
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit());
((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore;
}
protected void PresentScore(ScoreInfo score) =>
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)
{
@ -61,9 +70,9 @@ namespace osu.Game.Screens.Select
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()
{
@ -100,14 +109,26 @@ namespace osu.Game.Screens.Select
Player createPlayer()
{
Player player;
var replayGeneratingMod = Mods.Value.OfType<ICreateReplayData>().FirstOrDefault();
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]
public class DrawableAvatar : Sprite
{
private readonly APIUser user;
private readonly IUser user;
/// <summary>
/// A simple, non-interactable avatar sprite for the specified user.
/// </summary>
/// <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;
@ -33,10 +33,10 @@ namespace osu.Game.Users.Drawables
[BackgroundDependencyLoader]
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
// 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");
}