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 7c7eb01051..a71b8aa982 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -136,8 +136,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: @@ -151,7 +152,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/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index ed3fc34d94..c28226fcf4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -259,6 +259,10 @@ namespace osu.Game.Rulesets.Osu.Edit var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition), fixedTime); + + if (pos.X < 0 || pos.X > OsuPlayfield.BASE_SIZE.X || pos.Y < 0 || pos.Y > OsuPlayfield.BASE_SIZE.Y) + return null; + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, playfield); } 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.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs new file mode 100644 index 0000000000..1e2c2a21ce --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSimplifiedRhythm.cs @@ -0,0 +1,152 @@ +// 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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModSimplifiedRhythm : TaikoModTestScene + { + [Test] + public void TestOneThirdConversion() + { + CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneThirdConversion = { Value = true }, + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2333, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 2666, Type = HitType.Centre }, // mod moves this to 2500 + new Hit { StartTime = 3000, Type = HitType.Centre }, + new Hit { StartTime = 3500, Type = HitType.Centre }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1700), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2700), + new TaikoReplayFrame(3000, TaikoAction.LeftCentre), + new TaikoReplayFrame(3200), + new TaikoReplayFrame(3500, TaikoAction.LeftCentre), + new TaikoReplayFrame(3700), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + } + + [Test] + public void TestOneSixthConversion() => CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneSixthConversion = { Value = true } + }, + Autoplay = false, + CreateBeatmap = () => new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1250, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 1666, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 1833, Type = HitType.Centre }, // mod moves this to 1750 + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2250, Type = HitType.Centre }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1200), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1450), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1600), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1800), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2200), + new TaikoReplayFrame(2250, TaikoAction.LeftCentre), + new TaikoReplayFrame(2450), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 6 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + + [Test] + public void TestOneEighthConversion() => CreateModTest(new ModTestData + { + Mod = new TaikoModSimplifiedRhythm + { + OneEighthConversion = { Value = true } + }, + Autoplay = false, + CreateBeatmap = () => + { + const double one_eighth_timing = 125; + + return new Beatmap + { + HitObjects = new List + { + new Hit { StartTime = 1000, Type = HitType.Centre }, + new Hit { StartTime = 1250, Type = HitType.Centre }, + new Hit { StartTime = 1500, Type = HitType.Centre }, + new Hit { StartTime = 1500 + one_eighth_timing * 1, Type = HitType.Rim }, // mod removes this + new Hit { StartTime = 1500 + one_eighth_timing * 2 }, + new Hit { StartTime = 2000, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 1, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 2, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 3, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 4, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 5, Type = HitType.Centre }, // mod removes this + new Hit { StartTime = 2000 + one_eighth_timing * 6, Type = HitType.Centre }, + new Hit { StartTime = 2000 + one_eighth_timing * 7, Type = HitType.Centre }, // mod removes this + }, + }; + }, + ReplayFrames = new List + { + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1000), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1250), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1500), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1750), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2000), + new TaikoReplayFrame(2250, TaikoAction.LeftCentre), + new TaikoReplayFrame(2250), + new TaikoReplayFrame(2500, TaikoAction.LeftCentre), + new TaikoReplayFrame(2500), + new TaikoReplayFrame(2750, TaikoAction.LeftCentre), + new TaikoReplayFrame(2750), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 8 && Player.ScoreProcessor.Accuracy.Value == 1 + }); + } +} 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.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs new file mode 100644 index 0000000000..e690ff075b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSimplifiedRhythm.cs @@ -0,0 +1,130 @@ +// 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 osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModSimplifiedRhythm : Mod, IApplicableToBeatmap + { + public override string Name => "Simplified Rhythm"; + public override string Acronym => "SR"; + public override double ScoreMultiplier => 0.6; + public override LocalisableString Description => "Simplify tricky rhythms!"; + public override ModType Type => ModType.DifficultyReduction; + + [SettingSource("1/3 to 1/2 conversion", "Converts 1/3 patterns to 1/2 rhythm.")] + public Bindable OneThirdConversion { get; } = new BindableBool(); + + [SettingSource("1/6 to 1/4 conversion", "Converts 1/6 patterns to 1/4 rhythm.")] + public Bindable OneSixthConversion { get; } = new BindableBool(true); + + [SettingSource("1/8 to 1/4 conversion", "Converts 1/8 patterns to 1/4 rhythm.")] + public Bindable OneEighthConversion { get; } = new BindableBool(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var taikoBeatmap = (TaikoBeatmap)beatmap; + var controlPointInfo = taikoBeatmap.ControlPointInfo; + + Hit[] hits = taikoBeatmap.HitObjects.Where(obj => obj is Hit).Cast().ToArray(); + + if (hits.Length == 0) + return; + + var conversions = new List<(int, int)>(); + + if (OneEighthConversion.Value) conversions.Add((8, 4)); + if (OneSixthConversion.Value) conversions.Add((6, 4)); + if (OneThirdConversion.Value) conversions.Add((3, 2)); + + bool inPattern = false; + + foreach ((int baseRhythm, int adjustedRhythm) in conversions) + { + int patternStartIndex = 0; + + for (int i = 1; i < hits.Length; i++) + { + double snapValue = getSnapBetweenNotes(controlPointInfo, hits[i - 1], hits[i]); + + if (inPattern) + { + // pattern continues + if (snapValue == baseRhythm) continue; + + inPattern = false; + + processPattern(i); + } + else + { + if (snapValue == baseRhythm) + { + patternStartIndex = i - 1; + inPattern = true; + } + } + } + + // Process the last pattern if we reached the end of the beatmap and are still in a pattern. + if (inPattern) + processPattern(hits.Length); + + void processPattern(int patternEndIndex) + { + // Iterate through the pattern + for (int j = patternStartIndex; j < patternEndIndex; j++) + { + int indexInPattern = j - patternStartIndex; + + switch (baseRhythm) + { + // 1/8: Remove every second note + case 8: + { + if (indexInPattern % 2 == 1) + { + taikoBeatmap.HitObjects.Remove(hits[j]); + } + + break; + } + + // 1/6 and 1/3: Remove every second note and adjust time of every third + case 6: + case 3: + { + if (indexInPattern % 3 == 1) + taikoBeatmap.HitObjects.Remove(hits[j]); + else if (indexInPattern % 3 == 2) + hits[j].StartTime = hits[j + 1].StartTime - controlPointInfo.TimingPointAt(hits[j].StartTime).BeatLength / adjustedRhythm; + + break; + } + + default: + throw new ArgumentOutOfRangeException(nameof(baseRhythm)); + } + } + } + } + } + + private int getSnapBetweenNotes(ControlPointInfo controlPointInfo, Hit currentNote, Hit nextNote) + { + var currentTimingPoint = controlPointInfo.TimingPointAt(currentNote.StartTime); + return controlPointInfo.GetClosestBeatDivisor(currentTimingPoint.Time + (nextNote.StartTime - currentNote.StartTime)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 24fcc570bd..8cc14ca651 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -134,6 +134,7 @@ namespace osu.Game.Rulesets.Taiko new TaikoModEasy(), new TaikoModNoFail(), new MultiMod(new TaikoModHalfTime(), new TaikoModDaycore()), + new TaikoModSimplifiedRhythm(), }; case ModType.DifficultyIncrease: diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 4a1c0121ae..f8caa121a9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("toggle expanded", () => { if (leaderboard.IsNotNull()) - leaderboard.Expanded.Value = !leaderboard.Expanded.Value; + leaderboard.ForceExpand.Value = !leaderboard.ForceExpand.Value; }); AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 1481629ba0..3008edf41f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -167,7 +167,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestScoreUpdates() { AddRepeatStep("update state", UpdateUserStatesRandomly, 100); - AddToggleStep("switch compact mode", expanded => Leaderboard!.Expanded.Value = expanded); + AddToggleStep("switch compact mode", expanded => Leaderboard!.ForceExpand.Value = expanded); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 806de68f07..131b644dcb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Expanded = { Value = true } + ForceExpand = { Value = true } } }); }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 15efde7abe..40d8650c69 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Origin = Anchor.BottomCentre, Team1Score = { BindTarget = LeaderboardProvider.TeamScores[0] }, Team2Score = { BindTarget = LeaderboardProvider.TeamScores[1] }, - Expanded = { BindTarget = Leaderboard!.Expanded }, + Expanded = { BindTarget = Leaderboard!.ForceExpand }, }, Add); }); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index c793535255..e7337769fd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -75,9 +75,11 @@ namespace osu.Game.Tests.Visual.Online [TestCase("[Markdown link format with escaped [and \\[ paired] braces](https://dev.ppy.sh/home)", LinkAction.External)] [TestCase("(Old link format with escaped (and \\( paired) parentheses)[https://dev.ppy.sh/home] and [[also a rogue wiki link]]", LinkAction.External, LinkAction.OpenWiki)] [TestCase("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present).")] // note that there's 0 links here (they get removed if a channel is not found) - [TestCase("Join my multiplayer game osump://12346.", LinkAction.JoinMultiplayerMatch)] - [TestCase("Join my multiplayer gameosump://12346.", LinkAction.JoinMultiplayerMatch)] - [TestCase("Join my [multiplayer game](osump://12346).", LinkAction.JoinMultiplayerMatch)] + [TestCase("Join my multiplayer game osu://room/12346.", LinkAction.JoinRoom)] + [TestCase("Join my multiplayer gameosu://room/12346.", LinkAction.JoinRoom)] + [TestCase("Join my [multiplayer game](osu://room/12346).", LinkAction.JoinRoom)] + [TestCase("Join my multiplayer game http://dev.ppy.sh/multiplayer/rooms/12346", LinkAction.JoinRoom)] + [TestCase("Join my [multiplayer game](http://dev.ppy.sh/multiplayer/rooms/12346).", LinkAction.JoinRoom)] [TestCase($"Join my [#english]({OsuGameBase.OSU_PROTOCOL}chan/#english).", LinkAction.OpenChannel)] [TestCase($"Join my {OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] [TestCase($"Join my{OsuGameBase.OSU_PROTOCOL}chan/#english.", LinkAction.OpenChannel)] 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/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 28a0948696..649dc7f6a4 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -20,6 +20,7 @@ using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; @@ -48,6 +49,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private int beatmapCount; + protected int NewItemsPresentedInvocationCount; + protected BeatmapCarouselTestScene() { store = new TestBeatmapStore @@ -64,6 +67,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { AddStep("create components", () => { + NewItemsPresentedInvocationCount = 0; + Box topBox; Children = new Drawable[] { @@ -97,6 +102,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { Carousel = new BeatmapCarousel { + NewItemsPresented = () => NewItemsPresentedInvocationCount++, BleedTop = 50, BleedBottom = 50, Anchor = Anchor.Centre, @@ -127,12 +133,35 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }, }; }); + + // Prefer title sorting so that order of carousel panels match order of BeatmapSets bindable. + SortBy(SortMode.Title); } - protected void SortBy(FilterCriteria criteria) => AddStep($"sort:{criteria.Sort} group:{criteria.Group}", () => Carousel.Filter(criteria)); + protected void SortBy(SortMode mode) => ApplyToFilter($"sort by {mode.ToString().ToLowerInvariant()}", c => c.Sort = mode); + protected void GroupBy(GroupMode mode) => ApplyToFilter($"group by {mode.ToString().ToLowerInvariant()}", c => c.Group = mode); + + protected void SortAndGroupBy(SortMode sort, GroupMode group) + { + ApplyToFilter($"sort by {sort.ToString().ToLowerInvariant()} & group by {group.ToString().ToLowerInvariant()}", c => + { + c.Sort = sort; + c.Group = group; + }); + } + + protected void ApplyToFilter(string description, Action? apply) + { + AddStep(description, () => + { + var criteria = Carousel.Criteria; + apply?.Invoke(criteria); + Carousel.Filter(criteria); + }); + } protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType().Count(), () => Is.GreaterThan(0)); - protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False); + protected void WaitForFiltering() => AddUntilStep("filtering finished", () => Carousel.IsFiltering, () => Is.False); protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down)); @@ -145,6 +174,32 @@ namespace osu.Game.Tests.Visual.SongSelectV2 protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null); protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null); + protected void CheckDisplayedBeatmapsCount(int expected) + { + AddAssert($"{expected} diffs displayed", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); + } + + protected void CheckDisplayedBeatmapSetsCount(int expected) + { + AddAssert($"{expected} sets displayed", () => + { + var groupingFilter = Carousel.Filters.OfType().Single(); + + // Using groupingFilter.SetItems.Count alone doesn't work. + // When sorting by difficulty, there can be more than one set panel for the same set displayed. + return groupingFilter.SetItems.Sum(s => s.Value.Count(i => i.Model is BeatmapSetInfo)); + }, () => Is.EqualTo(expected)); + } + + protected void CheckDisplayedGroupsCount(int expected) + { + AddAssert($"{expected} groups displayed", () => + { + var groupingFilter = Carousel.Filters.OfType().Single(); + return groupingFilter.GroupItems.Count; + }, () => Is.EqualTo(expected)); + } + protected ICarouselPanel? GetSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.Selected.Value); protected ICarouselPanel? GetKeyboardSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.KeyboardSelected.Value); 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/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs index 5fd921645b..21030e0b88 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarousel.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Tests.Resources; @@ -34,9 +33,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Explicit] public void TestSorting() { - SortBy(new FilterCriteria { Sort = SortMode.Artist }); - SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); - SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist }); + SortAndGroupBy(SortMode.Artist, GroupMode.All); + SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs index f0caa796b6..aabb2705fd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselArtistGrouping.cs @@ -5,9 +5,10 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Screens.Select; +using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; +using osuTK; namespace osu.Game.Tests.Visual.SongSelectV2 { @@ -19,7 +20,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { RemoveAllBeatmaps(); CreateCarousel(); - SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist }); + + SortAndGroupBy(SortMode.Artist, GroupMode.Artist); AddBeatmaps(10, 3, true); WaitForDrawablePanels(); @@ -173,5 +175,71 @@ namespace osu.Game.Tests.Visual.SongSelectV2 SelectNextGroup(); WaitForGroupSelection(1, 1); } + + [Test] + public void TestInputHandlingWithinGaps() + { + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + // Clicks just above the first group panel should not actuate any action. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2 + 1))); + + AddAssert("no sets visible", () => !GetVisiblePanels().Any()); + + // add lenience to avoid floating-point inaccuracies at edge. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2 - 1))); + + AddUntilStep("wait for sets visible", () => GetVisiblePanels().Any()); + CheckNoSelection(); + + AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); + + ClickVisiblePanelWithOffset(0, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 1); + + AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels().Any()); + + // Beatmap panels expand their selection area to cover holes from spacing. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 1); + + ClickVisiblePanelWithOffset(1, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 2); + + ClickVisiblePanelWithOffset(1, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); + WaitForGroupSelection(0, 5); + } + + [Test] + public void TestBasicFiltering() + { + ApplyToFilter("filter", c => c.SearchText = BeatmapSets[2].Metadata.Title); + WaitForFiltering(); + + CheckDisplayedGroupsCount(1); + CheckDisplayedBeatmapSetsCount(1); + CheckDisplayedBeatmapsCount(3); + + CheckNoSelection(); + SelectNextPanel(); + Select(); + SelectNextPanel(); + Select(); + WaitForGroupSelection(0, 1); + + for (int i = 0; i < 6; i++) + SelectNextPanel(); + + Select(); + + WaitForGroupSelection(0, 2); + + ApplyToFilter("remove filter", c => c.SearchText = string.Empty); + WaitForFiltering(); + + CheckDisplayedGroupsCount(5); + CheckDisplayedBeatmapSetsCount(10); + CheckDisplayedBeatmapsCount(30); + } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs index a4cdf8abcb..6050d516d6 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselDifficultyGrouping.cs @@ -6,7 +6,6 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; -using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; using osuTK; @@ -21,7 +20,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { RemoveAllBeatmaps(); CreateCarousel(); - SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); + + SortAndGroupBy(SortMode.Difficulty, GroupMode.Difficulty); AddBeatmaps(10, 3); WaitForDrawablePanels(); @@ -179,7 +179,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); - ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2))); + // add lenience to avoid floating-point inaccuracies at edge. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelGroup.HEIGHT / 2 - 1))); AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels().Any()); CheckNoSelection(); @@ -191,5 +192,37 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ClickVisiblePanelWithOffset(1, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1))); WaitForGroupSelection(0, 1); } + + [Test] + public void TestBasicFiltering() + { + ApplyToFilter("filter", c => c.SearchText = BeatmapSets[2].Metadata.Title); + WaitForFiltering(); + + CheckDisplayedGroupsCount(3); + CheckDisplayedBeatmapsCount(3); + + CheckNoSelection(); + SelectNextPanel(); + Select(); + SelectNextPanel(); + Select(); + WaitForGroupSelection(0, 0); + + for (int i = 0; i < 5; i++) + SelectNextPanel(); + + Select(); + SelectNextPanel(); + Select(); + + WaitForGroupSelection(1, 0); + + ApplyToFilter("remove filter", c => c.SearchText = string.Empty); + WaitForFiltering(); + + CheckDisplayedGroupsCount(3); + CheckDisplayedBeatmapsCount(30); + } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs new file mode 100644 index 0000000000..2381ebcf6e --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselFiltering.cs @@ -0,0 +1,296 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselFiltering : BeatmapCarouselTestScene + { + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + } + + [Test] + public void TestBasicFiltering() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + AddAssert("invocation count correct", () => NewItemsPresentedInvocationCount, () => Is.EqualTo(1)); + + ApplyToFilter("filter", c => c.SearchText = BeatmapSets[2].Metadata.Title); + WaitForFiltering(); + + AddAssert("invocation count correct", () => NewItemsPresentedInvocationCount, () => Is.EqualTo(2)); + + CheckDisplayedBeatmapSetsCount(1); + CheckDisplayedBeatmapsCount(3); + + SelectNextPanel(); + Select(); + + WaitForSelection(2, 0); + + for (int i = 0; i < 5; i++) + SelectNextPanel(); + + Select(); + WaitForSelection(2, 1); + + ApplyToFilter("remove filter", c => c.SearchText = string.Empty); + WaitForFiltering(); + + AddAssert("invocation count correct", () => NewItemsPresentedInvocationCount, () => Is.EqualTo(3)); + + CheckDisplayedBeatmapSetsCount(10); + CheckDisplayedBeatmapsCount(30); + } + + [Test] + public void TestFilteringByUserStarDifficulty() + { + AddStep("add mixed difficulty set", () => + { + var set = TestResources.CreateTestBeatmapSetInfo(1); + set.Beatmaps.Clear(); + + for (int i = 1; i <= 15; i++) + { + set.Beatmaps.Add(new BeatmapInfo(new OsuRuleset().RulesetInfo, new BeatmapDifficulty(), new BeatmapMetadata()) + { + BeatmapSet = set, + DifficultyName = $"Stars: {i}", + StarRating = i, + }); + } + + BeatmapSets.Add(set); + }); + + WaitForDrawablePanels(); + + ApplyToFilter("filter [5..]", c => + { + c.UserStarDifficulty.Min = 5; + c.UserStarDifficulty.Max = null; + }); + WaitForFiltering(); + CheckDisplayedBeatmapsCount(11); + + ApplyToFilter("filter to [0..7]", c => + { + c.UserStarDifficulty.Min = null; + c.UserStarDifficulty.Max = 7; + }); + WaitForFiltering(); + CheckDisplayedBeatmapsCount(7); + + ApplyToFilter("filter to [5..7]", c => + { + c.UserStarDifficulty.Min = 5; + c.UserStarDifficulty.Max = 7; + }); + + WaitForFiltering(); + CheckDisplayedBeatmapsCount(3); + + ApplyToFilter("filter to [2..2]", c => + { + c.UserStarDifficulty.Min = 2; + c.UserStarDifficulty.Max = 2; + }); + + WaitForFiltering(); + CheckDisplayedBeatmapsCount(1); + + ApplyToFilter("filter to [0..]", c => + { + c.UserStarDifficulty.Min = 0; + c.UserStarDifficulty.Max = null; + }); + WaitForFiltering(); + CheckDisplayedBeatmapsCount(15); + } + + [Test] + public void TestCarouselRemembersSelection() + { + Guid selectedID = Guid.Empty; + + AddBeatmaps(50, 3); + WaitForDrawablePanels(); + + SelectNextGroup(); + SelectNextPanel(); + Select(); + + AddStep("record selection", () => selectedID = ((BeatmapInfo)Carousel.CurrentSelection!).ID); + + for (int i = 0; i < 5; i++) + { + ApplyToFilter("filter all", c => c.SearchText = Guid.NewGuid().ToString()); + AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID); + ApplyToFilter("remove filter", c => c.SearchText = string.Empty); + AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID); + } + } + + [Test] + public void TestCarouselRemembersSelectionDifficultySort() + { + Guid selectedID = Guid.Empty; + + AddBeatmaps(50, 3); + WaitForDrawablePanels(); + + SortBy(SortMode.Difficulty); + + SelectNextGroup(); + + AddStep("record selection", () => selectedID = ((BeatmapInfo)Carousel.CurrentSelection!).ID); + + for (int i = 0; i < 5; i++) + { + ApplyToFilter("filter all", c => c.SearchText = Guid.NewGuid().ToString()); + AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID); + ApplyToFilter("remove filter", c => c.SearchText = string.Empty); + AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID); + } + } + + [Test] + public void TestCarouselRetainsSelectionFromDifficultySort() + { + AddBeatmaps(50, 3); + WaitForDrawablePanels(); + + BeatmapInfo chosenBeatmap = null!; + + for (int i = 0; i < 3; i++) + { + int diff = i; + + AddStep($"select diff {diff}", () => Carousel.CurrentSelection = chosenBeatmap = BeatmapSets[20].Beatmaps[diff]); + AddUntilStep("selection changed", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap)); + + SortBy(SortMode.Difficulty); + AddAssert("selection retained", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap)); + + SortBy(SortMode.Title); + AddAssert("selection retained", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap)); + } + } + + [Test] + public void TestExternalRulesetChange() + { + ApplyToFilter("allow converted beatmaps", c => c.AllowConvertedBeatmaps = true); + ApplyToFilter("filter to osu", c => c.Ruleset = rulesets.AvailableRulesets.ElementAt(0)); + + WaitForFiltering(); + + AddStep("add mixed ruleset beatmapset", () => + { + var testMixed = TestResources.CreateTestBeatmapSetInfo(3); + + for (int i = 0; i <= 2; i++) + testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); + + BeatmapSets.Add(testMixed); + }); + WaitForDrawablePanels(); + + SelectNextPanel(); + Select(); + + AddUntilStep("wait for filtered difficulties", () => + { + var visibleBeatmapPanels = GetVisiblePanels(); + + return visibleBeatmapPanels.Count() == 1 + && visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1; + }); + + ApplyToFilter("filter to taiko", c => c.Ruleset = rulesets.AvailableRulesets.ElementAt(1)); + + WaitForFiltering(); + + AddUntilStep("wait for filtered difficulties", () => + { + var visibleBeatmapPanels = GetVisiblePanels(); + + return visibleBeatmapPanels.Count() == 2 + && visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1 + && visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 1) == 1; + }); + + ApplyToFilter("filter to catch", c => c.Ruleset = rulesets.AvailableRulesets.ElementAt(2)); + + WaitForFiltering(); + + AddUntilStep("wait for filtered difficulties", () => + { + var visibleBeatmapPanels = GetVisiblePanels(); + + return visibleBeatmapPanels.Count() == 2 + && visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1 + && visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 2) == 1; + }); + } + + [Test] + [Ignore("Difficulty sorting is broken when set headers are included.")] // todo: fix. + public void TestSortingWithDifficultyFiltered() + { + const int diffs_per_set = 3; + const int local_set_count = 2; + + AddStep("populate beatmap sets", () => + { + for (int i = 0; i < local_set_count; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(diffs_per_set); + set.Beatmaps[0].StarRating = 3 - i; + set.Beatmaps[0].DifficultyName += $" ({3 - i}*)"; + set.Beatmaps[1].StarRating = 6 + i; + set.Beatmaps[1].DifficultyName += $" ({6 + i}*)"; + BeatmapSets.Add(set); + } + }); + + SortBy(SortMode.Difficulty); + WaitForFiltering(); + + CheckDisplayedBeatmapSetsCount(3); + CheckDisplayedBeatmapsCount(local_set_count * diffs_per_set); + + ApplyToFilter("filter to normal", c => c.SearchText = "Normal"); + + CheckDisplayedBeatmapSetsCount(local_set_count); + CheckDisplayedBeatmapsCount(local_set_count); + + ApplyToFilter("filter to insane", c => c.SearchText = "Insane"); + + CheckDisplayedBeatmapSetsCount(local_set_count); + CheckDisplayedBeatmapsCount(local_set_count); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs index ac02d7a3a9..efb39e2cc9 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselNoGrouping.cs @@ -6,8 +6,6 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Carousel; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; using osuTK; using osuTK.Input; @@ -22,7 +20,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { RemoveAllBeatmaps(); CreateCarousel(); - SortBy(new FilterCriteria { Sort = SortMode.Title }); } /// @@ -221,7 +218,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("no beatmaps visible", () => !GetVisiblePanels().Any()); - ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelBeatmapSet.HEIGHT / 2))); + // add lenience to avoid floating-point inaccuracies at edge. + ClickVisiblePanelWithOffset(0, new Vector2(0, -(PanelBeatmapSet.HEIGHT / 2 - 1))); AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels().Any()); WaitForSelection(0, 0); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs index da3fc98c19..f5574d2789 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselScrolling.cs @@ -5,7 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; -using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; namespace osu.Game.Tests.Visual.SongSelectV2 @@ -18,7 +18,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { RemoveAllBeatmaps(); CreateCarousel(); - SortBy(new FilterCriteria()); + + SortBy(SortMode.Artist); AddBeatmaps(10); WaitForDrawablePanels(); @@ -37,7 +38,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); RemoveFirstBeatmap(); - WaitForSorting(); + WaitForFiltering(); AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, () => Is.EqualTo(positionBefore)); @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); RemoveFirstBeatmap(); - WaitForSorting(); + WaitForFiltering(); AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, () => Is.EqualTo(positionBefore)); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs index 31aa1b6f94..b9a468d580 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 BeatmapSets.Add(baseTestBeatmap); }); - WaitForSorting(); + WaitForFiltering(); } [Test] @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddStep("update beatmap with same reference", () => BeatmapSets.ReplaceRange(1, 1, [baseTestBeatmap])); - WaitForSorting(); + WaitForFiltering(); AddAssert("drawables unchanged", () => Carousel.ChildrenOfType(), () => Is.EqualTo(originalDrawables)); } @@ -78,21 +78,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2 updateBeatmap(b => b.Metadata = metadata); - WaitForSorting(); + WaitForFiltering(); AddAssert("drawables changed", () => Carousel.ChildrenOfType(), () => Is.Not.EqualTo(originalDrawables)); } [Test] public void TestSelectionHeld() { - SelectPrevGroup(); + SelectNextGroup(); WaitForSelection(1, 0); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); updateBeatmap(); - WaitForSorting(); + WaitForFiltering(); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); @@ -101,14 +101,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] // Checks that we keep selection based on online ID where possible. public void TestSelectionHeldDifficultyNameChanged() { - SelectPrevGroup(); + SelectNextGroup(); WaitForSelection(1, 0); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); updateBeatmap(b => b.DifficultyName = "new name"); - WaitForSorting(); + WaitForFiltering(); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); @@ -117,14 +117,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] // Checks that we fallback to keeping selection based on difficulty name. public void TestSelectionHeldDifficultyOnlineIDChanged() { - SelectPrevGroup(); + SelectNextGroup(); WaitForSelection(1, 0); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); updateBeatmap(b => b.OnlineID = b.OnlineID + 1); - WaitForSorting(); + WaitForFiltering(); AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs new file mode 100644 index 0000000000..284484d2df --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapFilterControl.cs @@ -0,0 +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 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 + { + private FilterControl filterControl = null!; + + 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 = filterControl = new FilterControl + { + State = { Value = Visibility.Visible }, + RelativeSizeAxes = Axes.X, + }, + }; + }); + + [Test] + public void TestSearch() + { + AddStep("search for text", () => filterControl.Search("test search")); + } + } +} 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/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index 986ad6fc46..5718bbfc50 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -12,13 +12,11 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Overlays; using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Catch; -using osu.Game.Rulesets.Mania; +using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Taiko; using osu.Game.Screens; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; @@ -30,11 +28,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public partial class TestSceneSongSelect : ScreenTestScene { [Cached] - private readonly ScreenFooter screenScreenFooter; + private readonly ScreenFooter screenFooter; [Cached] private readonly OsuLogo logo; + [Cached(typeof(INotificationOverlay))] + private readonly INotificationOverlay notificationOverlay = new NotificationOverlay(); + protected override bool UseOnlineAPI => true; public TestSceneSongSelect() @@ -44,16 +45,25 @@ namespace osu.Game.Tests.Visual.SongSelectV2 new PopoverContainer { RelativeSizeAxes = Axes.Both, - Child = screenScreenFooter = new ScreenFooter + Children = new Drawable[] { - OnBack = () => Stack.CurrentScreen.Exit(), + new Toolbar + { + State = { Value = Visibility.Visible }, + }, + screenFooter = new ScreenFooter + { + OnBack = () => Stack.CurrentScreen.Exit(), + }, + logo = new OsuLogo + { + Alpha = 0f, + }, }, }, - logo = new OsuLogo - { - Alpha = 0f, - }, }; + + Stack.Padding = new MarginPadding { Top = Toolbar.HEIGHT }; } [BackgroundDependencyLoader] @@ -73,29 +83,13 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Stack.ScreenExited += updateFooter; } - [SetUpSteps] - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("load screen", () => Stack.Push(new SoloSongSelect())); - AddUntilStep("wait for load", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect songSelect && songSelect.IsLoaded); - } - - [Test] - public void TestRulesets() - { - AddStep("set osu ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo); - AddStep("set taiko ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); - AddStep("set catch ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); - AddStep("set mania ruleset", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); - } - #region Footer [Test] public void TestMods() { + loadSongSelect(); + AddStep("one mod", () => SelectedMods.Value = new List { new OsuModHidden() }); AddStep("two mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock() }); AddStep("three mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() }); @@ -123,6 +117,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestShowOptions() { + loadSongSelect(); + AddStep("enable options", () => { var optionsButton = this.ChildrenOfType().Last(); @@ -135,6 +131,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestState() { + loadSongSelect(); + AddToggleStep("set options enabled state", state => this.ChildrenOfType().Last().Enabled.Value = state); } @@ -142,6 +140,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 // [Test] // public void TestFooterRandom() // { + // loadSongSelect(); + // // AddStep("press F2", () => InputManager.Key(Key.F2)); // AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); // } @@ -149,6 +149,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 // [Test] // public void TestFooterRandomViaMouse() // { + // loadSongSelect(); + // // AddStep("click button", () => // { // InputManager.MoveMouseTo(randomButton); @@ -160,6 +162,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 // [Test] // public void TestFooterRewind() // { + // loadSongSelect(); + // // AddStep("press Shift+F2", () => // { // InputManager.PressKey(Key.LShift); @@ -173,6 +177,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 // [Test] // public void TestFooterRewindViaShiftMouseLeft() // { + // loadSongSelect(); + // // AddStep("shift + click button", () => // { // InputManager.PressKey(Key.LShift); @@ -186,6 +192,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 // [Test] // public void TestFooterRewindViaMouseRight() // { + // loadSongSelect(); + // // AddStep("right click button", () => // { // InputManager.MoveMouseTo(randomButton); @@ -197,6 +205,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestOverlayPresent() { + loadSongSelect(); + AddStep("Press F1", () => { InputManager.MoveMouseTo(this.ChildrenOfType().Single()); @@ -208,17 +218,23 @@ namespace osu.Game.Tests.Visual.SongSelectV2 #endregion + private void loadSongSelect() + { + AddStep("load screen", () => Stack.Push(new SoloSongSelect())); + AddUntilStep("wait for load", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect songSelect && songSelect.IsLoaded); + } + private void updateFooter(IScreen? _, IScreen? newScreen) { if (newScreen is IOsuScreen osuScreen && osuScreen.ShowFooter) { - screenScreenFooter.Show(); - screenScreenFooter.SetButtons(osuScreen.CreateFooterButtons()); + screenFooter.Show(); + screenFooter.SetButtons(osuScreen.CreateFooterButtons()); } else { - screenScreenFooter.Hide(); - screenScreenFooter.SetButtons(Array.Empty()); + screenFooter.Hide(); + screenFooter.SetButtons(Array.Empty()); } } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs new file mode 100644 index 0000000000..1e368dbee3 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -0,0 +1,448 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Input; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; +using osuTK.Input; +using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; +using FilterControl = osu.Game.Screens.SelectV2.FilterControl; +using NoResultsPlaceholder = osu.Game.Screens.SelectV2.NoResultsPlaceholder; +using BeatmapDeleteDialog = osu.Game.Screens.Select.BeatmapDeleteDialog; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneSongSelectFiltering : ScreenTestScene + { + private BeatmapManager manager = null!; + private RealmRulesetStore rulesets = null!; + + private OsuConfigManager config = null!; + + private SoloSongSelect songSelect = null!; + private BeatmapCarousel carousel => songSelect.ChildrenOfType().Single(); + + private FilterControl filter => songSelect.ChildrenOfType().Single(); + private ShearedFilterTextBox filterTextBox => songSelect.ChildrenOfType().Single(); + private int filterOperationsCount; + + [Cached] + private readonly ScreenFooter screenFooter; + + [Cached] + private readonly OsuLogo logo; + + [Cached(typeof(INotificationOverlay))] + private readonly INotificationOverlay notificationOverlay = new NotificationOverlay(); + + public TestSceneSongSelectFiltering() + { + Children = new Drawable[] + { + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Toolbar + { + State = { Value = Visibility.Visible }, + }, + screenFooter = new ScreenFooter + { + OnBack = () => Stack.CurrentScreen.Exit(), + }, + logo = new OsuLogo + { + Alpha = 0f, + }, + }, + }, + }; + + Stack.Padding = new MarginPadding { Top = Toolbar.HEIGHT }; + } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + RealmDetachedBeatmapStore beatmapStore; + + // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. + // At a point we have isolated interactive test runs enough, this can likely be removed. + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); + + Add(beatmapStore); + + Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Stack.ScreenPushed += updateFooter; + Stack.ScreenExited += updateFooter; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset defaults", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + + Beatmap.SetDefault(); + SelectedMods.SetDefault(); + + config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title); + config.SetValue(OsuSetting.SongSelectGroupingMode, GroupMode.All); + + songSelect = null!; + filterOperationsCount = 0; + }); + + AddStep("delete all beatmaps", () => manager.Delete()); + } + + [Test] + public void TestSingleFilterOnEnter() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + loadSongSelect(); + + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestNoFilterOnSimpleResume() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + loadSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + waitForSuspension(); + + AddStep("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestFilterOnResumeAfterChange() + { + importBeatmapForRuleset(0); + importBeatmapForRuleset(0); + + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + loadSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + waitForSuspension(); + + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + + AddStep("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + } + + [Test] + public void TestSorting() + { + loadSongSelect(); + addManyTestMaps(); + + // TODO: old test has this step, but there doesn't seem to be any purpose for it. + // AddUntilStep("random map selected", () => Beatmap.Value != defaultBeatmap); + + AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); + AddStep(@"Sort by Author", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Author)); + AddStep(@"Sort by DateAdded", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); + AddStep(@"Sort by BPM", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.BPM)); + AddStep(@"Sort by Length", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Length)); + AddStep(@"Sort by Difficulty", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); + AddStep(@"Sort by Source", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Source)); + } + + [Test] + public void TestCutInFilterTextBox() + { + loadSongSelect(); + + AddStep("set filter text", () => filterTextBox.Current.Value = "nonono"); + AddStep("select all", () => InputManager.Keys(PlatformAction.SelectAll)); + AddStep("press ctrl/cmd-x", () => InputManager.Keys(PlatformAction.Cut)); + + AddAssert("filter text cleared", () => filterTextBox.Current.Value, () => Is.Empty); + } + + [Test] + public void TestNonFilterableModChange() + { + importBeatmapForRuleset(0); + + loadSongSelect(); + + // Mod that is guaranteed to never re-filter. + AddStep("add non-filterable mod", () => SelectedMods.Value = new Mod[] { new OsuModCinema() }); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + + // Removing the mod should still not re-filter. + AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); + AddAssert("filter count is 0", () => filterOperationsCount, () => Is.EqualTo(0)); + } + + [Test] + public void TestFilterableModChange() + { + importBeatmapForRuleset(3); + + loadSongSelect(); + + // Change to mania ruleset. + AddStep("filter to mania ruleset", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 3)); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + + // Apply a mod, but this should NOT re-filter because there's no search text. + AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); + AddAssert("filter count is 1", () => filterOperationsCount, () => Is.EqualTo(1)); + + // Set search text. Should re-filter. + AddStep("set search text to match mods", () => filterTextBox.Current.Value = "keys=3"); + AddAssert("filter count is 2", () => filterOperationsCount, () => Is.EqualTo(2)); + + // Change filterable mod. Should re-filter. + AddStep("change new filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey5() }); + AddAssert("filter count is 3", () => filterOperationsCount, () => Is.EqualTo(3)); + + // Add non-filterable mod. Should NOT re-filter. + AddStep("apply non-filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail(), new ManiaModKey5() }); + AddAssert("filter count is 3", () => filterOperationsCount, () => Is.EqualTo(3)); + + // Remove filterable mod. Should re-filter. + AddStep("remove filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail() }); + AddAssert("filter count is 4", () => filterOperationsCount, () => Is.EqualTo(4)); + + // Remove non-filterable mod. Should NOT re-filter. + AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); + AddAssert("filter count is 4", () => filterOperationsCount, () => Is.EqualTo(4)); + + // Add filterable mod. Should re-filter. + AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); + AddAssert("filter count is 5", () => filterOperationsCount, () => Is.EqualTo(5)); + } + + // This test should probably not be in this test class, it has nothing to do with filtering. + // TestSceneSongSelect is a better place, but doesn't have local storage isolation setup (yet). + [Test] + public void TestDeleteHotkey() + { + loadSongSelect(); + + importBeatmapForRuleset(0); + + AddAssert("beatmap imported", () => manager.GetAllUsableBeatmapSets().Any(), () => Is.True); + + // song select should automatically select the beatmap for us but this is not implemented yet. + // todo: remove when that's the case. + AddAssert("no beatmap selected", () => Beatmap.IsDefault); + AddStep("select beatmap", () => Beatmap.Value = manager.GetWorkingBeatmap(manager.GetAllUsableBeatmapSets().Single().Beatmaps.First())); + AddAssert("beatmap selected", () => !Beatmap.IsDefault); + + AddStep("press shift-delete", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Delete); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddUntilStep("delete dialog shown", () => DialogOverlay.CurrentDialog, Is.InstanceOf); + AddStep("confirm deletion", () => DialogOverlay.CurrentDialog!.PerformAction()); + + AddAssert("beatmap set deleted", () => manager.GetAllUsableBeatmapSets().Any(), () => Is.False); + } + + [Test] + public void TestPlaceholderVisibleAfterDeleteAll() + { + loadSongSelect(); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + importBeatmapForRuleset(0); + AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); + + AddStep("delete all beatmaps", () => manager.Delete()); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + } + + [Test] + public void TestPlaceholderVisibleAfterStarDifficultyFilter() + { + importBeatmapForRuleset(0); + AddStep("change star filter", () => config.SetValue(OsuSetting.DisplayStarsMinimum, 10.0)); + + loadSongSelect(); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); + + AddUntilStep("star filter reset", () => config.Get(OsuSetting.DisplayStarsMinimum) == 0.0); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); + } + + [Test] + public void TestPlaceholderVisibleWithConvertSetting() + { + importBeatmapForRuleset(0); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + loadSongSelect(); + + changeRuleset(2); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); + + AddUntilStep("convert setting changed", () => config.Get(OsuSetting.ShowConvertedBeatmaps)); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); + } + + [Test] + public void TestCorrectMatchCountAfterDeleteAll() + { + loadSongSelect(); + checkMatchedBeatmaps(0); + + importBeatmapForRuleset(0); + checkMatchedBeatmaps(3); + + AddStep("delete all beatmaps", () => manager.Delete()); + checkMatchedBeatmaps(0); + } + + [Test] + public void TestCorrectMatchCountAfterHardDelete() + { + loadSongSelect(); + checkMatchedBeatmaps(0); + + importBeatmapForRuleset(0); + checkMatchedBeatmaps(3); + + AddStep("hard delete beatmap", () => Realm.Write(r => r.RemoveRange(r.All().Where(s => !s.Protected)))); + checkMatchedBeatmaps(0); + } + + private void loadSongSelect() + { + AddStep("load screen", () => Stack.Push(songSelect = new SoloSongSelect())); + AddUntilStep("wait for load", () => Stack.CurrentScreen == songSelect && songSelect.IsLoaded); + AddStep("hook events", () => + { + filterOperationsCount = 0; + filter.CriteriaChanged += _ => filterOperationsCount++; + }); + } + + private NoResultsPlaceholder? getPlaceholder() => songSelect.ChildrenOfType().FirstOrDefault(); + + private void importBeatmapForRuleset(int rulesetId) + { + int beatmapsCount = 0; + + AddStep($"import test map for ruleset {rulesetId}", () => + { + beatmapsCount = songSelect.IsNull() ? 0 : carousel.Filters.OfType().Single().SetItems.Count; + manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == rulesetId).ToArray())); + }); + + // This is specifically for cases where the add is happening post song select load. + // For cases where song select is null, the assertions are provided by the load checks. + AddUntilStep("wait for imported to arrive in carousel", () => songSelect.IsNull() || carousel.Filters.OfType().Single().SetItems.Count > beatmapsCount); + } + + private void changeRuleset(int rulesetId) + { + AddStep($"change ruleset to {rulesetId}", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == rulesetId)); + } + + /// + /// Imports test beatmap sets to show in the carousel. + /// + /// + /// The exact count of difficulties to create for each beatmap set. + /// A value causes the count of difficulties to be selected randomly. + /// + private void addManyTestMaps(int? difficultyCountPerSet = null) + { + AddStep("import test maps", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); + + for (int i = 0; i < 10; i++) + manager.Import(TestResources.CreateTestBeatmapSetInfo(difficultyCountPerSet, usableRulesets)); + }); + } + + private void checkMatchedBeatmaps(int expected) => + AddUntilStep($"{expected} matching shown", () => carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); + + private void waitForSuspension() => AddUntilStep("wait for not current", () => !songSelect.AsNonNull().IsCurrentScreen()); + + private void updateFooter(IScreen? _, IScreen? newScreen) + { + if (newScreen is IOsuScreen osuScreen && osuScreen.ShowFooter) + { + screenFooter.Show(); + screenFooter.SetButtons(osuScreen.CreateFooterButtons()); + } + else + { + screenFooter.Hide(); + screenFooter.SetButtons(Array.Empty()); + } + } + } +} 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..0ecaf4900a 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, + StatusText = "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/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 77aca5eecf..28997509dc 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -72,58 +72,66 @@ namespace osu.Game.Beatmaps first.PerformWrite(updated => { - var realm = updated.Realm; - - Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); - - // Re-fetch as we are likely on a different thread. - original = realm!.Find(originalId)!; - - // Generally the import process will do this for us if the OnlineIDs match, - // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). - original.DeletePending = true; - - // Transfer local values which should be persisted across a beatmap update. - updated.DateAdded = originalDateAdded; - - transferCollectionReferences(realm, original, updated); - - foreach (var beatmap in original.Beatmaps.ToArray()) + try { - var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + var realm = updated.Realm; - if (updatedBeatmap != null) + // Re-fetch as we are likely on a different thread. + original = realm!.Find(originalId)!; + + // Generally the import process will do this for us if the OnlineIDs match, + // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). + original.DeletePending = true; + + // Transfer local values which should be persisted across a beatmap update. + updated.DateAdded = originalDateAdded; + + transferCollectionReferences(realm, original, updated); + + foreach (var beatmap in original.Beatmaps.ToArray()) { - // If the updated beatmap matches an existing one, transfer any user data across.. - if (beatmap.Scores.Any()) + var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + + if (updatedBeatmap != null) { - Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); + // If the updated beatmap matches an existing one, transfer any user data across.. + if (beatmap.Scores.Any()) + { + Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); - foreach (var score in beatmap.Scores) - score.BeatmapInfo = updatedBeatmap; + foreach (var score in beatmap.Scores) + score.BeatmapInfo = updatedBeatmap; + } + + // ..then nuke the old beatmap completely. + // this is done instead of a soft deletion to avoid a user potentially creating weird + // interactions, like restoring the outdated beatmap then updating a second time + // (causing user data to be wiped). + original.Beatmaps.Remove(beatmap); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + else + { + // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. + // This caters to the case where a user has made modifications they potentially want to restore, + // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. + beatmap.ResetOnlineInfo(); } - - // ..then nuke the old beatmap completely. - // this is done instead of a soft deletion to avoid a user potentially creating weird - // interactions, like restoring the outdated beatmap then updating a second time - // (causing user data to be wiped). - original.Beatmaps.Remove(beatmap); - - realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); - } - else - { - // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. - // This caters to the case where a user has made modifications they potentially want to restore, - // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. - beatmap.ResetOnlineInfo(); } + + // If the original has no beatmaps left, delete the set as well. + if (!original.Beatmaps.Any()) + realm.Remove(original); + + Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); + } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update beatmap \"{updated}\"", LoggingTarget.Database); + throw; } - - // If the original has no beatmaps left, delete the set as well. - if (!original.Beatmaps.Any()) - realm.Remove(original); }); return first; 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/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 8d8289422b..34d1c39dcb 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -35,6 +35,11 @@ namespace osu.Game.Graphics.Carousel { #region Properties and methods for external usage + /// + /// Called after a filter operation or change in items results in the visible carousel items changing. + /// + public Action? NewItemsPresented { private get; init; } + /// /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. /// @@ -68,7 +73,7 @@ namespace osu.Game.Graphics.Carousel public int ItemsTracked => Items.Count; /// - /// The number of carousel items currently in rotation for display. + /// The items currently in rotation for display. /// public int DisplayableItems => carouselItems?.Count ?? 0; @@ -265,7 +270,7 @@ namespace osu.Game.Graphics.Carousel // Copy must be performed on update thread for now (see ConfigureAwait above). // Could potentially be optimised in the future if it becomes an issue. - IEnumerable items = new List(Items.Select(m => new CarouselItem(m))); + List items = new List(Items.Select(m => new CarouselItem(m))); await Task.Run(async () => { @@ -275,6 +280,11 @@ namespace osu.Game.Graphics.Carousel { log($"Performing {filter.GetType().ReadableName()}"); items = await filter.Run(items, cts.Token).ConfigureAwait(false); + + // To avoid shooting ourselves in the foot, ensure that we manifest a list after each filter. + // + // A future improvement may be passing a reference list through each filter rather than copying each time, + // but this is the safest approach. } log("Updating Y positions"); @@ -292,13 +302,15 @@ namespace osu.Game.Graphics.Carousel Schedule(() => { log("Items ready for display"); - carouselItems = items.ToList(); + carouselItems = items; displayedRange = null; // Need to call this to ensure correct post-selection logic is handled on the new items list. HandleItemSelected(currentSelection.Model); refreshAfterSelection(); + + NewItemsPresented?.Invoke(); }); void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}"); diff --git a/osu.Game/Graphics/Carousel/ICarouselFilter.cs b/osu.Game/Graphics/Carousel/ICarouselFilter.cs index 570f480aab..a85b44b46a 100644 --- a/osu.Game/Graphics/Carousel/ICarouselFilter.cs +++ b/osu.Game/Graphics/Carousel/ICarouselFilter.cs @@ -18,6 +18,6 @@ namespace osu.Game.Graphics.Carousel /// The items to be filtered. /// A cancellation token. /// The post-filtered items. - Task> Run(IEnumerable items, CancellationToken cancellationToken); + Task> Run(IEnumerable items, CancellationToken cancellationToken); } } 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..635990ec9c --- /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 StatusText + { + get => ((InnerFilterTextBox)TextBox).StatusText.Text; + set => Schedule(() => ((InnerFilterTextBox)TextBox).StatusText.Text = value); + } + + public ShearedFilterTextBox() + { + Height += filter_text_size; + } + + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerFilterTextBox(); + + protected partial class InnerFilterTextBox : InnerSearchTextBox + { + public OsuSpriteText StatusText { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + TextContainer.Add(StatusText = 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/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index bb2990f782..3614ed9133 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -135,6 +135,11 @@ Click to see what's new!", version); /// public static LocalisableString DownloadingUpdate => new TranslatableString(getKey(@"downloading_update"), @"Downloading update..."); + /// + /// "This multiplayer room has ended. Click to display room results." + /// + public static LocalisableString MultiplayerRoomEnded => new TranslatableString(getKey(@"multiplayer_room_ended"), @"This multiplayer room has ended. Click to display room results."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } 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/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index f354eea027..9478f13074 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -129,8 +129,8 @@ namespace osu.Game.Online.Chat switch (args[0]) { - case "http": - case "https": + case @"http": + case @"https": // length > 3 since all these links need another argument to work if (args.Length > 3 && args[1].EndsWith(WebsiteRootUrl, StringComparison.OrdinalIgnoreCase)) { @@ -139,8 +139,8 @@ namespace osu.Game.Online.Chat switch (args[2]) { // old site only - case "b": - case "beatmaps": + case @"b": + case @"beatmaps": { string trimmed = mainArg.Split('?').First(); if (int.TryParse(trimmed, out int id)) @@ -149,11 +149,11 @@ namespace osu.Game.Online.Chat break; } - case "s": - case "beatmapsets": - case "d": + case @"s": + case @"beatmapsets": + case @"d": { - if (mainArg == "discussions") + if (mainArg == @"discussions") // handle discussion links externally for now return new LinkDetails(LinkAction.External, url); @@ -169,15 +169,15 @@ namespace osu.Game.Online.Chat break; } - case "u": - case "users": + case @"u": + case @"users": return getUserLink(mainArg); - case "wiki": + case @"wiki": return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); - case "home": - if (mainArg != "changelog") + case @"home": + if (mainArg != @"changelog") // handle link other than changelog as external for now return new LinkDetails(LinkAction.External, url); @@ -192,13 +192,26 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}"); } + break; + + case @"multiplayer": + if (mainArg != @"rooms") + return new LinkDetails(LinkAction.External, url); + + if (args.Length == 5) + { + // https://osu.ppy.sh/multiplayer/rooms/{id} + // route used for both multiplayer and playlists + return new LinkDetails(LinkAction.JoinRoom, args[4]); + } + break; } } break; - case "osu": + case @"osu": // every internal link also needs some kind of argument if (args.Length < 3) break; @@ -207,38 +220,39 @@ namespace osu.Game.Online.Chat switch (args[1]) { - case "chan": + case @"chan": linkType = LinkAction.OpenChannel; break; - case "edit": + case @"edit": linkType = LinkAction.OpenEditorTimestamp; break; - case "b": + case @"b": linkType = LinkAction.OpenBeatmap; break; - case "s": - case "dl": + case @"s": + case @"dl": linkType = LinkAction.OpenBeatmapSet; break; - case "spectate": + case @"spectate": linkType = LinkAction.Spectate; break; - case "u": + case @"u": return getUserLink(args[2]); + case @"room": + linkType = LinkAction.JoinRoom; + break; + default: return new LinkDetails(LinkAction.External, url); } return new LinkDetails(linkType, HttpUtility.UrlDecode(args[2])); - - case "osump": - return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); } return new LinkDetails(LinkAction.External, url); @@ -337,7 +351,7 @@ namespace osu.Game.Online.Chat OpenBeatmapSet, OpenChannel, OpenEditorTimestamp, - JoinMultiplayerMatch, + JoinRoom, Spectate, OpenUserProfile, SearchBeatmapSet, diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 962718b564..fc9d99f687 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -67,6 +67,7 @@ using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; @@ -79,6 +80,7 @@ using osu.Game.Utils; using osuTK; using osuTK.Graphics; using Sentry; +using MatchType = osu.Game.Online.Rooms.MatchType; namespace osu.Game { @@ -491,7 +493,6 @@ namespace osu.Game HandleTimestamp(argString); break; - case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: waitForReady(() => Notifications, _ => Notifications.Post(new SimpleNotification { @@ -523,6 +524,11 @@ namespace osu.Game break; + case LinkAction.JoinRoom: + if (long.TryParse(argString, out long roomId)) + JoinRoom(roomId); + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } @@ -598,6 +604,28 @@ namespace osu.Game /// The build version of the update stream public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version)); + /// + /// Joins a multiplayer or playlists room with the given . + /// + public void JoinRoom(long id) + { + var request = new GetRoomRequest(id); + request.Success += room => + { + switch (room.Type) + { + case MatchType.Playlists: + PresentPlaylist(room); + break; + + default: + PresentMultiplayerMatch(room, string.Empty); + break; + } + }; + API.Queue(request); + } + /// /// Seeks to the provided if the editor is currently open. /// Can also select objects as indicated by the (depends on ruleset implementation). @@ -725,6 +753,22 @@ namespace osu.Game /// The password to join the room, if any is given. public void PresentMultiplayerMatch(Room room, string password) { + if (room.HasEnded) + { + // TODO: Eventually it should be possible to display ended multiplayer rooms in game too, + // but it generally will require turning off the entirety of communication with spectator server which is currently embedded into multiplayer screens. + Notifications.Post(new SimpleNotification + { + Text = NotificationsStrings.MultiplayerRoomEnded, + Activated = () => + { + OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}"); + return true; + } + }); + return; + } + PerformFromScreen(screen => { if (!(screen is Multiplayer multiplayer)) @@ -736,6 +780,23 @@ namespace osu.Game // but `PerformFromScreen` doesn't understand nested stacks. } + /// + /// Join a playlist immediately. + /// + /// The playlist to join. + public void PresentPlaylist(Room room) + { + PerformFromScreen(screen => + { + if (!(screen is Playlists playlists)) + screen.Push(playlists = new Playlists()); + + playlists.Join(room); + }); + // TODO: We should really be able to use `validScreens: new[] { typeof(Playlists) }` here + // but `PerformFromScreen` doesn't understand nested stacks. + } + /// /// Present a score's replay immediately. /// The user should have already requested this interactively. diff --git a/osu.Game/Overlays/BeatmapSet/MetadataType.cs b/osu.Game/Overlays/BeatmapSet/MetadataType.cs index dba6a63679..fe38d23242 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataType.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataType.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -9,10 +8,10 @@ namespace osu.Game.Overlays.BeatmapSet { public enum MetadataType { - [Description("User Tags")] // TODO: use translated string after osu-resources update + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoUserTags))] UserTags, - [Description("Mapper Tags")] // TODO: use translated string after osu-resources update + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoMapperTags))] MapperTags, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoSource))] 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/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 1c94048758..0f613585e2 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -116,7 +116,7 @@ namespace osu.Game.Overlays.Profile.Sections CurrentPage = CurrentPage?.TakeNext(ItemsPerPage) ?? new PaginationParameters(InitialItemsCount); - retrievalRequest = CreateRequest(User.Value, CurrentPage.Value); + retrievalRequest = CreateRequest(User.Value, new PaginationParameters(CurrentPage.Value.Offset, InitialItemsCount + 1)); retrievalRequest.Success += items => UpdateItems(items, loadCancellation); api.Queue(retrievalRequest); @@ -124,8 +124,6 @@ namespace osu.Game.Overlays.Profile.Sections protected virtual void UpdateItems(List items, CancellationTokenSource cancellationTokenSource) => Schedule(() => { - OnItemsReceived(items); - if (!items.Any() && CurrentPage?.Offset == 0) { moreButton.Hide(); @@ -137,11 +135,18 @@ namespace osu.Game.Overlays.Profile.Sections return; } + bool hasMore = items.Count > CurrentPage?.Limit; + + if (hasMore) + items.RemoveAt(items.Count - 1); + + OnItemsReceived(items); + LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null).Cast(), drawables => { missing.Hide(); - moreButton.FadeTo(items.Count == CurrentPage?.Limit ? 1 : 0); + moreButton.FadeTo(hasMore ? 1 : 0); moreButton.IsLoading = false; ItemsContainer.AddRange(drawables); 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/Edit/Submission/SubmissionBeatmapExporter.cs b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs index fab080cdba..158b6bc02d 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionBeatmapExporter.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Edit.Submission } if (playableBeatmap.BeatmapInfo.OnlineID > 0) - throw new InvalidOperationException(@"Encountered beatmap with ID that has not been assigned to it by the server!"); + throw new InvalidOperationException($@"Difficulty ""{playableBeatmap.BeatmapInfo.DifficultyName}"" has BeatmapID {playableBeatmap.BeatmapInfo.OnlineID} that has not been assigned to it by the server!"); if (allocatedBeatmapIds.Count == 0) throw new InvalidOperationException(@"Ran out of new beatmap IDs to assign to unsubmitted beatmaps!"); 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/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 59cbef0d15..06efffbf6e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -154,7 +154,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate }); leaderboardFlow.Insert(0, Leaderboard = new DrawableGameplayLeaderboard { - Expanded = { Value = true } + ForceExpand = { Value = true } }); LoadComponentAsync(new GameplayChatDisplay(room) diff --git a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs index af03a6b73f..e04d91b5b7 100644 --- a/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/DrawableGameplayLeaderboard.cs @@ -20,7 +20,7 @@ namespace osu.Game.Screens.Play.HUD { public partial class DrawableGameplayLeaderboard : CompositeDrawable, ISerialisableDrawable { - public Bindable Expanded = new Bindable(); + public readonly Bindable ForceExpand = new Bindable(); protected readonly FillFlowContainer Flow; @@ -40,7 +40,7 @@ namespace osu.Game.Screens.Play.HUD private readonly IBindable userPlayingState = new Bindable(); private readonly IBindable holdingForHUD = new Bindable(); - private const int max_panels = 8; + private readonly Bindable expanded = new Bindable(); /// /// Create a new leaderboard. @@ -100,6 +100,7 @@ namespace osu.Game.Screens.Play.HUD configVisibility.BindValueChanged(_ => Scheduler.AddOnce(updateState)); userPlayingState.BindValueChanged(_ => Scheduler.AddOnce(updateState)); holdingForHUD.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + ForceExpand.BindValueChanged(_ => Scheduler.AddOnce(updateState)); updateState(); } @@ -110,7 +111,7 @@ namespace osu.Game.Screens.Play.HUD scroll.ScrollToStart(false); Flow.FadeTo(player?.Configuration.ShowLeaderboard != false && configVisibility.Value ? 1 : 0, 100, Easing.OutQuint); - Expanded.Value = userPlayingState.Value == LocalUserPlayingState.Playing || holdingForHUD.Value; + expanded.Value = ForceExpand.Value || userPlayingState.Value != LocalUserPlayingState.Playing || holdingForHUD.Value; } /// @@ -128,7 +129,7 @@ namespace osu.Game.Screens.Play.HUD TrackedScore = drawable; } - drawable.Expanded.BindTo(Expanded); + drawable.Expanded.BindTo(expanded); Flow.Add(drawable); drawable.ScorePosition.BindValueChanged(_ => Scheduler.AddOnce(sort)); 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/GameplayLeaderboardScore.cs b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs index 2837da23f4..bf99472dd7 100644 --- a/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Select/Leaderboards/GameplayLeaderboardScore.cs @@ -113,7 +113,7 @@ namespace osu.Game.Screens.Select.Leaderboards Tracked = tracked; TotalScore.Value = scoreInfo.TotalScore; Accuracy.Value = scoreInfo.Accuracy; - Combo.Value = scoreInfo.Combo; + Combo.Value = scoreInfo.MaxCombo; TotalScoreTiebreaker = scoreInfo.OnlineID > 0 ? scoreInfo.OnlineID : scoreInfo.Date.ToUnixTimeSeconds(); GetDisplayScore = scoreInfo.GetDisplayScore; InitialPosition = scoreInfo.Position; diff --git a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs index 41d57f7d24..d17d55e4dd 100644 --- a/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/SoloGameplayLeaderboardProvider.cs @@ -70,6 +70,9 @@ namespace osu.Game.Screens.Select.Leaderboards { 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. diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index 9f870503d3..50577d5fea 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -137,8 +137,8 @@ namespace osu.Game.Screens.Select // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) { - textFlow.AddParagraph("- Try"); - textFlow.AddLink(" enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddParagraph("- Try "); + textFlow.AddLink("enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); textFlow.AddText("automatic conversion!"); } } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 4af5e759a7..1e18aea961 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Pooling; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; @@ -31,12 +32,22 @@ namespace osu.Game.Screens.SelectV2 private readonly LoadingLayer loading; + private readonly BeatmapCarouselFilterMatching matching; private readonly BeatmapCarouselFilterGrouping grouping; + /// + /// Total number of beatmap difficulties displayed with the filter. + /// + public int MatchedBeatmapsCount => matching.BeatmapItemsCount; + protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) { + // Group panels do not overlap with any other panel but should overlap with themselves. + if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition)) + return SPACING * 2; + + // Beatmap difficulty panels do not overlap with themselves or any other panel. if (top.Model is BeatmapInfo || bottom.Model is BeatmapInfo) - // Beatmap difficulty panels do not overlap with themselves or any other panel. return SPACING; return -SPACING; @@ -49,6 +60,7 @@ namespace osu.Game.Screens.SelectV2 Filters = new ICarouselFilter[] { + matching = new BeatmapCarouselFilterMatching(() => Criteria), new BeatmapCarouselFilterSorting(() => Criteria), grouping = new BeatmapCarouselFilterGrouping(() => Criteria), }; @@ -331,11 +343,21 @@ namespace osu.Game.Screens.SelectV2 public FilterCriteria Criteria { get; private set; } = new FilterCriteria(); + private ScheduledDelegate? loadingDebounce; + public void Filter(FilterCriteria criteria) { Criteria = criteria; - loading.Show(); - FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide())); + + loadingDebounce ??= Scheduler.AddDelayed(() => loading.Show(), 250); + + FilterAsync().ContinueWith(_ => Schedule(() => + { + loadingDebounce?.Cancel(); + loadingDebounce = null; + + loading.Hide(); + })); } #endregion diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index a628595477..6fbaa19045 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.SelectV2 this.getCriteria = getCriteria; } - public async Task> Run(IEnumerable items, CancellationToken cancellationToken) + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) { return await Task.Run(() => { diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs new file mode 100644 index 0000000000..ee213f1e93 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.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 System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Carousel; +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.SelectV2 +{ + public class BeatmapCarouselFilterMatching : ICarouselFilter + { + private readonly Func getCriteria; + + /// + /// The total number of beatmap difficulties displayed post filter. + /// + public int BeatmapItemsCount { get; private set; } + + public BeatmapCarouselFilterMatching(Func getCriteria) + { + this.getCriteria = getCriteria; + } + + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + var criteria = getCriteria(); + + return matchItems(items, criteria).ToList(); + }, cancellationToken).ConfigureAwait(false); + + private IEnumerable matchItems(IEnumerable items, FilterCriteria criteria) + { + int countMatching = 0; + + foreach (var item in items) + { + var beatmap = (BeatmapInfo)item.Model; + + if (checkMatch(beatmap, criteria)) + { + countMatching++; + yield return item; + } + } + + BeatmapItemsCount = countMatching; + } + + private static bool checkMatch(BeatmapInfo beatmap, FilterCriteria criteria) + { + bool match = criteria.Ruleset == null || + beatmap.Ruleset.ShortName == criteria.Ruleset.ShortName || + (beatmap.Ruleset.OnlineID == 0 && criteria.Ruleset.OnlineID != 0 && criteria.AllowConvertedBeatmaps); + + if (beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + { + // only check ruleset equality or convertability for selected beatmap + return match; + } + + if (!match) return false; + + if (criteria.SearchTerms.Length > 0) + { + match = beatmap.Match(criteria.SearchTerms); + + // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. + // this should be done after text matching so we can prioritise matching numbers in metadata. + if (!match && criteria.SearchNumber.HasValue) + { + match = (beatmap.OnlineID == criteria.SearchNumber.Value) || + (beatmap.BeatmapSet?.OnlineID == criteria.SearchNumber.Value); + } + } + + if (!match) return false; + + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(beatmap.StarRating); + match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(beatmap.Difficulty.ApproachRate); + match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(beatmap.Difficulty.DrainRate); + match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(beatmap.Difficulty.CircleSize); + match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(beatmap.Difficulty.OverallDifficulty); + match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(beatmap.Length); + match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(beatmap.LastPlayed ?? DateTimeOffset.MinValue); + match &= !criteria.DateRanked.HasFilter || (beatmap.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(beatmap.BeatmapSet.DateRanked.Value)); + match &= !criteria.DateSubmitted.HasFilter || (beatmap.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(beatmap.BeatmapSet.DateSubmitted.Value)); + match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(beatmap.BPM); + + match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(beatmap.BeatDivisor); + match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(beatmap.Status); + + if (!match) return false; + + match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(beatmap.Metadata.Author.Username); + match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(beatmap.Metadata.Artist) || + criteria.Artist.Matches(beatmap.Metadata.ArtistUnicode); + match &= !criteria.Title.HasFilter || criteria.Title.Matches(beatmap.Metadata.Title) || + criteria.Title.Matches(beatmap.Metadata.TitleUnicode); + match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(beatmap.DifficultyName); + match &= !criteria.Source.HasFilter || criteria.Source.Matches(beatmap.Metadata.Source); + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(beatmap.StarRating); + + if (!match) return false; + + match &= criteria.CollectionBeatmapMD5Hashes?.Contains(beatmap.MD5Hash) ?? true; + if (match && criteria.RulesetCriteria != null) + match &= criteria.RulesetCriteria.Matches(beatmap, criteria); + + if (match && criteria.HasOnlineID == true) + match &= beatmap.OnlineID >= 0; + + if (match && criteria.BeatmapSetId != null) + match &= criteria.BeatmapSetId == beatmap.BeatmapSet?.OnlineID; + + return match; + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs index 22a67321db..2a4f534a47 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs @@ -23,7 +23,7 @@ namespace osu.Game.Screens.SelectV2 this.getCriteria = getCriteria; } - public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => { var criteria = getCriteria(); @@ -55,7 +55,7 @@ namespace osu.Game.Screens.SelectV2 } return comparison; - })); + })).ToList(); }, cancellationToken).ConfigureAwait(false); } } 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..5845c36882 --- /dev/null +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -0,0 +1,292 @@ +// 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.Collections.Immutable; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +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.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osuTK; +using osuTK.Input; + +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 SongSelectSearchTextBox searchTextBox = null!; + private ShearedToggleButton showConvertedBeatmapsButton = null!; + private DifficultyRangeSlider difficultyRangeSlider = null!; + private ShearedDropdown sortDropdown = null!; + private ShearedDropdown groupDropdown = null!; + private CollectionDropdown collectionDropdown = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + public LocalisableString StatusText + { + get => searchTextBox.StatusText; + set => searchTextBox.StatusText = value; + } + + public event Action? CriteriaChanged; + + private FilterCriteria currentCriteria = 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 = searchTextBox = new SongSelectSearchTextBox + { + RelativeSizeAxes = Axes.X, + HoldFocus = true, + }, + }, + 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[] + { + sortDropdown = new ShearedDropdown(SortStrings.Default) + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + // todo: pending localisation + groupDropdown = new ShearedDropdown("Group by") + { + RelativeSizeAxes = Axes.X, + Items = Enum.GetValues(), + }, + Empty(), + collectionDropdown = 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); + config.BindWith(OsuSetting.SongSelectSortingMode, sortDropdown.Current); + config.BindWith(OsuSetting.SongSelectGroupingMode, groupDropdown.Current); + + ruleset.BindValueChanged(_ => updateCriteria()); + mods.BindValueChanged(m => + { + // The following is a note carried from old song select and may not be a valid reason anymore: + // // Mods are updated once by the mod select overlay when song select is entered, + // // regardless of if there are any mods or any changes have taken place. + // // Updating the criteria here so early triggers a re-ordering of panels on song select, via... some mechanism. + // // Todo: Investigate/fix and potentially remove this. + // TODO: this might be simply removable with the new song select & carousel code. + if (m.NewValue.SequenceEqual(m.OldValue)) + return; + + var rulesetCriteria = currentCriteria.RulesetCriteria; + if (rulesetCriteria?.FilterMayChangeFromMods(m) == true) + updateCriteria(); + }); + + searchTextBox.Current.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria()); + difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria()); + showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria()); + sortDropdown.Current.BindValueChanged(_ => updateCriteria()); + groupDropdown.Current.BindValueChanged(_ => updateCriteria()); + collectionDropdown.Current.BindValueChanged(_ => updateCriteria()); + updateCriteria(); + } + + /// + /// Creates a based on the current state of the controls. + /// + public FilterCriteria CreateCriteria() + { + string query = searchTextBox.Current.Value; + + var criteria = new FilterCriteria + { + Sort = sortDropdown.Current.Value, + Group = groupDropdown.Current.Value, + AllowConvertedBeatmaps = showConvertedBeatmapsButton.Active.Value, + Ruleset = ruleset.Value, + Mods = mods.Value, + CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes).ToImmutableHashSet() + }; + + if (!difficultyRangeSlider.LowerBound.IsDefault) + criteria.UserStarDifficulty.Min = difficultyRangeSlider.LowerBound.Value; + + if (!difficultyRangeSlider.UpperBound.IsDefault) + criteria.UserStarDifficulty.Max = difficultyRangeSlider.UpperBound.Value; + + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + + FilterQueryParser.ApplyQueries(criteria, query); + return criteria; + } + + private void updateCriteria() + { + currentCriteria = CreateCriteria(); + CriteriaChanged?.Invoke(currentCriteria); + } + + /// + /// Set the query to the search text box. + /// + /// The string to search. + public void Search(string query) + { + searchTextBox.Current.Value = query; + } + + 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; + + public override bool OnPressed(KeyBindingPressEvent e) + { + // the "cut" platform key binding (shift-delete) conflicts with the beatmap deletion action. + if (e.Action == PlatformAction.Cut && e.ShiftPressed && e.CurrentState.Keyboard.Keys.IsPressed(Key.Delete)) + return false; + + return base.OnPressed(e); + } + } + } + } +} 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/NoResultsPlaceholder.cs b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs new file mode 100644 index 0000000000..5ca6dad2a2 --- /dev/null +++ b/osu.Game/Screens/SelectV2/NoResultsPlaceholder.cs @@ -0,0 +1,180 @@ +// 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.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class NoResultsPlaceholder : VisibilityContainer + { + private FilterCriteria? filter; + + private LinkFlowContainer textFlow = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Resolved] + private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + protected override bool StartHidden => true; + + public FilterCriteria Filter + { + set + { + if (filter == value) + return; + + filter = value; + Scheduler.AddOnce(updateText); + } + } + + [BackgroundDependencyLoader] + private void load() + { + Width = 400; + AutoSizeAxes = Axes.Y; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Ghost, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(10), + Size = new Vector2(50), + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Style.Title, + Text = "No beatmaps found" + }, + textFlow = new LinkFlowContainer + { + Alpha = 0, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding { Top = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }, + }; + } + + protected override void PopIn() + { + this.FadeIn(600, Easing.OutQuint); + + Scheduler.AddOnce(updateText); + } + + protected override void PopOut() + { + this.FadeOut(200, Easing.OutQuint); + } + + private void updateText() + { + // TODO: Refresh this text when new beatmaps are imported. Right now it won't get up-to-date suggestions. + + // Bounce should play every time the filter criteria is updated. + this.ScaleTo(0.9f) + .ScaleTo(1f, 1000, Easing.OutQuint); + + textFlow.FadeInFromZero(800, Easing.OutQuint); + + textFlow.Clear(); + + if (beatmaps.QueryBeatmapSet(s => !s.Protected && !s.DeletePending) == null) + { + addBulletPoint(); + textFlow.AddText("Consider running the \""); + textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); + textFlow.AddText("\" to download or import some beatmaps!"); + } + else + { + textFlow.AddParagraph("No beatmaps match your filter criteria!"); + textFlow.AddParagraph(string.Empty); + + if (filter?.UserStarDifficulty.HasFilter == true) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("removing", () => + { + config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0); + config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); + }); + + string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; + string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; + + textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); + } + + // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). + // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. + if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddText("automatic conversion!"); + } + } + + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + addBulletPoint(); + textFlow.AddText("Try "); + textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); + textFlow.AddText($" for \"{filter.SearchText}\"."); + } + // TODO: add clickable link to reset criteria. + } + + private void addBulletPoint() + { + textFlow.NewLine(); + textFlow.AddIcon(FontAwesome.Solid.Circle, i => + { + i.Padding = new MarginPadding { Top = 24, Right = 15 }; + i.Scale *= 0.3f; + }); + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelGroup.cs b/osu.Game/Screens/SelectV2/PanelGroup.cs index 4370146dbc..bf9ea0e3c6 100644 --- a/osu.Game/Screens/SelectV2/PanelGroup.cs +++ b/osu.Game/Screens/SelectV2/PanelGroup.cs @@ -26,6 +26,7 @@ namespace osu.Game.Screens.SelectV2 private Drawable iconContainer = null!; private OsuSpriteText titleText = null!; private TrianglesV2 triangles = null!; + private CircularContainer countPill = null!; private OsuSpriteText countText = null!; private Box glow = null!; @@ -86,12 +87,12 @@ namespace osu.Game.Screens.SelectV2 UseFullGlyphHeight = false, X = 10f, }, - new CircularContainer + countPill = new CircularContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Size = new Vector2(50f, 14f), - Margin = new MarginPadding { Right = 20f }, + Margin = new MarginPadding { Right = 30f }, Masking = true, Children = new Drawable[] { @@ -145,5 +146,13 @@ namespace osu.Game.Screens.SelectV2 titleText.Text = group.Title; countText.Text = Item.NestedItemCount.ToString("N0"); } + + protected override void Update() + { + base.Update(); + + // Move the count pill in the opposite direction to keep it pinned to the screen regardless of the X position of TopLevelContent. + countPill.X = -TopLevelContent.X; + } } } diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index ca09b2a40a..a647e04c0a 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -3,14 +3,25 @@ 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.Input.Events; using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +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; +using osuTK.Input; namespace osu.Game.Screens.SelectV2 { @@ -21,12 +32,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,48 +48,122 @@ 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!; + + private NoResultsPlaceholder noResultsPlaceholder = null!; + public override bool ShowFooter => true; [Resolved] private OsuLogo? logo { get; set; } + [Resolved] + private IDialogOverlay? dialogs { get; set; } + [BackgroundDependencyLoader] private void load() { 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(), + NewItemsPresented = newItemsPresented, + RelativeSizeAxes = Axes.Both, + }, + noResultsPlaceholder = new NoResultsPlaceholder(), + } + }, + filterControl = new FilterControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + }, + } + }, + }, + } }, } - } + }, }, modSelectOverlay, }); } + /// + /// Called when a selection is made. + /// + /// If a resultant action occurred that takes the user away from SongSelect. + protected abstract bool OnStart(); + public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] { new FooterButtonMods(modSelectOverlay) { Current = Mods }, @@ -89,6 +175,8 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); + filterControl.CriteriaChanged += criteriaChanged; + modSelectOverlay.State.BindValueChanged(v => { logo?.ScaleTo(v.NewValue == Visibility.Visible ? 0f : logo_scale, 400, Easing.OutQuint) @@ -96,36 +184,55 @@ namespace osu.Game.Screens.SelectV2 }, true); } - public override void OnEntering(ScreenTransitionEvent e) + protected override void Update() { - this.FadeIn(); + base.Update(); - modSelectOverlay.SelectedMods.BindTo(Mods); - - base.OnEntering(e); + detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; } - private const double fade_duration = 300; + #region Transitions + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + this.FadeIn(); + + titleWedge.Show(); + detailsArea.Show(); + filterControl.Show(); + + modSelectOverlay.SelectedMods.BindTo(Mods); + } 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 +241,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); } @@ -160,12 +272,6 @@ namespace osu.Game.Screens.SelectV2 }; } - /// - /// Called when a selection is made. - /// - /// If a resultant action occurred that takes the user away from SongSelect. - protected abstract bool OnStart(); - protected override void LogoSuspending(OsuLogo logo) { base.LogoSuspending(logo); @@ -180,17 +286,77 @@ namespace osu.Game.Screens.SelectV2 logo.FadeOut(120, Easing.Out); } + #endregion + + #region Filtering + + private const double filter_delay = 250; + + private ScheduledDelegate? filterDebounce; + /// /// Set the query to the search text box. /// /// The string to search. - public void Search(string query) + public void Search(string query) => filterControl.Search(query); + + private void criteriaChanged(FilterCriteria criteria) { - carousel.Filter(new FilterCriteria + filterDebounce?.Cancel(); + filterDebounce = Scheduler.AddDelayed(() => { - // TODO: this should only set the text of the current criteria, not use a completely new criteria. - SearchText = query, - }); + noResultsPlaceholder.Filter = criteria; + carousel.Filter(criteria); + }, filter_delay); } + + private void newItemsPresented() + { + int count = carousel.MatchedBeatmapsCount; + + noResultsPlaceholder.State.Value = count == 0 ? Visibility.Visible : Visibility.Hidden; + + // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 + // but also in this case we want support for formatting a number within a string). + filterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match"; + } + + #endregion + + #region Beatmap management + + /// + /// Opens up with the given beatmap set. + /// + public void RequestDeleteBeatmap(BeatmapSetInfo set) + { + dialogs?.Push(new BeatmapDeleteDialog(set)); + } + + #endregion + + #region Hotkeys + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) return false; + + switch (e.Key) + { + case Key.Delete: + if (e.ShiftPressed) + { + if (!Beatmap.IsDefault) + RequestDeleteBeatmap(Beatmap.Value.BeatmapSetInfo); + return true; + } + + break; + } + + return base.OnKeyDown(e); + } + + #endregion } } 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 0e782203b2..bbed434b3a 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); } }