1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-31 01:50:20 +08:00

Merge pull request #32844 from frenzibyte/song-select-v2-wedges-leaderboard

Add song select beatmap leaderboard display
This commit is contained in:
Dean Herbert
2025-04-30 14:37:55 +09:00
committed by GitHub
Unverified
5 changed files with 1560 additions and 518 deletions
@@ -7,6 +7,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
@@ -28,7 +29,7 @@ using osuTK.Input;
namespace osu.Game.Tests.Visual.SongSelectV2
{
public partial class TestSceneLeaderboardScore : SongSelectComponentsTestScene
public partial class TestSceneBeatmapLeaderboardScore : SongSelectComponentsTestScene
{
[Cached]
private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
@@ -44,18 +45,23 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
AddStep("create content", () =>
{
Children = new Drawable[]
Child = new PopoverContainer
{
fillFlow = new FillFlowContainer
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
Shear = OsuGame.SHEAR,
},
drawWidthText = new OsuSpriteText(),
fillFlow = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
Shear = OsuGame.SHEAR,
},
drawWidthText = new OsuSpriteText(),
}
};
foreach (var scoreInfo in getTestScores())
@@ -78,22 +84,27 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
AddStep("create content", () =>
{
Children = new Drawable[]
Child = new PopoverContainer
{
fillFlow = new FillFlowContainer
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
},
drawWidthText = new OsuSpriteText(),
fillFlow = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
},
drawWidthText = new OsuSpriteText(),
}
};
foreach (var scoreInfo in getTestScores())
{
fillFlow.Add(new BeatmapLeaderboardScore(scoreInfo)
fillFlow.Add(new BeatmapLeaderboardScore(scoreInfo, sheared: false)
{
Rank = scoreInfo.Position,
IsPersonalBest = scoreInfo.User.Id == 2,
@@ -112,18 +123,23 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddStep("create content", () =>
{
Children = new Drawable[]
Child = new PopoverContainer
{
fillFlow = new FillFlowContainer
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new Drawable[]
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
Shear = OsuGame.SHEAR,
},
drawWidthText = new OsuSpriteText(),
fillFlow = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 2f),
Shear = OsuGame.SHEAR,
},
drawWidthText = new OsuSpriteText(),
}
};
var scoreInfo = new ScoreInfo
@@ -260,9 +276,10 @@ namespace osu.Game.Tests.Visual.SongSelectV2
scores[2].TotalScore = RNG.Next(120_000, 400_000);
scores[2].MaximumStatistics[HitResult.Great] = 3000;
scores[1].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight() };
scores[1].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime { SpeedChange = { Value = 2 } }, new OsuModHardRock(), new OsuModFlashlight() };
scores[2].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight(), new OsuModClassic() };
scores[3].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight(), new OsuModClassic(), new OsuModDifficultyAdjust() };
scores[3].Mods = new Mod[]
{ new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight { ComboBasedSize = { Value = false } }, new OsuModClassic(), new OsuModDifficultyAdjust { CircleSize = { Value = 3.2f } } };
scores[4].Mods = new ManiaRuleset().CreateAllMods().ToArray();
return scores;
@@ -0,0 +1,370 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.SongSelect;
using osu.Game.Users;
using osuTK.Input;
namespace osu.Game.Tests.Visual.SongSelectV2
{
public partial class TestSceneBeatmapLeaderboardWedge : SongSelectComponentsTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
private TestBeatmapLeaderboardWedge leaderboard = null!;
private ScoreManager scoreManager = null!;
private RulesetStore rulesetStore = null!;
private BeatmapManager beatmapManager = null!;
private OsuContextMenuContainer contentContainer = null!;
private DialogOverlay dialogOverlay = null!;
private LeaderboardManager leaderboardManager = null!;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm));
dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get<AudioManager>(), Resources, dependencies.Get<GameHost>(), Beatmap.Default));
dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API));
dependencies.Cache(leaderboardManager = new LeaderboardManager());
Dependencies.Cache(Realm);
return dependencies;
}
[BackgroundDependencyLoader]
private void load()
{
LoadComponent(dialogOverlay = new DialogOverlay
{
Depth = -1
});
LoadComponent(leaderboardManager);
Child = contentContainer = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.X,
Height = 500,
Children = new Drawable[]
{
dialogOverlay,
}
};
AddSliderStep("change relative height", 0f, 1f, 0.65f, v => Schedule(() =>
{
contentContainer.Height = v * DrawHeight;
}));
}
[SetUp]
public void SetUp() => Schedule(() =>
{
if (leaderboard.IsNotNull())
contentContainer.Remove(leaderboard, false);
contentContainer.Add(leaderboard = new TestBeatmapLeaderboardWedge
{
RelativeSizeAxes = Axes.Both,
State = { Value = Visibility.Visible },
});
});
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
}
[Test]
public void TestPersonalBest()
{
AddStep(@"Show personal best", showPersonalBest);
}
[Test]
public void TestGlobalScoresDisplay()
{
setScope(BeatmapLeaderboardScope.Global);
AddStep(@"New Scores", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo())));
AddStep(@"New Scores with teams", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()).Select(s =>
{
s.User.Team = new APITeam();
return s;
})));
}
[Test]
public void TestPersonalBestWithNullPosition()
{
AddStep("null personal best position", showPersonalBestWithNullPosition);
}
[Test]
public void TestPlaceholderStates()
{
AddStep("ensure no scores displayed", () => leaderboard.SetScores(Array.Empty<ScoreInfo>()));
AddStep(@"Retrieving", () => leaderboard.SetState(LeaderboardState.Retrieving));
AddStep(@"Network failure", () => leaderboard.SetState(LeaderboardState.NetworkFailure));
AddStep(@"No team", () => leaderboard.SetState(LeaderboardState.NoTeam));
AddStep(@"No supporter", () => leaderboard.SetState(LeaderboardState.NotSupporter));
AddStep(@"Not logged in", () => leaderboard.SetState(LeaderboardState.NotLoggedIn));
AddStep(@"Ruleset unavailable", () => leaderboard.SetState(LeaderboardState.RulesetUnavailable));
AddStep(@"Beatmap unavailable", () => leaderboard.SetState(LeaderboardState.BeatmapUnavailable));
AddStep(@"None selected", () => leaderboard.SetState(LeaderboardState.NoneSelected));
}
[Test]
public void TestUseTheseModsDoesNotCopySystemMods()
{
AddStep(@"set scores", () => leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo
{
OnlineID = 1337,
Position = 999,
Rank = ScoreRank.XH,
Accuracy = 1,
MaxCombo = 244,
TotalScore = 1707827,
Ruleset = new OsuRuleset().RulesetInfo,
Mods = new Mod[] { new OsuModHidden(), new ModScoreV2(), },
User = new APIUser
{
Id = 6602580,
Username = @"waaiiru",
CountryCode = CountryCode.ES,
}
}));
AddUntilStep("wait for scores", () => this.ChildrenOfType<BeatmapLeaderboardScore>().Count(), () => Is.GreaterThan(0));
AddStep("right click panel", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<BeatmapLeaderboardScore>().Last());
InputManager.Click(MouseButton.Right);
});
AddStep("click use these mods", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<DrawableOsuMenuItem>().First());
InputManager.Click(MouseButton.Left);
});
AddAssert("received HD", () => this.ChildrenOfType<BeatmapLeaderboardScore>().Last().SelectedMods.Value.Any(m => m is OsuModHidden));
AddAssert("did not receive SV2", () => !this.ChildrenOfType<BeatmapLeaderboardScore>().Last().SelectedMods.Value.Any(m => m is ModScoreV2));
}
[Test]
public void TestLocalScoresDisplay()
{
BeatmapInfo beatmapInfo = null!;
setScope(BeatmapLeaderboardScope.Local);
AddStep(@"Set beatmap", () =>
{
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First();
Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo);
});
clearScores();
checkDisplayedCount(0);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(10);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(20);
clearScores();
checkDisplayedCount(0);
}
[Test]
public void TestLocalScoresDisplayWorksWhenStartingOffline()
{
BeatmapInfo beatmapInfo = null!;
AddStep("Log out", () => API.Logout());
setScope(BeatmapLeaderboardScope.Local);
AddStep(@"Import beatmap", () =>
{
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First();
Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo);
});
clearScores();
importMoreScores(() => beatmapInfo);
checkDisplayedCount(10);
}
[Test]
public void TestLocalScoresDisplayOnBeatmapEdit()
{
BeatmapInfo beatmapInfo = null!;
string originalHash = string.Empty;
setScope(BeatmapLeaderboardScope.Local);
AddStep(@"Import beatmap", () =>
{
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First();
Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo);
});
clearScores();
checkDisplayedCount(0);
AddStep(@"Perform initial save to guarantee stable hash", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmapManager.Save(beatmapInfo, beatmap);
originalHash = beatmapInfo.Hash;
});
importMoreScores(() => beatmapInfo);
checkDisplayedCount(10);
checkStoredCount(10);
AddStep(@"Save with changes", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmap.Difficulty.ApproachRate = 12;
beatmapManager.Save(beatmapInfo, beatmap);
});
AddAssert("Hash changed", () => beatmapInfo.Hash, () => Is.Not.EqualTo(originalHash));
checkDisplayedCount(0);
checkStoredCount(10);
importMoreScores(() => beatmapInfo);
importMoreScores(() => beatmapInfo);
checkDisplayedCount(20);
checkStoredCount(30);
AddStep(@"Revert changes", () =>
{
IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap;
beatmap.Difficulty.ApproachRate = 8;
beatmapManager.Save(beatmapInfo, beatmap);
});
AddAssert("Hash restored", () => beatmapInfo.Hash, () => Is.EqualTo(originalHash));
checkDisplayedCount(10);
checkStoredCount(30);
clearScores();
checkDisplayedCount(0);
checkStoredCount(0);
}
private void showPersonalBestWithNullPosition()
{
leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo
{
OnlineID = 1337,
Rank = ScoreRank.XH,
Accuracy = 1,
MaxCombo = 244,
TotalScore = 1707827,
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() },
Ruleset = new OsuRuleset().RulesetInfo,
User = new APIUser
{
Id = 6602580,
Username = @"waaiiru",
CountryCode = CountryCode.ES,
},
});
}
private void showPersonalBest()
{
leaderboard.SetScores(TestSceneBeatmapLeaderboard.GenerateSampleScores(new BeatmapInfo()), new ScoreInfo
{
OnlineID = 1337,
Position = 999,
Rank = ScoreRank.XH,
Accuracy = 1,
MaxCombo = 244,
TotalScore = 1707827,
Ruleset = new OsuRuleset().RulesetInfo,
Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() },
User = new APIUser
{
Id = 6602580,
Username = @"waaiiru",
CountryCode = CountryCode.ES,
}
});
}
private void setScope(BeatmapLeaderboardScope scope)
{
AddStep(@"Set scope", () => ((Bindable<BeatmapLeaderboardScope>)leaderboard.Scope).Value = scope);
}
private void importMoreScores(Func<BeatmapInfo> beatmapInfo)
{
AddStep(@"Import new scores", () =>
{
foreach (var score in TestSceneBeatmapLeaderboard.GenerateSampleScores(beatmapInfo()))
scoreManager.Import(score);
});
}
private void clearScores()
{
AddStep("Clear all scores", () => scoreManager.Delete());
}
private void checkDisplayedCount(int expected) =>
AddUntilStep($"{expected} scores displayed", () => leaderboard.ChildrenOfType<BeatmapLeaderboardScore>().Count(), () => Is.EqualTo(expected));
private void checkStoredCount(int expected) =>
AddUntilStep($"Total scores stored is {expected}", () => Realm.Run(r => r.All<ScoreInfo>().Count(s => !s.DeletePending)), () => Is.EqualTo(expected));
private partial class TestBeatmapLeaderboardWedge : BeatmapLeaderboardWedge
{
public new void SetState(LeaderboardState state) => base.SetState(state);
public new void SetScores(IEnumerable<ScoreInfo> scores, ScoreInfo? userScore = null) => base.SetScores(scores, userScore);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,371 @@
// 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.Extensions.Color4Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class BeatmapLeaderboardScore
{
public partial class LeaderboardScoreTooltip : VisibilityContainer, ITooltip<ScoreInfo>
{
private const float spacing = 20f;
private DateAndStatisticsPanel dateAndStatistics = null!;
private ModsPanel modsPanel = null!;
private TotalScoreRankPanel totalScoreRankPanel = null!;
[Cached]
private readonly OverlayColourProvider colourProvider;
public LeaderboardScoreTooltip(OverlayColourProvider colourProvider)
{
this.colourProvider = colourProvider;
}
[BackgroundDependencyLoader]
private void load()
{
Width = 170;
AutoSizeAxes = Axes.Y;
InternalChild = new ReverseChildIDFillFlowContainer<Drawable>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, -spacing),
Children = new Drawable[]
{
dateAndStatistics = new DateAndStatisticsPanel(),
modsPanel = new ModsPanel(),
totalScoreRankPanel = new TotalScoreRankPanel(),
},
};
}
private ScoreInfo? lastContent;
public void SetContent(ScoreInfo content)
{
if (lastContent != null && lastContent.Equals(content))
return;
dateAndStatistics.Score = content;
modsPanel.Score = content;
totalScoreRankPanel.Score = content;
lastContent = content;
}
protected override void PopIn() => this.FadeIn(300, Easing.OutQuint);
protected override void PopOut() => this.FadeOut(300, Easing.OutQuint);
public void Move(Vector2 pos) => Position = pos;
private partial class DateAndStatisticsPanel : CompositeDrawable
{
private OsuSpriteText absoluteDate = null!;
private DrawableDate relativeDate = null!;
private FillFlowContainer statistics = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public ScoreInfo Score
{
set
{
absoluteDate.Text = value.Date.ToLocalisableString(@"dd MMMM yyyy h:mm tt");
relativeDate.Date = value.Date;
var judgementsStatistics = value.GetStatisticsForDisplay().Select(s =>
new StatisticRow(s.DisplayName.ToUpper(), colours.ForHitResult(s.Result), s.Count.ToLocalisableString("N0")));
double multiplier = 1.0;
foreach (var mod in value.Mods)
multiplier *= mod.ScoreMultiplier;
var generalStatistics = new[]
{
new StatisticRow("Score Multiplier", colourProvider.Content2, ModUtils.FormatScoreMultiplier(multiplier)),
new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersCombo, colourProvider.Content2, value.MaxCombo.ToLocalisableString(@"0\x")),
new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, colourProvider.Content2, value.Accuracy.FormatAccuracy()),
};
if (value.PP != null)
{
generalStatistics = new[]
{
new StatisticRow(BeatmapsetsStrings.ShowScoreboardHeaderspp.ToUpper(), colourProvider.Content2, value.PP.ToLocalisableString("N0"))
}.Concat(generalStatistics).ToArray();
}
statistics.ChildrenEnumerable = judgementsStatistics
.Append(Empty().With(d => d.Height = 20))
.Concat(generalStatistics);
}
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
CornerRadius = corner_radius;
Masking = true;
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Colour = Color4.Black.Opacity(0.25f),
Radius = 4f,
};
InternalChildren = new Drawable[]
{
new Box
{
Colour = colourProvider.Background4,
RelativeSizeAxes = Axes.Both,
},
new FillFlowContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 4f),
Margin = new MarginPadding { Top = 8f },
Children = new Drawable[]
{
absoluteDate = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold),
UseFullGlyphHeight = false,
},
relativeDate = new DrawableDate(default, OsuFont.Style.Caption1.Size)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Colour = colourProvider.Content2,
UseFullGlyphHeight = false,
},
new Container
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
CornerRadius = corner_radius,
Masking = true,
Margin = new MarginPadding { Top = 4f },
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background3,
},
statistics = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(0f, 4f),
Padding = new MarginPadding(8f),
},
},
},
},
},
};
}
}
private partial class StatisticRow : CompositeDrawable
{
public StatisticRow(LocalisableString label, Color4 labelColour, LocalisableString value)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new[]
{
new OsuSpriteText
{
Text = label,
Colour = labelColour,
Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold),
},
new OsuSpriteText
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Text = value,
Colour = Color4.White,
Font = OsuFont.Style.Caption2,
},
};
}
}
private partial class ModsPanel : CompositeDrawable
{
private FillFlowContainer modsFlow = null!;
public ScoreInfo Score
{
set
{
var mods = value.Mods;
if (!mods.Any())
Hide();
else
{
Show();
modsFlow.ChildrenEnumerable = mods.AsOrdered().Select(m => new ModIcon(m)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.3f),
});
}
}
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
CornerRadius = corner_radius;
Masking = true;
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Colour = Color4.Black.Opacity(0.25f),
Radius = 4f,
};
InternalChildren = new Drawable[]
{
new Box
{
Colour = colourProvider.Background4,
RelativeSizeAxes = Axes.Both,
},
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.Transparent,
},
modsFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Bottom = 6f, Top = 6f + spacing },
Padding = new MarginPadding { Horizontal = 16f },
Spacing = new Vector2(2f, -4f),
},
};
}
}
public partial class TotalScoreRankPanel : CompositeDrawable
{
private Box rankBackground = null!;
private Container<DrawableRank> rankContainer = null!;
private OsuSpriteText totalScore = null!;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
public ScoreInfo Score
{
set
{
rankBackground.Colour = ColourInfo.GradientVertical(
OsuColour.ForRank(value.Rank).Opacity(0f),
OsuColour.ForRank(value.Rank).Opacity(0.5f));
rankContainer.Child = new DrawableRank(value.Rank);
totalScore.Current = scoreManager.GetBindableTotalScoreString(value);
}
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
CornerRadius = corner_radius;
Masking = true;
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Shadow,
Colour = Color4.Black.Opacity(0.25f),
Radius = 4f,
};
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Color4Extensions.FromHex("#353535"),
},
rankBackground = new Box
{
RelativeSizeAxes = Axes.Both,
},
rankContainer = new Container<DrawableRank>
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Size = new Vector2(25f, 14f),
Margin = new MarginPadding { Bottom = 5f },
},
totalScore = new OsuSpriteText
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Margin = new MarginPadding { Bottom = 25f, Top = 10f + spacing },
Font = OsuFont.Style.Subtitle.With(weight: FontWeight.Light, fixedWidth: true),
Spacing = new Vector2(-1.5f),
UseFullGlyphHeight = false,
},
};
}
}
}
}
}
@@ -0,0 +1,381 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Online.Leaderboards;
using osu.Game.Online.Placeholders;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Select.Leaderboards;
using osuTK;
namespace osu.Game.Screens.SelectV2
{
public partial class BeatmapLeaderboardWedge : VisibilityContainer
{
public IBindable<BeatmapLeaderboardScope> Scope { get; } = new Bindable<BeatmapLeaderboardScope>();
public IBindable<bool> FilterBySelectedMods { get; } = new BindableBool();
[Resolved]
private LeaderboardManager leaderboardManager { get; set; } = null!;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
private Container<Placeholder> placeholderContainer = null!;
private Placeholder? placeholder;
private Container scoresContainer = null!;
private OsuScrollContainer scoresScroll = null!;
private Container personalBestDisplay = null!;
private Container<BeatmapLeaderboardScore> personalBestScoreContainer = null!;
private LoadingLayer loading = null!;
private CancellationTokenSource? cancellationTokenSource;
private readonly IBindable<LeaderboardScores?> fetchedScores = new Bindable<LeaderboardScores?>();
private const float personal_best_height = 80;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
Child = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
scoresScroll = new OsuScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false,
Shear = OsuGame.SHEAR,
Child = scoresContainer = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding
{
Top = 5,
// Left padding offsets the shear to create a visually appealing list display.
Left = 80f,
// Bottom padding ensures the last entry's full width is displayed
// (ie it is fully on screen after shear is considered).
Bottom = BeatmapLeaderboardScore.HEIGHT * 3
},
},
},
personalBestDisplay = new Container
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
RelativeSizeAxes = Axes.X,
Height = personal_best_height,
Shear = OsuGame.SHEAR,
Margin = new MarginPadding { Left = -40f },
CornerRadius = 10f,
Masking = true,
// push the personal best 1px down to hide masking issues
Y = 1f,
X = -100f,
Alpha = 0f,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background4,
},
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Shear = -OsuGame.SHEAR,
Padding = new MarginPadding { Top = 5f, Bottom = 5f, Left = 70f, Right = 10f },
Children = new Drawable[]
{
new OsuSpriteText
{
Colour = colourProvider.Content2,
Text = "Personal Best",
Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold),
},
personalBestScoreContainer = new Container<BeatmapLeaderboardScore>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Margin = new MarginPadding { Top = 20f },
},
}
},
},
},
placeholderContainer = new Container<Placeholder>
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
},
loading = new LoadingLayer(),
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Scope.BindValueChanged(_ => refetchScores());
FilterBySelectedMods.BindValueChanged(_ => refetchScores());
beatmap.BindValueChanged(_ => refetchScores());
ruleset.BindValueChanged(_ => refetchScores());
mods.BindValueChanged(_ => refetchScoresFromMods());
refetchScores();
}
protected override void PopIn()
{
this.FadeIn(300, Easing.OutQuint);
}
protected override void PopOut()
{
this.FadeOut(300, Easing.OutQuint);
}
private void refetchScoresFromMods()
{
if (FilterBySelectedMods.Value)
refetchScores();
}
private bool initialFetchComplete;
private void refetchScores()
{
SetScores(Array.Empty<ScoreInfo>(), null);
if (beatmap.IsDefault)
{
SetState(LeaderboardState.NoneSelected);
return;
}
SetState(LeaderboardState.Retrieving);
var fetchBeatmapInfo = beatmap.Value.BeatmapInfo;
var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset;
// For now, we forcefully refresh to keep things simple.
// In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios
// (like returning from gameplay after setting a new score, returning to song select after main menu).
leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null), forceRefresh: true);
if (!initialFetchComplete)
{
// only bind this after the first fetch to avoid reading stale scores.
fetchedScores.BindTo(leaderboardManager.Scores);
fetchedScores.BindValueChanged(_ => updateScores(), true);
initialFetchComplete = true;
}
}
private void updateScores()
{
var scores = fetchedScores.Value;
if (scores == null) return;
if (scores.FailState != null)
SetState((LeaderboardState)scores.FailState);
else
SetScores(scores.TopScores, scores.UserScore);
}
protected void SetScores(IEnumerable<ScoreInfo> scores, ScoreInfo? userScore)
{
cancellationTokenSource?.Cancel();
cancellationTokenSource = new CancellationTokenSource();
clearScores();
SetState(LeaderboardState.Success);
if (!scores.Any())
{
SetState(LeaderboardState.NoScores);
return;
}
LoadComponentsAsync(scores.Select((s, i) => new BeatmapLeaderboardScore(s)
{
Rank = i + 1,
IsPersonalBest = s.OnlineID == userScore?.OnlineID,
SelectedMods = { BindTarget = mods },
}), loadedScores =>
{
int delay = 200;
int i = 0;
foreach (var d in loadedScores)
{
d.Y = (BeatmapLeaderboardScore.HEIGHT + 4f) * i;
// This is a bit of a weird one. We're already in a sheared state and don't want top-level
// shear applied, but still need the `BeatmapLeadeboardScore` to be in "sheared" mode (see ctor).
d.Shear = Vector2.Zero;
scoresContainer.Add(d);
d.FadeOut()
.MoveToX(-20f)
.Delay(delay)
.FadeIn(300, Easing.OutQuint)
.MoveToX(0f, 300, Easing.OutQuint);
delay += 30;
i++;
}
}, cancellation: cancellationTokenSource.Token);
if (userScore != null)
{
personalBestDisplay.MoveToX(0, 600, Easing.OutQuint);
personalBestDisplay.FadeIn(600, Easing.OutQuint);
personalBestScoreContainer.Child = new BeatmapLeaderboardScore(userScore)
{
IsPersonalBest = true,
Rank = userScore.Position,
SelectedMods = { BindTarget = mods },
};
scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding { Bottom = personal_best_height }, 300, Easing.OutQuint);
}
}
private void clearScores()
{
float delay = 0;
foreach (var d in scoresContainer)
{
// Avoid applying animations a second time to drawables which are already fading out.
if (d.LifetimeEnd != double.MaxValue)
continue;
d.Delay(delay)
.MoveToX(-10f, 120, Easing.Out)
.FadeOut(120, Easing.Out)
.Expire();
delay += 20;
}
personalBestDisplay.MoveToX(-100, 300, Easing.OutQuint);
personalBestDisplay.FadeOut(300, Easing.OutQuint);
scoresScroll.TransformTo(nameof(scoresScroll.Padding), new MarginPadding(), 300, Easing.OutQuint);
}
private LeaderboardState displayedState;
protected void SetState(LeaderboardState state)
{
if (state == displayedState)
return;
if (state == LeaderboardState.Retrieving)
loading.Show();
else
loading.Hide();
displayedState = state;
placeholder?.FadeOut(150, Easing.OutQuint).Expire();
placeholder = getPlaceholderFor(state);
if (placeholder == null)
return;
clearScores();
placeholderContainer.Child = placeholder;
placeholder.ScaleTo(0.8f).Then().ScaleTo(1, 900, Easing.OutQuint);
placeholder.FadeInFromZero(300, Easing.OutQuint);
}
private Placeholder? getPlaceholderFor(LeaderboardState state)
{
switch (state)
{
case LeaderboardState.NetworkFailure:
return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync)
{
Action = refetchScores
};
case LeaderboardState.NoneSelected:
return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap);
case LeaderboardState.RulesetUnavailable:
return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset);
case LeaderboardState.BeatmapUnavailable:
return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap);
case LeaderboardState.NoScores:
return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet);
case LeaderboardState.NotLoggedIn:
return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards);
case LeaderboardState.NotSupporter:
return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard);
case LeaderboardState.NoTeam:
return new MessagePlaceholder(LeaderboardStrings.NoTeam);
case LeaderboardState.Retrieving:
return null;
case LeaderboardState.Success:
return null;
default:
throw new ArgumentOutOfRangeException(nameof(state));
}
}
}
}