diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 1312f45cdc..c300afa79f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Mods; @@ -36,6 +37,24 @@ namespace osu.Game.Rulesets.Catch.Mods [SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")] public BindableBool HardRockOffsets { get; } = new BindableBool(); + public override string ExtendedIconInformation + { + get + { + if (UserAdjustedSettingsCount != 1) + return string.Empty; + + if (!CircleSize.IsDefault) return format("CS", CircleSize); + if (!ApproachRate.IsDefault) return format("AR", ApproachRate); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + } + } + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs index 3b9cca8ef0..bbf065f388 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -68,7 +69,7 @@ namespace osu.Game.Rulesets.Catch.UI // needs to be scaled down to remain playable. const float base_aspect_ratio = 1024f / 768f; float aspectRatio = osuGame.ScalingContainerTargetDrawSize.X / osuGame.ScalingContainerTargetDrawSize.Y; - scaleContainer.Scale = new Vector2(base_aspect_ratio / aspectRatio); + scaleContainer.Scale = new Vector2(Math.Min(1, base_aspect_ratio / aspectRatio)); } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaTouchInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaTouchInput.cs index fc495a5ab0..3e83f4a5e8 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaTouchInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaTouchInput.cs @@ -18,7 +18,12 @@ namespace osu.Game.Rulesets.Mania.Tests protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); [SetUp] - public void SetUp() => Schedule(() => toggleTouchControls(false)); + public void SetUp() => Schedule(() => + { + InputManager.EndTouch(new Touch(TouchSource.Touch1, Vector2.Zero)); + InputManager.EndTouch(new Touch(TouchSource.Touch2, Vector2.Zero)); + toggleTouchControls(false); + }); #region Without touch controls @@ -71,6 +76,35 @@ namespace osu.Game.Rulesets.Mania.Tests () => Does.Not.Contain(getColumn(0).Action.Value)); } + [Test] + public void TestBetweenTwoColumns() + { + AddStep("touch after column 0", () => + { + var column = getColumn(0); + InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(column.LayoutSize.X + 0.5f, column.LayoutSize.Y / 2)))); + }); + AddAssert("column 0 pressed", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Contain(getColumn(0).Action.Value)); + AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre))); + AddAssert("column 0 released", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Not.Contain(getColumn(0).Action.Value)); + AddStep("touch before column 1", () => + { + var column = getColumn(1); + InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(-0.5f, column.LayoutSize.Y / 2)))); + }); + AddAssert("column 1 pressed", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Contain(getColumn(1).Action.Value)); + AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getColumn(0).ScreenSpaceDrawQuad.Centre))); + AddAssert("column 1 released", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Not.Contain(getColumn(1).Action.Value)); + } + #endregion #region With touch controls @@ -132,6 +166,38 @@ namespace osu.Game.Rulesets.Mania.Tests () => Does.Not.Contain(getColumn(0).Action.Value)); } + [Test] + public void TestTouchControlBetweenTwoColumns() + { + AddStep("enable touch controls", () => toggleTouchControls(true)); + + AddStep("touch after receptor 0", () => + { + var column = getReceptor(0); + InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(column.LayoutSize.X + 1f, column.LayoutSize.Y / 2)))); + }); + + AddAssert("column 0 pressed", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Contain(getReceptor(0).Action.Value)); + AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre))); + AddAssert("column 0 released", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Not.Contain(getReceptor(0).Action.Value)); + AddStep("touch before receptor 1", () => + { + var column = getReceptor(1); + InputManager.BeginTouch(new Touch(TouchSource.Touch1, column.ToScreenSpace(new Vector2(-1f, column.LayoutSize.Y / 2)))); + }); + AddAssert("column 1 pressed", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Contain(getReceptor(1).Action.Value)); + AddStep("release finger", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, getReceptor(0).ScreenSpaceDrawQuad.Centre))); + AddAssert("column 1 released", + () => this.ChildrenOfType().SelectMany(m => m.KeyBindingContainer.PressedActions), + () => Does.Not.Contain(getReceptor(1).Action.Value)); + } + #endregion private void toggleTouchControls(bool enabled) diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index 6f010ffe48..f5bbd0fae8 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -131,8 +131,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon switch (maniaLookup.Lookup) { - case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - return SkinUtils.As(new Bindable(2)); + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + return SkinUtils.As(new Bindable(1)); case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: case LegacyManiaSkinConfigurationLookups.StagePaddingTop: @@ -146,7 +147,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return SkinUtils.As(new Bindable(width)); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - var colour = getColourForLayout(columnIndex, stage); return SkinUtils.As(new Bindable(colour)); diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index cb825761d1..eccececd22 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Mania.UI private IBindable mobilePlayStyle = null!; + private float leftColumnSpacing; + private float rightColumnSpacing; + public Column(int index, bool isSpecial) { Index = index; @@ -126,6 +129,14 @@ namespace osu.Game.Rulesets.Mania.UI private void onSourceChanged() { AccentColour.Value = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black; + + leftColumnSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, Index)) + ?.Value ?? Stage.COLUMN_SPACING; + + rightColumnSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, Index)) + ?.Value ?? Stage.COLUMN_SPACING; } protected override void LoadComplete() @@ -187,8 +198,11 @@ namespace osu.Game.Rulesets.Mania.UI } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) - // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border - => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + { + // Extend input coverage to the gaps close to this column. + var spacingInflation = new MarginPadding { Left = leftColumnSpacing, Right = rightColumnSpacing }; + return DrawRectangle.Inflate(spacingInflation).Contains(ToLocalSpace(screenSpacePos)); + } #region Touch Input diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index cee43b300a..953be8d507 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -124,14 +124,15 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < stageDefinition.Columns; i++) { - if (i > 0) - { - float spacing = skin.GetConfig( - new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, i - 1)) + float leftSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.LeftColumnSpacing, i)) ?.Value ?? Stage.COLUMN_SPACING; - columns[i].Margin = new MarginPadding { Left = spacing }; - } + float rightSpacing = skin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.RightColumnSpacing, i)) + ?.Value ?? Stage.COLUMN_SPACING; + + columns[i].Margin = new MarginPadding { Left = leftSpacing, Right = rightSpacing }; float? width = skin.GetConfig( new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) diff --git a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs index 2a2faf0cf7..7c5f759833 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs @@ -74,6 +74,7 @@ namespace osu.Game.Rulesets.Mania.UI receptorGridContent.Add(new ColumnInputReceptor { Action = { BindTarget = column.Action }, + Spacing = { BindTarget = Spacing }, }); receptorGridDimensions.Add(new Dimension()); @@ -122,6 +123,7 @@ namespace osu.Game.Rulesets.Mania.UI public partial class ColumnInputReceptor : CompositeDrawable { public readonly IBindable Action = new Bindable(); + public readonly IBindable Spacing = new BindableFloat(); private readonly Box highlightOverlay; @@ -159,6 +161,10 @@ namespace osu.Game.Rulesets.Mania.UI }; } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + // Extend input coverage to the gaps close to this receptor. + => DrawRectangle.Inflate(new Vector2(Spacing.Value / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + protected override bool OnTouchDown(TouchDownEvent e) { updateButton(true); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 77e9aeb123..1d94ac6335 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -7,6 +7,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -36,6 +37,24 @@ namespace osu.Game.Rulesets.Osu.Mods ReadCurrentFromDifficulty = diff => diff.ApproachRate, }; + public override string ExtendedIconInformation + { + get + { + if (UserAdjustedSettingsCount != 1) + return string.Empty; + + if (!CircleSize.IsDefault) return format("CS", CircleSize); + if (!ApproachRate.IsDefault) return format("AR", ApproachRate); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + } + } + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs index c3d08116ac..abb414c82c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs @@ -3,12 +3,16 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Argon { public partial class ArgonSliderBody : PlaySliderBody { + // Eventually this would be a user setting. + public float BodyAlpha { get; init; } = 1; + protected override void LoadComplete() { const float path_radius = ArgonMainCirclePiece.OUTER_GRADIENT_SIZE / 2; @@ -26,6 +30,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon protected override Default.DrawableSliderPath CreateSliderPath() => new DrawableSliderPath(); + protected override Color4 GetBodyAccentColour(ISkinSource skin, Color4 hitObjectAccentColour) + { + return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(BodyAlpha); + } + private partial class DrawableSliderPath : Default.DrawableSliderPath { protected override Color4 ColourAt(float position) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs index 9f6f65c206..2d1d5826b1 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -16,13 +16,15 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { + bool isPro = Skin is ArgonProSkin; + switch (lookup) { case SkinComponentLookup resultComponent: HitResult result = resultComponent.Component; // This should eventually be moved to a skin setting, when supported. - if (Skin is ArgonProSkin && (result == HitResult.Great || result == HitResult.Perfect)) + if (isPro && (result == HitResult.Great || result == HitResult.Perfect)) return Drawable.Empty(); switch (result) @@ -46,7 +48,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon return new ArgonMainCirclePiece(false); case OsuSkinComponents.SliderBody: - return new ArgonSliderBody(); + return new ArgonSliderBody + { + BodyAlpha = isPro ? 0.92f : 0.98f + }; case OsuSkinComponents.SliderBall: return new ArgonSliderBall(); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 000736e9f7..57b57555c2 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods @@ -20,6 +21,23 @@ namespace osu.Game.Rulesets.Taiko.Mods ReadCurrentFromDifficulty = _ => 1, }; + public override string ExtendedIconInformation + { + get + { + if (UserAdjustedSettingsCount != 1) + return string.Empty; + + if (!ScrollSpeed.IsDefault) return format("SC", ScrollSpeed); + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + } + } + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index bef43b3108..31037635cb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -77,33 +77,6 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); } - [Test] - public void TestPlayerScore() - { - createLeaderboard(); - addLocalPlayer(); - - var player2Score = new BindableLong(1234567); - var player3Score = new BindableLong(1111111); - - AddStep("add player 2", () => createLeaderboardScore(player2Score, new APIUser { Username = "Player 2" })); - AddStep("add player 3", () => createLeaderboardScore(player3Score, new APIUser { Username = "Player 3" })); - - AddUntilStep("is player 2 position #1", () => leaderboard.CheckPositionByUsername("Player 2", 1)); - AddUntilStep("is player position #2", () => leaderboard.CheckPositionByUsername("You", 2)); - AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3)); - - AddStep("set score above player 3", () => player2Score.Value = playerScore.Value - 500); - AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1)); - AddUntilStep("is player 2 position #2", () => leaderboard.CheckPositionByUsername("Player 2", 2)); - AddUntilStep("is player 3 position #3", () => leaderboard.CheckPositionByUsername("Player 3", 3)); - - AddStep("set score below players", () => player2Score.Value = playerScore.Value - 123456); - AddUntilStep("is player position #1", () => leaderboard.CheckPositionByUsername("You", 1)); - AddUntilStep("is player 3 position #2", () => leaderboard.CheckPositionByUsername("Player 3", 2)); - AddUntilStep("is player 2 position #3", () => leaderboard.CheckPositionByUsername("Player 2", 3)); - } - [Test] public void TestRandomScores() { @@ -183,30 +156,6 @@ namespace osu.Game.Tests.Visual.Gameplay () => Does.Contain("#FF549A")); } - [Test] - public void TestTrackedScorePosition([Values] bool partial) - { - createLeaderboard(partial); - - AddStep("add many scores in one go", () => - { - for (int i = 0; i < 49; i++) - createRandomScore(new APIUser { Username = $"Player {i + 1}" }); - - // Add player at end to force an animation down the whole list. - playerScore.Value = 0; - createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); - }); - - if (partial) - AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null); - else - AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50)); - - AddStep("move tracked player to top", () => leaderboard.TrackedScore!.TotalScore.Value = 8_000_000); - AddUntilStep("all players have non-null position", () => leaderboard.AllScores.Select(s => s.ScorePosition), () => Does.Not.Contain(null)); - } - private void addLocalPlayer() { AddStep("add local player", () => @@ -216,12 +165,11 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void createLeaderboard(bool partial = false) + private void createLeaderboard() { AddStep("create leaderboard", () => { leaderboardProvider.Scores.Clear(); - leaderboardProvider.IsPartial = partial; Child = leaderboard = new TestDrawableGameplayLeaderboard { Anchor = Anchor.Centre, @@ -243,24 +191,14 @@ namespace osu.Game.Tests.Visual.Gameplay { public float Spacing => Flow.Spacing.Y; - public bool CheckPositionByUsername(string username, int? expectedPosition) - { - var scoreItem = Flow.FirstOrDefault(i => i.User?.Username == username); - - return scoreItem != null && scoreItem.ScorePosition == expectedPosition; - } - public IEnumerable GetAllScoresForUsername(string username) => Flow.Where(i => i.User?.Username == username); - - public IEnumerable AllScores => Flow; } private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider { IBindableList IGameplayLeaderboardProvider.Scores => Scores; public BindableList Scores { get; } = new BindableList(); - public bool IsPartial { get; set; } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs new file mode 100644 index 0000000000..964f53c973 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboardProvider.cs @@ -0,0 +1,162 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Gameplay; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public partial class TestSceneSoloGameplayLeaderboardProvider : OsuTestScene + { + [Test] + public void TestLocalLeaderboardHasPositionsAutofilled() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Local, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 100).Select(i => new ScoreInfo + { + TotalScore = 10_000 * (100 - i), + Position = i, + }).ToArray(), + null + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows #101", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(101)); + AddUntilStep("tracked score ordered #101", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(101)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + + [Test] + public void TestFullGlobalLeaderboard() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Global, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 40).Select(i => new ScoreInfo + { + TotalScore = 600_000 + 10_000 * (40 - i), + Position = i, + }).ToArray(), + null + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows #41", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(41)); + AddUntilStep("tracked score ordered #41", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(41)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + + [Test] + public void TestPartialGlobalLeaderboard() + { + SoloGameplayLeaderboardProvider provider = null!; + + var leaderboardManager = new LeaderboardManager(); + LoadComponent(leaderboardManager); + var gameplayState = TestGameplayState.Create(new OsuRuleset()); + + AddStep("fetch local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(Beatmap.Value.BeatmapInfo, Ruleset.Value, BeatmapLeaderboardScope.Global, null))); + AddStep("set scores", () => + { + // this is dodgy but anything less dodgy is a lot of work + ((Bindable)leaderboardManager.Scores).Value = LeaderboardScores.Success( + Enumerable.Range(1, 50).Select(i => new ScoreInfo + { + TotalScore = 500_000 + 10_000 * (50 - i), + Position = i + }).ToArray(), + new ScoreInfo { TotalScore = 200_000 } + ); + }); + AddStep("create content", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(LeaderboardManager), leaderboardManager), + (typeof(GameplayState), gameplayState) + ], + Children = new Drawable[] + { + leaderboardManager, + provider = new SoloGameplayLeaderboardProvider() + } + }); + AddUntilStep("tracked score shows no position", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.Null); + AddUntilStep("tracked score ordered #52", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(52)); + AddStep("move score above user best", () => gameplayState.ScoreProcessor.TotalScore.Value = 202_000); + AddUntilStep("tracked score shows no position", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.Null); + AddUntilStep("tracked score ordered #51", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(51)); + AddStep("move score to #20", () => gameplayState.ScoreProcessor.TotalScore.Value = 802_000); + AddUntilStep("tracked score shows #20", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(20)); + AddUntilStep("tracked score ordered #20", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(20)); + AddStep("move score to #1", () => gameplayState.ScoreProcessor.TotalScore.Value = 1_002_000); + AddUntilStep("tracked score shows #1", () => provider.Scores.Single(s => s.Tracked).Position.Value, () => Is.EqualTo(1)); + AddUntilStep("tracked score ordered #1", () => provider.Scores.Single(s => s.Tracked).DisplayOrder.Value, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs index 3d7ee137ba..60b10b9899 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneImageProxying.cs @@ -12,6 +12,7 @@ using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online { + [Ignore("This test hits online resources (and online retrieval can fail at any time), and also performs network calls to the production instance of the website. Un-ignore this test when it's actually actively needed.")] public partial class TestSceneImageProxying : OsuTestScene { [Test] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs new file mode 100644 index 0000000000..b3f01d093f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneSoloResultsScreen.cs @@ -0,0 +1,362 @@ +// Copyright (c) ppy Pty Ltd . 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.Audio; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneSoloResultsScreen : ScreenTestScene + { + private ScoreManager scoreManager = null!; + private RulesetStore rulesetStore = null!; + private BeatmapManager beatmapManager = null!; + + private LeaderboardManager leaderboardManager = null!; + private BeatmapInfo importedBeatmap = null!; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + 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(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); + dependencies.Cache(leaderboardManager = new LeaderboardManager()); + + Dependencies.Cache(Realm); + + return dependencies; + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load leaderboard manager", () => LoadComponent(leaderboardManager)); + + AddStep(@"set beatmap", () => + { + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Realm.Write(r => + { + foreach (var set in r.All()) + set.Status = BeatmapOnlineStatus.Ranked; + + foreach (var b in r.All()) + b.Status = BeatmapOnlineStatus.Ranked; + }); + importedBeatmap = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + }); + AddStep("clear all scores", () => Realm.Write(r => r.RemoveAll())); + } + + [Test] + public void TestLocalLeaderboardWithOfflineScore() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Local, null))); + AddStep("import some local scores", () => + { + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + scoreManager.Import(score); + } + + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + scoreManager.Import(localScore); + localScore = localScore.Detach(); + }); + + AddStep("show results", () => LoadScreen(new SoloResultsScreen(localScore))); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestLocalLeaderboardWithOnlineScore() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to local", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Local, null))); + AddStep("import some local scores", () => + { + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.OnlineID = i; + score.TotalScore = 10_000 * (30 - i); + scoreManager.Import(score); + } + + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.OnlineID = 30; + localScore.Position = null; + scoreManager.Import(localScore); + localScore = localScore.Detach(); + }); + + AddStep("show results", () => LoadScreen(new SoloResultsScreen(localScore))); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 10_000 * (30 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + getScoresRequest.TriggerSuccess(new APIScoresCollection { Scores = scores }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score is #16", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(16)); + } + + [Test] + public void TestOnlineLeaderboardWithLessThan50Scores_UserIsLast() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 30; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 300_000 + 10_000 * (30 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + getScoresRequest.TriggerSuccess(new APIScoresCollection { Scores = scores }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score is #31", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(31)); + } + + [Test] + public void TestOnlineLeaderboardWithMoreThan50Scores_UserOutsideOfTop50() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = TestResources.CreateTestScoreInfo(importedBeatmap); + userBest.TotalScore = 50_000; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = SoloScoreInfo.ForSubmission(userBest), + Position = 133_337, + } + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.Position = null; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score has no position", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.Null); + AddAssert("user best position preserved", () => this.ChildrenOfType().Any(p => p.ScorePosition.Value == 133_337)); + } + + [Test] + public void TestOnlineLeaderboardWithMoreThan50Scores_UserInTop50() + { + ScoreInfo localScore = null!; + + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = TestResources.CreateTestScoreInfo(importedBeatmap); + userBest.TotalScore = 50_000; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = SoloScoreInfo.ForSubmission(userBest), + Position = 133_337, + } + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 651_000; + localScore.Position = null; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("local score is #36", () => this.ChildrenOfType().Single().GetPanelForScore(localScore).ScorePosition.Value, () => Is.EqualTo(36)); + AddAssert("user best position incremented by 1", () => this.ChildrenOfType().Any(p => p.ScorePosition.Value == 133_338)); + } + + [Test] + public void TestOnlineLeaderboardDeduplication() + { + AddStep("set leaderboard to global", () => leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(importedBeatmap, importedBeatmap.Ruleset, BeatmapLeaderboardScope.Global, null))); + AddStep("set up request handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetScoresRequest getScoresRequest: + var scores = new List(); + + for (int i = 0; i < 50; ++i) + { + var score = TestResources.CreateTestScoreInfo(importedBeatmap); + score.TotalScore = 500_000 + 10_000 * (50 - i); + score.Position = i + 1; + scores.Add(SoloScoreInfo.ForSubmission(score)); + } + + var userBest = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo(importedBeatmap)); + userBest.TotalScore = 151_000; + userBest.ID = 12345; + + getScoresRequest.TriggerSuccess(new APIScoresCollection + { + Scores = scores, + UserScore = new APIScoreWithPosition + { + Score = userBest, + Position = 133_337, + } + }); + return true; + } + + return false; + }); + + AddStep("show results", () => + { + var localScore = TestResources.CreateTestScoreInfo(importedBeatmap); + localScore.TotalScore = 151_000; + localScore.OnlineID = 12345; + localScore.Position = null; + LoadScreen(new SoloResultsScreen(localScore)); + }); + AddUntilStep("wait for loaded", () => ((Drawable)Stack.CurrentScreen).IsLoaded); + AddAssert("only one score with ID 12345", () => this.ChildrenOfType().Count(s => s.Score.OnlineID == 12345), () => Is.EqualTo(1)); + AddAssert("user best position preserved", () => this.ChildrenOfType().Any(p => p.ScorePosition.Value == 133_337)); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 8132f8a841..0e0f3c554a 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -163,8 +163,8 @@ namespace osu.Game.Tests.Visual.SongSelect [TestCase(120, 125, null, "120-125 (mostly 120)")] [TestCase(120, 120.6, null, "120-121 (mostly 120)")] [TestCase(120, 120.4, null, "120")] - [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] - [TestCase(120, 120.4, "DT", "180")] + [TestCase(120, 120.6, "DT", "180-181 (mostly 180)")] + [TestCase(120, 120.4, "DT", "180-181 (mostly 180)")] public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) { IBeatmap beatmap = CreateTestBeatmap(new OsuRuleset().RulesetInfo); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index d8ab367ebd..9dc6bc8a33 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1277,12 +1277,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddStep("select all", () => InputManager.Keys(PlatformAction.SelectAll)); - AddStep("press ctrl-x", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.Key(Key.X); - InputManager.ReleaseKey(Key.ControlLeft); - }); + AddStep("press ctrl/cmd-x", () => InputManager.Keys(PlatformAction.Cut)); AddAssert("filter text cleared", () => songSelect!.FilterControl.ChildrenOfType().First().Text, () => Is.Empty); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs index f86ca869e1..843d65b7f8 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs @@ -23,7 +23,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }; private Container? resizeContainer; - private float relativeWidth; protected virtual Anchor ComponentAnchor => Anchor.TopLeft; protected virtual float InitialRelativeWidth => 0.5f; @@ -40,7 +39,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Origin = ComponentAnchor, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Width = relativeWidth, + Width = InitialRelativeWidth, Child = Content } }; @@ -49,8 +48,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { if (resizeContainer != null) resizeContainer.Width = v; - - relativeWidth = v; }); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs new file mode 100644 index 0000000000..df7e5ee645 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapFilterControl : SongSelectComponentsTestScene + { + protected override Anchor ComponentAnchor => Anchor.TopRight; + protected override float InitialRelativeWidth => 0.7f; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new FilterControl + { + State = { Value = Visibility.Visible }, + RelativeSizeAxes = Axes.X, + }, + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs similarity index 76% rename from osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs rename to osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs index 9d827fdc72..1b6d56df16 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardScore.cs @@ -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; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs new file mode 100644 index 0000000000..61d23c4513 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapLeaderboardWedge.cs @@ -0,0 +1,370 @@ +// Copyright (c) ppy Pty Ltd . 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(), Resources, dependencies.Get(), 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())); + + 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().Count(), () => Is.GreaterThan(0)); + AddStep("right click panel", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Right); + }); + AddStep("click use these mods", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("received HD", () => this.ChildrenOfType().Last().SelectedMods.Value.Any(m => m is OsuModHidden)); + AddAssert("did not receive SV2", () => !this.ChildrenOfType().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)leaderboard.Scope).Value = scope); + } + + private void importMoreScores(Func 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().Count(), () => Is.EqualTo(expected)); + + private void checkStoredCount(int expected) => + AddUntilStep($"Total scores stored is {expected}", () => Realm.Run(r => r.All().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 scores, ScoreInfo? userScore = null) => base.SetScores(scores, userScore); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs index be2e6eb9bf..3cdb513b38 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs @@ -142,6 +142,63 @@ namespace osu.Game.Tests.Visual.SongSelectV2 working.BeatmapInfo.Metadata.Tags = string.Join(' ', Enumerable.Repeat(working.BeatmapInfo.Metadata.Tags, 3)); onlineSet.Genre = new BeatmapSetOnlineGenre { Id = 12, Name = "Verrrrryyyy llooonngggggg genre" }; onlineSet.Language = new BeatmapSetOnlineLanguage { Id = 12, Name = "Verrrrryyyy llooonngggggg language" }; + onlineSet.Beatmaps.Single().TopTags = Enumerable.Repeat(onlineSet.Beatmaps.Single().TopTags, 3).SelectMany(t => t!).ToArray(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + } + + [Test] + public void TestOnlineAvailability() + { + AddStep("online beatmapset", () => + { + var (working, onlineSet) = createTestBeatmap(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddUntilStep("rating wedge visible", () => wedge.RatingsVisible); + AddUntilStep("fail time wedge visible", () => wedge.FailRetryVisible); + AddStep("online beatmapset with local diff", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.ResetOnlineInfo(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddUntilStep("rating wedge hidden", () => !wedge.RatingsVisible); + AddUntilStep("fail time wedge hidden", () => !wedge.FailRetryVisible); + AddStep("local beatmap", () => + { + var (working, _) = createTestBeatmap(); + + currentOnlineSet = null; + Beatmap.Value = working; + }); + AddAssert("rating wedge still hidden", () => !wedge.RatingsVisible); + AddAssert("fail time wedge still hidden", () => !wedge.FailRetryVisible); + } + + [Test] + public void TestUserTags() + { + AddStep("user tags", () => + { + var (working, onlineSet) = createTestBeatmap(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no user tags", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Beatmaps.Single().TopTags = null; + onlineSet.RelatedTags = null; currentOnlineSet = onlineSet; Beatmap.Value = working; @@ -164,13 +221,40 @@ namespace osu.Game.Tests.Visual.SongSelectV2 OnlineID = working.BeatmapInfo.OnlineID, PlayCount = 10000, PassCount = 4567, + TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ], FailTimes = new APIFailTimes { Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), }, }, - } + }, + RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ] }; working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 8b89de5fce..85d82e536d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -1,20 +1,32 @@ // Copyright (c) ppy Pty Ltd . 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.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Drawables; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.SelectV2; +using osu.Game.Skinning; using osu.Game.Tests.Visual.SongSelect; namespace osu.Game.Tests.Visual.SongSelectV2 @@ -26,6 +38,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private BeatmapTitleWedge titleWedge = null!; private BeatmapTitleWedge.DifficultyDisplay difficultyDisplay => titleWedge.ChildrenOfType().Single(); + private APIBeatmapSet? currentOnlineSet; + [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { @@ -36,11 +50,30 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { base.LoadComplete(); + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + default: + return false; + } + }; + AddRange(new Drawable[] { new Container { RelativeSizeAxes = Axes.Both, + Shear = OsuGame.SHEAR, Children = new Drawable[] { titleWedge = new BeatmapTitleWedge @@ -115,11 +148,45 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("check visibility", () => titleWedge.Alpha > 0); } + [Test] + public void TestOnlineAvailability() + { + AddStep("online beatmapset", () => + { + var (working, onlineSet) = createTestBeatmap(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddAssert("play count = 10000", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "10,000"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddStep("online beatmapset with local diff", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.ResetOnlineInfo(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddStep("local beatmapset", () => + { + var (working, _) = createTestBeatmap(); + + currentOnlineSet = null; + Beatmap.Value = working; + }); + AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); + AddAssert("favourites count = -", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "-"); + } + [TestCase(120, 125, null, "120-125 (mostly 120)")] [TestCase(120, 120.6, null, "120-121 (mostly 120)")] [TestCase(120, 120.4, null, "120")] - [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] - [TestCase(120, 120.4, "DT", "180")] + [TestCase(120, 120.6, "DT", "180-181 (mostly 180)")] + [TestCase(120, 120.4, "DT", "180-181 (mostly 180)")] public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) { IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); @@ -134,6 +201,16 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkDisplayedBPM(expectedDisplay); } + [Test] + [Explicit] + public void TestPerformanceWithLongBeatmap() + { + AddStep("select heavy beatmap", () => Beatmap.Value = new HeavyWorkingBeatmap(Audio)); + + foreach (var rulesetInfo in rulesets.AvailableRulesets) + setRuleset(rulesetInfo); + } + private void setRuleset(RulesetInfo rulesetInfo) { AddStep("set ruleset", () => Ruleset.Value = rulesetInfo); @@ -155,5 +232,73 @@ namespace osu.Game.Tests.Visual.SongSelectV2 return label.Text == target; }); } + + private (WorkingBeatmap, APIBeatmapSet) createTestBeatmap() + { + var working = CreateWorkingBeatmap(Ruleset.Value); + var onlineSet = new APIBeatmapSet + { + OnlineID = working.BeatmapSetInfo.OnlineID, + FavouriteCount = 2345, + Beatmaps = new[] + { + new APIBeatmap + { + OnlineID = working.BeatmapInfo.OnlineID, + PlayCount = 10000, + PassCount = 4567, + UserPlayCount = 123, + }, + } + }; + + working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; + working.BeatmapSetInfo.DateRanked = DateTimeOffset.Now; + return (working, onlineSet); + } + + private class TestHitObject : ConvertHitObject; + + private class HeavyWorkingBeatmap : WorkingBeatmap + { + private static readonly BeatmapInfo beatmap_info = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Author = { Username = "osuAuthor" }, + Artist = "osuArtist", + Source = "osuSource", + Title = "osuTitle" + }, + Ruleset = new OsuRuleset().RulesetInfo, + StarRating = 6, + DifficultyName = "osuVersion", + Difficulty = new BeatmapDifficulty() + }; + + public HeavyWorkingBeatmap(AudioManager audioManager) + : base(beatmap_info, audioManager) + { + } + + protected override IBeatmap GetBeatmap() + { + List objects = new List(); + + for (int i = 0; i < 200_000; i++) + objects.Add(new TestHitObject { StartTime = i * 1000 }); + + return new Beatmap + { + BeatmapInfo = beatmap_info, + HitObjects = objects + }; + } + + public override Texture? GetBackground() => null; + public override Stream? GetStream(string storagePath) => null; + protected override Track? GetBeatmapTrack() => null; + protected internal override ISkin? GetSkin() => null; + } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs new file mode 100644 index 0000000000..3cadbeb1e3 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyRangeSlider.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneDifficultyRangeSlider : ThemeComparisonTestScene + { + private readonly BindableNumber customStart = new BindableNumber + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + private readonly BindableNumber customEnd = new BindableNumber(10) + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + public TestSceneDifficultyRangeSlider() + : base(false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + CreateThemedContent(OverlayColourScheme.Aquamarine); + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f), + }, + new FilterControl.DifficultyRangeSlider + { + Width = 600, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1), + LowerBound = customStart, + UpperBound = customEnd, + TooltipSuffix = "suffix", + NubWidth = 32, + MinRange = 0.1f, + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index 11cd122c99..c8283d0956 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -12,22 +12,81 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneModIcon : OsuTestScene { + private FillFlowContainer spreadOutFlow = null!; + private ModDisplay modDisplay = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create flows", () => + { + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f), + new Dimension(GridSizeMode.Relative, 0.5f), + }, + Content = new[] + { + new Drawable[] + { + modDisplay = new ModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + new Drawable[] + { + spreadOutFlow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + } + } + } + }; + }); + } + + private void addRange(IEnumerable mods) + { + spreadOutFlow.AddRange(mods.Select(m => new ModIcon(m))); + modDisplay.Current.Value = modDisplay.Current.Value.Concat(mods.OfType()).ToList(); + } + [Test] public void TestShowAllMods() { AddStep("create mod icons", () => { - Child = new FillFlowContainer + addRange(Ruleset.Value.CreateInstance().CreateAllMods().Select(m => { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Full, - ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)), - }; + if (m is OsuModFlashlight fl) + fl.FollowDelay.Value = 1245; + + if (m is OsuModDaycore dc) + dc.SpeedChange.Value = 0.74f; + + if (m is OsuModDifficultyAdjust da) + da.CircleSize.Value = 8.2f; + + if (m is ModAdaptiveSpeed ad) + ad.AdjustPitch.Value = false; + + return m; + })); }); AddStep("toggle selected", () => @@ -42,26 +101,22 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("create mod icons", () => { - Child = new FillFlowContainer + var rateAdjustMods = Ruleset.Value.CreateInstance().CreateAllMods() + .OfType(); + + addRange(rateAdjustMods.SelectMany(m => { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Full, - ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods() - .OfType() - .SelectMany(m => - { - List icons = new List { new ModIcon(m) }; + List mods = new List { m }; - for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10) - { - m = (ModRateAdjust)m.DeepClone(); - m.SpeedChange.Value = i; - icons.Add(new ModIcon(m)); - } + for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10) + { + m = (ModRateAdjust)m.DeepClone(); + m.SpeedChange.Value = i; + mods.Add(m); + } - return icons; - }), - }; + return mods; + })); }); AddStep("adjust rates", () => @@ -81,21 +136,50 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestChangeModType() { - ModIcon icon = null!; - - AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); - AddStep("change mod", () => icon.Mod = new OsuModEasy()); + AddStep("create mod icon", () => addRange([new OsuModDoubleTime()])); + AddStep("change mod", () => + { + foreach (var modIcon in this.ChildrenOfType()) + modIcon.Mod = new OsuModEasy(); + }); } [Test] public void TestInterfaceModType() { - ModIcon icon = null!; - var ruleset = new OsuRuleset(); - AddStep("create mod icon", () => Child = icon = new ModIcon(ruleset.AllMods.First(m => m.Acronym == "DT"))); - AddStep("change mod", () => icon.Mod = ruleset.AllMods.First(m => m.Acronym == "EZ")); + AddStep("create mod icon", () => addRange([ruleset.AllMods.First(m => m.Acronym == "DT")])); + AddStep("change mod", () => + { + foreach (var modIcon in this.ChildrenOfType()) + modIcon.Mod = ruleset.AllMods.First(m => m.Acronym == "EZ"); + }); + } + + [Test] + public void TestDifficultyAdjust() + { + AddStep("create icons", () => + { + addRange([ + new OsuModDifficultyAdjust + { + CircleSize = { Value = 8 } + }, + new OsuModDifficultyAdjust + { + CircleSize = { Value = 5.5f } + }, + new OsuModDifficultyAdjust + { + CircleSize = { Value = 8 }, + ApproachRate = { Value = 8 }, + OverallDifficulty = { Value = 8 }, + DrainRate = { Value = 8 }, + } + ]); + }); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index 8db22f2d65..bdec96f446 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -13,7 +13,6 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -183,32 +182,31 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Scale = new Vector2(2.5f), Children = new Drawable[] { - new ShearedButton(120) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding(), + Height = 30, }, - new ShearedButton(120, 40) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding { Left = -1f }, + Height = 30, }, - new ShearedButton(120, 70) + new ShearedButton { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "Test", + Text = "Button", Action = () => { }, - Padding = new MarginPadding { Left = 3f }, + Height = 30, }, } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.cs new file mode 100644 index 0000000000..21fa82eda8 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedRangeSlider.cs @@ -0,0 +1,148 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearedRangeSlider : ThemeComparisonTestScene + { + private readonly BindableNumber customStart = new BindableNumber + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + private readonly BindableNumber customEnd = new BindableNumber(10) + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f + }; + + private ShearedRangeSlider shearedRangeSlider = null!; + + public TestSceneShearedRangeSlider() + : base(false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + CreateThemedContent(OverlayColourScheme.Aquamarine); + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f), + }, + shearedRangeSlider = new ShearedRangeSlider("Test") + { + Width = 600, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1), + LowerBound = customStart, + UpperBound = customEnd, + TooltipSuffix = "suffix", + NubWidth = 32, + DefaultStringLowerBound = "0.0", + DefaultStringUpperBound = "∞", + MinRange = 0.1f, + } + } + }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset range", () => + { + customStart.SetDefault(); + customEnd.SetDefault(); + }); + + AddAssert("Initial lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.1f)); + AddAssert("Initial upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(10).Within(0.1f)); + } + + [Test] + public void TestAdjustRange() + { + AddStep("Adjust range", () => + { + customStart.Value = 5; + customEnd.Value = 7.5; + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(5).Within(0.1f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.1f)); + + AddStep("Test nub pushing", () => + { + customStart.Value = 9; + }); + + AddAssert("Pushed lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(9).Within(0.1f)); + AddAssert("Pushed upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(9.1).Within(0.1f)); + } + + [Test] + public void TestAdjustRangeClickOutsideNub() + { + Vector2 lowerBoundNub = Vector2.Zero; + Vector2 upperBoundNub = Vector2.Zero; + + AddStep("click 75%", () => + { + // save out original positions so we can use as absolute selection range. + lowerBoundNub = shearedRangeSlider.ChildrenOfType().Last().ScreenSpaceDrawQuad.Centre - OsuGame.SHEAR * 2; + upperBoundNub = shearedRangeSlider.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre - OsuGame.SHEAR * 2; + + InputManager.MoveMouseTo(lowerBoundNub + new Vector2((upperBoundNub.X - lowerBoundNub.X) * 0.75f, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + + AddStep("click 30%", () => + { + InputManager.MoveMouseTo(lowerBoundNub + new Vector2((upperBoundNub.X - lowerBoundNub.X) * 0.3f, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(3.0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + + AddStep("click 0%", () => + { + InputManager.MoveMouseTo(lowerBoundNub); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Adjusted lower bound is correct", () => shearedRangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.11f)); + AddAssert("Adjusted upper bound is correct", () => shearedRangeSlider.UpperBound.Value, () => Is.EqualTo(7.5).Within(0.11f)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs index f3a7f1481a..d4141f2b64 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs @@ -5,8 +5,10 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Tests.Visual.UserInterface { @@ -30,16 +32,32 @@ namespace osu.Game.Tests.Visual.UserInterface { (typeof(OverlayColourProvider), colourProvider) }, - Children = new Drawable[] + Child = new FillFlowContainer { - new ShearedSearchTextBox + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 5f), + Children = new Drawable[] { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Width = 0.5f + new ShearedSearchTextBox + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.5f + }, + new ShearedFilterTextBox + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + FilterText = "12345 matches", + }, } - } + }, }; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs index c3038ddb3d..7a654fcb4b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs @@ -3,38 +3,50 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneShearedSliderBar : OsuManualInputManagerTestScene + public partial class TestSceneShearedSliderBar : ThemeComparisonTestScene { - [Cached] - private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Purple); + private TestSliderBar slider = null!; - private ShearedSliderBar slider = null!; - - [SetUpSteps] - public void SetUpSteps() + protected override Drawable CreateContent() => slider = new TestSliderBar { - AddStep("create slider", () => Child = slider = new ShearedSliderBar + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = new BindableDouble(5) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Current = new BindableDouble(5) + Precision = 0.1, + MinValue = 0, + MaxValue = 15 + }, + RelativeSizeAxes = Axes.X, + Width = 0.4f + }; + + [Test] + public void TestNubDisplay() + { + AddSliderStep("nub width", 20, 80, 50, v => + { + if (slider.IsNotNull()) { - Precision = 0.1, - MinValue = 0, - MaxValue = 15 - }, - RelativeSizeAxes = Axes.X, - Width = 0.4f + slider.Nub.Width = v; + slider.RangePadding = v / 2f; + } + }); + AddToggleStep("nub shadow", v => + { + if (slider.IsNotNull()) + slider.NubShadowColour = v ? Color4.Black.Opacity(0.2f) : Color4.Black.Opacity(0f); }); } @@ -69,6 +81,12 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddAssert("slider is still at 1", () => slider.Current.Value, () => Is.EqualTo(1)); + AddStep("enable slider", () => slider.Current.Disabled = false); + } + + public partial class TestSliderBar : ShearedSliderBar + { + public new ShearedNub Nub => base.Nub; } } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 0399f50ded..167e52ad0d 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -179,7 +179,10 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f, 0.01f); SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f, 0.01f); - SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); + if (RuntimeInfo.IsMobile) + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.1f, 0.01f); + else + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); SetDefault(OsuSetting.UIHoldActivationDelay, 200.0, 0.0, 500.0, 50.0); @@ -222,6 +225,8 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, true); SetDefault(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, true); + + SetDefault(OsuSetting.WasSupporter, false); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -463,5 +468,11 @@ namespace osu.Game.Configuration EditorShowStoryboard, EditorSubmissionNotifyOnDiscussionReplies, EditorSubmissionLoadInBrowserAfterSubmission, + + /// + /// Cached state of whether local user is a supporter. + /// Used to allow early checks (ie for startup samples) to be in the correct state, even if the API authentication process has not completed. + /// + WasSupporter } } diff --git a/osu.Game/Extensions/NumberFormattingExtensions.cs b/osu.Game/Extensions/NumberFormattingExtensions.cs index 618b086a5b..33252448fc 100644 --- a/osu.Game/Extensions/NumberFormattingExtensions.cs +++ b/osu.Game/Extensions/NumberFormattingExtensions.cs @@ -17,7 +17,7 @@ namespace osu.Game.Extensions /// The maximum number of decimals to be considered in the original value. /// Whether the output should be a percentage. For integer types, 0-100 is mapped to 0-100%; for other types 0-1 is mapped to 0-100%. /// The formatted output. - public static string ToStandardFormattedString(this T value, int maxDecimalDigits, bool asPercentage) where T : struct, INumber, IMinMaxValue + public static string ToStandardFormattedString(this T value, int maxDecimalDigits, bool asPercentage = false) where T : struct, INumber, IMinMaxValue { double floatValue = double.CreateTruncating(value); diff --git a/osu.Game/Graphics/Containers/ShearAligningWrapper.cs b/osu.Game/Graphics/Containers/ShearAligningWrapper.cs index d720120b4f..542f269f93 100644 --- a/osu.Game/Graphics/Containers/ShearAligningWrapper.cs +++ b/osu.Game/Graphics/Containers/ShearAligningWrapper.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Layout; using osuTK; @@ -18,6 +19,10 @@ namespace osu.Game.Graphics.Containers { private readonly LayoutValue layout = new LayoutValue(Invalidation.MiscGeometry); + // Sheared components regularly end up off the side of the screen due to padding considerations. + // If we use this class in places where performance is important, we should reconsider the handling of this. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + public ShearAligningWrapper(Drawable drawable) { RelativeSizeAxes = drawable.RelativeSizeAxes; diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index a059490aa8..16891babf3 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -88,12 +88,12 @@ namespace osu.Game.Graphics.UserInterface public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) { Height = height; - Padding = new MarginPadding { Horizontal = OsuGame.SHEAR.X * height }; - Content.CornerRadius = CORNER_RADIUS; - Content.Shear = OsuGame.SHEAR; - Content.Masking = true; + Shear = OsuGame.SHEAR; + Content.Anchor = Content.Origin = Anchor.Centre; + Content.CornerRadius = CORNER_RADIUS; + Content.Masking = true; Children = new Drawable[] { diff --git a/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs new file mode 100644 index 0000000000..cffe34650c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedFilterTextBox.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedFilterTextBox : ShearedSearchTextBox + { + private const float filter_text_size = 12; + + public LocalisableString FilterText + { + get => ((InnerFilterTextBox)TextBox).FilterText.Text; + set => Schedule(() => ((InnerFilterTextBox)TextBox).FilterText.Text = value); + } + + public ShearedFilterTextBox() + { + Height += filter_text_size; + } + + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerFilterTextBox(); + + protected partial class InnerFilterTextBox : InnerSearchTextBox + { + public OsuSpriteText FilterText { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + TextContainer.Add(FilterText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Torus.With(size: filter_text_size, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Top = 2, Left = -1 }, + Colour = colours.Yellow + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextContainer.Height *= (DrawHeight - filter_text_size) / DrawHeight; + TextContainer.Margin = new MarginPadding { Bottom = filter_text_size }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedNub.cs b/osu.Game/Graphics/UserInterface/ShearedNub.cs index 17b50b5d58..0021c1cbd2 100644 --- a/osu.Game/Graphics/UserInterface/ShearedNub.cs +++ b/osu.Game/Graphics/UserInterface/ShearedNub.cs @@ -21,37 +21,54 @@ namespace osu.Game.Graphics.UserInterface { public Action? OnDoubleClicked { get; init; } - protected const float BORDER_WIDTH = 3; - public const int HEIGHT = 30; public const float EXPANDED_SIZE = 50; + public const float CORNER_RADIUS = 5; private readonly Box fill; private readonly Container main; + private readonly Container shadow; - /// - /// Implements the shape for the nub, allowing for any type of container to be used. - /// - /// public ShearedNub() { Size = new Vector2(EXPANDED_SIZE, HEIGHT); - InternalChild = main = new Container + InternalChildren = new Drawable[] { - Shear = OsuGame.SHEAR, - BorderColour = Colour4.White, - BorderThickness = BORDER_WIDTH, - Masking = true, - CornerRadius = 5, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Child = fill = new Box + shadow = new Container { + Shear = OsuGame.SHEAR, + Masking = true, + CornerRadius = CORNER_RADIUS, RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - } + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 20f, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, + main = new Container + { + Shear = OsuGame.SHEAR, + BorderColour = Colour4.White, + BorderThickness = 8f, + Masking = true, + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }, }; } @@ -76,6 +93,7 @@ namespace osu.Game.Graphics.UserInterface base.LoadComplete(); Current.BindValueChanged(onCurrentValueChanged, true); + FinishTransforms(true); } private bool glowing; @@ -89,22 +107,22 @@ namespace osu.Game.Graphics.UserInterface return; glowing = value; + updateDisplay(); + } + } - if (value) - { - main.FadeColour(GlowingAccentColour.Lighten(0.1f), 40, Easing.OutQuint) - .Then() - .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + private Color4 shadowColour = Color4.Black.Opacity(0f); - main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) - .Then() - .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); - } - else - { - main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); - main.FadeColour(AccentColour, 800, Easing.OutQuint); - } + public Color4 ShadowColour + { + get => shadowColour; + set + { + if (shadowColour == value) + return; + + shadowColour = value; + shadow.FadeEdgeEffectTo(value, 800, Easing.OutQuint); } } @@ -130,8 +148,7 @@ namespace osu.Game.Graphics.UserInterface set { accentColour = value; - if (!Glowing) - main.Colour = value; + updateDisplay(); } } @@ -143,8 +160,7 @@ namespace osu.Game.Graphics.UserInterface set { glowingAccentColour = value; - if (Glowing) - main.Colour = value; + updateDisplay(); } } @@ -156,10 +172,7 @@ namespace osu.Game.Graphics.UserInterface set { glowColour = value; - - var effect = main.EdgeEffect; - effect.Colour = Glowing ? value : value.Opacity(0); - main.EdgeEffect = effect; + updateDisplay(); } } @@ -177,7 +190,26 @@ namespace osu.Game.Graphics.UserInterface else { main.ResizeWidthTo(0.75f, duration, Easing.OutQuint); - main.TransformTo(nameof(BorderThickness), BORDER_WIDTH, duration, Easing.OutQuint); + main.TransformTo(nameof(BorderThickness), 8f, duration, Easing.OutQuint); + } + } + + private void updateDisplay() + { + if (Glowing) + { + main.FadeColour(GlowingAccentColour.Lighten(0.1f), 40, Easing.OutQuint) + .Then() + .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + + main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) + .Then() + .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); + } + else + { + main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); + main.FadeColour(AccentColour, 800, Easing.OutQuint); } } diff --git a/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs b/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs new file mode 100644 index 0000000000..3aaa143987 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedRangeSlider.cs @@ -0,0 +1,279 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedRangeSlider : CompositeDrawable + { + private readonly LocalisableString label; + + private readonly BindableNumberWithCurrent lowerBound = new BindableNumberWithCurrent(); + + /// + /// The lower limiting value. + /// + public Bindable LowerBound + { + get => lowerBound.Current; + set => lowerBound.Current = value; + } + + private readonly BindableNumberWithCurrent upperBound = new BindableNumberWithCurrent(); + + /// + /// The upper limiting value. + /// + public Bindable UpperBound + { + get => upperBound.Current; + set => upperBound.Current = value; + } + + public float NubWidth { get; init; } + + /// + /// Minimum difference between the lower bound and higher bound + /// + public float MinRange + { + set => minRange = value; + } + + /// + /// Lower bound display for when it is set to its default value. + /// + public string DefaultStringLowerBound { get; init; } = string.Empty; + + /// + /// Upper bound display for when it is set to its default value. + /// + public string DefaultStringUpperBound { get; init; } = string.Empty; + + public LocalisableString DefaultTooltipLowerBound { get; init; } = string.Empty; + + public LocalisableString DefaultTooltipUpperBound { get; init; } = string.Empty; + + public string TooltipSuffix { get; init; } = string.Empty; + + private float minRange = 0.1f; + + protected Container SliderContainer { get; private set; } = null!; + + protected BoundSliderBar LowerBoundSlider { get; private set; } = null!; + protected BoundSliderBar UpperBoundSlider { get; private set; } = null!; + + protected Vector2 ScreenSpaceHalfwayPoint + { + get + { + var lowerSS = LowerBoundSlider.Nub.ScreenSpaceDrawQuad.TopLeft; + var upperSS = UpperBoundSlider.Nub.ScreenSpaceDrawQuad.TopLeft; + + return lowerSS + (upperSS - lowerSS) / 2; + } + } + + public ShearedRangeSlider(LocalisableString label) + { + this.label = label; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = ShearedNub.HEIGHT; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5f, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = label, + Shear = -OsuGame.SHEAR, + Margin = new MarginPadding { Horizontal = 12, Vertical = 5 }, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + SliderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = -10 }, + Children = new[] + { + UpperBoundSlider = CreateBoundSlider(true).With(d => + { + d.KeyboardStep = 0.1f; + d.RelativeSizeAxes = Axes.X; + d.TooltipSuffix = TooltipSuffix; + d.DefaultString = DefaultStringUpperBound; + d.DefaultTooltip = DefaultTooltipUpperBound; + d.NubWidth = NubWidth; + d.Current = upperBound; + }), + LowerBoundSlider = CreateBoundSlider(false).With(d => + { + d.KeyboardStep = 0.1f; + d.RelativeSizeAxes = Axes.X; + d.TooltipSuffix = TooltipSuffix; + d.DefaultString = DefaultStringLowerBound; + d.DefaultTooltip = DefaultTooltipLowerBound; + d.NubWidth = NubWidth; + d.Current = lowerBound; + }), + UpperBoundSlider.Nub.CreateProxy(), + LowerBoundSlider.Nub.CreateProxy(), + }, + }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LowerBoundSlider.Current.ValueChanged += min => UpperBoundSlider.Current.Value = Math.Max(min.NewValue + minRange, UpperBoundSlider.Current.Value); + UpperBoundSlider.Current.ValueChanged += max => LowerBoundSlider.Current.Value = Math.Min(max.NewValue - minRange, LowerBoundSlider.Current.Value); + } + + protected virtual BoundSliderBar CreateBoundSlider(bool isUpper) => new BoundSliderBar(this, isUpper); + + protected partial class BoundSliderBar : ShearedSliderBar + { + private readonly ShearedRangeSlider rangeSlider; + private readonly bool isUpper; + + public new float NormalizedValue => base.NormalizedValue; + + public new ShearedNub Nub => base.Nub; + + public string? DefaultString; + public LocalisableString? DefaultTooltip; + public string? TooltipSuffix; + + public float NubWidth { get; set; } = ShearedNub.HEIGHT; + + public override LocalisableString TooltipText => + (Current.IsDefault ? DefaultTooltip : Current.Value.ToString($@"0.## {TooltipSuffix}")) ?? Current.Value.ToString($@"0.## {TooltipSuffix}"); + + protected OsuSpriteText NubText { get; private set; } = null!; + + public override bool AcceptsFocus => false; + + public BoundSliderBar(ShearedRangeSlider rangeSlider, bool isUpper) + { + this.rangeSlider = rangeSlider; + this.isUpper = isUpper; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Nub.Width = NubWidth; + RangePadding = Nub.Width / 2; + + Nub.Add(NubText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -3, + UseFullGlyphHeight = false, + Colour = OsuColour.ForegroundTextColourFor(colourProvider.Light1), + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }); + + AccentColour = colourProvider.Highlight1.Darken(0.1f); + Nub.AccentColour = colourProvider.Highlight1; + Nub.GlowingAccentColour = colourProvider.Highlight1; + Nub.GlowColour = colourProvider.Highlight1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (!isUpper) + { + AccentColour = BackgroundColour; + BackgroundColour = Color4.Transparent; + } + + Current.BindValueChanged(current => UpdateDisplay(current.NewValue), true); + FinishTransforms(true); + } + + protected virtual void UpdateDisplay(double value) + { + string defaultString = DefaultString ?? value.ToString("N1"); + NubText.Text = Current.IsDefault ? defaultString : value.ToString("N1"); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (isUpper) + return base.ReceivePositionalInputAt(screenSpacePos) && screenSpacePos.X > rangeSlider.ScreenSpaceHalfwayPoint.X; + + return base.ReceivePositionalInputAt(screenSpacePos) && screenSpacePos.X <= rangeSlider.ScreenSpaceHalfwayPoint.X; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (isUpper) + { + // Only draw left box where required to avoid masking bleed issues. + LeftBox.X = ToParentSpace(ToLocalSpace(rangeSlider.LowerBoundSlider.Nub.ScreenSpaceDrawQuad.Centre)).X; + LeftBox.Size -= new Vector2(LeftBox.X, 0); + } + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + return true; // Make sure only one nub shows hover effect at once. + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index f5fbb3411f..b1b93dcbca 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -21,33 +21,33 @@ namespace osu.Game.Graphics.UserInterface private const float corner_radius = 7; private readonly Box background; - private readonly SearchTextBox textBox; + protected readonly InnerSearchTextBox TextBox; public Bindable Current { - get => textBox.Current; - set => textBox.Current = value; + get => TextBox.Current; + set => TextBox.Current = value; } public bool HoldFocus { - get => textBox.HoldFocus; - set => textBox.HoldFocus = value; + get => TextBox.HoldFocus; + set => TextBox.HoldFocus = value; } public LocalisableString PlaceholderText { - get => textBox.PlaceholderText; - set => textBox.PlaceholderText = value; + get => TextBox.PlaceholderText; + set => TextBox.PlaceholderText = value; } - public new bool HasFocus => textBox.HasFocus; + public new bool HasFocus => TextBox.HasFocus; - public void TakeFocus() => textBox.TakeFocus(); + public void TakeFocus() => TextBox.TakeFocus(); - public void KillFocus() => textBox.KillFocus(); + public void KillFocus() => TextBox.KillFocus(); - public bool SelectAll() => textBox.SelectAll(); + public bool SelectAll() => TextBox.SelectAll(); public ShearedSearchTextBox() { @@ -69,13 +69,7 @@ namespace osu.Game.Graphics.UserInterface { new Drawable[] { - textBox = new InnerSearchTextBox - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Size = Vector2.One - }, + TextBox = CreateInnerTextBox(), new SpriteIcon { Icon = FontAwesome.Solid.Search, @@ -101,10 +95,20 @@ namespace osu.Game.Graphics.UserInterface background.Colour = colourProvider.Background3; } - public override bool HandleNonPositionalInput => textBox.HandleNonPositionalInput; + public override bool HandleNonPositionalInput => TextBox.HandleNonPositionalInput; - private partial class InnerSearchTextBox : SearchTextBox + protected virtual InnerSearchTextBox CreateInnerTextBox() => new InnerSearchTextBox(); + + protected partial class InnerSearchTextBox : SearchTextBox { + public InnerSearchTextBox() + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + RelativeSizeAxes = Axes.Both; + Size = Vector2.One; + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { diff --git a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs index e7b57f5c9e..9404b813f9 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; -using static osu.Game.Graphics.UserInterface.ShearedNub; using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface @@ -29,6 +28,8 @@ namespace osu.Game.Graphics.UserInterface private readonly Container mainContent; + protected virtual bool FocusIndicator => true; + private Color4 accentColour; public Color4 AccentColour @@ -56,43 +57,41 @@ namespace osu.Game.Graphics.UserInterface } } + public Color4 NubShadowColour + { + get => Nub.ShadowColour; + set => Nub.ShadowColour = value; + } + public ShearedSliderBar() { Shear = OsuGame.SHEAR; - Height = HEIGHT; - RangePadding = EXPANDED_SIZE / 2; + Height = ShearedNub.HEIGHT; + RangePadding = ShearedNub.EXPANDED_SIZE / 2; Children = new Drawable[] { mainContent = new Container { RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Child = new Container + Masking = true, + CornerRadius = 5, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Masking = true, - CornerRadius = 5, - Children = new Drawable[] + LeftBox = new Box { - LeftBox = new Box - { - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - RightBox = new Box - { - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + RightBox = new Box + { + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, }, }, }, @@ -102,7 +101,6 @@ namespace osu.Game.Graphics.UserInterface RelativeSizeAxes = Axes.Both, Child = Nub = new ShearedNub { - X = -OsuGame.SHEAR.X * HEIGHT / 2f, Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, Current = { Value = true }, @@ -146,13 +144,16 @@ namespace osu.Game.Graphics.UserInterface { base.OnFocus(e); - mainContent.EdgeEffect = new EdgeEffectParameters + if (FocusIndicator) { - Type = EdgeEffectType.Glow, - Colour = AccentColour.Darken(1), - Hollow = true, - Radius = 2, - }; + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } } protected override void OnFocusLost(FocusLostEvent e) @@ -191,8 +192,9 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); - RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); + + LeftBox.Size = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2f + ShearedNub.CORNER_RADIUS - 0.5f, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Size = new Vector2(Math.Clamp(DrawWidth - RangePadding - Nub.DrawPosition.X - Nub.DrawWidth / 2f + ShearedNub.CORNER_RADIUS - 0.5f, 0, Math.Max(0, DrawWidth)), 1); } protected override void UpdateValue(float value) diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index dceedca05c..95d0a4a9ec 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -84,6 +84,11 @@ namespace osu.Game.Localisation /// public static LocalisableString RightMouseScroll => new TranslatableString(getKey(@"right_mouse_scroll"), @"Right mouse drag to absolute scroll"); + /// + /// "Show converts" + /// + public static LocalisableString ShowConverts => new TranslatableString(getKey(@"show_converts"), @"Show converts"); + /// /// "Show converted beatmaps" /// diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 51fadb521a..525eb98a86 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -72,6 +72,8 @@ namespace osu.Game.Online.API protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly Bindable configStatus = new Bindable(); + private readonly Bindable configSupporter = new Bindable(); + private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); private readonly Logger log; @@ -104,6 +106,7 @@ namespace osu.Game.Online.API authentication.Token.ValueChanged += onTokenChanged; config.BindWith(OsuSetting.UserOnlineStatus, configStatus); + config.BindWith(OsuSetting.WasSupporter, configSupporter); if (HasLogin) { @@ -333,6 +336,7 @@ namespace osu.Game.Online.API Debug.Assert(ThreadSafety.IsUpdateThread); localUser.Value = me; + configSupporter.Value = me.IsSupporter; state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; failureCount = 0; }; @@ -368,7 +372,8 @@ namespace osu.Game.Online.API localUser.Value = new APIUser { - Username = ProvidedUsername + Username = ProvidedUsername, + IsSupporter = configSupporter.Value, }; } @@ -607,6 +612,7 @@ namespace osu.Game.Online.API Schedule(() => { localUser.Value = createGuestUser(); + configSupporter.Value = false; friends.Clear(); }); diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index dd68085103..4aca3b1a4a 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -125,7 +125,14 @@ namespace osu.Game.Online.Leaderboards var result = LeaderboardScores.Success ( - response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)).OrderByTotalScore().ToArray(), + response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)) + .OrderByTotalScore() + .Select((s, idx) => + { + s.Position = idx + 1; + return s; + }) + .ToArray(), response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap) ); inFlightOnlineRequest = null; diff --git a/osu.Game/Overlays/Mods/AddPresetButton.cs b/osu.Game/Overlays/Mods/AddPresetButton.cs index 276afd9bec..e4f7f83c11 100644 --- a/osu.Game/Overlays/Mods/AddPresetButton.cs +++ b/osu.Game/Overlays/Mods/AddPresetButton.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Mods Height = ModSelectPanel.HEIGHT; // shear will be applied at a higher level in `ModPresetColumn`. - Content.Shear = Vector2.Zero; + Shear = Vector2.Zero; Padding = new MarginPadding(); Text = "+"; diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 7df7d6339c..817a61f7ac 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -40,11 +40,13 @@ namespace osu.Game.Overlays.Mods public AddPresetPopover(AddPresetButton addPresetButton) { + const float content_width = 300; + button = addPresetButton; Child = new FillFlowContainer { - Width = 300, + Width = content_width, AutoSizeAxes = Axes.Y, Spacing = new Vector2(7), Children = new Drawable[] @@ -63,12 +65,24 @@ namespace osu.Game.Overlays.Mods Label = CommonStrings.Description, TabbableContentContainer = this }, - createButton = new ShearedButton + new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = ModSelectOverlayStrings.AddPreset, - Action = createPreset + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + createButton = new ShearedButton(content_width) + { + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Text = ModSelectOverlayStrings.AddPreset, + Action = createPreset + } + } } } }; diff --git a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs index bdb10a477c..b806059e19 100644 --- a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs +++ b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs @@ -62,7 +62,7 @@ namespace osu.Game.Overlays.Mods { new OsuSpriteText { - Text = "One or more values are being adjusted by mods that change speed.", + Text = "One or more values are being adjusted by mods.", }, attributesFillFlow = new FillFlowContainer { diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index 8014126942..eb128c7792 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -52,9 +52,11 @@ namespace osu.Game.Overlays.Mods [BackgroundDependencyLoader] private void load() { + const float content_width = 300; + Child = new FillFlowContainer { - Width = 300, + Width = content_width, AutoSizeAxes = Axes.Y, Spacing = new Vector2(7), Direction = FillDirection.Vertical, @@ -107,25 +109,27 @@ namespace osu.Game.Overlays.Mods { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + AutoSizeAxes = Axes.Both, Spacing = new Vector2(7), + Direction = FillDirection.Vertical, Children = new Drawable[] { - useCurrentModsButton = new ShearedButton + useCurrentModsButton = new ShearedButton(content_width) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, Text = ModSelectOverlayStrings.UseCurrentMods, DarkerColour = colours.Blue1, LighterColour = colours.Blue0, TextColour = colourProvider.Background6, Action = useCurrentMods, }, - saveButton = new ShearedButton + saveButton = new ShearedButton(content_width) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + // todo: for some very odd reason, this needs to be anchored to topright for the fill flow to be correctly sized to the AABB of the sheared button + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, Text = Resources.Localisation.Web.CommonStrings.ButtonsSave, DarkerColour = colours.Orange1, LighterColour = colours.Orange0, diff --git a/osu.Game/Overlays/WizardOverlay.cs b/osu.Game/Overlays/WizardOverlay.cs index 2a881045fd..3cc403dbff 100644 --- a/osu.Game/Overlays/WizardOverlay.cs +++ b/osu.Game/Overlays/WizardOverlay.cs @@ -243,11 +243,10 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both; + Padding = new MarginPadding { Right = OsuGame.SCREEN_EDGE_MARGIN }; + InternalChild = NextButton = new ShearedButton(0) { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Right = 12f }, RelativeSizeAxes = Axes.X, Width = 1, Text = FirstRunSetupOverlayStrings.GetStarted, diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 5d4cc5fd12..08e64c4aa9 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods { @@ -81,5 +83,33 @@ namespace osu.Game.Rulesets.Mods /// Create a fresh instance based on this mod. /// Mod CreateInstance() => (Mod)Activator.CreateInstance(GetType())!; + + /// + /// Whether any user adjustable setting attached to this mod has a non-default value. + /// + /// + /// This returns the instantaneous state of this mod. It may change over time. + /// For tracking changes on a dynamic display, make sure to setup a . + /// + bool HasNonDefaultSettings + { + get + { + bool hasAdjustments = false; + + foreach (var (_, property) in this.GetSettingsSourceProperties()) + { + var bindable = (IBindable)property.GetValue(this)!; + + if (!bindable.IsDefault) + { + hasAdjustments = true; + break; + } + } + + return hasAdjustments; + } + } } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index 79fc918487..15ce583413 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; namespace osu.Game.Rulesets.Mods { @@ -67,6 +68,22 @@ namespace osu.Game.Rulesets.Mods } } + public override string ExtendedIconInformation + { + get + { + if (UserAdjustedSettingsCount != 1) + return string.Empty; + + if (!OverallDifficulty.IsDefault) return format("OD", OverallDifficulty); + if (!DrainRate.IsDefault) return format("HP", DrainRate); + + return string.Empty; + + string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; + } + } + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get @@ -94,5 +111,26 @@ namespace osu.Game.Rulesets.Mods if (DrainRate.Value != null) difficulty.DrainRate = DrainRate.Value.Value; if (OverallDifficulty.Value != null) difficulty.OverallDifficulty = OverallDifficulty.Value.Value; } + + /// + /// The number of settings on this mod instance which have been adjusted by the user from their default values. + /// + protected int UserAdjustedSettingsCount + { + get + { + int count = 0; + + foreach (var (_, property) in this.GetSettingsSourceProperties()) + { + var bindable = (IBindable)property.GetValue(this)!; + + if (!bindable.IsDefault) + count++; + } + + return count; + } + } } } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index ee0103a8e5..d3f04e7e74 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; @@ -81,6 +82,11 @@ namespace osu.Game.Rulesets.UI private Container extendedContent = null!; + private Drawable adjustmentMarker = null!; + + private Circle cogBackground = null!; + private SpriteIcon cog = null!; + private ModSettingChangeTracker? modSettingsChangeTracker; /// @@ -139,7 +145,7 @@ namespace osu.Game.Rulesets.UI Origin = Anchor.CentreLeft, Name = "main content", Size = MOD_ICON_SIZE, - Children = new Drawable[] + Children = new[] { background = new Sprite { @@ -165,6 +171,29 @@ namespace osu.Game.Rulesets.UI Size = new Vector2(45), Icon = FontAwesome.Solid.Question }, + adjustmentMarker = new Container + { + Size = new Vector2(20), + Origin = Anchor.Centre, + Position = new Vector2(64, 14), + Children = new Drawable[] + { + cogBackground = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + cog = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Cog, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.6f), + } + } + }, } }, }; @@ -216,11 +245,18 @@ namespace osu.Game.Rulesets.UI extendedContent.Alpha = showExtended ? 1 : 0; extendedText.Text = mod.ExtendedIconInformation; + + if (mod.HasNonDefaultSettings) + adjustmentMarker.Show(); + else + adjustmentMarker.Hide(); } private void updateColour() { modAcronym.Colour = modIcon.Colour = Interpolation.ValueAt(0.1f, Colour4.Black, backgroundColour, 0, 1); + cogBackground.Colour = Interpolation.ValueAt(0.1f, Colour4.Black, backgroundColour, 0, 1); + cog.Colour = backgroundColour; extendedText.Colour = background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; extendedBackground.Colour = Selected.Value ? backgroundColour.Darken(2.4f) : backgroundColour.Darken(2.8f); diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 5f80c2cd96..3f53801372 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -108,12 +108,14 @@ namespace osu.Game.Screens.Backgrounds if (Background != null) { newDepth = Background.Depth + 1; - Background.FinishTransforms(); Background.FadeOut(250); Background.Expire(); } b.Depth = newDepth; + b.Anchor = b.Origin = Anchor.Centre; + b.FadeInFromZero(500, Easing.OutQuint); + b.ScaleTo(1.02f).ScaleTo(1, 3500, Easing.OutQuint); dimmable.Background = Background = b; } diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 94f4ceeb1a..b2f2903d41 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -75,7 +75,7 @@ namespace osu.Game.Screens.Footer new GridContainer { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, + Padding = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize), @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Footer { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Y = 10f, + Y = ScreenFooterButton.Y_OFFSET, Direction = FillDirection.Horizontal, Spacing = new Vector2(7, 0), AutoSizeAxes = Axes.Both, @@ -97,7 +97,7 @@ namespace osu.Game.Screens.Footer footerContentContainer = new Container { RelativeSizeAxes = Axes.Both, - Y = -15f, + Y = -OsuGame.SCREEN_EDGE_MARGIN, }, }, } @@ -112,7 +112,7 @@ namespace osu.Game.Screens.Footer hiddenButtonsContainer = new Container { Margin = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, - Y = 10f, + Y = ScreenFooterButton.Y_OFFSET, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, AutoSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Footer/ScreenFooterButton.cs b/osu.Game/Screens/Footer/ScreenFooterButton.cs index 5e96eadfea..6385901db7 100644 --- a/osu.Game/Screens/Footer/ScreenFooterButton.cs +++ b/osu.Game/Screens/Footer/ScreenFooterButton.cs @@ -25,7 +25,8 @@ namespace osu.Game.Screens.Footer { public partial class ScreenFooterButton : OsuClickableContainer, IKeyBindingHandler { - protected const int CORNER_RADIUS = 10; + public const int Y_OFFSET = 10; + protected const int BUTTON_HEIGHT = 75; protected const int BUTTON_WIDTH = 116; @@ -87,7 +88,7 @@ namespace osu.Game.Screens.Footer }, Shear = OsuGame.SHEAR, Masking = true, - CornerRadius = CORNER_RADIUS, + CornerRadius = 10, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -134,7 +135,7 @@ namespace osu.Game.Screens.Footer Shear = -OsuGame.SHEAR, Anchor = Anchor.BottomCentre, Origin = Anchor.Centre, - Y = -CORNER_RADIUS, + Y = -Y_OFFSET, Size = new Vector2(100, 5), Masking = true, CornerRadius = 3, diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs index 005cd784c4..af286731aa 100644 --- a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs @@ -5,7 +5,6 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -20,8 +19,6 @@ namespace osu.Game.Screens.Play.HUD { public partial class DrawableGameplayLeaderboard : CompositeDrawable { - private readonly Cached sorting = new Cached(); - public Bindable Expanded = new Bindable(); protected readonly FillFlowContainer Flow; @@ -87,7 +84,6 @@ namespace osu.Game.Screens.Play.HUD }, true); } - Scheduler.AddDelayed(sort, 1000, true); configVisibility.BindValueChanged(_ => this.FadeTo(configVisibility.Value ? 1 : 0, 100, Easing.OutQuint), true); } @@ -109,8 +105,8 @@ namespace osu.Game.Screens.Play.HUD drawable.Expanded.BindTo(Expanded); Flow.Add(drawable); - drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true); - drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); + drawable.ScorePosition.BindValueChanged(_ => Scheduler.AddOnce(sort)); + drawable.DisplayOrder.BindValueChanged(_ => Scheduler.AddOnce(sort), true); int displayCount = Math.Min(Flow.Count, max_panels); Height = displayCount * (DrawableGameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); @@ -179,22 +175,8 @@ namespace osu.Game.Screens.Play.HUD private void sort() { - if (sorting.IsValid) - return; - - var orderedByScore = Flow - .OrderByDescending(i => i.TotalScore.Value) - .ThenBy(i => i.DisplayOrder.Value) - .ToList(); - - for (int i = 0; i < Flow.Count; i++) - { - var score = orderedByScore[i]; - Flow.SetLayoutPosition(score, i); - score.ScorePosition = i + 1 == Flow.Count && leaderboardProvider?.IsPartial == true && score.Tracked ? null : i + 1; - } - - sorting.Validate(); + foreach (var score in Flow.ToArray()) + Flow.SetLayoutPosition(score, score.DisplayOrder.Value); } private partial class InputDisabledScrollContainer : OsuScrollContainer diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs index b14e31983c..e4f2cc0d68 100644 --- a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboardScore.cs @@ -56,6 +56,7 @@ namespace osu.Game.Screens.Play.HUD public BindableDouble Accuracy { get; } = new BindableDouble(1); public BindableInt Combo { get; } = new BindableInt(); public BindableBool HasQuit { get; } = new BindableBool(); + public Bindable ScorePosition { get; } = new Bindable(); public Bindable DisplayOrder { get; } = new Bindable(); private Func? getDisplayScoreFunction; @@ -69,28 +70,6 @@ namespace osu.Game.Screens.Play.HUD public Color4? TextColour { get; set; } - private int? scorePosition; - - private bool scorePositionIsSet; - - public int? ScorePosition - { - get => scorePosition; - set - { - // We always want to run once, as the incoming value may be null and require a visual update to "-". - if (value == scorePosition && scorePositionIsSet) - return; - - scorePosition = value; - - positionText.Text = scorePosition.HasValue ? $"#{scorePosition.Value.FormatRank()}" : "-"; - scorePositionIsSet = true; - - updateState(); - } - } - public IUser? User { get; } /// @@ -123,6 +102,7 @@ namespace osu.Game.Screens.Play.HUD Accuracy.BindTo(score.Accuracy); Combo.BindTo(score.Combo); HasQuit.BindTo(score.HasQuit); + ScorePosition.BindTo(score.Position); DisplayOrder.BindTo(score.DisplayOrder); GetDisplayScore = score.GetDisplayScore; @@ -334,6 +314,7 @@ namespace osu.Game.Screens.Play.HUD updateState(); Expanded.BindValueChanged(changeExpandedState, true); + ScorePosition.BindValueChanged(_ => updateState(), true); FinishTransforms(true); } @@ -392,7 +373,9 @@ namespace osu.Game.Screens.Play.HUD return; } - if (scorePosition == 1) + positionText.Text = ScorePosition.Value.HasValue ? $"#{ScorePosition.Value.Value.FormatRank()}" : "-"; + + if (ScorePosition.Value == 1) { widthExtension = true; panelColour = BackgroundColour ?? Color4Extensions.FromHex("7fcc33"); diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 3486d81e8a..8ef083d287 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -1,37 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Extensions; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; +using osu.Game.Online.Leaderboards; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Ranking { public partial class SoloResultsScreen : ResultsScreen { - private GetScoresRequest? getScoreRequest; + private readonly IBindable globalScores = new Bindable(); [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; + private LeaderboardManager leaderboardManager { get; set; } = null!; public SoloResultsScreen(ScoreInfo score) : base(score) { } + protected override void LoadComplete() + { + base.LoadComplete(); + globalScores.BindTo(leaderboardManager.Scores); + } + protected override async Task FetchScores() { Debug.Assert(Score != null); @@ -39,52 +41,93 @@ namespace osu.Game.Screens.Ranking if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) return []; - var requestTaskSource = new TaskCompletionSource(); - - getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += requestTaskSource.SetResult; - getScoreRequest.Failure += requestTaskSource.SetException; - api.Queue(getScoreRequest); - - try + var criteria = new LeaderboardCriteria( + Score.BeatmapInfo!, + Score.Ruleset, + leaderboardManager.CurrentCriteria?.Scope ?? BeatmapLeaderboardScope.Global, + leaderboardManager.CurrentCriteria?.ExactMods + ); + var requestTaskSource = new TaskCompletionSource(); + globalScores.BindValueChanged(_ => { - var scores = await requestTaskSource.Task.ConfigureAwait(false); - var toDisplay = new List(); + if (globalScores.Value != null && leaderboardManager.CurrentCriteria?.Equals(criteria) == true) + requestTaskSource.TrySetResult(globalScores.Value); + }); + leaderboardManager.FetchWithCriteria(criteria, forceRefresh: true); - for (int i = 0; i < scores.Scores.Count; ++i) - { - var score = scores.Scores[i]; - int position = i + 1; + var result = await requestTaskSource.Task.ConfigureAwait(false); - if (score.MatchesOnlineID(Score)) - { - // we don't want to add the same score twice, but also setting any properties of `Score` this late will have no visible effect, - // so we have to fish out the actual drawable panel and set the position to it directly. - var panel = ScorePanelList.GetPanelForScore(Score); - Score.Position = panel.ScorePosition.Value = position; - } - else - { - var converted = score.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo); - converted.Position = position; - toDisplay.Add(converted); - } - } - - return toDisplay.ToArray(); - } - catch (Exception ex) + if (result.FailState != null) { - Logger.Log($"Failed to fetch scores (beatmap: {Score.BeatmapInfo}, ruleset: {Score.Ruleset}): {ex}"); + Logger.Log($"Failed to fetch scores (beatmap: {Score.BeatmapInfo}, ruleset: {Score.Ruleset}): {result.FailState}"); return []; } - } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); + var clonedScores = result.AllScores.Select(s => s.DeepClone()).ToArray(); - getScoreRequest?.Cancel(); + List sortedScores = []; + + foreach (var clonedScore in clonedScores) + { + // ensure that we do not double up on the score being presented here. + // additionally, ensure that the reference that ends up in `sortedScores` is the `Score` reference specifically. + // this simplifies handling later. + if (clonedScore.Equals(Score) || clonedScore.MatchesOnlineID(Score)) + { + Score.Position = clonedScore.Position; + sortedScores.Add(Score); + } + else + sortedScores.Add(clonedScore); + } + + // if we haven't encountered a match for the presented score, we still need to attach it. + // note that the above block ensuring that the `Score` reference makes it in here makes this valid to write in this way. + if (!sortedScores.Contains(Score)) + sortedScores.Add(Score); + + sortedScores = sortedScores.OrderByTotalScore().ToList(); + + int delta = 0; + bool isPartialLeaderboard = leaderboardManager.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && result.TopScores.Count >= 50; + + for (int i = 0; i < sortedScores.Count; i++) + { + var sortedScore = sortedScores[i]; + + // see `SoloGameplayLeaderboardProvider.sort()` for another place that does the same thing with slight deviations + // if this code is changed, that code should probably be changed as well + + if (!isPartialLeaderboard) + sortedScore.Position = i + 1; + else + { + if (ReferenceEquals(sortedScore, Score) && sortedScore.Position == null) + { + int? previousScorePosition = i > 0 ? sortedScores[i - 1].Position : 0; + int? nextScorePosition = i < result.TopScores.Count - 1 ? sortedScores[i + 1].Position : null; + + if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition) + { + sortedScore.Position = previousScorePosition + 1; + delta += 1; + } + else + sortedScore.Position = null; + } + else + sortedScore.Position += delta; + } + } + + // there's a non-zero chance that the `Score.Position` was mutated above, + // but that is not actually coupled to `ScorePosition` of the relevant score panel in any way, + // so ensure that the drawable panel also receives the updated position. + // note that this is valid to do precisely because we ensured `Score` was in `sortedScores` earlier. + ScorePanelList.GetPanelForScore(Score).ScorePosition.Value = Score.Position; + + sortedScores.Remove(Score); + return sortedScores.ToArray(); } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 8197319102..ddb7814d12 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -100,7 +100,7 @@ namespace osu.Game.Screens.Select.Leaderboards // 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, filterMods ? mods.Value.ToArray() : null), forceRefresh: true); + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.Where(m => m.UserPlayable).ToArray() : null), forceRefresh: true); if (!initialFetchComplete) { diff --git a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs index 2655fd8dba..2837da23f4 100644 --- a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs @@ -53,7 +53,7 @@ namespace osu.Game.Screens.Select.Leaderboards /// An optional value to guarantee stable ordering. /// Lower numbers will appear higher in cases of ties. /// - public Bindable DisplayOrder { get; } = new BindableLong(); + public long TotalScoreTiebreaker { get; init; } /// /// A custom function which handles converting a score to a display score using a provided . @@ -68,6 +68,25 @@ namespace osu.Game.Screens.Select.Leaderboards /// public Colour4? TeamColour { get; init; } + /// + /// The initial position of the score on the leaderboard. + /// Mostly used for cases like the local user's best score on the global leaderboard (which will not be contiguous with the other scores). + /// + public int? InitialPosition { get; init; } + + /// + /// The displayed rank of the score on the leaderboard. + /// + public Bindable Position { get; } = new Bindable(); + + /// + /// The index of the score on the leaderboard. + /// This differs from in that it is required (must always be known) + /// and that it doesn't represent the score's position on global leaderboards. + /// It's a property completely local to and relative to all scores provided by the managing . + /// + public Bindable DisplayOrder { get; } = new BindableLong(); + public GameplayLeaderboardScore(IUser user, ScoreProcessor scoreProcessor, bool tracked) { User = user; @@ -95,8 +114,9 @@ namespace osu.Game.Screens.Select.Leaderboards TotalScore.Value = scoreInfo.TotalScore; Accuracy.Value = scoreInfo.Accuracy; Combo.Value = scoreInfo.Combo; - DisplayOrder.Value = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds(); + TotalScoreTiebreaker = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds(); GetDisplayScore = scoreInfo.GetDisplayScore; + InitialPosition = scoreInfo.Position; } /// diff --git a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs index 4399c422b4..468a5cbf9c 100644 --- a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs @@ -14,14 +14,5 @@ namespace osu.Game.Screens.Select.Leaderboards /// List of all scores to display on the leaderboard. /// public IBindableList Scores { get; } - - /// - /// Whether this leaderboard is a partial leaderboard (e.g. contains only the top 50 of all scores), - /// or is a full leaderboard (contains all scores that there will ever be). - /// - /// - /// If this is and a tracked score is last on the leaderboard, it will show an "unknown" score position. - /// - bool IsPartial { get; } } } diff --git a/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs index edfccd0e7e..80a5692841 100644 --- a/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/MultiplayerLeaderboardProvider.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; @@ -55,6 +56,8 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private OsuColour colours { get; set; } = null!; + private readonly Cached sorting = new Cached(); + public MultiplayerLeaderboardProvider(MultiplayerRoomUser[] users) { this.users = users; @@ -101,6 +104,8 @@ namespace osu.Game.Screens.Select.Leaderboards HasQuit = { BindTarget = trackedUser.UserQuit }, TeamColour = UserScores[user.OnlineID].Team is int team ? getTeamColour(team) : null, }; + leaderboardScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); + leaderboardScore.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); scores.Add(leaderboardScore); } }); @@ -124,6 +129,8 @@ namespace osu.Game.Screens.Select.Leaderboards // new players are not supported. playingUserIds.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); playingUserIds.BindCollectionChanged(playingUsersChanged); + + Scheduler.AddDelayed(sort, 1000, true); } private void playingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -174,6 +181,26 @@ namespace osu.Game.Screens.Select.Leaderboards } } + private void sort() + { + if (sorting.IsValid) + return; + + var orderedByScore = scores + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.TotalScoreTiebreaker) + .ToList(); + + for (int i = 0; i < orderedByScore.Count; i++) + { + var score = orderedByScore[i]; + score.DisplayOrder.Value = i; + score.Position.Value = i + 1; + } + + sorting.Validate(); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs index 5cbbb3f3b0..d17d55e4dd 100644 --- a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Game.Online.Leaderboards; using osu.Game.Scoring; @@ -12,8 +14,6 @@ namespace osu.Game.Screens.Select.Leaderboards { public partial class SoloGameplayLeaderboardProvider : Component, IGameplayLeaderboardProvider { - public bool IsPartial { get; private set; } - public IBindableList Scores => scores; private readonly BindableList scores = new BindableList(); @@ -23,13 +23,16 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private GameplayState? gameplayState { get; set; } + private readonly Cached sorting = new Cached(); + private bool isPartial; + protected override void LoadComplete() { base.LoadComplete(); var globalScores = leaderboardManager?.Scores.Value; - IsPartial = leaderboardManager?.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && globalScores?.TopScores.Count >= 50; + isPartial = leaderboardManager?.CurrentCriteria?.Scope != BeatmapLeaderboardScope.Local && globalScores?.TopScores.Count >= 50; if (globalScores != null) { @@ -39,12 +42,73 @@ namespace osu.Game.Screens.Select.Leaderboards if (gameplayState != null) { - scores.Add(new GameplayLeaderboardScore(gameplayState.Score.ScoreInfo.User, gameplayState.ScoreProcessor, true) + var localScore = new GameplayLeaderboardScore(gameplayState.Score.ScoreInfo.User, gameplayState.ScoreProcessor, true) { // Local score should always show lower than any existing scores in cases of ties. - DisplayOrder = { Value = long.MaxValue } - }); + TotalScoreTiebreaker = long.MaxValue + }; + localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); + scores.Add(localScore); } + + Scheduler.AddDelayed(sort, 1000, true); + } + + private void sort() + { + if (sorting.IsValid) + return; + + var orderedByScore = scores + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.TotalScoreTiebreaker) + .ToList(); + + int delta = 0; + + for (int i = 0; i < orderedByScore.Count; i++) + { + var score = orderedByScore[i]; + + // see `SoloResultsScreen.FetchScores()` for another place that does the same thing with slight deviations + // if this code is changed, that code should probably be changed as well + + score.DisplayOrder.Value = i + 1; + + // if we know we have all scores there can ever be, we can do the simple and obvious thing. + if (!isPartial) + score.Position.Value = i + 1; + else + { + // we have a partial leaderboard, with potential gaps. + // we have initial score positions which were valid at the point of starting play. + // the assumption here is that non-tracked scores here cannot move around, only tracked ones can. + if (score.Tracked) + { + int? previousScorePosition = i > 0 ? orderedByScore[i - 1].InitialPosition : 0; + int? nextScorePosition = i < orderedByScore.Count - 1 ? orderedByScore[i + 1].InitialPosition : null; + + // if the tracked score is perfectly between two scores which have known neighbouring initial positions, + // we can assign it the position of the previous score plus one... + if (previousScorePosition != null && nextScorePosition != null && previousScorePosition + 1 == nextScorePosition) + { + score.Position.Value = previousScorePosition + 1; + // but we also need to ensure all subsequent scores get shifted down one position, too. + delta++; + } + // conversely, if the tracked score is not between neighbouring two scores and the leaderboard is partial, + // we can't really assign a valid position at all. it could be any number between the two neighbours. + else + score.Position.Value = null; + } + // for non-tracked scores, we just need to apply any delta that might have come from the tracked scores + // which might have been encountered and assigned a position earlier. + else + score.Position.Value = score.InitialPosition + delta; + } + } + + sorting.Validate(); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs new file mode 100644 index 0000000000..99e3155a7a --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// The left portion of the song select screen which houses the metadata or leaderboards wedge, along with controls + /// to switch between them and adjust specifics. + /// + public partial class BeatmapDetailsArea : VisibilityContainer + { + private Header header = null!; + private Container contentContainer = null!; + + public BeatmapDetailsArea() + { + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load() + { + const float header_height = 35f; + + InternalChildren = new Drawable[] + { + new ShearAligningWrapper(header = new Header + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = header_height, + }), + new ShearAligningWrapper(contentContainer = new Container + { + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Top = header_height }, + RelativeSizeAxes = Axes.Both, + }) + { + Depth = 1f, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + header.Type.BindValueChanged(_ => updateDisplay(), true); + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(-150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + private Drawable? currentContent; + + private void updateDisplay() + { + if (currentContent != null) + { + currentContent.Hide(); + currentContent.Expire(); + } + + switch (header.Type.Value) + { + default: + case Header.Selection.Details: + currentContent = new BeatmapMetadataWedge(); + break; + + case Header.Selection.Ranking: + currentContent = new BeatmapLeaderboardWedge + { + Scope = { BindTarget = header.Scope }, + FilterBySelectedMods = { BindTarget = header.FilterBySelectedMods }, + }; + + break; + } + + contentContainer.Add(currentContent); + currentContent.Show(); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs new file mode 100644 index 0000000000..73e964faf7 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_Header.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Select.Leaderboards; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapDetailsArea + { + public partial class Header : CompositeDrawable + { + private WedgeSelector tabControl = null!; + private FillFlowContainer leaderboardControls = null!; + + private ShearedDropdown scopeDropdown = null!; + private ShearedToggleButton selectedModsToggle = null!; + + public IBindable Type => tabControl.Current; + + public IBindable Scope => scopeDropdown.Current; + + public IBindable FilterBySelectedMods => selectedModsToggle.Active; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f }, + Children = new Drawable[] + { + tabControl = new WedgeSelector(20f) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 200, + Height = 22, + Margin = new MarginPadding { Top = 2f }, + }, + leaderboardControls = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5f, 0f), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(128f, 30f), + Child = selectedModsToggle = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = @"Selected Mods", + Height = 30, + }, + }, + // new Container + // { + // Anchor = Anchor.CentreRight, + // Origin = Anchor.CentreRight, + // Size = new Vector2(150f, 33f), + // Child = new ShearedDropdown(@"Sort") + // { + // Width = 150f, + // Items = Enum.GetValues(), + // }, + // }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(160f, 32f), + Child = scopeDropdown = new ScopeDropdown + { + Width = 160f, + Current = { Value = BeatmapLeaderboardScope.Global }, + }, + }, + }, + }, + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + tabControl.Current.BindValueChanged(v => + { + leaderboardControls.FadeTo(v.NewValue == Selection.Ranking ? 1 : 0, 300, Easing.OutQuint); + }, true); + } + + public enum Selection + { + Details, + Ranking, + } + + // public enum RankingsSort + // { + // Score, + // Accuracy, + // Combo, + // Misses, + // Date, + // } + + private partial class ScopeDropdown : ShearedDropdown + { + public ScopeDropdown() + : base("Scope") + { + Items = Enum.GetValues(); + } + + protected override LocalisableString GenerateItemText(BeatmapLeaderboardScope item) => item.ToString(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea_WedgeSelector.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_WedgeSelector.cs new file mode 100644 index 0000000000..7509c3115a --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea_WedgeSelector.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapDetailsArea + { + public partial class WedgeSelector : TabControl + where T : struct, Enum + { + private Circle strip = null!; + + protected override Dropdown? CreateDropdown() => null; + + protected override TabItem CreateTabItem(T value) => new TabItem(value); + + protected new TabItem SelectedTab => (TabItem)base.SelectedTab; + + public WedgeSelector(float spacing) + { + TabContainer.Spacing = new Vector2(spacing, 0f); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AddInternal(strip = new Circle + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 2, + Colour = colourProvider.Highlight1, + }); + + foreach (var type in Enum.GetValues()) + AddItem(type); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay()); + + ScheduleAfterChildren(() => + { + updateDisplay(); + FinishTransforms(true); + }); + } + + private void updateDisplay() + { + strip.MoveToX(SelectedTab.Text.ToSpaceOfOtherDrawable(Vector2.Zero, this).X, 300, Easing.OutQuint); + strip.ResizeWidthTo(SelectedTab.Text.Width, 0, Easing.OutQuint); + } + + protected partial class TabItem : TabItem + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public readonly OsuSpriteText Text; + + public TabItem(T value) + : base(value) + { + AutoSizeAxes = Axes.Both; + + Children = new[] + { + Text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = value.ToString(), + Font = OsuFont.Style.Body, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + protected override void OnActivated() => updateDisplay(); + + protected override void OnDeactivated() => updateDisplay(); + + protected override bool OnHover(HoverEvent e) + { + updateDisplay(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => updateDisplay(); + + private void updateDisplay() + { + if (Active.Value || IsHovered) + Text.FadeColour(colourProvider.Content1, 300, Easing.OutQuint); + else + Text.FadeColour(colourProvider.Content2, 300, Easing.OutQuint); + + Text.Font = Text.Font.With(weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs index c9413a9414..b422a6474e 100644 --- a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore.cs @@ -25,7 +25,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -43,8 +42,10 @@ using CommonStrings = osu.Game.Localisation.CommonStrings; namespace osu.Game.Screens.SelectV2 { - public partial class BeatmapLeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip + public sealed partial class BeatmapLeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip { + public const int HEIGHT = 50; + public Bindable> SelectedMods = new Bindable>(); /// @@ -57,24 +58,6 @@ namespace osu.Game.Screens.SelectV2 public int? Rank { get; init; } public bool IsPersonalBest { get; init; } - private const float expanded_right_content_width = 210; - private const float grade_width = 40; - private const float username_min_width = 125; - private const float statistics_regular_min_width = 175; - private const float statistics_compact_min_width = 100; - private const float rank_label_width = 65; - - private readonly ScoreInfo score; - private readonly bool sheared; - - private const int height = 60; - private const int corner_radius = 10; - private const int transition_duration = 200; - - private Colour4 foregroundColour; - private Colour4 backgroundColour; - private ColourInfo totalScoreBackgroundGradient; - [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -84,38 +67,55 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private ScoreManager scoreManager { get; set; } = null!; + [Resolved] + private OsuConfigManager config { get; set; } = null!; + [Resolved] private Clipboard? clipboard { get; set; } [Resolved] private IAPIProvider api { get; set; } = null!; - private Container content = null!; + private const float expanded_right_content_width = 200; + private const float grade_width = 35; + private const float username_min_width = 120; + private const float statistics_regular_min_width = 165; + private const float statistics_compact_min_width = 90; + private const float rank_label_width = 60; + + private const int corner_radius = 10; + private const int transition_duration = 200; + + private static readonly Color4 personal_best_gradient_left = Color4Extensions.FromHex("#66FFCC"); + private static readonly Color4 personal_best_gradient_right = Color4Extensions.FromHex("#51A388"); + + private Colour4 foregroundColour; + private Colour4 backgroundColour; + private ColourInfo totalScoreBackgroundGradient; + + private ColourInfo personalBestGradient; + + private IBindable scoringMode { get; set; } = null!; + private Box background = null!; private Box foreground = null!; - private Drawable avatar = null!; private ClickableAvatar innerAvatar = null!; - private OsuSpriteText nameLabel = null!; - private List statisticsLabels = null!; - private Container rightContent = null!; - protected Container RankContainer { get; private set; } = null!; - private FillFlowContainer flagBadgeAndDateContainer = null!; - private FillFlowContainer modsContainer = null!; + private FillFlowContainer modsContainer = null!; - private OsuSpriteText scoreText = null!; - private Drawable scoreRank = null!; private Box totalScoreBackground = null!; private FillFlowContainer statisticsContainer = null!; - private RankLabel rankLabel = null!; + private Container personalBestIndicator = null!; + private Container rankLabelStandalone = null!; private Container rankLabelOverlay = null!; - public ITooltip GetCustomTooltip() => new LeaderboardScoreTooltip(); - public virtual ScoreInfo TooltipContent => score; + private readonly ScoreInfo score; + + private readonly bool sheared; public BeatmapLeaderboardScore(ScoreInfo score, bool sheared = true) { @@ -124,25 +124,18 @@ namespace osu.Game.Screens.SelectV2 Shear = sheared ? OsuGame.SHEAR : Vector2.Zero; RelativeSizeAxes = Axes.X; - Height = height; + Height = HEIGHT; } [BackgroundDependencyLoader] private void load() { - var user = score.User; - - foregroundColour = IsPersonalBest ? colourProvider.Background1 : colourProvider.Background5; - backgroundColour = IsPersonalBest ? colourProvider.Background2 : colourProvider.Background4; + foregroundColour = colourProvider.Background5; + backgroundColour = colourProvider.Background3; totalScoreBackgroundGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour); + personalBestGradient = ColourInfo.GradientHorizontal(personal_best_gradient_left, personal_best_gradient_right); - statisticsLabels = GetStatistics(score).Select(s => new ScoreComponentLabel(s, score) - { - // ensure statistics container is the correct width when invalidating - AlwaysPresent = true, - }).ToList(); - - Child = content = new Container + Child = new Container { Masking = true, CornerRadius = corner_radius, @@ -167,18 +160,297 @@ namespace osu.Game.Screens.SelectV2 { new Drawable[] { - new Container + rankLabelStandalone = new Container { - AutoSizeAxes = Axes.X, + Width = rank_label_width, RelativeSizeAxes = Axes.Y, - Child = rankLabel = new RankLabel(Rank, sheared) + Children = new Drawable[] { - Width = rank_label_width, - RelativeSizeAxes = Axes.Y, + personalBestIndicator = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -10f }, + Alpha = IsPersonalBest ? 1 : 0, + Colour = personalBestGradient, + Child = new Box { RelativeSizeAxes = Axes.Both }, + }, + new RankLabel(Rank, sheared, darkText: IsPersonalBest) + { + RelativeSizeAxes = Axes.Both, + } }, }, - createCentreContent(user), - createRightContent() + new Container + { + Name = @"Centre container", + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = foregroundColour + }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = score.User, + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0.5f), Colour4.FromHex(@"222A27").Opacity(1)), + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Children = new Drawable[] + { + new DelayedLoadWrapper(innerAvatar = new ClickableAvatar(score.User) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.1f), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + RelativeSizeAxes = Axes.Both, + }) + { + RelativeSizeAxes = Axes.None, + Size = new Vector2(HEIGHT) + }, + rankLabelOverlay = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.5f), + }, + new RankLabel(Rank, sheared, false) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + } + }, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Children = new Drawable[] + { + new FillFlowContainer + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new UpdateableFlag(score.User.CountryCode) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(20, 14), + }, + new UpdateableTeamFlag(score.User.Team) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(30, 15), + }, + new DateLabel(score.Date) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colourProvider.Content2, + UseFullGlyphHeight = false, + } + } + }, + new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Text = score.User.Username, + Font = OsuFont.Style.Heading2, + } + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Child = statisticsContainer = new FillFlowContainer + { + Name = @"Statistics container", + Padding = new MarginPadding { Right = 40 }, + Spacing = new Vector2(25, 0), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = getStatistics(score).Select(s => new ScoreComponentLabel(s, score)).ToList(), + Alpha = 0, + } + } + } + }, + }, + }, + }, + rightContent = new Container + { + Name = @"Right content", + RelativeSizeAxes = Axes.Y, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = grade_width }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank)), + }, + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = OsuColour.ForRank(score.Rank), + }, + new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + SpawnRatio = 2, + Velocity = 0.7f, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Darken(0.2f)), + }, + new Container + { + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-2), + Colour = DrawableRank.GetRankNameColour(score.Rank), + Font = OsuFont.Numeric.With(size: 14), + Text = DrawableRank.GetRankName(score.Rank), + ShadowColour = Color4.Black.Opacity(0.3f), + ShadowOffset = new Vector2(0, 0.08f), + Shadow = true, + UseFullGlyphHeight = false, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = grade_width }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius, + Children = new Drawable[] + { + totalScoreBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = totalScoreBackgroundGradient, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Opacity(0.5f)), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Spacing = new Vector2(0f, -2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + UseFullGlyphHeight = false, + Current = scoreManager.GetBindableTotalScoreString(score), + Spacing = new Vector2(-1.5f), + Font = OsuFont.Style.Subtitle.With(weight: FontWeight.Light, fixedWidth: true), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + }, + new InputBlockingContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Child = modsContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(-10, 0), + Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, + }, + }, + } + } + } + } + } + } + }, + } } } } @@ -188,11 +460,6 @@ namespace osu.Game.Screens.SelectV2 innerAvatar.OnLoadComplete += d => d.FadeInFromZero(200); } - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - private IBindable scoringMode { get; set; } = null!; - protected override void LoadComplete() { base.LoadComplete(); @@ -203,7 +470,7 @@ namespace osu.Game.Screens.SelectV2 switch (s.NewValue) { case ScoringMode.Standardised: - rightContent.Width = 180f; + rightContent.Width = 170; break; case ScoringMode.Classic: @@ -217,342 +484,24 @@ namespace osu.Game.Screens.SelectV2 private void updateModDisplay() { - int maxMods = scoringMode.Value == ScoringMode.Standardised ? 4 : 5; - if (score.Mods.Length > 0) { modsContainer.Padding = new MarginPadding { Top = 4f }; - modsContainer.ChildrenEnumerable = score.Mods.AsOrdered().Take(Math.Min(maxMods, score.Mods.Length)).Select(mod => new ColouredModSwitchTiny(mod) + modsContainer.ChildrenEnumerable = score.Mods.AsOrdered().Select(mod => new ModIcon(mod) { - Scale = new Vector2(0.375f) + Scale = new Vector2(0.3f), + // trim mod icon height down to its true height for alignment purposes. + Height = ModIcon.MOD_ICON_SIZE.Y * 3 / 4f, }); - - if (score.Mods.Length > maxMods) - { - modsContainer.Remove(modsContainer[^1], true); - modsContainer.Add(new MoreModSwitchTiny(score.Mods.Length - maxMods + 1) - { - Scale = new Vector2(0.375f), - }); - } } } - private Container createCentreContent(APIUser user) => new Container - { - Name = @"Centre container", - Masking = true, - CornerRadius = corner_radius, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - foreground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = foregroundColour - }, - new UserCoverBackground - { - RelativeSizeAxes = Axes.Both, - User = score.User, - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0.5f), Colour4.FromHex(@"222A27").Opacity(1)), - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.Both, - CornerRadius = corner_radius, - Masking = true, - Children = new[] - { - avatar = new DelayedLoadWrapper( - innerAvatar = new ClickableAvatar(user) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(1.1f), - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - RelativeSizeAxes = Axes.Both, - }) - { - RelativeSizeAxes = Axes.None, - Size = new Vector2(height) - }, - rankLabelOverlay = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.Black.Opacity(0.5f), - }, - new RankLabel(Rank, sheared) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - } - } - }, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = corner_radius }, - Children = new Drawable[] - { - flagBadgeAndDateContainer = new FillFlowContainer - { - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - AutoSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - new UpdateableFlag(user.CountryCode) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(24, 16), - }, - new UpdateableTeamFlag(user.Team) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(40, 20), - }, - new DateLabel(score.Date) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - UseFullGlyphHeight = false, - } - } - }, - nameLabel = new TruncatingSpriteText - { - RelativeSizeAxes = Axes.X, - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Text = user.Username, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold) - } - } - }, - new Container - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Child = statisticsContainer = new FillFlowContainer - { - Name = @"Statistics container", - Padding = new MarginPadding { Right = 40 }, - Spacing = new Vector2(25, 0), - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = statisticsLabels, - Alpha = 0, - LayoutEasing = Easing.OutQuint, - LayoutDuration = transition_duration, - } - } - } - }, - }, - }, - }; - - private Container createRightContent() => rightContent = new Container - { - Name = @"Right content", - RelativeSizeAxes = Axes.Y, - Child = new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = grade_width }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank)), - }, - }, - new Box - { - RelativeSizeAxes = Axes.Y, - Width = grade_width, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = OsuColour.ForRank(score.Rank), - }, - new TrianglesV2 - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - SpawnRatio = 2, - Velocity = 0.7f, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Darken(0.2f)), - }, - RankContainer = new Container - { - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Y, - Width = grade_width, - Child = scoreRank = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Spacing = new Vector2(-2), - Colour = DrawableRank.GetRankNameColour(score.Rank), - Font = OsuFont.Numeric.With(size: 16), - Text = DrawableRank.GetRankName(score.Rank), - ShadowColour = Color4.Black.Opacity(0.3f), - ShadowOffset = new Vector2(0, 0.08f), - Shadow = true, - UseFullGlyphHeight = false, - }, - }, - new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Right = grade_width }, - Child = new Container - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Masking = true, - CornerRadius = corner_radius, - Children = new Drawable[] - { - totalScoreBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = totalScoreBackgroundGradient, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Opacity(0.5f)), - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = corner_radius }, - Children = new Drawable[] - { - scoreText = new OsuSpriteText - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - UseFullGlyphHeight = false, - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - Current = scoreManager.GetBindableTotalScoreString(score), - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Light), - }, - modsContainer = new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2f, 0f), - }, - } - } - } - } - } - } - }, - }; - - protected (CaseTransformableString, LocalisableString DisplayAccuracy)[] GetStatistics(ScoreInfo model) => new[] + private (CaseTransformableString, LocalisableString DisplayAccuracy)[] getStatistics(ScoreInfo model) => new[] { (BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), model.MaxCombo.ToString().Insert(model.MaxCombo.ToString().Length, "x")), (BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), model.DisplayAccuracy), }; - public override void Show() - { - foreach (var d in new[] { avatar, nameLabel, scoreText, scoreRank, flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels)) - d.FadeOut(); - - Alpha = 0; - - content.MoveToY(75); - avatar.MoveToX(75); - nameLabel.MoveToX(150); - - this.FadeIn(200); - content.MoveToY(0, 800, Easing.OutQuint); - - using (BeginDelayedSequence(100)) - { - avatar.FadeIn(300, Easing.OutQuint); - nameLabel.FadeIn(350, Easing.OutQuint); - - avatar.MoveToX(0, 300, Easing.OutQuint); - nameLabel.MoveToX(0, 350, Easing.OutQuint); - - using (BeginDelayedSequence(250)) - { - scoreText.FadeIn(200); - scoreRank.FadeIn(200); - - using (BeginDelayedSequence(50)) - { - var drawables = new Drawable[] { flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels).ToArray(); - for (int i = 0; i < drawables.Length; i++) - drawables[i].FadeIn(100 + i * 50); - } - } - } - } - protected override bool OnHover(HoverEvent e) { updateState(); @@ -568,10 +517,12 @@ namespace osu.Game.Screens.SelectV2 private void updateState() { var lightenedGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0).Lighten(0.2f), backgroundColour.Lighten(0.2f)); + var personalBestLightenedGradient = ColourInfo.GradientHorizontal(personal_best_gradient_left.Lighten(0.2f), personal_best_gradient_right.Lighten(0.2f)); foreground.FadeColour(IsHovered ? foregroundColour.Lighten(0.2f) : foregroundColour, transition_duration, Easing.OutQuint); background.FadeColour(IsHovered ? backgroundColour.Lighten(0.2f) : backgroundColour, transition_duration, Easing.OutQuint); totalScoreBackground.FadeColour(IsHovered ? lightenedGradient : totalScoreBackgroundGradient, transition_duration, Easing.OutQuint); + personalBestIndicator.FadeColour(IsHovered ? personalBestLightenedGradient : personalBestGradient, transition_duration, Easing.OutQuint); if (IsHovered && currentMode != DisplayMode.Full) rankLabelOverlay.FadeIn(transition_duration, Easing.OutQuint); @@ -589,25 +540,26 @@ namespace osu.Game.Screens.SelectV2 if (currentMode != mode) { + double duration = currentMode == null ? 0 : transition_duration; if (mode >= DisplayMode.Full) - rankLabel.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + rankLabelStandalone.FadeIn(duration, Easing.OutQuint).ResizeWidthTo(rank_label_width, duration, Easing.OutQuint); else - rankLabel.FadeOut(transition_duration, Easing.OutQuint).MoveToX(-rankLabel.DrawWidth, transition_duration, Easing.OutQuint); + rankLabelStandalone.FadeOut(duration, Easing.OutQuint).ResizeWidthTo(0, duration, Easing.OutQuint); if (mode >= DisplayMode.Regular) { - statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + statisticsContainer.FadeIn(duration, Easing.OutQuint).MoveToX(0, duration, Easing.OutQuint); statisticsContainer.Direction = FillDirection.Horizontal; - statisticsContainer.ScaleTo(1, transition_duration, Easing.OutQuint); + statisticsContainer.ScaleTo(1, duration, Easing.OutQuint); } else if (mode >= DisplayMode.Compact) { - statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + statisticsContainer.FadeIn(duration, Easing.OutQuint).MoveToX(0, duration, Easing.OutQuint); statisticsContainer.Direction = FillDirection.Vertical; - statisticsContainer.ScaleTo(0.8f, transition_duration, Easing.OutQuint); + statisticsContainer.ScaleTo(0.8f, duration, Easing.OutQuint); } else - statisticsContainer.FadeOut(transition_duration, Easing.OutQuint).MoveToX(statisticsContainer.DrawWidth, transition_duration, Easing.OutQuint); + statisticsContainer.FadeOut(duration, Easing.OutQuint).MoveToX(statisticsContainer.DrawWidth, duration, Easing.OutQuint); currentMode = mode; } @@ -615,19 +567,45 @@ namespace osu.Game.Screens.SelectV2 private DisplayMode getCurrentDisplayMode() { - if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) + if (DrawWidth >= HEIGHT + username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) return DisplayMode.Full; - if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width) + if (DrawWidth >= HEIGHT + username_min_width + statistics_regular_min_width + expanded_right_content_width) return DisplayMode.Regular; - if (DrawWidth >= height + username_min_width + statistics_compact_min_width + expanded_right_content_width) + if (DrawWidth >= HEIGHT + username_min_width + statistics_compact_min_width + expanded_right_content_width) return DisplayMode.Compact; return DisplayMode.Minimal; } - #region Subclasses + ITooltip IHasCustomTooltip.GetCustomTooltip() => new LeaderboardScoreTooltip(colourProvider); + + ScoreInfo IHasCustomTooltip.TooltipContent => score; + + MenuItem[] IHasContextMenu.ContextMenuItems + { + get + { + List items = new List(); + + // system mods should never be copied across regardless of anything. + var copyableMods = score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); + + if (copyableMods.Length > 0) + items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); + + if (score.OnlineID > 0) + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{score.OnlineID}"))); + + if (score.Files.Count <= 0) return items.ToArray(); + + items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(score))); + items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + + return items.ToArray(); + } + } private enum DisplayMode { @@ -642,7 +620,7 @@ namespace osu.Game.Screens.SelectV2 public DateLabel(DateTimeOffset date) : base(date) { - Font = OsuFont.GetFont(size: 16, weight: FontWeight.Medium, italics: true); + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold); } protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); @@ -677,7 +655,7 @@ namespace osu.Game.Screens.SelectV2 { Colour = colourProvider.Content2, Text = statisticInfo.Name, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), }, value = new OsuSpriteText { @@ -685,7 +663,7 @@ namespace osu.Game.Screens.SelectV2 // since the accuracy is sometimes longer than its name. BypassAutoSizeAxes = Axes.X, Text = statisticInfo.Value, - Font = OsuFont.GetFont(size: 19, weight: FontWeight.Medium), + Font = OsuFont.Style.Body, } } }; @@ -697,108 +675,33 @@ namespace osu.Game.Screens.SelectV2 private partial class RankLabel : Container, IHasTooltip { - public RankLabel(int? rank, bool sheared) + private readonly bool darkText; + private readonly OsuSpriteText text; + + public RankLabel(int? rank, bool sheared, bool darkText) { + this.darkText = darkText; if (rank >= 1000) TooltipText = $"#{rank:N0}"; - Child = new OsuSpriteText + Child = text = new OsuSpriteText { Shear = sheared ? -OsuGame.SHEAR : Vector2.Zero, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold, italics: true), - Text = rank == null ? "-" : rank.Value.FormatRank().Insert(0, "#") + Font = OsuFont.Style.Heading2, + Text = rank == null ? "-" : rank.Value.FormatRank().Insert(0, "#"), + Shadow = !darkText, }; } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + text.Colour = darkText ? colourProvider.Background3 : colourProvider.Content1; + } + public LocalisableString TooltipText { get; } } - - private sealed partial class ColouredModSwitchTiny : ModSwitchTiny, IHasCustomTooltip - { - public Mod? TooltipContent { get; } - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - public ColouredModSwitchTiny(Mod mod) - : base(mod) - { - TooltipContent = mod; - Active.Value = true; - } - - public ITooltip GetCustomTooltip() => new ModTooltip(colourProvider); - } - - private sealed partial class MoreModSwitchTiny : CompositeDrawable - { - private readonly int count; - - public MoreModSwitchTiny(int count) - { - this.count = count; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Size = new Vector2(ModSwitchTiny.WIDTH, ModSwitchTiny.DEFAULT_HEIGHT); - - InternalChild = new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f), - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shadow = false, - Font = OsuFont.Numeric.With(size: 24, weight: FontWeight.Black), - Text = $"+{count}", - Colour = colours.Yellow, - Margin = new MarginPadding - { - Top = 4 - } - } - } - }; - } - } - - #endregion - - public MenuItem[] ContextMenuItems - { - get - { - List items = new List(); - - // system mods should never be copied across regardless of anything. - var copyableMods = score.Mods.Where(m => IsValidMod.Invoke(m) && m.Type != ModType.System).ToArray(); - - if (copyableMods.Length > 0) - items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = copyableMods)); - - if (score.OnlineID > 0) - items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{score.OnlineID}"))); - - if (score.Files.Count <= 0) return items.ToArray(); - - items.Add(new OsuMenuItem(CommonStrings.Export, MenuItemType.Standard, () => scoreManager.Export(score))); - items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); - - return items.ToArray(); - } - } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs new file mode 100644 index 0000000000..c6fe1e5f25 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardScore_Tooltip.cs @@ -0,0 +1,371 @@ +// Copyright (c) ppy Pty Ltd . 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 + { + 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 + { + 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 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 + { + 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, + }, + }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs new file mode 100644 index 0000000000..b8c4d07d04 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapLeaderboardWedge.cs @@ -0,0 +1,381 @@ +// Copyright (c) ppy Pty Ltd . 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 Scope { get; } = new Bindable(); + + public IBindable FilterBySelectedMods { get; } = new BindableBool(); + + [Resolved] + private LeaderboardManager leaderboardManager { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Container placeholderContainer = null!; + private Placeholder? placeholder; + + private Container scoresContainer = null!; + + private OsuScrollContainer scoresScroll = null!; + private Container personalBestDisplay = null!; + + private Container personalBestScoreContainer = null!; + private LoadingLayer loading = null!; + + private CancellationTokenSource? cancellationTokenSource; + + private readonly IBindable fetchedScores = new Bindable(); + + 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 + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 20f }, + }, + } + }, + }, + }, + placeholderContainer = new Container + { + 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(), 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 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)); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 3d01cae614..da9d5fe89b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . 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; @@ -23,7 +24,8 @@ namespace osu.Game.Screens.SelectV2 private MetadataDisplay source = null!; private MetadataDisplay genre = null!; private MetadataDisplay language = null!; - private MetadataDisplay tag = null!; + private MetadataDisplay userTags = null!; + private MetadataDisplay mapperTags = null!; private MetadataDisplay submitted = null!; private MetadataDisplay ranked = null!; @@ -35,6 +37,9 @@ namespace osu.Game.Screens.SelectV2 private Drawable failRetryWedge = null!; private FailRetryDisplay failRetryDisplay = null!; + public bool RatingsVisible => ratingsWedge.Alpha > 0; + public bool FailRetryVisible => failRetryWedge.Alpha > 0; + protected override bool StartHidden => true; [Resolved] @@ -92,6 +97,8 @@ namespace osu.Game.Screens.SelectV2 AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0f, 10f), + AutoSizeDuration = (float)transition_duration / 3, + AutoSizeEasing = Easing.OutQuint, Children = new Drawable[] { new GridContainer @@ -148,7 +155,11 @@ namespace osu.Game.Screens.SelectV2 }, }, }, - tag = new MetadataDisplay("Tags"), + userTags = new MetadataDisplay("User Tags") + { + Alpha = 0, + }, + mapperTags = new MetadataDisplay("Mapper Tags"), }, }, }, @@ -250,7 +261,10 @@ namespace osu.Game.Screens.SelectV2 // We could consider hiding individual wedges based on zero data in the future. // Needs some experimentation on what looks good. - if (State.Value == Visibility.Visible && currentOnlineBeatmapSet != null) + var beatmapInfo = beatmap.Value.BeatmapInfo; + var currentOnlineBeatmap = currentOnlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + if (State.Value == Visibility.Visible && currentOnlineBeatmap != null) { ratingsWedge.FadeIn(transition_duration, Easing.OutQuint) .MoveToX(0, transition_duration, Easing.OutQuint); @@ -282,7 +296,7 @@ namespace osu.Game.Screens.SelectV2 else source.Data = ("-", null); - tag.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + mapperTags.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); submitted.Date = beatmapSetInfo.DateSubmitted; ranked.Date = beatmapSetInfo.DateRanked; @@ -351,7 +365,34 @@ namespace osu.Game.Screens.SelectV2 } } + updateUserTags(); updateSubWedgeVisibility(); } + + private void updateUserTags() + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + var onlineBeatmapSet = currentOnlineBeatmapSet; + var onlineBeatmap = onlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + if (onlineBeatmap?.TopTags == null || onlineBeatmap.TopTags.Length == 0 || onlineBeatmapSet?.RelatedTags == null) + { + userTags.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + var tagsById = onlineBeatmapSet.RelatedTags.ToDictionary(t => t.Id); + string[] userTagsArray = onlineBeatmap.TopTags + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!.Name) + .ToArray(); + + userTags.FadeIn(transition_duration, Easing.OutQuint); + userTags.Tags = (userTagsArray, t => songSelect?.Search(t)); + } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs index 56b83a2578..185b1ac451 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs @@ -17,6 +17,7 @@ using osu.Framework.Layout; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osuTK; @@ -163,7 +164,8 @@ namespace osu.Game.Screens.SelectV2 Text = "...", Colour = colourProvider.Background4, Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), - } + }, + new HoverClickSounds(HoverSampleSet.Button), }; } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index d892fcb485..a73fc78771 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -33,7 +35,7 @@ namespace osu.Game.Screens.SelectV2 private const float corner_radius = 10; [Resolved] - private IBindable beatmap { get; set; } = null!; + private IBindable working { get; set; } = null!; [Resolved] private IBindable ruleset { get; set; } = null!; @@ -84,7 +86,6 @@ namespace osu.Game.Screens.SelectV2 [BackgroundDependencyLoader] private void load() { - Shear = OsuGame.SHEAR; Masking = true; CornerRadius = corner_radius; @@ -185,7 +186,7 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); - beatmap.BindValueChanged(_ => updateDisplay()); + working.BindValueChanged(_ => updateDisplay()); ruleset.BindValueChanged(_ => updateDisplay()); mods.BindValueChanged(m => @@ -225,9 +226,9 @@ namespace osu.Game.Screens.SelectV2 private void updateDisplay() { - var metadata = beatmap.Value.Metadata; - var beatmapInfo = beatmap.Value.BeatmapInfo; - var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + var metadata = working.Value.Metadata; + var beatmapInfo = working.Value.BeatmapInfo; + var beatmapSetInfo = working.Value.BeatmapSetInfo; statusPill.Status = beatmapInfo.Status; @@ -247,30 +248,48 @@ namespace osu.Game.Screens.SelectV2 updateOnlineDisplay(); } + private CancellationTokenSource? lengthBpmCancellationSource; + private void updateLengthAndBpmStatistics() { - var beatmapInfo = beatmap.Value.BeatmapInfo; + lengthBpmCancellationSource?.Cancel(); + lengthBpmCancellationSource = new CancellationTokenSource(); - double rate = ModUtils.CalculateRateWithMods(mods.Value); + var token = lengthBpmCancellationSource.Token; - int bpmMax = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMaximum, rate); - int bpmMin = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMinimum, rate); - int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.Value.Beatmap.GetMostCommonBeatLength(), rate); + Task.Run(() => + { + var beatmapInfo = working.Value.BeatmapInfo; + // This can take time as it is a synchronous task. + var beatmap = working.Value.Beatmap; - double drainLength = Math.Round(beatmap.Value.Beatmap.CalculateDrainLength() / rate); - double hitLength = Math.Round(beatmapInfo.Length / rate); + double rate = ModUtils.CalculateRateWithMods(mods.Value); - lengthStatistic.Text = hitLength.ToFormattedDuration(); - lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); + int bpmMin = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMinimum, rate); + int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.GetMostCommonBeatLength(), rate); - bpmStatistic.Text = bpmMin == bpmMax - ? $"{bpmMin}" - : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + double drainLength = Math.Round(beatmap.CalculateDrainLength() / rate); + double hitLength = Math.Round(beatmapInfo.Length / rate); + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + lengthStatistic.Text = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + + bpmStatistic.Text = bpmMin == bpmMax + ? $"{bpmMin}" + : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + }); + }, token); } private void refetchBeatmapSet() { - var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + var beatmapSetInfo = working.Value.BeatmapSetInfo; currentRequest?.Cancel(); currentRequest = null; @@ -306,20 +325,9 @@ namespace osu.Game.Screens.SelectV2 else { var onlineBeatmapSet = currentOnlineBeatmapSet; - var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmap.Value.BeatmapInfo.OnlineID); + var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); - if (onlineBeatmap != null) - { - playCount.FadeIn(300, Easing.OutQuint); - playCount.Value = new StatisticPlayCount.Data(onlineBeatmap.PlayCount, onlineBeatmap.UserPlayCount); - } - else - { - playCount.FadeOut(300, Easing.OutQuint); - playCount.Value = null; - } - - favouritesStatistic.FadeIn(300, Easing.OutQuint); + playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 7e3589b001..9aaf317cb0 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -241,8 +242,6 @@ namespace osu.Game.Screens.SelectV2 cancellationSource?.Cancel(); cancellationSource = new CancellationTokenSource(); - computeStarDifficulty(cancellationSource.Token); - if (beatmap.IsDefault) { ratingAndNameContainer.FadeOut(300, Easing.OutQuint); @@ -254,17 +253,55 @@ namespace osu.Game.Screens.SelectV2 difficultyText.Text = beatmap.Value.BeatmapInfo.DifficultyName; mapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, beatmap.Value.Metadata.Author)); mapperText.Text = beatmap.Value.Metadata.Author.Username; - - var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value); - - countStatisticsDisplay.Statistics = playableBeatmap.GetStatistics() - .Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) - .ToList(); } + updateStarDifficulty(cancellationSource.Token); + updateCountStatistics(cancellationSource.Token); updateDifficultyStatistics(); } + private void updateStarDifficulty(CancellationToken cancellationToken) + { + difficultyCache.GetDifficultyAsync(beatmap.Value.BeatmapInfo, ruleset.Value, mods.Value, cancellationToken) + .ContinueWith(task => + { + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + starRatingDisplay.Current.Value = task.GetResultSafely() ?? default; + }); + }, cancellationToken); + } + + private void updateCountStatistics(CancellationToken cancellationToken) + { + if (beatmap.IsDefault) + { + countStatisticsDisplay.Statistics = Array.Empty(); + return; + } + + Task.Run(() => + { + // This can take time as it is a synchronous task. + // TODO: We're calling `GetPlayableBeatmap` multiple times every map load at song select. + var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value); + var statistics = playableBeatmap.GetStatistics() + .Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + countStatisticsDisplay.Statistics = statistics; + }); + }, cancellationToken); + } + private void updateDifficultyStatistics() => Scheduler.AddOnce(() => { if (beatmap.IsDefault) @@ -321,21 +358,6 @@ namespace osu.Game.Screens.SelectV2 }; }); - private void computeStarDifficulty(CancellationToken cancellationToken) - { - difficultyCache.GetDifficultyAsync(beatmap.Value.BeatmapInfo, ruleset.Value, mods.Value, cancellationToken) - .ContinueWith(task => - { - Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; - - starRatingDisplay.Current.Value = task.GetResultSafely() ?? default; - }); - }, cancellationToken); - } - protected override void Update() { base.Update(); diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs new file mode 100644 index 0000000000..bb795e5717 --- /dev/null +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -0,0 +1,182 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Select.Filter; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FilterControl : OverlayContainer + { + // taken from draw visualiser. used for carousel alignment purposes. + public const float HEIGHT_FROM_SCREEN_TOP = 141 - corner_radius; + + private const float corner_radius = 8; + + private ShearedToggleButton showConvertedBeatmapsButton = null!; + private DifficultyRangeSlider difficultyRangeSlider = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Shear = OsuGame.SHEAR; + Margin = new MarginPadding { Top = -corner_radius, Right = -40 }; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Child = new WedgeBackground + { + Anchor = Anchor.TopRight, + Scale = new Vector2(-1, 1), + } + }, + new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 5f), + Padding = new MarginPadding { Top = corner_radius + 5, Bottom = 2, Right = 40f, Left = 2f }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Child = new SongSelectSearchTextBox + { + RelativeSizeAxes = Axes.X, + HoldFocus = true, + // TODO: pending implementation + FilterText = "12345 matches", + }, + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute), // can probably be removed? + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + difficultyRangeSlider = new DifficultyRangeSlider + { + RelativeSizeAxes = Axes.X, + MinRange = 0.1f, + }, + Empty(), + showConvertedBeatmapsButton = new ShearedToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = UserInterfaceStrings.ShowConverts, + Height = 30f, + }, + }, + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + Height = 30, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(maxSize: 210), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(maxSize: 230), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + }, + Content = new[] + { + new[] + { + new ShearedDropdown(SortStrings.Default) + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + // todo: pending localisation + new ShearedDropdown("Group by") + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + new CollectionDropdown + { + RelativeSizeAxes = Axes.X, + }, + } + } + }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + difficultyRangeSlider.LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum); + difficultyRangeSlider.UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum); + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmapsButton.Active); + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + private partial class SongSelectSearchTextBox : ShearedFilterTextBox + { + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox(); + + private partial class InnerTextBox : InnerFilterTextBox + { + public override bool HandleLeftRightArrows => false; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs b/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs new file mode 100644 index 0000000000..58c9c60460 --- /dev/null +++ b/osu.Game/Screens/SelectV2/FilterControl_DifficultyRangeSlider.cs @@ -0,0 +1,171 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class FilterControl + { + public partial class DifficultyRangeSlider : ShearedRangeSlider + { + private Container borderContainer = null!; + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + private static readonly (float, Color4)[] spectrum = OsuColour.STAR_DIFFICULTY_SPECTRUM + .Skip(1) + .Prepend((0.0f, OsuColour.STAR_DIFFICULTY_SPECTRUM.ElementAt(1).Item2)).ToArray(); + + public DifficultyRangeSlider() + : base("Star Rating") + { + NubWidth = ShearedNub.HEIGHT * 1.16f; + TooltipSuffix = "stars"; + DefaultStringLowerBound = "0.0"; + DefaultStringUpperBound = "∞"; + DefaultTooltipUpperBound = UserInterfaceStrings.NoLimit; + + AddLayout(drawSizeLayout); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + SliderContainer.AddRange(new Drawable[] + { + new Container + { + Depth = 1, + RelativeSizeAxes = Axes.Both, + Shear = OsuGame.SHEAR, + CornerRadius = 5f, + Masking = true, + ChildrenEnumerable = spectrum.Zip(spectrum.Skip(1)) + .Select(p => new Box + { + RelativePositionAxes = Axes.X, + X = p.First.Item1 / 10f, + RelativeSizeAxes = Axes.Both, + Width = (p.Second.Item1 - p.First.Item1) / 10f, + Colour = ColourInfo.GradientHorizontal(p.First.Item2, p.Second.Item2), + }), + }, + borderContainer = new Container + { + Depth = -1, + RelativePositionAxes = Axes.X, + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + BorderColour = colourProvider.Highlight1, + BorderThickness = 2, + Masking = true, + Shear = OsuGame.SHEAR, + CornerRadius = 5f, + Child = new Box + { + Colour = Color4.Transparent, + RelativeSizeAxes = Axes.Both, + } + }, + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + LowerBoundSlider.Current.ValueChanged += _ => updateBorderDisplay(false); + UpperBoundSlider.Current.ValueChanged += _ => updateBorderDisplay(false); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!drawSizeLayout.IsValid) + { + updateBorderDisplay(true); + drawSizeLayout.Validate(); + } + } + + private void updateBorderDisplay(bool instant) + { + float borderStart = LowerBoundSlider.NormalizedValue * LowerBoundSlider.UsableWidth / LowerBoundSlider.DrawWidth; + float borderEnd = UpperBoundSlider.NormalizedValue * UpperBoundSlider.UsableWidth / UpperBoundSlider.DrawWidth; + borderEnd += UpperBoundSlider.NubWidth / UpperBoundSlider.DrawWidth; + + borderContainer.MoveToX(borderStart, instant ? 0 : 250, Easing.OutQuint); + borderContainer.ResizeWidthTo(borderEnd - borderStart, instant ? 0 : 250, Easing.OutQuint); + } + + protected override BoundSliderBar CreateBoundSlider(bool isUpper) => new DifficultyBoundSliderBar(this, isUpper); + + private partial class DifficultyBoundSliderBar : BoundSliderBar + { + private readonly bool isUpper; + + protected override bool FocusIndicator => false; + + public DifficultyBoundSliderBar(ShearedRangeSlider slider, bool isUpper) + : base(slider, isUpper) + { + this.isUpper = isUpper; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (isUpper) + { + LeftBox.Colour = OsuColour.Gray(0.4f).Opacity(0.2f); + RightBox.Colour = OsuColour.Gray(0.05f).Opacity(0.7f); + } + else + { + LeftBox.Colour = OsuColour.Gray(0.05f).Opacity(0.7f); + RightBox.Colour = OsuColour.Gray(0.4f).Opacity(0.2f); + } + } + + protected override void UpdateDisplay(double value) + { + Colour4 nubColour = ColourUtils.SampleFromLinearGradient(spectrum, (float)Math.Round(value, 2, MidpointRounding.AwayFromZero)); + nubColour = nubColour.Lighten(0.4f); + + if (value >= 8.0) + nubColour = colours.Gray4; + + Nub.AccentColour = nubColour; + Nub.GlowingAccentColour = nubColour.Lighten(0.2f); + Nub.ShadowColour = Color4.Black.Opacity(0.2f); + NubText.Colour = OsuColour.ForegroundTextColourFor(nubColour); + + base.UpdateDisplay(value); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/FooterButtonMods.cs b/osu.Game/Screens/SelectV2/FooterButtonMods.cs index 833ea96139..3a270d8a68 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonMods.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonMods.cs @@ -79,7 +79,7 @@ namespace osu.Game.Screens.SelectV2 Depth = float.MaxValue, Origin = Anchor.BottomLeft, Shear = OsuGame.SHEAR, - CornerRadius = CORNER_RADIUS, + CornerRadius = Y_OFFSET, Size = new Vector2(BUTTON_WIDTH, bar_height), Masking = true, EdgeEffect = new EdgeEffectParameters @@ -115,7 +115,7 @@ namespace osu.Game.Screens.SelectV2 }, new Container { - CornerRadius = CORNER_RADIUS, + CornerRadius = Y_OFFSET, RelativeSizeAxes = Axes.Both, Width = mod_display_portion, Masking = true, @@ -264,7 +264,7 @@ namespace osu.Game.Screens.SelectV2 private void load() { AutoSizeAxes = Axes.Both; - CornerRadius = CORNER_RADIUS; + CornerRadius = Y_OFFSET; Masking = true; InternalChildren = new Drawable[] @@ -306,7 +306,7 @@ namespace osu.Game.Screens.SelectV2 Depth = float.MaxValue; Origin = Anchor.BottomLeft; Shear = OsuGame.SHEAR; - CornerRadius = CORNER_RADIUS; + CornerRadius = Y_OFFSET; AutoSizeAxes = Axes.X; Height = bar_height; Masking = true; diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index ca09b2a40a..3144168712 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -3,14 +3,21 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; +using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.Select; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.SelectV2 { @@ -21,12 +28,13 @@ namespace osu.Game.Screens.SelectV2 public abstract partial class SongSelect : OsuScreen { private const float logo_scale = 0.4f; + private const double fade_duration = 300; public const float WEDGE_CONTENT_MARGIN = CORNER_RADIUS_HIDE_OFFSET + OsuGame.SCREEN_EDGE_MARGIN; public const float CORNER_RADIUS_HIDE_OFFSET = 20f; public const float ENTER_DURATION = 600; - private readonly ModSelectOverlay modSelectOverlay = new ModSelectOverlay(OverlayColourScheme.Aquamarine) + private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Aquamarine) { ShowPresets = true, }; @@ -36,6 +44,11 @@ namespace osu.Game.Screens.SelectV2 private BeatmapCarousel carousel = null!; + private FilterControl filterControl = null!; + private BeatmapTitleWedge titleWedge = null!; + private BeatmapDetailsArea detailsArea = null!; + private FillFlowContainer wedgesContainer = null!; + public override bool ShowFooter => true; [Resolved] @@ -46,33 +59,89 @@ namespace osu.Game.Screens.SelectV2 { AddRangeInternal(new Drawable[] { - new GridContainer // used for max width implementation + new Box { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Width = 0.5f, + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.5f), Color4.Black.Opacity(0f)), + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, + Child = new PopoverContainer { - new Dimension(), - new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 750), - }, - Content = new[] - { - new[] + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Empty(), - new Container + new GridContainer // used for max width implementation { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = ScreenFooter.HEIGHT }, - Child = carousel = new BeatmapCarousel + ColumnDimensions = new[] { - RequestPresentBeatmap = _ => OnStart(), - RelativeSizeAxes = Axes.Both + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 850), + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 750), }, + Content = new[] + { + new[] + { + wedgesContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding + { + Top = -CORNER_RADIUS_HIDE_OFFSET, + Left = -CORNER_RADIUS_HIDE_OFFSET + }, + Spacing = new Vector2(0f, 4f), + Direction = FillDirection.Vertical, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge()), + new ShearAligningWrapper(detailsArea = new BeatmapDetailsArea()), + }, + }, + Empty(), + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new CompositeDrawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Top = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + Bottom = 5, + }, + Children = new Drawable[] + { + carousel = new BeatmapCarousel + { + BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, + BleedBottom = ScreenFooter.HEIGHT + 5, + RequestPresentBeatmap = _ => OnStart(), + RelativeSizeAxes = Axes.Both, + }, + } + }, + filterControl = new FilterControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + }, + } + }, + }, + } }, } - } + }, }, modSelectOverlay, }); @@ -98,34 +167,44 @@ namespace osu.Game.Screens.SelectV2 public override void OnEntering(ScreenTransitionEvent e) { + base.OnEntering(e); + this.FadeIn(); + titleWedge.Show(); + detailsArea.Show(); + filterControl.Show(); + modSelectOverlay.SelectedMods.BindTo(Mods); - - base.OnEntering(e); } - private const double fade_duration = 300; - public override void OnResuming(ScreenTransitionEvent e) { + base.OnResuming(e); + this.FadeIn(fade_duration, Easing.OutQuint); carousel.VisuallyFocusSelected = false; + titleWedge.Show(); + detailsArea.Show(); + filterControl.Show(); + // required due to https://github.com/ppy/osu-framework/issues/3218 modSelectOverlay.SelectedMods.Disabled = false; modSelectOverlay.SelectedMods.BindTo(Mods); - - base.OnResuming(e); } public override void OnSuspending(ScreenTransitionEvent e) { - this.Delay(100).FadeOut(fade_duration, Easing.OutQuint); + this.FadeOut(fade_duration, Easing.OutQuint); modSelectOverlay.SelectedMods.UnbindFrom(Mods); + titleWedge.Hide(); + detailsArea.Hide(); + filterControl.Hide(); + carousel.VisuallyFocusSelected = true; base.OnSuspending(e); @@ -134,6 +213,11 @@ namespace osu.Game.Screens.SelectV2 public override bool OnExiting(ScreenExitEvent e) { this.FadeOut(fade_duration, Easing.OutQuint); + + titleWedge.Hide(); + detailsArea.Hide(); + filterControl.Hide(); + return base.OnExiting(e); } @@ -192,5 +276,11 @@ namespace osu.Game.Screens.SelectV2 SearchText = query, }); } + + protected override void Update() + { + base.Update(); + detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; + } } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index e94fb23681..b198dd3203 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -37,10 +37,7 @@ namespace osu.Game.Skinning public enum LegacyManiaSkinConfigurationLookups { ColumnWidth, - ColumnSpacing, LightImage, - LeftLineWidth, - RightLineWidth, HitPosition, ComboPosition, ScorePosition, @@ -56,10 +53,8 @@ namespace osu.Game.Skinning HoldNoteTailImage, HoldNoteBodyImage, HoldNoteLightImage, - HoldNoteLightScale, WidthForNoteHeightScale, ExplosionImage, - ExplosionScale, ColumnLineColour, JudgementLineColour, ColumnBackgroundColour, @@ -83,6 +78,16 @@ namespace osu.Game.Skinning Hit0, KeysUnderNotes, NoteBodyStyle, - LightFramePerSecond + LightFramePerSecond, + + // The following lookup entries are not directly tied to skin.ini settings + // but are defined to simplify the process of determining such values. + + LeftColumnSpacing, + RightColumnSpacing, + LeftLineWidth, + RightLineWidth, + ExplosionScale, + HoldNoteLightScale, } } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 51c1473303..56fa0e4706 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -148,10 +148,6 @@ namespace osu.Game.Skinning Debug.Assert(maniaLookup.ColumnIndex != null); return SkinUtils.As(new Bindable(existing.WidthForNoteHeightScale)); - case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value])); - case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); @@ -170,17 +166,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.ExplosionImage: return SkinUtils.As(getManiaImage(existing, "LightingN")); - case LegacyManiaSkinConfigurationLookups.ExplosionScale: - Debug.Assert(maniaLookup.ColumnIndex != null); - - if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) - return SkinUtils.As(new Bindable(1)); - - if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) - return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); @@ -236,17 +221,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); - case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: - Debug.Assert(maniaLookup.ColumnIndex != null); - - if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) - return SkinUtils.As(new Bindable(1)); - - if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) - return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - case LegacyManiaSkinConfigurationLookups.KeyImage: Debug.Assert(maniaLookup.ColumnIndex != null); return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}")); @@ -270,14 +244,6 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.HitTargetImage: return SkinUtils.As(getManiaImage(existing, "StageHint")); - case LegacyManiaSkinConfigurationLookups.LeftLineWidth: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); - - case LegacyManiaSkinConfigurationLookups.RightLineWidth: - Debug.Assert(maniaLookup.ColumnIndex != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); - case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: case LegacyManiaSkinConfigurationLookups.Hit100: @@ -291,6 +257,50 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.LightFramePerSecond: return SkinUtils.As(new Bindable(existing.LightFramePerSecond)); + + case LegacyManiaSkinConfigurationLookups.LeftColumnSpacing: + Debug.Assert(maniaLookup.ColumnIndex != null); + if (maniaLookup.ColumnIndex == 0) + return SkinUtils.As(new Bindable()); + + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value - 1] / 2)); + + case LegacyManiaSkinConfigurationLookups.RightColumnSpacing: + Debug.Assert(maniaLookup.ColumnIndex != null); + if (maniaLookup.ColumnIndex == existing.ColumnSpacing.Length) + return SkinUtils.As(new Bindable()); + + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value] / 2)); + + case LegacyManiaSkinConfigurationLookups.LeftLineWidth: + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); + + case LegacyManiaSkinConfigurationLookups.RightLineWidth: + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); + + case LegacyManiaSkinConfigurationLookups.ExplosionScale: + Debug.Assert(maniaLookup.ColumnIndex != null); + + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As(new Bindable(1)); + + if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: + Debug.Assert(maniaLookup.ColumnIndex != null); + + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As(new Bindable(1)); + + if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); } return null; diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index e93a494b65..f7250c6833 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -59,11 +59,8 @@ namespace osu.Game.Utils /// /// Applies rounding to the given BPM value. /// - /// - /// Double-rounding is applied intentionally (see https://github.com/ppy/osu/pull/18345#issue-1243311382 for rationale). - /// /// The base BPM to round. /// Rate adjustment, if applicable. - public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(Math.Round(baseBpm) * rate); + public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(baseBpm * rate); } }