diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3e99cb61..29cbdd2d37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: - { prettyname: macOS, fullname: macos-latest } - { prettyname: Linux, fullname: ubuntu-latest } threadingMode: ['SingleThread', 'MultiThreaded'] + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index 381d2d49c5..e0ccd50989 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -21,6 +21,7 @@ jobs: - { prettyname: macOS } - { prettyname: Linux } threadingMode: ['SingleThread', 'MultiThreaded'] + timeout-minutes: 5 steps: - name: Annotate CI run with test results uses: dorny/test-reporter@v1.4.2 diff --git a/.idea/.idea.osu.Desktop/.idea/dataSources.xml b/.idea/.idea.osu.Desktop/.idea/dataSources.xml deleted file mode 100644 index 10f8c1c84d..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/dataSources.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:$USER_HOME$/.local/share/osu/client.db - - - - - - \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 3c4380e355..c845d7f276 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs new file mode 100644 index 0000000000..161c685043 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs @@ -0,0 +1,14 @@ +// 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.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + [TestFixture] + public class TestSceneEditor : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 900691ecae..1ad45d2f13 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -6,8 +6,8 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; @@ -31,10 +31,23 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - private Container droppedObjectContainer; + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + private readonly Container trailContainer; private TestCatcher catcher; + public TestSceneCatcher() + { + Add(trailContainer = new Container + { + Anchor = Anchor.Centre, + Depth = -1 + }); + Add(droppedObjectContainer = new DroppedObjectContainer()); + } + [SetUp] public void SetUp() => Schedule(() => { @@ -43,20 +56,13 @@ namespace osu.Game.Rulesets.Catch.Tests CircleSize = 0, }; - var trailContainer = new Container(); - droppedObjectContainer = new Container(); - catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty); + if (catcher != null) + Remove(catcher); - Child = new Container + Add(catcher = new TestCatcher(trailContainer, difficulty) { - Anchor = Anchor.Centre, - Children = new Drawable[] - { - trailContainer, - droppedObjectContainer, - catcher - } - }; + Anchor = Anchor.Centre + }); }); [Test] @@ -293,8 +299,8 @@ namespace osu.Game.Rulesets.Catch.Tests { public IEnumerable CaughtObjects => this.ChildrenOfType(); - public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty) - : base(trailsTarget, droppedObjectTarget, difficulty) + public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty) + : base(trailsTarget, difficulty) { } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 4af5098451..877e115e2f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -6,7 +6,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Threading; using osu.Framework.Utils; @@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests SetContents(_ => { - var droppedObjectContainer = new Container - { - RelativeSizeAxes = Axes.Both - }; - return new CatchInputManager(catchRuleset) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - droppedObjectContainer, - new TestCatcherArea(droppedObjectContainer, beatmapDifficulty) + new TestCatcherArea(beatmapDifficulty) { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, @@ -126,9 +119,13 @@ namespace osu.Game.Rulesets.Catch.Tests private class TestCatcherArea : CatcherArea { - public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty) - : base(droppedObjectContainer, beatmapDifficulty) + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) + : base(beatmapDifficulty) { + AddInternal(droppedObjectContainer = new DroppedObjectContainer()); } public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 683a776dcc..7fa981d492 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -118,11 +118,10 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container()) + Child = setupSkinHierarchy(catcherArea = new TestCatcherArea { Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(4f), + Origin = Anchor.Centre }, skin); }); @@ -206,5 +205,18 @@ namespace osu.Game.Rulesets.Catch.Tests { } } + + private class TestCatcherArea : CatcherArea + { + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + public TestCatcherArea() + { + Scale = new Vector2(4f); + + AddInternal(droppedObjectContainer = new DroppedObjectContainer()); + } + } } } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index fac5d03833..3a5322ce82 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -8,7 +8,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.MathUtils; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Beatmaps @@ -17,6 +16,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps { public const int RNG_SEED = 1337; + public bool HardRockOffsets { get; set; } + public CatchBeatmapProcessor(IBeatmap beatmap) : base(beatmap) { @@ -43,11 +44,10 @@ namespace osu.Game.Rulesets.Catch.Beatmaps } } - public static void ApplyPositionOffsets(IBeatmap beatmap, params Mod[] mods) + public void ApplyPositionOffsets(IBeatmap beatmap) { var rng = new FastRandom(RNG_SEED); - bool shouldApplyHardRockOffset = mods.Any(m => m is ModHardRock); float? lastPosition = null; double lastStartTime = 0; @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps switch (obj) { case Fruit fruit: - if (shouldApplyHardRockOffset) + if (HardRockOffsets) applyHardRockOffset(fruit, ref lastPosition, ref lastStartTime, rng); break; diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index ec4c5dfe4c..76863acc78 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -22,7 +22,9 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; using osu.Framework.Extensions.EnumExtensions; +using osu.Game.Rulesets.Catch.Edit; using osu.Game.Rulesets.Catch.Skinning.Legacy; +using osu.Game.Rulesets.Edit; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch @@ -182,5 +184,7 @@ namespace osu.Game.Rulesets.Catch public int LegacyID => 2; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame(); + + public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this); } } diff --git a/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs new file mode 100644 index 0000000000..31075db7d1 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs @@ -0,0 +1,24 @@ +// 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.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class BananaShowerCompositionTool : HitObjectCompositionTool + { + public BananaShowerCompositionTool() + : base(nameof(BananaShower)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + + public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs new file mode 100644 index 0000000000..6dea8b0712 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs @@ -0,0 +1,73 @@ +// 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.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class BananaShowerPlacementBlueprint : CatchPlacementBlueprint + { + private readonly TimeSpanOutline outline; + + public BananaShowerPlacementBlueprint() + { + InternalChild = outline = new TimeSpanOutline(); + } + + protected override void Update() + { + base.Update(); + + outline.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (e.Button != MouseButton.Left) break; + + BeginPlacement(true); + return true; + + case PlacementState.Active: + if (e.Button != MouseButton.Right) break; + + // If the duration is negative, swap the start and the end time to make the duration positive. + if (HitObject.Duration < 0) + { + HitObject.StartTime = HitObject.EndTime; + HitObject.Duration = -HitObject.Duration; + } + + EndPlacement(HitObject.Duration > 0); + return true; + } + + return base.OnMouseDown(e); + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + base.UpdateTimeAndPosition(result); + + if (!(result.Time is double time)) return; + + switch (PlacementActive) + { + case PlacementState.Waiting: + HitObject.StartTime = time; + break; + + case PlacementState.Active: + HitObject.EndTime = time; + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs new file mode 100644 index 0000000000..9132b1a9e8 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Objects; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class BananaShowerSelectionBlueprint : CatchSelectionBlueprint + { + public BananaShowerSelectionBlueprint(BananaShower hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs new file mode 100644 index 0000000000..69054e2c81 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs @@ -0,0 +1,27 @@ +// 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.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class CatchPlacementBlueprint : PlacementBlueprint + where THitObject : CatchHitObject, new() + { + protected new THitObject HitObject => (THitObject)base.HitObject; + + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + public CatchPlacementBlueprint() + : base(new THitObject()) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs new file mode 100644 index 0000000000..298f9474b0 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs @@ -0,0 +1,38 @@ +// 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.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public abstract class CatchSelectionBlueprint : HitObjectSelectionBlueprint + where THitObject : CatchHitObject + { + public override Vector2 ScreenSpaceSelectionPoint + { + get + { + float x = HitObject.OriginalX; + float y = HitObjectContainer.PositionAtTime(HitObject.StartTime); + return HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight)); + } + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SelectionQuad.Contains(screenSpacePos); + + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + protected CatchSelectionBlueprint(THitObject hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs new file mode 100644 index 0000000000..8769acc382 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs @@ -0,0 +1,38 @@ +// 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; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Skinning.Default; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class FruitOutline : CompositeDrawable + { + public FruitOutline() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.Centre; + Size = new Vector2(2 * CatchHitObject.OBJECT_RADIUS); + InternalChild = new BorderPiece(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + Colour = osuColour.Yellow; + } + + public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject) + { + X = hitObject.EffectiveX; + Y = hitObjectContainer.PositionAtTime(hitObject.StartTime); + Scale = new Vector2(hitObject.Scale); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs new file mode 100644 index 0000000000..65dfce0493 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class TimeSpanOutline : CompositeDrawable + { + private const float border_width = 4; + + private const float opacity_when_empty = 0.5f; + + private bool isEmpty = true; + + public TimeSpanOutline() + { + Anchor = Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.X; + + Masking = true; + BorderThickness = border_width; + Alpha = opacity_when_empty; + + // A box is needed to make the border visible. + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Transparent + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + BorderColour = osuColour.Yellow; + } + + public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, BananaShower hitObject) + { + float startY = hitObjectContainer.PositionAtTime(hitObject.StartTime); + float endY = hitObjectContainer.PositionAtTime(hitObject.EndTime); + + Y = Math.Max(startY, endY); + float height = Math.Abs(startY - endY); + + bool wasEmpty = isEmpty; + isEmpty = height == 0; + if (wasEmpty != isEmpty) + this.FadeTo(isEmpty ? opacity_when_empty : 1f, 150); + + Height = Math.Max(height, border_width); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs new file mode 100644 index 0000000000..0f28cf6786 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs @@ -0,0 +1,50 @@ +// 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.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class FruitPlacementBlueprint : CatchPlacementBlueprint + { + private readonly FruitOutline outline; + + public FruitPlacementBlueprint() + { + InternalChild = outline = new FruitOutline(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BeginPlacement(); + } + + protected override void Update() + { + base.Update(); + + outline.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) return base.OnMouseDown(e); + + EndPlacement(true); + return true; + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + base.UpdateTimeAndPosition(result); + + HitObject.X = ToLocalSpace(result.ScreenSpacePosition).X; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs new file mode 100644 index 0000000000..9665aac2fb --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class FruitSelectionBlueprint : CatchSelectionBlueprint + { + private readonly FruitOutline outline; + + public FruitSelectionBlueprint(Fruit hitObject) + : base(hitObject) + { + InternalChild = outline = new FruitOutline(); + } + + protected override void Update() + { + base.Update(); + + if (IsSelected) + outline.UpdateFrom(HitObjectContainer, HitObject); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs new file mode 100644 index 0000000000..d6b8c35a09 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -0,0 +1,57 @@ +// 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.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint + { + public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight))); + + private float minNestedX; + private float maxNestedX; + + public JuiceStreamSelectionBlueprint(JuiceStream hitObject) + : base(hitObject) + { + } + + [BackgroundDependencyLoader] + private void load() + { + HitObject.DefaultsApplied += onDefaultsApplied; + computeObjectBounds(); + } + + private void onDefaultsApplied(HitObject _) => computeObjectBounds(); + + private void computeObjectBounds() + { + minNestedX = HitObject.NestedHitObjects.OfType().Min(nested => nested.OriginalX) - HitObject.OriginalX; + maxNestedX = HitObject.NestedHitObjects.OfType().Max(nested => nested.OriginalX) - HitObject.OriginalX; + } + + private RectangleF getBoundingBox() + { + float left = HitObject.OriginalX + minNestedX; + float right = HitObject.OriginalX + maxNestedX; + float top = HitObjectContainer.PositionAtTime(HitObject.EndTime); + float bottom = HitObjectContainer.PositionAtTime(HitObject.StartTime); + float objectRadius = CatchHitObject.OBJECT_RADIUS * HitObject.Scale; + return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + HitObject.DefaultsApplied -= onDefaultsApplied; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs new file mode 100644 index 0000000000..7f2782a474 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchBlueprintContainer : ComposeBlueprintContainer + { + public CatchBlueprintContainer(CatchHitObjectComposer composer) + : base(composer) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new CatchSelectionHandler(); + + public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) + { + switch (hitObject) + { + case Fruit fruit: + return new FruitSelectionBlueprint(fruit); + + case JuiceStream juiceStream: + return new JuiceStreamSelectionBlueprint(juiceStream); + + case BananaShower bananaShower: + return new BananaShowerSelectionBlueprint(bananaShower); + } + + return base.CreateHitObjectBlueprintFor(hitObject); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs new file mode 100644 index 0000000000..d383eb9ba6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.UI; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchEditorPlayfield : CatchPlayfield + { + // TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen. + public CatchEditorPlayfield(BeatmapDifficulty difficulty) + : base(difficulty) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // TODO: honor "hit animation" setting? + CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + + // TODO: disable hit lighting as well + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs new file mode 100644 index 0000000000..d9712bc8e9 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -0,0 +1,42 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchHitObjectComposer : HitObjectComposer + { + public CatchHitObjectComposer(CatchRuleset ruleset) + : base(ruleset) + { + } + + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => + new DrawableCatchEditorRuleset(ruleset, beatmap, mods); + + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + { + new FruitCompositionTool(), + new BananaShowerCompositionTool() + }; + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + // TODO: implement position snap + result.ScreenSpacePosition.X = screenSpacePosition.X; + return result; + } + + protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs new file mode 100644 index 0000000000..c1a491d1ce --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -0,0 +1,46 @@ +// 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.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchSelectionHandler : EditorSelectionHandler + { + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) + { + var blueprint = moveEvent.Blueprint; + Vector2 originalPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint); + Vector2 targetPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta); + float deltaX = targetPosition.X - originalPosition.X; + + EditorBeatmap.PerformOnSelection(h => + { + if (!(h is CatchHitObject hitObject)) return; + + if (hitObject is BananaShower) return; + + // TODO: confine in bounds + hitObject.OriginalXBindable.Value += deltaX; + + // Move the nested hit objects to give an instant result before nested objects are recreated. + foreach (var nested in hitObject.NestedHitObjects.OfType()) + nested.OriginalXBindable.Value += deltaX; + }); + + return true; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs new file mode 100644 index 0000000000..0344709d45 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs @@ -0,0 +1,21 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class DrawableCatchEditorRuleset : DrawableCatchRuleset + { + public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + } + + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.BeatmapInfo.BaseDifficulty); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs new file mode 100644 index 0000000000..f776fe39c1 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs @@ -0,0 +1,24 @@ +// 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.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class FruitCompositionTool : HitObjectCompositionTool + { + public FruitCompositionTool() + : base(nameof(Fruit)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + + public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 5f1736450a..bd7a1df2e4 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -5,11 +5,12 @@ using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModDifficultyAdjust : ModDifficultyAdjust + public class CatchModDifficultyAdjust : ModDifficultyAdjust, IApplicableToBeatmapProcessor { [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension @@ -31,6 +32,9 @@ namespace osu.Game.Rulesets.Catch.Mods Value = 5, }; + [SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")] + public BindableBool HardRockOffsets { get; } = new BindableBool(); + protected override void ApplyLimits(bool extended) { base.ApplyLimits(extended); @@ -45,12 +49,14 @@ namespace osu.Game.Rulesets.Catch.Mods { string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + string spicyPatterns = HardRockOffsets.IsDefault ? string.Empty : "Spicy patterns"; return string.Join(", ", new[] { circleSize, base.SettingDescription, - approachRate + approachRate, + spicyPatterns, }.Where(s => !string.IsNullOrEmpty(s))); } } @@ -70,5 +76,11 @@ namespace osu.Game.Rulesets.Catch.Mods ApplySetting(CircleSize, cs => difficulty.CircleSize = cs); ApplySetting(ApproachRate, ar => difficulty.ApproachRate = ar); } + + public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) + { + var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor; + catchProcessor.HardRockOffsets = HardRockOffsets.Value; + } } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs index 0dde6aa06e..68b6ce96a3 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs @@ -7,10 +7,14 @@ using osu.Game.Rulesets.Catch.Beatmaps; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModHardRock : ModHardRock, IApplicableToBeatmap + public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor { public override double ScoreMultiplier => 1.12; - public void ApplyToBeatmap(IBeatmap beatmap) => CatchBeatmapProcessor.ApplyPositionOffsets(beatmap, this); + public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) + { + var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor; + catchProcessor.HardRockOffsets = true; + } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 644facdabc..05cd29dff5 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; @@ -27,6 +26,9 @@ namespace osu.Game.Rulesets.Catch.UI /// public const float CENTER_X = WIDTH / 2; + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + internal readonly CatcherArea CatcherArea; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => @@ -35,12 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI public CatchPlayfield(BeatmapDifficulty difficulty) { - var droppedObjectContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }; - - CatcherArea = new CatcherArea(droppedObjectContainer, difficulty) + CatcherArea = new CatcherArea(difficulty) { Anchor = Anchor.BottomLeft, Origin = Anchor.TopLeft, @@ -48,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.UI InternalChildren = new[] { - droppedObjectContainer, + droppedObjectContainer = new DroppedObjectContainer(), CatcherArea.MovableCatcher.CreateProxiedContent(), HitObjectContainer.CreateProxy(), // This ordering (`CatcherArea` before `HitObjectContainer`) is important to diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 1f01dbabb5..dcab9459ee 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -79,7 +79,8 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Contains objects dropped from the plate. /// - private readonly Container droppedObjectTarget; + [Resolved] + private DroppedObjectContainer droppedObjectTarget { get; set; } public CatcherAnimationState CurrentState { @@ -134,10 +135,9 @@ namespace osu.Game.Rulesets.Catch.UI private readonly DrawablePool caughtBananaPool; private readonly DrawablePool caughtDropletPool; - public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null) + public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; - this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index cdb15c2b4c..fea314df8d 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.UI /// private int currentDirection; - public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null) + public CatcherArea(BeatmapDifficulty difficulty = null) { Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); Children = new Drawable[] @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.UI Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X }, - MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X }, + MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, }; } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs index 80522ab36b..c961d98dc5 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs @@ -37,6 +37,7 @@ namespace osu.Game.Rulesets.Catch.UI protected override void FreeAfterUse() { ClearTransforms(); + Alpha = 1; base.FreeAfterUse(); } } diff --git a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs new file mode 100644 index 0000000000..b44b0caae4 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs @@ -0,0 +1,17 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class DroppedObjectContainer : Container + { + public DroppedObjectContainer() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 1c89d9cd00..f89750a96e 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mania.Configuration; @@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania private class TimeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString("N0") + "ms"; + public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms"; } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index 48e4db11ca..5b476526c9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; @@ -283,6 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } } - public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; + public LocalisableString TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 97e3d82664..d1212096bf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; using osuTK; namespace osu.Game.Rulesets.Osu.Mods @@ -23,15 +24,6 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Description => "It never gets boring!"; - // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. - // The closer the hit objects draw to the border, the sharper the turn - private const float playfield_edge_ratio = 0.375f; - - private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio; - private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio; - - private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2; - private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; private Random rng; @@ -113,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Mods distanceToPrev * (float)Math.Sin(current.AngleRad) ); - posRelativeToPrev = getRotatedVector(previous.EndPositionRandomised, posRelativeToPrev); + posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev); current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); @@ -185,73 +177,6 @@ namespace osu.Game.Rulesets.Osu.Mods } } - /// - /// Determines the position of the current hit object relative to the previous one. - /// - /// The position of the current hit object relative to the previous one - private Vector2 getRotatedVector(Vector2 prevPosChanged, Vector2 posRelativeToPrev) - { - var relativeRotationDistance = 0f; - - if (prevPosChanged.X < playfield_middle.X) - { - relativeRotationDistance = Math.Max( - (border_distance_x - prevPosChanged.X) / border_distance_x, - relativeRotationDistance - ); - } - else - { - relativeRotationDistance = Math.Max( - (prevPosChanged.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x, - relativeRotationDistance - ); - } - - if (prevPosChanged.Y < playfield_middle.Y) - { - relativeRotationDistance = Math.Max( - (border_distance_y - prevPosChanged.Y) / border_distance_y, - relativeRotationDistance - ); - } - else - { - relativeRotationDistance = Math.Max( - (prevPosChanged.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y, - relativeRotationDistance - ); - } - - return rotateVectorTowardsVector(posRelativeToPrev, playfield_middle - prevPosChanged, relativeRotationDistance / 2); - } - - /// - /// Rotates vector "initial" towards vector "destinantion" - /// - /// Vector to rotate to "destination" - /// Vector "initial" should be rotated to - /// The angle the vector should be rotated relative to the difference between the angles of the the two vectors. - /// Resulting vector - private Vector2 rotateVectorTowardsVector(Vector2 initial, Vector2 destination, float relativeDistance) - { - var initialAngleRad = Math.Atan2(initial.Y, initial.X); - var destAngleRad = Math.Atan2(destination.Y, destination.X); - - var diff = destAngleRad - initialAngleRad; - - while (diff < -Math.PI) diff += 2 * Math.PI; - - while (diff > Math.PI) diff -= 2 * Math.PI; - - var finalAngleRad = initialAngleRad + relativeDistance * diff; - - return new Vector2( - initial.Length * (float)Math.Cos(finalAngleRad), - initial.Length * (float)Math.Sin(finalAngleRad) - ); - } - private class RandomObjectInfo { public float AngleRad { get; set; } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs new file mode 100644 index 0000000000..06b964a647 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -0,0 +1,104 @@ +// 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.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Utils +{ + public static class OsuHitObjectGenerationUtils + { + // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. + // The closer the hit objects draw to the border, the sharper the turn + private const float playfield_edge_ratio = 0.375f; + + private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio; + private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio; + + private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2; + + /// + /// Rotate a hit object away from the playfield edge, while keeping a constant distance + /// from the previous object. + /// + /// + /// The extent of rotation depends on the position of the hit object. Hit objects + /// closer to the playfield edge will be rotated to a larger extent. + /// + /// Position of the previous hit object. + /// Position of the hit object to be rotated, relative to the previous hit object. + /// + /// The extent of rotation. + /// 0 means the hit object is never rotated. + /// 1 means the hit object will be fully rotated towards playfield center when it is originally at playfield edge. + /// + /// The new position of the hit object, relative to the previous one. + public static Vector2 RotateAwayFromEdge(Vector2 prevObjectPos, Vector2 posRelativeToPrev, float rotationRatio = 0.5f) + { + var relativeRotationDistance = 0f; + + if (prevObjectPos.X < playfield_middle.X) + { + relativeRotationDistance = Math.Max( + (border_distance_x - prevObjectPos.X) / border_distance_x, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevObjectPos.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x, + relativeRotationDistance + ); + } + + if (prevObjectPos.Y < playfield_middle.Y) + { + relativeRotationDistance = Math.Max( + (border_distance_y - prevObjectPos.Y) / border_distance_y, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevObjectPos.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y, + relativeRotationDistance + ); + } + + return RotateVectorTowardsVector( + posRelativeToPrev, + playfield_middle - prevObjectPos, + Math.Min(1, relativeRotationDistance * rotationRatio) + ); + } + + /// + /// Rotates vector "initial" towards vector "destination". + /// + /// The vector to be rotated. + /// The vector that "initial" should be rotated towards. + /// How much "initial" should be rotated. 0 means no rotation. 1 means "initial" is fully rotated to equal "destination". + /// The rotated vector. + public static Vector2 RotateVectorTowardsVector(Vector2 initial, Vector2 destination, float rotationRatio) + { + var initialAngleRad = MathF.Atan2(initial.Y, initial.X); + var destAngleRad = MathF.Atan2(destination.Y, destination.X); + + var diff = destAngleRad - initialAngleRad; + + while (diff < -MathF.PI) diff += 2 * MathF.PI; + + while (diff > MathF.PI) diff -= 2 * MathF.PI; + + var finalAngleRad = initialAngleRad + rotationRatio * diff; + + return new Vector2( + initial.Length * MathF.Cos(finalAngleRad), + initial.Length * MathF.Sin(finalAngleRad) + ); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs new file mode 100644 index 0000000000..cf5b3a42a4 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs @@ -0,0 +1,241 @@ +// 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.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckFewHitsoundsTest + { + private CheckFewHitsounds check; + + private List notHitsounded; + private List hitsounded; + + [SetUp] + public void Setup() + { + check = new CheckFewHitsounds(); + notHitsounded = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + hitsounded = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL), + new HitSampleInfo(HitSampleInfo.HIT_FINISH) + }; + } + + [Test] + public void TestHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 16; ++i) + { + var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + + if ((i + 1) % 2 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + if ((i + 1) % 3 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE)); + if ((i + 1) % 4 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertOk(hitObjects); + } + + [Test] + public void TestHitsoundedWithBreak() + { + var hitObjects = new List(); + + for (int i = 0; i < 32; ++i) + { + var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + + if ((i + 1) % 2 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + if ((i + 1) % 3 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE)); + if ((i + 1) % 4 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + // Leaves a gap in which no hitsounds exist or can be added, and so shouldn't be an issue. + if (i > 8 && i < 24) + continue; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertOk(hitObjects); + } + + [Test] + public void TestLightlyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 30; ++i) + { + var samples = i % 8 == 0 ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertLongPeriodNegligible(hitObjects, count: 3); + } + + [Test] + public void TestRarelyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 30; ++i) + { + var samples = (i == 0 || i == 15) ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + // Should prompt one warning between 1st and 16th, and another between 16th and 31st. + assertLongPeriodWarning(hitObjects, count: 2); + } + + [Test] + public void TestExtremelyRarelyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 80; ++i) + { + var samples = i == 40 ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + // Should prompt one problem between 1st and 41st, and another between 41st and 81st. + assertLongPeriodProblem(hitObjects, count: 2); + } + + [Test] + public void TestNotHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 20; ++i) + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = notHitsounded }); + + assertNoHitsounds(hitObjects); + } + + [Test] + public void TestNestedObjectsHitsounded() + { + var ticks = new List(); + for (int i = 1; i < 16; ++i) + ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = hitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000) + { + Samples = hitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertOk(new List { nested }); + } + + [Test] + public void TestNestedObjectsRarelyHitsounded() + { + var ticks = new List(); + for (int i = 1; i < 16; ++i) + ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = i == 0 ? hitsounded : notHitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000) + { + Samples = hitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertLongPeriodWarning(new List { nested }); + } + + [Test] + public void TestConcurrentObjects() + { + var hitObjects = new List(); + + var ticks = new List(); + for (int i = 1; i < 10; ++i) + ticks.Add(new SliderTick { StartTime = 5000 * i, Samples = hitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 50000) + { + Samples = notHitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + hitObjects.Add(nested); + + for (int i = 1; i <= 6; ++i) + hitObjects.Add(new HitCircle { StartTime = 10000 * i, Samples = notHitsounded }); + + assertOk(hitObjects); + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assertLongPeriodProblem(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodProblem)); + } + + private void assertLongPeriodWarning(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodWarning)); + } + + private void assertLongPeriodNegligible(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodNegligible)); + } + + private void assertNoHitsounds(List hitObjects) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds)); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap { HitObjects = hitObjects }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs new file mode 100644 index 0000000000..41a8f72305 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -0,0 +1,289 @@ +// 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.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckMutedObjectsTest + { + private CheckMutedObjects check; + private ControlPointInfo cpi; + + private const int volume_regular = 50; + private const int volume_low = 15; + private const int volume_muted = 5; + + [SetUp] + public void Setup() + { + check = new CheckMutedObjects(); + + cpi = new ControlPointInfo(); + cpi.Add(0, new SampleControlPoint { SampleVolume = volume_regular }); + cpi.Add(1000, new SampleControlPoint { SampleVolume = volume_low }); + cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted }); + } + + [Test] + public void TestNormalControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { hitcircle }); + } + + [Test] + public void TestLowControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 1000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertLowVolume(new List { hitcircle }); + } + + [Test] + public void TestMutedControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { hitcircle }); + } + + [Test] + public void TestNormalSampleVolume() + { + // The sample volume should take precedence over the control point volume. + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { hitcircle }); + } + + [Test] + public void TestLowSampleVolume() + { + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_low) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertLowVolume(new List { hitcircle }); + } + + [Test] + public void TestMutedSampleVolume() + { + var hitcircle = new HitCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { hitcircle }); + } + + [Test] + public void TestNormalSampleVolumeSlider() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick", volume: volume_muted) } // Should be fine. + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { slider }); + } + + [Test] + public void TestMutedSampleVolumeSliderHead() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } // Applies to the tail. + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { slider }); + } + + [Test] + public void TestMutedSampleVolumeSliderTail() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } // Applies to the tail. + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMutedPassive(new List { slider }); + } + + [Test] + public void TestMutedControlPointVolumeSliderHead() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 2250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 2000, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { slider }); + } + + [Test] + public void TestMutedControlPointVolumeSliderTail() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + // Ends after the 5% control point. + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMutedPassive(new List { slider }); + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assertLowVolume(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateLowVolumeActive)); + } + + private void assertMuted(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedActive)); + } + + private void assertMutedPassive(List hitObjects) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Any(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedPassive)); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap + { + ControlPointInfo = cpi, + HitObjects = hitObjects + }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs new file mode 100644 index 0000000000..29938839d3 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs @@ -0,0 +1,36 @@ +// 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.Threading; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Tests.Editing.Checks +{ + public sealed class MockNestableHitObject : HitObject, IHasDuration + { + private readonly IEnumerable toBeNested; + + public MockNestableHitObject(IEnumerable toBeNested, double startTime, double endTime) + { + this.toBeNested = toBeNested; + StartTime = startTime; + EndTime = endTime; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + foreach (var hitObject in toBeNested) + AddNested(hitObject); + } + + public double EndTime { get; } + + public double Duration + { + get => EndTime - StartTime; + set => throw new System.NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 9bd262a569..a55bdd2df8 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -90,6 +90,20 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.Less(filterCriteria.DrainRate.Min, 6.1f); } + [Test] + public void TestApplyOverallDifficultyQueries() + { + const string query = "od>4 easy od<8"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.Greater(filterCriteria.OverallDifficulty.Min, 4.0); + Assert.Less(filterCriteria.OverallDifficulty.Min, 4.1); + Assert.Greater(filterCriteria.OverallDifficulty.Max, 7.9); + Assert.Less(filterCriteria.OverallDifficulty.Max, 8.0); + } + [Test] public void TestApplyBPMQueries() { diff --git a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs index e9f9766725..e060c8578a 100644 --- a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs +++ b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -23,28 +24,55 @@ namespace osu.Game.Tests.Rulesets protected override Ruleset CreateRuleset() => new TestSceneRulesetDependencies.TestRuleset(); - [SetUp] - public void SetUp() => Schedule(() => - { - Child = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin) - .WithChild(requester = new SkinRequester()); - }); - [Test] public void TestRulesetResources() { + setupProviderStep(); + AddAssert("ruleset texture retrieved via skin", () => requester.GetTexture("test-image") != null); AddAssert("ruleset sample retrieved via skin", () => requester.GetSample(new SampleInfo("test-sample")) != null); } + [Test] + public void TestEarlyAddedSkinRequester() + { + Texture textureOnLoad = null; + + AddStep("setup provider", () => + { + var rulesetSkinProvider = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin); + + rulesetSkinProvider.Add(requester = new SkinRequester()); + + requester.OnLoadAsync += () => textureOnLoad = requester.GetTexture("test-image"); + + Child = rulesetSkinProvider; + }); + + AddAssert("requester got correct initial texture", () => textureOnLoad != null); + } + + private void setupProviderStep() + { + AddStep("setup provider", () => + { + Child = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin) + .WithChild(requester = new SkinRequester()); + }); + } + private class SkinRequester : Drawable, ISkin { private ISkinSource skin; + public event Action OnLoadAsync; + [BackgroundDependencyLoader] private void load(ISkinSource skin) { this.skin = skin; + + OnLoadAsync?.Invoke(); } public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 07162c3cd1..b6ae91844a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -55,7 +55,12 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestExitWithoutSave() { - AddStep("exit without save", () => Editor.Exit()); + AddStep("exit without save", () => + { + Editor.Exit(); + DialogOverlay.CurrentDialog.PerformOkAction(); + }); + AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == true); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index c5a6723508..599dfb082b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -6,15 +6,20 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Resources; @@ -159,6 +164,50 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen()); } + [Test] + public void TestLeaveNavigation() + { + loadMultiplayer(); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + AllowedMods = { new OsuModHidden() } + } + } + }); + + AddStep("open mod overlay", () => this.ChildrenOfType().ElementAt(2).Click()); + + AddStep("invoke on back button", () => multiplayerScreen.OnBackButton()); + + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); + + testLeave("lounge tab item", () => this.ChildrenOfType.BreadcrumbTabItem>().First().Click()); + + testLeave("back button", () => multiplayerScreen.OnBackButton()); + + // mimics home button and OS window close + testLeave("forced exit", () => multiplayerScreen.Exit()); + + void testLeave(string actionName, Action action) + { + AddStep($"leave via {actionName}", action); + + AddAssert("dialog overlay is visible", () => DialogOverlay.State.Value == Visibility.Visible); + + AddStep("close dialog overlay", () => InputManager.Key(Key.Escape)); + } + } + private void createRoom(Func room) { AddStep("open room", () => diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 156d6b744e..5bfb676f81 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.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 System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -14,6 +15,8 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { @@ -23,6 +26,8 @@ namespace osu.Game.Tests.Visual.Online private BeatmapListingOverlay overlay; + private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType().Single(); + [BackgroundDependencyLoader] private void load() { @@ -39,6 +44,16 @@ namespace osu.Game.Tests.Visual.Online return true; }; + + AddStep("initialize dummy", () => + { + // non-supporter user + ((DummyAPIAccess)API).LocalUser.Value = new User + { + Username = "TestBot", + Id = API.LocalUser.Value.Id + 1, + }; + }); } [Test] @@ -58,13 +73,164 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); } + [Test] + public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults() + { + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + notFoundPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + + // both RankAchieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + } + + [Test] + public void TestUserWithSupporterUsesSupporterOnlyFiltersWithoutResults() + { + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + notFoundPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + notFoundPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + notFoundPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + notFoundPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + } + + [Test] + public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults() + { + AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + noPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + } + + [Test] + public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults() + { + AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + noPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + noPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + noPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + noPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + } + private void fetchFor(params BeatmapSetInfo[] beatmaps) { setsForResponse.Clear(); setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); // trigger arbitrary change for fetching. - overlay.ChildrenOfType().Single().Query.TriggerChange(); + searchControl.Query.TriggerChange(); + } + + private void setRankAchievedFilter(ScoreRank[] ranks) + { + AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () => + { + searchControl.Ranks.Clear(); + searchControl.Ranks.AddRange(ranks); + }); + } + + private void setPlayedFilter(SearchPlayed played) + { + AddStep($"set Played filter to {played}", () => searchControl.Played.Value = played); + } + + private void supporterRequiredPlaceholderShown() + { + AddUntilStep("\"supporter required\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + } + + private void notFoundPlaceholderShown() + { + AddUntilStep("\"no maps found\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + } + + private void noPlaceholderShown() + { + AddUntilStep("no placeholder shown", () => + !overlay.ChildrenOfType().Any() + && !overlay.ChildrenOfType().Any()); } private class TestAPIBeatmapSet : APIBeatmapSet diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 2885dbee00..df8ef92a05 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; @@ -107,7 +106,6 @@ namespace osu.Game.Tests.Visual.UserInterface var conversionMods = osu.GetModsFor(ModType.Conversion); var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); - var hiddenMod = harderMods.FirstOrDefault(m => m is OsuModHidden); var doubleTimeMod = harderMods.OfType().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime)); @@ -120,8 +118,6 @@ namespace osu.Game.Tests.Visual.UserInterface testMultiMod(doubleTimeMod); testIncompatibleMods(easy, hardRock); testDeselectAll(easierMods.Where(m => !(m is MultiMod))); - testMultiplierTextColour(noFailMod, () => modSelect.LowMultiplierColour); - testMultiplierTextColour(hiddenMod, () => modSelect.HighMultiplierColour); testUnimplementedMod(targetMod); } @@ -149,7 +145,7 @@ namespace osu.Game.Tests.Visual.UserInterface changeRuleset(0); - AddAssert("ensure mods still selected", () => modDisplay.Current.Value.Single(m => m is OsuModNoFail) != null); + AddAssert("ensure mods still selected", () => modDisplay.Current.Value.SingleOrDefault(m => m is OsuModNoFail) != null); changeRuleset(3); @@ -316,17 +312,6 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any()); } - private void testMultiplierTextColour(Mod mod, Func getCorrectColour) - { - checkLabelColor(() => Color4.White); - selectNext(mod); - AddWaitStep("wait for changing colour", 1); - checkLabelColor(getCorrectColour); - selectPrevious(mod); - AddWaitStep("wait for changing colour", 1); - checkLabelColor(() => Color4.White); - } - private void testModsWithSameBaseType(Mod modA, Mod modB) { selectNext(modA); @@ -348,7 +333,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert($"check {mod.Name} is selected", () => { var button = modSelect.GetModButton(mod); - return modSelect.SelectedMods.Value.Single(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; + return modSelect.SelectedMods.Value.SingleOrDefault(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; }); } @@ -370,8 +355,6 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour()); - private void createDisplay(Func createOverlayFunc) { Children = new Drawable[] @@ -408,7 +391,6 @@ namespace osu.Game.Tests.Visual.UserInterface return section.ButtonsContainer.OfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); } - public new OsuSpriteText MultiplierLabel => base.MultiplierLabel; public new TriangleButton DeselectAllButton => base.DeselectAllButton; public new Color4 LowMultiplierColour => base.LowMultiplierColour; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs index c5374d50ab..096bccae9e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; @@ -59,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface private class Icon : Container, IHasTooltip { - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public SpriteIcon SpriteIcon { get; } diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 069ddfa4db..27ad6650d1 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] protected IAPIProvider API { get; private set; } - private readonly Bindable beatmapId = new Bindable(); + private readonly Bindable beatmapId = new Bindable(); private readonly Bindable mods = new Bindable(); @@ -220,14 +220,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - beatmapId.Value = Model.ID.ToString(); - beatmapId.BindValueChanged(idString => + beatmapId.Value = Model.ID; + beatmapId.BindValueChanged(id => { - int.TryParse(idString.NewValue, out var parsed); + Model.ID = id.NewValue ?? 0; - Model.ID = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) Model.BeatmapInfo = null; if (Model.BeatmapInfo != null) diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 7bd8d3f6a0..6418bf97da 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] protected IAPIProvider API { get; private set; } - private readonly Bindable beatmapId = new Bindable(); + private readonly Bindable beatmapId = new Bindable(); private readonly Bindable score = new Bindable(); @@ -228,16 +228,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - beatmapId.Value = Model.ID.ToString(); - beatmapId.BindValueChanged(idString => + beatmapId.Value = Model.ID; + beatmapId.BindValueChanged(id => { - int parsed; + Model.ID = id.NewValue ?? 0; - int.TryParse(idString.NewValue, out parsed); - - Model.ID = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) Model.BeatmapInfo = null; if (Model.BeatmapInfo != null) diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index aa1be143ea..0d2e64f300 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -214,7 +214,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] private TournamentGameBase game { get; set; } - private readonly Bindable userId = new Bindable(); + private readonly Bindable userId = new Bindable(); private readonly Container drawableContainer; @@ -278,14 +278,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load() { - userId.Value = user.Id.ToString(); - userId.BindValueChanged(idString => + userId.Value = user.Id; + userId.BindValueChanged(id => { - int.TryParse(idString.NewValue, out var parsed); + user.Id = id.NewValue ?? 0; - user.Id = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) user.Username = string.Empty; if (!string.IsNullOrEmpty(user.Username)) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 00af06703d..86c8fb611f 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -191,8 +191,6 @@ namespace osu.Game.Beatmaps { var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); - LogForModel(beatmapSet, $"Validating online IDs for {beatmapSet.Beatmaps.Count} beatmaps..."); - // ensure all IDs are unique if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) { diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index 5dff4fe282..7824205257 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -48,7 +48,6 @@ namespace osu.Game.Beatmaps public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) { - LogForModel(beatmapSet, "Performing online lookups..."); return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 712a3dd33a..662d24cc83 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -133,6 +133,9 @@ namespace osu.Game.Beatmaps IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + foreach (var mod in mods.OfType()) + mod.ApplyToBeatmapProcessor(processor); + processor?.PreProcess(); // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index b53cc659f7..fe04c70d62 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -35,6 +35,7 @@ namespace osu.Game.Collections private const int database_version = 30000000; private const string database_name = "collection.db"; + private const string database_backup_name = "collection.db.bak"; public readonly BindableList Collections = new BindableList(); @@ -56,6 +57,17 @@ namespace osu.Game.Collections { Collections.CollectionChanged += collectionsChanged; + if (storage.Exists(database_backup_name)) + { + // If a backup file exists, it means the previous write operation didn't run to completion. + // Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed. + // + // The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case. + if (storage.Exists(database_name)) + storage.Delete(database_name); + File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name)); + } + if (storage.Exists(database_name)) { List beatmapCollections; @@ -68,7 +80,7 @@ namespace osu.Game.Collections } } - private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) + private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => { switch (e.Action) { @@ -92,7 +104,7 @@ namespace osu.Game.Collections } backgroundSave(); - } + }); /// /// Set an endpoint for notifications to be posted to. @@ -257,27 +269,50 @@ namespace osu.Game.Collections { Interlocked.Increment(ref lastSave); + // This is NOT thread-safe!! try { - // This is NOT thread-safe!! + var tempPath = Path.GetTempFileName(); - using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write))) + using (var ms = new MemoryStream()) { - sw.Write(database_version); - - var collectionsCopy = Collections.ToArray(); - sw.Write(collectionsCopy.Length); - - foreach (var c in collectionsCopy) + using (var sw = new SerializationWriter(ms, true)) { - sw.Write(c.Name.Value); + sw.Write(database_version); - var beatmapsCopy = c.Beatmaps.ToArray(); - sw.Write(beatmapsCopy.Length); + var collectionsCopy = Collections.ToArray(); + sw.Write(collectionsCopy.Length); - foreach (var b in beatmapsCopy) - sw.Write(b.MD5Hash); + foreach (var c in collectionsCopy) + { + sw.Write(c.Name.Value); + + var beatmapsCopy = c.Beatmaps.ToArray(); + sw.Write(beatmapsCopy.Length); + + foreach (var b in beatmapsCopy) + sw.Write(b.MD5Hash); + } } + + using (var fs = File.OpenWrite(tempPath)) + ms.WriteTo(fs); + + var databasePath = storage.GetFullPath(database_name); + var databaseBackupPath = storage.GetFullPath(database_backup_name); + + // Back up the existing database, clearing any existing backup. + if (File.Exists(databaseBackupPath)) + File.Delete(databaseBackupPath); + if (File.Exists(databasePath)) + File.Move(databasePath, databaseBackupPath); + + // Move the new database in-place of the existing one. + File.Move(tempPath, databasePath); + + // If everything succeeded up to this point, remove the backup file. + if (File.Exists(databaseBackupPath)) + File.Delete(databaseBackupPath); } if (saveFailures < 10) diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 3e50613093..f373e59417 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -12,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; namespace osu.Game.Configuration @@ -30,7 +31,7 @@ namespace osu.Game.Configuration { public LocalisableString Label { get; } - public string Description { get; } + public LocalisableString Description { get; } public int? OrderPosition { get; } @@ -149,7 +150,7 @@ namespace osu.Game.Configuration break; case IBindable bindable: - var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); + var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdown = (Drawable)Activator.CreateInstance(dropdownType); dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); @@ -183,5 +184,17 @@ namespace osu.Game.Configuration => obj.GetSettingsSourceProperties() .OrderBy(attr => attr.Item1) .ToArray(); + + private class ModSettingsEnumDropdown : SettingsEnumDropdown + where T : struct, Enum + { + protected override OsuDropdown CreateDropdown() => new ModDropdownControl(); + + private class ModDropdownControl : DropdownControl + { + // Set menu's max height low enough to workaround nested scroll issues (see https://github.com/ppy/osu-framework/issues/4536). + protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 100); + } + } } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 8efd451857..c1a4a6e18a 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -727,7 +727,7 @@ namespace osu.Game.Database /// The model to populate. /// The archive to use as a reference for population. May be null. /// An optional cancellation token. - protected virtual Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default) => Task.CompletedTask; + protected abstract Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default); /// /// Perform any final actions before the import to database executes. diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs index 75c73af0ce..ce8a9c8f9f 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs @@ -4,12 +4,13 @@ using Markdig.Syntax.Inlines; using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; namespace osu.Game.Graphics.Containers.Markdown { public class OsuMarkdownImage : MarkdownImage, IHasTooltip { - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public OsuMarkdownImage(LinkInline linkInline) : base(linkInline.Url) diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index 60ded8952d..0bc3c876e1 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Containers @@ -24,7 +25,7 @@ namespace osu.Game.Graphics.Containers this.sampleSet = sampleSet; } - public virtual string TooltipText { get; set; } + public virtual LocalisableString TooltipText { get; set; } [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index 57f39bb8c7..81dca99ddd 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.Cursor @@ -32,7 +33,7 @@ namespace osu.Game.Graphics.Cursor public override bool SetContent(object content) { - if (!(content is string contentString)) + if (!(content is LocalisableString contentString)) return false; if (contentString == text.Text) return true; diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 5a1eb53fe1..6ad88eaaba 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Platform; using osuTK; using osuTK.Graphics; @@ -58,6 +59,6 @@ namespace osu.Game.Graphics.UserInterface return true; } - public string TooltipText => "view in browser"; + public LocalisableString TooltipText => "view in browser"; } } diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index ac6f5ceb1b..8e82f4a7c1 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Platform; namespace osu.Game.Graphics.UserInterface @@ -105,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface private class CapsWarning : SpriteIcon, IHasTooltip { - public string TooltipText => @"caps lock is active"; + public LocalisableString TooltipText => "caps lock is active"; public CapsWarning() { diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index f58962f8e1..ae16169123 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -34,7 +35,7 @@ namespace osu.Game.Graphics.UserInterface private readonly Box rightBox; private readonly Container nubContainer; - public virtual string TooltipText { get; private set; } + public virtual LocalisableString TooltipText { get; private set; } /// /// Whether to format the tooltip as a percentage or the actual value. diff --git a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs index e85525b2f8..d7bd7d7e01 100644 --- a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs @@ -3,6 +3,7 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; namespace osu.Game.Graphics.UserInterface @@ -19,8 +20,13 @@ namespace osu.Game.Graphics.UserInterface if (stack.CurrentScreen != null) onPushed(null, stack.CurrentScreen); + } - Current.ValueChanged += current => current.NewValue.MakeCurrent(); + protected override void SelectTab(TabItem tab) + { + // override base method to prevent current item from being changed on click. + // depend on screen push/exit to change current item instead. + tab.Value.MakeCurrent(); } private void onPushed(IScreen lastScreen, IScreen newScreen) diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index bb8014fe54..9ebeaf616e 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -18,8 +18,8 @@ namespace osu.Game.IO.Legacy /// handle null strings and simplify use with ISerializable. public class SerializationWriter : BinaryWriter { - public SerializationWriter(Stream s) - : base(s, Encoding.UTF8) + public SerializationWriter(Stream s, bool leaveOpen = false) + : base(s, Encoding.UTF8, leaveOpen) { } diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index 3c66f31c58..dc1fac47a8 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -103,8 +103,11 @@ namespace osu.Game.Localisation [Description(@"简体中文")] zh, - [Description(@"繁體中文(香港)")] - zh_hk, + // Traditional Chinese (Hong Kong) is listed in web sources but has no associated localisations, + // and was wrongly falling back to Simplified Chinese. + // Can be revisited if localisations ever arrive. + // [Description(@"繁體中文(香港)")] + // zh_hk, [Description(@"繁體中文(台灣)")] zh_tw diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 795540b65d..e35d3d6461 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -295,7 +296,7 @@ namespace osu.Game.Online.Leaderboards public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public ScoreComponentLabel(LeaderboardScoreStatistic statistic) { @@ -365,7 +366,7 @@ namespace osu.Game.Online.Leaderboards }; } - public string TooltipText { get; } + public LocalisableString TooltipText { get; } } public class LeaderboardScoreStatistic diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 3a08ef684f..6195d8e1ea 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -258,7 +258,7 @@ namespace osu.Game dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); - // this should likely be moved to ArchiveModelManager when another case appers where it is necessary + // this should likely be moved to ArchiveModelManager when another case appears where it is necessary // to have inter-dependent model managers. this could be obtained with an IHasForeign interface to // allow lookups to be done on the child (ScoreManager in this case) to perform the cascading delete. List getBeatmapScores(BeatmapSetInfo set) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 1935a250b7..d80ef075e9 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -10,11 +10,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -23,9 +25,9 @@ namespace osu.Game.Overlays.BeatmapListing public class BeatmapListingFilterControl : CompositeDrawable { /// - /// Fired when a search finishes. Contains only new items in the case of pagination. + /// Fired when a search finishes. /// - public Action> SearchFinished; + public Action SearchFinished; /// /// Fired when search criteria change. @@ -212,7 +214,25 @@ namespace osu.Game.Overlays.BeatmapListing lastResponse = response; getSetsRequest = null; - SearchFinished?.Invoke(sets); + // check if a non-supporter used supporter-only filters + if (!api.LocalUser.Value.IsSupporter) + { + List filters = new List(); + + if (searchControl.Played.Value != SearchPlayed.Any) + filters.Add(BeatmapsStrings.ListingSearchFiltersPlayed); + + if (searchControl.Ranks.Any()) + filters.Add(BeatmapsStrings.ListingSearchFiltersRank); + + if (filters.Any()) + { + SearchFinished?.Invoke(SearchResult.SupporterOnlyFilters(filters)); + return; + } + } + + SearchFinished?.Invoke(SearchResult.ResultsReturned(sets)); }; api.Queue(getSetsRequest); @@ -237,5 +257,53 @@ namespace osu.Game.Overlays.BeatmapListing base.Dispose(isDisposing); } + + /// + /// Indicates the type of result of a user-requested beatmap search. + /// + public enum SearchResultType + { + /// + /// Actual results have been returned from API. + /// + ResultsReturned, + + /// + /// The user is not a supporter, but used supporter-only search filters. + /// + SupporterOnlyFilters + } + + /// + /// Describes the result of a user-requested beatmap search. + /// + public struct SearchResult + { + public SearchResultType Type { get; private set; } + + /// + /// Contains the beatmap sets returned from API. + /// Valid for read if and only if is . + /// + public List Results { get; private set; } + + /// + /// Contains the names of supporter-only filters requested by the user. + /// Valid for read if and only if is . + /// + public List SupporterOnlyFiltersUsed { get; private set; } + + public static SearchResult ResultsReturned(List results) => new SearchResult + { + Type = SearchResultType.ResultsReturned, + Results = results + }; + + public static SearchResult SupporterOnlyFilters(List filters) => new SearchResult + { + Type = SearchResultType.SupporterOnlyFilters, + SupporterOnlyFiltersUsed = filters + }; + } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs index c2d0eea80c..e2c84c537c 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -22,14 +23,21 @@ namespace osu.Game.Overlays.BeatmapListing [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - AddItem(new RulesetInfo - { - Name = @"Any" - }); + AddTabItem(new RulesetFilterTabItemAny()); foreach (var r in rulesets.AvailableRulesets) AddItem(r); } } + + private class RulesetFilterTabItemAny : FilterTabItem + { + protected override LocalisableString LabelFor(RulesetInfo info) => BeatmapsStrings.ModeAny; + + public RulesetFilterTabItemAny() + : base(new RulesetInfo()) + { + } + } } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 5e65cd9488..460b4ba4c9 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Localisation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,7 +16,9 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Resources.Localisation.Web; @@ -33,6 +36,7 @@ namespace osu.Game.Overlays private Container panelTarget; private FillFlowContainer foundContent; private NotFoundDrawable notFoundContent; + private SupporterRequiredDrawable supporterRequiredContent; private BeatmapListingFilterControl filterControl; public BeatmapListingOverlay() @@ -76,6 +80,7 @@ namespace osu.Game.Overlays { foundContent = new FillFlowContainer(), notFoundContent = new NotFoundDrawable(), + supporterRequiredContent = new SupporterRequiredDrawable(), } } }, @@ -115,9 +120,16 @@ namespace osu.Game.Overlays private Task panelLoadDelegate; - private void onSearchFinished(List beatmaps) + private void onSearchFinished(BeatmapListingFilterControl.SearchResult searchResult) { - var newPanels = beatmaps.Select(b => new GridBeatmapPanel(b) + if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters) + { + supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed); + addContentToPlaceholder(supporterRequiredContent); + return; + } + + var newPanels = searchResult.Results.Select(b => new GridBeatmapPanel(b) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -128,7 +140,7 @@ namespace osu.Game.Overlays //No matches case if (!newPanels.Any()) { - LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + addContentToPlaceholder(notFoundContent); return; } @@ -170,9 +182,9 @@ namespace osu.Game.Overlays { var transform = lastContent.FadeOut(100, Easing.OutQuint); - if (lastContent == notFoundContent) + if (lastContent == notFoundContent || lastContent == supporterRequiredContent) { - // not found display may be used multiple times, so don't expire/dispose it. + // the placeholders may be used multiple times, so don't expire/dispose them. transform.Schedule(() => panelTarget.Remove(lastContent)); } else @@ -240,6 +252,67 @@ namespace osu.Game.Overlays } } + // TODO: localisation requires Text/LinkFlowContainer support for localising strings with links inside + // (https://github.com/ppy/osu-framework/issues/4530) + public class SupporterRequiredDrawable : CompositeDrawable + { + private LinkFlowContainer supporterRequiredText; + + public SupporterRequiredDrawable() + { + RelativeSizeAxes = Axes.X; + Height = 225; + Alpha = 0; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddInternal(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get(@"Online/supporter-required"), + }, + supporterRequiredText = new LinkFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 10 }, + }, + } + }); + } + + public void UpdateText(List filters) + { + supporterRequiredText.Clear(); + + supporterRequiredText.AddText( + BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filters), "").ToString(), + t => + { + t.Font = OsuFont.GetFont(size: 16); + t.Colour = Colour4.White; + } + ); + + supporterRequiredText.AddLink(BeatmapsStrings.ListingSearchSupporterFilterQuoteLinkText.ToString(), @"/store/products/supporter-tag"); + } + } + private const double time_between_fetches = 500; private double lastFetchDisplayedTime; diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index cf74c0d4d3..b81c60a5b9 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -95,7 +95,7 @@ namespace osu.Game.Overlays.BeatmapSet { private readonly OsuSpriteText value; - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public LocalisableString Value { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index 7ad6906cea..bb87e7151b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -28,7 +29,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly IBindable localUser = new Bindable(); - public string TooltipText + public LocalisableString TooltipText { get { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index 6d27342049..cef623e59b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -26,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly bool noVideo; - public string TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; + public LocalisableString TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; private readonly IBindable localUser = new Bindable(); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index f43420e35e..01cfe9a55b 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -240,12 +240,15 @@ namespace osu.Game.Overlays.Chat { get { + if (sender.Equals(User.SYSTEM_USER)) + return Array.Empty(); + List items = new List { new OsuMenuItem("View Profile", MenuItemType.Highlighted, Action) }; - if (sender.Id != api.LocalUser.Value.Id) + if (!sender.Equals(api.LocalUser.Value)) items.Add(new OsuMenuItem("Start Chat", MenuItemType.Standard, startChatAction)); return items.ToArray(); diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 7c47ac655f..d94f8c4b8b 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -20,6 +20,7 @@ using System; using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; +using osu.Framework.Localisation; using osu.Game.Overlays.Comments.Buttons; namespace osu.Game.Overlays.Comments @@ -395,7 +396,7 @@ namespace osu.Game.Overlays.Comments private class ParentUsername : FillFlowContainer, IHasTooltip { - public string TooltipText => getParentMessage(); + public LocalisableString TooltipText => getParentMessage(); private readonly Comment parentComment; @@ -427,7 +428,7 @@ namespace osu.Game.Overlays.Comments if (parentComment == null) return string.Empty; - return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? @"deleted" : string.Empty; + return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? "deleted" : string.Empty; } } } diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index 70424101fd..d0bd24496a 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -14,6 +14,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -34,7 +35,7 @@ namespace osu.Game.Overlays.Mods /// public Action SelectionChanged; - public string TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; + public LocalisableString TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; private const Easing mod_switch_easing = Easing.InOutSine; private const double mod_switch_duration = 120; diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index e31e307d4d..e4aab978fc 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -37,9 +37,6 @@ namespace osu.Game.Overlays.Mods protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; - protected readonly Drawable MultiplierSection; - protected readonly OsuSpriteText MultiplierLabel; - protected readonly FillFlowContainer FooterContainer; protected override bool BlockNonPositionalInput => false; @@ -324,30 +321,6 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, - MultiplierSection = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(footer_button_spacing / 2, 0), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = @"Score Multiplier:", - Font = OsuFont.GetFont(size: 30), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - }, - MultiplierLabel = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Width = 70, // make width fixed so reflow doesn't occur when multiplier number changes. - }, - }, - }, } } }, @@ -361,11 +334,8 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, AudioManager audio, OsuGameBase osu) + private void load(AudioManager audio, OsuGameBase osu) { - LowMultiplierColour = colours.Red; - HighMultiplierColour = colours.Green; - availableMods = osu.AvailableMods.GetBoundCopy(); sampleOn = audio.Samples.Get(@"UI/check-on"); @@ -495,26 +465,6 @@ namespace osu.Game.Overlays.Mods foreach (var section in ModSectionsContainer.Children) section.UpdateSelectedButtons(selectedMods); - - updateMultiplier(); - } - - private void updateMultiplier() - { - var multiplier = 1.0; - - foreach (var mod in SelectedMods.Value) - { - multiplier *= mod.ScoreMultiplier; - } - - MultiplierLabel.Text = $"{multiplier:N2}x"; - if (multiplier > 1.0) - MultiplierLabel.FadeColour(HighMultiplierColour, 200); - else if (multiplier < 1.0) - MultiplierLabel.FadeColour(LowMultiplierColour, 200); - else - MultiplierLabel.FadeColour(Color4.White, 200); } private void modButtonPressed(Mod selectedMod) diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index 87b9d89d4d..0ece96b56c 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; using osuTK.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; namespace osu.Game.Overlays { @@ -56,7 +57,7 @@ namespace osu.Game.Overlays [Resolved] private OverlayColourProvider colourProvider { get; set; } - public string TooltipText => $@"{Value} view"; + public LocalisableString TooltipText => $@"{Value} view"; private readonly SpriteIcon icon; diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs index 7eed4d3b6b..74f3ed846b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Users; using osuTK; @@ -42,6 +43,6 @@ namespace osu.Game.Overlays.Profile.Header.Components InternalChild.FadeInFromZero(200); } - public string TooltipText => badge.Description; + public LocalisableString TooltipText => badge.Description; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 29e13e4f51..527c70685f 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components @@ -14,7 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly BindableBool DetailsVisible = new BindableBool(); - public override string TooltipText => DetailsVisible.Value ? "collapse" : "expand"; + public override LocalisableString TooltipText => DetailsVisible.Value ? "collapse" : "expand"; private SpriteIcon icon; diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index bd8aa7b3bd..db94762efd 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "followers"; + public override LocalisableString TooltipText => "followers"; protected override IconUsage Icon => FontAwesome.Solid.User; diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index 29471375b5..a0b8ef0f11 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Users; @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; } + public LocalisableString TooltipText { get; } private OsuSpriteText levelText; diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index c97df3bc4d..528b05a80a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; } + public LocalisableString TooltipText { get; } private Bar levelProgressBar; private OsuSpriteText levelProgressText; diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs index b4d7c9a05c..ae3d024fbf 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "mapping subscribers"; + public override LocalisableString TooltipText => "mapping subscribers"; protected override IconUsage Icon => FontAwesome.Solid.Bell; diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs index 228765ee1a..4c2cc768ce 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Users; @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "send message"; + public override LocalisableString TooltipText => "send message"; [Resolved(CanBeNull = true)] private ChannelManager channelManager { get; set; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index be96840217..aa7cb8636a 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -14,7 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } private OverlinedInfoContainer info; diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index d581e2750c..9a43997030 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; namespace osu.Game.Overlays.Profile.Header.Components @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly FillFlowContainer iconContainer; private readonly CircularContainer content; - public string TooltipText => "osu!supporter"; + public LocalisableString TooltipText => "osu!supporter"; public int SupportLevel { diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 6d6ff32aac..6f1869966a 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -143,7 +143,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class PlayCountText : CompositeDrawable, IHasTooltip { - public string TooltipText => "times played"; + public LocalisableString TooltipText => "times played"; public PlayCountText(int playCount) { diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index 213ad2ba68..fe36f6ba6d 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -76,7 +77,7 @@ namespace osu.Game.Overlays UpdateState(); } - public string TooltipText => "revert to default"; + public LocalisableString TooltipText => "revert to default"; protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Overlays/Settings/OutlinedTextBox.cs b/osu.Game/Overlays/Settings/OutlinedTextBox.cs new file mode 100644 index 0000000000..93eaf74b77 --- /dev/null +++ b/osu.Game/Overlays/Settings/OutlinedTextBox.cs @@ -0,0 +1,49 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Settings +{ + public class OutlinedTextBox : OsuTextBox + { + private const float border_thickness = 3; + + private Color4 borderColourFocused; + private Color4 borderColourUnfocused; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + borderColourUnfocused = colour.Gray4.Opacity(0.5f); + borderColourFocused = BorderColour; + + updateBorder(); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + updateBorder(); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + updateBorder(); + } + + private void updateBorder() + { + BorderThickness = border_thickness; + BorderColour = HasFocus ? borderColourFocused : borderColourUnfocused; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index c9a81b955b..1ae297f2a9 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -32,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio private class OffsetSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0ms"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0ms"); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 937bcc8abf..669753d2cb 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -233,7 +233,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private class UIScaleSlider : OsuSliderBar { - public override string TooltipText => base.TooltipText + "x"; + public override LocalisableString TooltipText => base.TooltipText + "x"; } private class ResolutionSettingsDropdown : SettingsDropdown diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index fb908a7669..e87572e2ca 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -116,7 +117,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override string TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; + public override LocalisableString TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs index 101d8f43f7..8aeb440be1 100644 --- a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.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 osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings.Sections @@ -10,6 +11,6 @@ namespace osu.Game.Overlays.Settings.Sections /// internal class SizeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0.##x"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0.##x"); } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 19adfc5dd9..a6eb008623 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -44,7 +45,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private class TimeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString("N0") + "ms"; + public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index c73a783d37..2470c0a6c5 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -62,12 +63,12 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private class MaximumStarsSlider : StarsSlider { - public override string TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; + public override LocalisableString TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; } private class StarsSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0.## stars"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars"); } } } diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 088d69c031..87b1aa0e46 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings @@ -17,14 +18,15 @@ namespace osu.Game.Overlays.Settings Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; } - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } public override IEnumerable FilterTerms { get { - if (TooltipText != null) - return base.FilterTerms.Append(TooltipText); + if (TooltipText != default) + // TODO: this won't work as intended once the tooltip text is translated. + return base.FilterTerms.Append(TooltipText.ToString()); return base.FilterTerms; } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 807916e7f6..15a0a42d31 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -35,7 +35,7 @@ namespace osu.Game.Overlays.Settings public bool ShowsDefaultIndicator = true; - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } [Resolved] private OsuColour colours { get; set; } @@ -142,4 +142,4 @@ namespace osu.Game.Overlays.Settings labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; } } -} \ No newline at end of file +} diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index ca9a8e9c08..2fbe522479 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -1,19 +1,65 @@ // 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsNumberBox : SettingsItem + public class SettingsNumberBox : SettingsItem { - protected override Drawable CreateControl() => new NumberBox + protected override Drawable CreateControl() => new NumberControl { - Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = 5 } }; - public class NumberBox : SettingsTextBox.TextBox + private sealed class NumberControl : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public NumberControl() + { + AutoSizeAxes = Axes.Y; + + OutlinedNumberBox numberBox; + + InternalChildren = new[] + { + numberBox = new OutlinedNumberBox + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + CommitOnFocusLost = true + } + }; + + numberBox.Current.BindValueChanged(e => + { + int? value = null; + + if (int.TryParse(e.NewValue, out var intVal)) + value = intVal; + + current.Value = value; + }); + + Current.BindValueChanged(e => + { + numberBox.Current.Value = e.NewValue?.ToString(); + }); + } + } + + private class OutlinedNumberBox : OutlinedTextBox { protected override bool CanAddCharacter(char character) => char.IsNumber(character); } diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index 25424e85a1..d28dbf1068 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -1,60 +1,17 @@ // 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.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osuTK.Graphics; namespace osu.Game.Overlays.Settings { public class SettingsTextBox : SettingsItem { - protected override Drawable CreateControl() => new TextBox + protected override Drawable CreateControl() => new OutlinedTextBox { Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, - CommitOnFocusLost = true, + CommitOnFocusLost = true }; - - public class TextBox : OsuTextBox - { - private const float border_thickness = 3; - - private Color4 borderColourFocused; - private Color4 borderColourUnfocused; - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - borderColourUnfocused = colour.Gray4.Opacity(0.5f); - borderColourFocused = BorderColour; - - updateBorder(); - } - - protected override void OnFocus(FocusEvent e) - { - base.OnFocus(e); - - updateBorder(); - } - - protected override void OnFocusLost(FocusLostEvent e) - { - base.OnFocusLost(e); - - updateBorder(); - } - - private void updateBorder() - { - BorderThickness = border_thickness; - BorderColour = HasFocus ? borderColourFocused : borderColourUnfocused; - } - } } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index d208c7fe07..706eec226c 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Edit // Audio new CheckAudioPresence(), new CheckAudioQuality(), + new CheckMutedObjects(), + new CheckFewHitsounds(), // Compose new CheckUnsnappedObjects(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs new file mode 100644 index 0000000000..5185ba6c99 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs @@ -0,0 +1,164 @@ +// 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.Game.Audio; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckFewHitsounds : ICheck + { + /// + /// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map. + /// This is almost always ok, but can still be useful for the mapper to make sure hitsounding coverage is good. + /// + private const int negligible_threshold_time = 4000; + + /// + /// 4 measures (4/4) of 120 BPM, typically makes up a large portion of a section in the song. + /// This is ok if the section is a quiet intro, for example. + /// + private const int warning_threshold_time = 8000; + + /// + /// 12 measures (4/4) of 120 BPM, typically makes up multiple sections in the song. + /// + private const int problem_threshold_time = 24000; + + // Should pass at least this many objects without hitsounds to be considered an issue (should work for Easy diffs too). + private const int warning_threshold_objects = 4; + private const int problem_threshold_objects = 16; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Few or no hitsounds"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateLongPeriodProblem(this), + new IssueTemplateLongPeriodWarning(this), + new IssueTemplateLongPeriodNegligible(this), + new IssueTemplateNoHitsounds(this) + }; + + private bool mapHasHitsounds; + private int objectsWithoutHitsounds; + private double lastHitsoundTime; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (!context.Beatmap.HitObjects.Any()) + yield break; + + mapHasHitsounds = false; + objectsWithoutHitsounds = 0; + lastHitsoundTime = context.Beatmap.HitObjects.First().StartTime; + + var hitObjectsIncludingNested = new List(); + + foreach (var hitObject in context.Beatmap.HitObjects) + { + // Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat). + foreach (var nestedHitObject in hitObject.NestedHitObjects) + hitObjectsIncludingNested.Add(nestedHitObject); + + hitObjectsIncludingNested.Add(hitObject); + } + + var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList(); + var hitObjectCount = hitObjectsByEndTime.Count; + + for (int i = 0; i < hitObjectCount; ++i) + { + var hitObject = hitObjectsByEndTime[i]; + + // This is used to perform an update at the end so that the period after the last hitsounded object can be an issue. + bool isLastObject = i == hitObjectCount - 1; + + foreach (var issue in applyHitsoundUpdate(hitObject, isLastObject)) + yield return issue; + } + + if (!mapHasHitsounds) + yield return new IssueTemplateNoHitsounds(this).Create(); + } + + private IEnumerable applyHitsoundUpdate(HitObject hitObject, bool isLastObject = false) + { + var time = hitObject.GetEndTime(); + bool hasHitsound = hitObject.Samples.Any(isHitsound); + bool couldHaveHitsound = hitObject.Samples.Any(isHitnormal); + + // Only generating issues on hitsounded or last objects ensures we get one issue per long period. + // If there are no hitsounds we let the "No hitsounds" template take precedence. + if (hasHitsound || (isLastObject && mapHasHitsounds)) + { + var timeWithoutHitsounds = time - lastHitsoundTime; + + if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects) + yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds); + else if (timeWithoutHitsounds > warning_threshold_time && objectsWithoutHitsounds > warning_threshold_objects) + yield return new IssueTemplateLongPeriodWarning(this).Create(lastHitsoundTime, timeWithoutHitsounds); + else if (timeWithoutHitsounds > negligible_threshold_time && objectsWithoutHitsounds > warning_threshold_objects) + yield return new IssueTemplateLongPeriodNegligible(this).Create(lastHitsoundTime, timeWithoutHitsounds); + } + + if (hasHitsound) + { + mapHasHitsounds = true; + objectsWithoutHitsounds = 0; + lastHitsoundTime = time; + } + else if (couldHaveHitsound) + ++objectsWithoutHitsounds; + } + + private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains); + private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL); + + public abstract class IssueTemplateLongPeriod : IssueTemplate + { + protected IssueTemplateLongPeriod(ICheck check, IssueType type) + : base(check, type, "Long period without hitsounds ({0:F1} seconds).") + { + } + + public Issue Create(double time, double duration) => new Issue(this, duration / 1000f) { Time = time }; + } + + public class IssueTemplateLongPeriodProblem : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodProblem(ICheck check) + : base(check, IssueType.Problem) + { + } + } + + public class IssueTemplateLongPeriodWarning : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodWarning(ICheck check) + : base(check, IssueType.Warning) + { + } + } + + public class IssueTemplateLongPeriodNegligible : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodNegligible(ICheck check) + : base(check, IssueType.Negligible) + { + } + } + + public class IssueTemplateNoHitsounds : IssueTemplate + { + public IssueTemplateNoHitsounds(ICheck check) + : base(check, IssueType.Problem, "There are no hitsounds.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs new file mode 100644 index 0000000000..a4ff921b7e --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs @@ -0,0 +1,158 @@ +// 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.Utils; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckMutedObjects : ICheck + { + /// + /// Volume percentages lower than or equal to this are typically inaudible. + /// + private const int muted_threshold = 5; + + /// + /// Volume percentages lower than or equal to this can sometimes be inaudible depending on sample used and music volume. + /// + private const int low_volume_threshold = 20; + + private enum EdgeType + { + Head, + Repeat, + Tail, + None + } + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Low volume hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateMutedActive(this), + new IssueTemplateLowVolumeActive(this), + new IssueTemplateMutedPassive(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + foreach (var hitObject in context.Beatmap.HitObjects) + { + // Worth keeping in mind: The samples of an object always play at its end time. + // Objects like spinners have no sound at its start because of this, while hold notes have nested objects to accomplish this. + foreach (var nestedHitObject in hitObject.NestedHitObjects) + { + foreach (var issue in getVolumeIssues(hitObject, nestedHitObject)) + yield return issue; + } + + foreach (var issue in getVolumeIssues(hitObject)) + yield return issue; + } + } + + private IEnumerable getVolumeIssues(HitObject hitObject, HitObject sampledHitObject = null) + { + sampledHitObject ??= hitObject; + if (!sampledHitObject.Samples.Any()) + yield break; + + // Samples that allow themselves to be overridden by control points have a volume of 0. + int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume > 0 ? sample.Volume : sampledHitObject.SampleControlPoint.SampleVolume); + double samplePlayTime = sampledHitObject.GetEndTime(); + + EdgeType edgeType = getEdgeAtTime(hitObject, samplePlayTime); + // We only care about samples played on the edges of objects, not ones like spinnerspin or slidertick. + if (edgeType == EdgeType.None) + yield break; + + string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLower() : null; + + if (maxVolume <= muted_threshold) + { + if (edgeType == EdgeType.Head) + yield return new IssueTemplateMutedActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + else + yield return new IssueTemplateMutedPassive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + } + else if (maxVolume <= low_volume_threshold && edgeType == EdgeType.Head) + { + yield return new IssueTemplateLowVolumeActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + } + } + + private EdgeType getEdgeAtTime(HitObject hitObject, double time) + { + if (Precision.AlmostEquals(time, hitObject.StartTime, 1f)) + return EdgeType.Head; + if (Precision.AlmostEquals(time, hitObject.GetEndTime(), 1f)) + return EdgeType.Tail; + + if (hitObject is IHasRepeats hasRepeats) + { + double spanDuration = hasRepeats.Duration / hasRepeats.SpanCount(); + if (spanDuration <= 0) + // Prevents undefined behaviour in cases like where zero/negative-length sliders/hold notes exist. + return EdgeType.None; + + double spans = (time - hitObject.StartTime) / spanDuration; + double acceptableDifference = 1 / spanDuration; // 1 ms of acceptable difference, as with head/tail above. + + if (Precision.AlmostEquals(spans, Math.Ceiling(spans), acceptableDifference) || + Precision.AlmostEquals(spans, Math.Floor(spans), acceptableDifference)) + { + return EdgeType.Repeat; + } + } + + return EdgeType.None; + } + + public abstract class IssueTemplateMuted : IssueTemplate + { + protected IssueTemplateMuted(ICheck check, IssueType type, string unformattedMessage) + : base(check, type, unformattedMessage) + { + } + + public Issue Create(HitObject hitobject, double volume, double time, string postfix = "") + { + string objectName = hitobject.GetType().Name; + if (!string.IsNullOrEmpty(postfix)) + objectName += " " + postfix; + + return new Issue(hitobject, this, objectName, volume) { Time = time }; + } + } + + public class IssueTemplateMutedActive : IssueTemplateMuted + { + public IssueTemplateMutedActive(ICheck check) + : base(check, IssueType.Problem, "{0} has a volume of {1:0%}. Clickable objects must have clearly audible feedback.") + { + } + } + + public class IssueTemplateLowVolumeActive : IssueTemplateMuted + { + public IssueTemplateLowVolumeActive(ICheck check) + : base(check, IssueType.Warning, "{0} has a volume of {1:0%}, ensure this is audible.") + { + } + } + + public class IssueTemplateMutedPassive : IssueTemplateMuted + { + public IssueTemplateMutedPassive(ICheck check) + : base(check, IssueType.Negligible, "{0} has a volume of {1:0%}, ensure there is no distinct sound here in the song if inaudible.") + { + } + } + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs new file mode 100644 index 0000000000..e23a5d8d99 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Interface for a that applies changes to a . + /// + public interface IApplicableToBeatmapProcessor : IApplicableMod + { + /// + /// Applies this to a . + /// + void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor); + } +} diff --git a/osu.Game/Rulesets/Mods/IHasSeed.cs b/osu.Game/Rulesets/Mods/IHasSeed.cs new file mode 100644 index 0000000000..001a9d214c --- /dev/null +++ b/osu.Game/Rulesets/Mods/IHasSeed.cs @@ -0,0 +1,12 @@ +// 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; + +namespace osu.Game.Rulesets.Mods +{ + public interface IHasSeed + { + Bindable Seed { get; } + } +} diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index e0c3008ae8..1f7742b075 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -2,17 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods { - public abstract class ModRandom : Mod + public abstract class ModRandom : Mod, IHasSeed { public override string Name => "Random"; public override string Acronym => "RD"; @@ -20,88 +17,11 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.Dice; public override double ScoreMultiplier => 1; - [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(ModRandomSettingsControl))] + [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] public Bindable Seed { get; } = new Bindable { Default = null, Value = null }; - - private class ModRandomSettingsControl : SettingsItem - { - protected override Drawable CreateControl() => new SeedControl - { - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 5 } - }; - - private sealed class SeedControl : CompositeDrawable, IHasCurrentValue - { - private readonly BindableWithCurrent current = new BindableWithCurrent(); - - public Bindable Current - { - get => current; - set - { - current.Current = value; - seedNumberBox.Text = value.Value.ToString(); - } - } - - private readonly SettingsNumberBox.NumberBox seedNumberBox; - - public SeedControl() - { - AutoSizeAxes = Axes.Y; - - InternalChildren = new[] - { - new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 2), - new Dimension(GridSizeMode.Relative, 0.25f) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] - { - seedNumberBox = new SettingsNumberBox.NumberBox - { - RelativeSizeAxes = Axes.X, - CommitOnFocusLost = true - } - } - } - } - }; - - seedNumberBox.Current.BindValueChanged(e => - { - int? value = null; - - if (int.TryParse(e.NewValue, out var intVal)) - value = intVal; - - current.Value = value; - }); - } - - protected override void Update() - { - if (current.Value == null) - seedNumberBox.Text = current.Current.Value.ToString(); - } - } - } } } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index cae5da3d16..725cfa9c26 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; using osu.Framework.Bindables; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.UI { @@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.UI private const float size = 80; - public virtual string TooltipText => showTooltip ? mod.IconTooltip : null; + public virtual LocalisableString TooltipText => showTooltip ? mod.IconTooltip : null; private Mod mod; private readonly bool showTooltip; diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 9d3b952ada..d5bea0affc 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Linq.Expressions; using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using osu.Framework.Bindables; @@ -72,6 +73,9 @@ namespace osu.Game.Scoring } } + protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + => Task.CompletedTask; + protected override void ExportModelTo(ScoreInfo model, Stream outputStream) { var file = model.Files.SingleOrDefault(); diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 8a4d381535..185f029d14 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -94,6 +95,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Creates a for a specific item. /// /// The item to create the overlay for. + [CanBeNull] protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 3e97e15cca..79b38861ee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using Humanizer; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -256,9 +257,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (drawable == null) return null; - return CreateHitObjectBlueprintFor(item).With(b => b.DrawableObject = drawable); + return CreateHitObjectBlueprintFor(item)?.With(b => b.DrawableObject = drawable); } + [CanBeNull] public virtual HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => null; private void hitObjectAdded(HitObject obj) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 3b1dae6c3d..3ac40fda0f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osuTK; using osuTK.Graphics; @@ -58,6 +59,6 @@ namespace osu.Game.Screens.Edit.Compose.Components icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } - public string TooltipText { get; } + public LocalisableString TooltipText { get; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 6f04f36b83..a642768574 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -7,6 +7,7 @@ using System.Linq; 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.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -89,7 +90,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } else { - placementBlueprint = CreateBlueprintFor(obj.NewValue); + placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); placementBlueprint.Colour = Color4.MediumPurple; diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index a836f7bf09..38290a6530 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -261,7 +261,7 @@ namespace osu.Game.Screens.Menu switch (state) { default: - return true; + return false; case ButtonSystemState.Initial: State = ButtonSystemState.TopLevel; diff --git a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs index c4dc2a2b8f..ae1ca1b967 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Online.Rooms; @@ -16,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { private readonly GameType type; - public string TooltipText => type.Name; + public LocalisableString TooltipText => type.Name; public DrawableGameType(GameType type) { diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index 5e2e9fd087..d5abaaab4e 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -32,7 +32,6 @@ namespace osu.Game.Screens.OnlinePlay { IsValidMod = m => true; - MultiplierSection.Alpha = 0; DeselectAllButton.Alpha = 0; Drawable selectAllButton; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index f9b3549f3c..4b8c4422ec 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -305,18 +305,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return true; } + return base.OnBackButton(); + } + + public override bool OnExiting(IScreen next) + { + if (client.Room == null) + { + // room has not been created yet; exit immediately. + return base.OnExiting(next); + } + if (!exitConfirmed && dialogOverlay != null) { - dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else { - exitConfirmed = true; - this.Exit(); - })); + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + this.Exit(); + })); + } return true; } - return base.OnBackButton(); + return base.OnExiting(next); } private ModSettingChangeTracker modSettingChangeTracker; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index e418d36d40..ceee002c6e 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -240,13 +240,15 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(IScreen next) { + if (screenStack.CurrentScreen?.OnExiting(next) == true) + return true; + RoomManager.PartRoom(); waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); - screenStack.CurrentScreen?.OnExiting(next); base.OnExiting(next); return false; } diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index e1cf0cef4e..4a35202df2 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -431,7 +431,7 @@ namespace osu.Game.Screens.Select public class InfoLabel : Container, IHasTooltip { - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public InfoLabel(BeatmapStatistic statistic) { diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 521b90202d..f95ddfee41 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -42,6 +42,7 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate); match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate); match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(Beatmap.BaseDifficulty.CircleSize); + match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(Beatmap.BaseDifficulty.OverallDifficulty); match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(Beatmap.Length); match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(Beatmap.BPM); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 208048380a..b9e912df8e 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -24,6 +24,7 @@ namespace osu.Game.Screens.Select public OptionalRange ApproachRate; public OptionalRange DrainRate; public OptionalRange CircleSize; + public OptionalRange OverallDifficulty; public OptionalRange Length; public OptionalRange BPM; public OptionalRange BeatDivisor; diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index db2803d29a..72d10019b2 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -51,6 +51,9 @@ namespace osu.Game.Screens.Select case "cs": return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value); + case "od": + return TryUpdateCriteriaRange(ref criteria.OverallDifficulty, op, value); + case "bpm": return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index 5f0ca27170..c3d01218d7 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -45,35 +45,38 @@ namespace osu.Game.Skinning }; } - [Resolved] - private SkinManager skinManager { get; set; } - - [Resolved] - private ISkinSource skinSource { get; set; } + private SkinManager skinManager; + private ISkinSource parentSource; private ResourceStoreBackedSkin rulesetResourcesSkin; - [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - if (Ruleset.CreateResourceStore() is IResourceStore resources) - rulesetResourcesSkin = new ResourceStoreBackedSkin(resources, host, audio); + skinManager = parent.Get(); - UpdateSkins(); - skinSource.SourceChanged += OnSourceChanged; + parentSource = parent.Get(); + parentSource.SourceChanged += OnSourceChanged; + + if (Ruleset.CreateResourceStore() is IResourceStore resources) + rulesetResourcesSkin = new ResourceStoreBackedSkin(resources, parent.Get(), parent.Get()); + + // ensure sources are populated and ready for use before childrens' asynchronous load flow. + UpdateSkinSources(); + + return base.CreateChildDependencies(parent); } protected override void OnSourceChanged() { - UpdateSkins(); + UpdateSkinSources(); base.OnSourceChanged(); } - protected virtual void UpdateSkins() + protected virtual void UpdateSkinSources() { SkinSources.Clear(); - foreach (var skin in skinSource.AllSources) + foreach (var skin in parentSource.AllSources) { switch (skin) { @@ -114,8 +117,8 @@ namespace osu.Game.Skinning { base.Dispose(isDisposing); - if (skinSource != null) - skinSource.SourceChanged -= OnSourceChanged; + if (parentSource != null) + parentSource.SourceChanged -= OnSourceChanged; rulesetResourcesSkin?.Dispose(); } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 4cde4cd2b8..645c943d09 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -142,16 +142,16 @@ namespace osu.Game.Skinning return base.ComputeHash(item, reader); } - protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { - await base.Populate(model, archive, cancellationToken).ConfigureAwait(false); - var instance = GetSkin(model); model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) populateMetadata(model, instance); + + return Task.CompletedTask; } private void populateMetadata(SkinInfo item, Skin instance) diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 33cc00e748..b30be05ac4 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -1,9 +1,11 @@ // 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.Testing; +using osu.Game.Overlays; using osu.Game.Screens; namespace osu.Game.Tests.Visual @@ -19,12 +21,16 @@ namespace osu.Game.Tests.Visual protected override Container Content => content; + [Cached] + protected DialogOverlay DialogOverlay { get; private set; } + protected ScreenTestScene() { base.Content.AddRange(new Drawable[] { Stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - content = new Container { RelativeSizeAxes = Axes.Both } + content = new Container { RelativeSizeAxes = Axes.Both }, + DialogOverlay = new DialogOverlay() }); } diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index c3bf740108..f73489ac61 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; namespace osu.Game.Users.Drawables @@ -68,11 +69,11 @@ namespace osu.Game.Users.Drawables private class ClickableArea : OsuClickableContainer { - private string tooltip = default_tooltip_text; + private LocalisableString tooltip = default_tooltip_text; - public override string TooltipText + public override LocalisableString TooltipText { - get => Enabled.Value ? tooltip : null; + get => Enabled.Value ? tooltip : default; set => tooltip = value; } diff --git a/osu.Game/Users/Drawables/DrawableFlag.cs b/osu.Game/Users/Drawables/DrawableFlag.cs index 1d648e46b6..aea40a01ae 100644 --- a/osu.Game/Users/Drawables/DrawableFlag.cs +++ b/osu.Game/Users/Drawables/DrawableFlag.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; namespace osu.Game.Users.Drawables { @@ -13,7 +14,7 @@ namespace osu.Game.Users.Drawables { private readonly Country country; - public string TooltipText => country?.FullName; + public LocalisableString TooltipText => country?.FullName; public DrawableFlag(Country country) { diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index f91620bd25..f047859dbb 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 22c4340ba2..304047ad12 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -93,7 +93,7 @@ - +